Construyendo un Barman de IA sin Servidor - Parte 1: Deja de Ser el Menú

Este archivo ha sido traducido automáticamente por IA, pueden ocurrir errores
Mis mayores intereses y pasatiempos giran en torno a la comida y las bebidas, y me encanta mezclar cócteles en casa.
Hay algo extrañamente satisfactorio en mezclar ese perfecto Negroni o agitar un Whiskey Sour adecuado. Pero cuando organizo fiestas, enfrento el mismo problema cada vez, me convierto en el menú humano. La gente nunca sabe lo que quiere, aunque vengo equipado con un bar completamente abastecido, así que al final tienden a elegir los mismos cócteles de siempre.
En esta víspera de Año Nuevo íbamos a ir a una fiesta de unos amigos y, como siempre, me preguntaron si podía mezclar cócteles durante la noche. Por supuesto que podía hacerlo, pero necesitaba algún tipo de menú de bebidas del que la gente pudiera elegir, no iba a llevar todo mi equipo conmigo, solo casi...
Así que mi plan inicial era simplemente imprimirlo en papel y que la gente pudiera elegir de ahí. Entonces, el ingeniero en mí se despertó y ¿Qué tal si creo un sistema de pedidos de bebidas sin servidor?
Esta es la primera publicación de una serie de tres partes donde construyo un asistente de cócteles de IA sin servidor. Dejo que mis invitados naveguen por el menú ellos mismos y pidan lo que quieran directamente desde sus teléfonos. Y al final, también obtengo recomendaciones de IA sobre qué pedir, en otras palabras, menos trabajo para mí.
El proyecto se compartirá en serverless-handbook después de la publicación de la parte 3.
¡Comencemos!
Qué estamos construyendo
Una aplicación web donde los invitados pueden navegar por mi menú de bebidas, realizar pedidos y obtener recomendaciones de IA. Piensa en ello como un menú digital y un asistente de barman combinados.
Para lograrlo, necesito una base sólida que incluya una base de datos para almacenar bebidas y pedidos, APIs REST que permitan a la aplicación leer y escribir datos, autenticación para mantener seguro el panel de administración mientras permite que los invitados naveguen libremente, y carga de imágenes para que pueda agregar fotos a cada bebida. Todo sin servidor, por supuesto, sin servidores para administrar, solo servicios que escalan con la demanda.
Descripción general de la arquitectura
En la primera parte cubriremos la base. Este es el terreno sobre el que todo se sostiene, sin una base adecuada será difícil extenderlo con nuevas características y funcionalidades. Crearemos el alojamiento del frontend, la API alojada por Amazon API Gateway, la computación con AWS Lambda, la autenticación con Amazon Cognito, Amazon S3 para imágenes, Amazon EventBridge para flujos de trabajo impulsados por eventos como la generación de imágenes de IA, y Amazon Aurora DSQL como nuestra base de datos principal.

Aurora DSQL, la base de datos relacional sin servidor
Para la base de datos, elegí Aurora DSQL. Es una base de datos compatible con PostgreSQL sin servidor. Sin aprovisionamiento, sin piscinas de conexión para administrar, y escala automáticamente.
¿Por qué elegí DSQL? Cuando comencé el proyecto estaba entre usar DynamoDB o DSQL. Un aspecto importante era que la base de datos seleccionada tenía que ser sin servidor y funcionar sin VPC, lo que elimina a Aurora Serverless. La razón por la que elegí DSQL y un modelo relacional fue que necesitaba una búsqueda y filtrado adecuados, por ejemplo, por ingredientes. DynamoDB es un gran almacén de clave-valor, pero no todos los modelos de datos se ajustan a eso.
DSQL me ofrece varias ventajas que lo convierten en la elección correcta. Es sin servidor, pago solo por lo que uso y escala a cero cuando nadie está navegando por el menú. Ser compatible con PostgreSQL significa que obtengo SQL adecuado con capacidades de búsqueda y filtrado incorporadas, esenciales para consultar por ingredientes. Tampoco hay gestión de contraseñas, ya que la autenticación de IAM mantiene las cosas seguras sin rotar secretos. Y lo que es importante, funciona naturalmente con el modelo de conexión efímero de Lambda. Cada invocación de Lambda puede autenticarse de forma independiente sin administrar piscinas persistentes.
Configuración de DSQL
Crear un nuevo clúster DSQL es bastante sencillo. Uso CloudFormation para crear un clúster DSQL y dos roles de IAM, uno para leer datos y otro para escribir. ¿Por qué dos roles diferentes? Principalmente por separación de funciones. En este sistema, el 95% del tráfico serán operaciones de lectura, invitados navegando por el menú. Solo los administradores realizan escrituras al gestionar bebidas. Al dividir los roles, me aseguro de que las funciones Lambda que manejan las solicitudes de los invitados solo puedan leer. Físicamente no pueden modificar datos incluso si cometo un error en la función. El rol de escritor, por otro lado, está restringido a las funciones Lambda de administración detrás del autorizador de Cognito. Es un pequeño esfuerzo extra que crea un límite significativo.
Resources:
DSQLCluster:
Type: AWS::DSQL::Cluster
Properties:
DeletionProtectionEnabled: true
ClusterEndpointEncryptionType: TLS_1_2
# Rol de lector - utilizado por los puntos finales de la API pública
DatabaseReaderRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal:
AWS: !Sub "arn:aws:iam::${AWS::AccountId}:root"
Action: sts:AssumeRole
Policies:
- PolicyName: DSQLReadAccess
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Action: dsql:DbConnect
Resource: !GetAtt DSQLCluster.Arn
# DatabaseWriterRole sigue el mismo patrónRoles y permisos de la base de datos
DSQL utiliza un modelo de permisos de dos capas. Primero, necesitas un rol de IAM que pueda conectarse al clúster. Segundo, necesitas un rol de base de datos que controle lo que puedes hacer una vez conectado.
En mi configuración, así es como funciona.
- La función Lambda asume un rol de IAM, como el
DatabaseReaderRolede arriba, a través de STS - Usando esas credenciales, la función Lambda llama a
dsql.generate_db_connect_auth_token(), esto requerirá el permisodsql:DbConnect - La función Lambda se conecta a DSQL usando el token de autenticación como contraseña
- DSQL mapea el rol de IAM a un rol de base de datos que controla el acceso a la tabla
Aquí está el código Python que hace esto.
def get_auth_token(endpoint: str, region: str, role_arn: str) -> str:
"""Genera el token de autenticación DSQL usando credenciales de rol asumido."""
# Paso 1: Asume el rol de IAM de la base de datos
sts = boto3.client("sts", region_name=region)
creds = sts.assume_role(
RoleArn=role_arn,
RoleSessionName="dsql-session"
)["Credentials"]
# Paso 2: Crea el cliente DSQL con credenciales asumidas
dsql = boto3.client(
"dsql",
region_name=region,
aws_access_key_id=creds["AccessKeyId"],
aws_secret_access_key=creds["SecretAccessKey"],
aws_session_token=creds["SessionToken"],
)
# Paso 3: Genera el token de autenticación (requiere permiso dsql:DbConnect)
return dsql.generate_db_connect_auth_token(
Hostname=endpoint,
Region=region
)La configuración del rol de la base de datos se ve así.
-- Crea un rol de base de datos para acceso de solo lectura
CREATE ROLE lambda_drink_reader WITH LOGIN;
-- Vincula el rol de IAM a este rol de base de datos
AWS IAM GRANT lambda_drink_reader TO 'arn:aws:iam::[ACCOUNT_ID]:role/drink-assistant-data-reader-role';
-- Concede permisos a nivel de base de datos
GRANT USAGE ON SCHEMA cocktails TO lambda_drink_reader;
GRANT SELECT ON ALL TABLES IN SCHEMA cocktails TO lambda_drink_reader;El rol de escritor sigue el mismo patrón pero también otorga permisos INSERT, UPDATE, DELETE.
Esquema de la base de datos
El esquema de la base de datos consta de varias tablas e índices, pero la tabla principal es la tabla de bebidas. Tuve que tomar algunas decisiones de diseño deliberadas aquí. Lo más importante, ¿cómo almaceno los ingredientes?
CREATE SCHEMA IF NOT EXISTS cocktails;
CREATE TABLE cocktails.drinks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
section_id UUID REFERENCES cocktails.sections(id),
name VARCHAR(100) NOT NULL,
description TEXT,
ingredients JSONB NOT NULL, -- Flexible para diferentes cantidades de ingredientes
recipe_steps TEXT[],
image_url TEXT,
is_active BOOLEAN DEFAULT true
);
-- Las tablas de secciones y pedidos siguen patrones similaresUso JSONB para los ingredientes porque cada bebida tiene una lista muy diferente. Una Margarita necesita tres ingredientes como tequila, jugo de lima y triple sec. Una bebida Tiki clásica podría necesitar ocho o más. Con un modelo relacional, necesitaría una tabla separada de ingredientes con una relación de muchos a uno (agregando complejidad), o crear un número fijo de columnas de ingredientes (desperdiciando espacio o limitando la lista). JSONB me da flexibilidad sin sacrificar la capacidad de búsqueda: PostgreSQL puede consultar dentro de los campos JSONB con índices adecuados, así que si más tarde quiero buscar "todas las bebidas con tequila", sigue siendo eficiente.
Consideré almacenar los ingredientes como un simple campo TEXT con valores separados por comas, pero eso no escala cuando quiero agregar más metadatos más adelante, cantidad, unidad de medida, sustitutos. JSONB prepara el esquema para el futuro sin requerir migraciones.
El problema de permisos que me costó horas
Mientras desarrollaba la solución, necesitaba actualizar tablas y crear nuevas tablas y aquí hay algo que me tomó por sorpresa con DSQL. Tenía mis roles de base de datos configurados, todo funcionaba perfectamente. Pero después de agregar una nueva tabla para el registro de usuarios, la implementé e inmediatamente encontré errores de permisos.
permiso denegado para la tabla registration_codesEl problema? DSQL no admite ALTER DEFAULT PRIVILEGES.
En PostgreSQL regular, puedes ejecutar algo como esto.
-- Esto NO funciona en DSQL
ALTER DEFAULT PRIVILEGES IN SCHEMA cocktails
GRANT SELECT, INSERT, UPDATE ON TABLES TO lambda_drink_writer;Esto otorgaría automáticamente permisos en cualquier tabla futura. Pero DSQL no admite este comando. Cuando creas una nueva tabla, tus roles de base de datos existentes no tienen acceso a ella hasta que otorgues permisos explícitamente.
La solución es simple pero fácil de olvidar. Cada vez que agregas una tabla, necesitas una declaración GRANT correspondiente.
-- Nueva tabla
CREATE TABLE cocktails.registration_codes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(100) NOT NULL UNIQUE,
expires_at TIMESTAMP NOT NULL,
used_at TIMESTAMP
);
-- ¡No olvides esto!
GRANT SELECT ON cocktails.registration_codes TO lambda_drink_reader;
GRANT SELECT, INSERT, UPDATE ON cocktails.registration_codes TO lambda_drink_writer;Resolví esto manteniendo un archivo de migración dedicado que rastrea todos los permisos de las tablas. Cuando agrego una nueva tabla, actualizo este archivo con las concesiones explícitas. Es manual, pero evita las sesiones de depuración de "por qué mi Lambda no puede leer esta tabla".
Autenticación y Autorización
Antes de profundizar en la API, veamos y configuremos la autenticación. El sistema tiene dos tipos de usuarios con niveles de acceso muy diferentes. Los invitados pueden navegar por las bebidas libremente, no necesitan probar quiénes son hasta que quieren hacer un pedido. Los administradores, por otro lado, necesitan autenticación completa para gestionar el menú, ver todos los pedidos y actualizar el estado de los pedidos.
Esta división significa que necesito tanto autenticación, verificando quién dices ser, como autorización, decidiendo qué se te permite hacer. Los administradores pasan por un flujo completo de Cognito con nombre de usuario y contraseña. Los invitados obtienen un registro ligero basado en invitación que detallaré en la Parte 2.
Así que los administradores necesitan iniciar sesión para realizar tareas de administración, como crear o actualizar bebidas existentes.

Grupo de usuarios de Cognito para administradores
Amazon Cognito User Pool contendrá y manejará la autenticación de administradores. Proporciona un grupo de usuarios alojado, tokens JWT y gestión de grupos sin que yo construya nada de eso.
UserPool:
Type: AWS::Cognito::UserPool
Properties:
UserPoolName: !Sub "${AWS::StackName}-users"
AutoVerifiedAttributes:
- email
Schema:
- Name: email
Required: true
- Name: name
Required: true
UserPoolClient:
Type: AWS::Cognito::UserPoolClient
Properties:
UserPoolId: !Ref UserPool
GenerateSecret: true
AllowedOAuthFlowsUserPoolClient: true
CallbackURLs:
- !Sub "https://${DomainName}/admin/callback"
AllowedOAuthFlows:
- code
AllowedOAuthScopes:
- email
- openid
- profile
SupportedIdentityProviders:
- COGNITO
AccessTokenValidity: 1 # 1 hora
RefreshTokenValidity: 30 # 30 días
AdminGroup:
Type: AWS::Cognito::UserPoolGroup
Properties:
GroupName: admin
UserPoolId: !Ref UserPoolInicio de sesión alojado de Cognito
En lugar de construir mis propios formularios de inicio de sesión, uso la UI alojada de Cognito. Maneja todo el flujo de inicio de sesión, incluyendo nombre de usuario/contraseña, mensajes de error y restablecimiento de contraseña, todo con una interfaz limpia y personalizable.
HostedUserPoolDomain:
Type: AWS::Cognito::UserPoolDomain
Properties:
Domain: !Ref HostedAuthDomainPrefix # e.g., "drink-assistant-auth"
ManagedLoginVersion: 2
UserPoolId: !Ref UserPool
ManagedLoginStyle:
Type: AWS::Cognito::ManagedLoginBranding
Properties:
ClientId: !Ref UserPoolClient
UserPoolId: !Ref UserPool
UseCognitoProvidedValues: true # Usar estilo predeterminadoEl ManagedLoginVersion: 2 te da la experiencia de inicio de sesión más nueva y personalizable. El recurso ManagedLoginBranding te permite dar estilo a la página de inicio de sesión. Estoy usando los valores predeterminados de Cognito aquí, pero puedes personalizar colores, logotipos y CSS.
La URL de la UI alojada sigue este patrón.
https://.auth..amazoncognito.com/login
?client_id=
&response_type=code
&scope=email+openid+profile
&redirect_uri=https:///admin/callback El flujo de inicio de sesión funciona así.
- El administrador hace clic en "Iniciar sesión" en el panel de administración
- El frontend redirige a la UI alojada de Cognito
- El administrador ingresa las credenciales
- Cognito valida y redirige de vuelta con un código de autorización
- El frontend intercambia el código por tokens JWT (acceso, ID, refresco)
- El token de acceso se almacena y se envía con las solicitudes de API
Cuando un administrador inicia sesión, Cognito devuelve un token de acceso JWT. Este token contiene afirmaciones incluyendo cognito:groups que enumera las membresías de grupo del usuario. Esto se usará más adelante al hacer una autorización del administrador.
Para una inmersión más profunda en los patrones de autenticación y autorización con Cognito, consulta mi publicación PEP y PDP para Autorización Segura con Cognito.
API REST con API Gateway
Configurar y crear la API REST no es nada especial, usaremos Amazon API Gateway, y elijo la versión REST (v1) para el trabajo. Hay algunas razones para eso, pero en esta parte cubriré una de ellas. Necesito soporte para API Keys y limitación y, por ahora, la versión REST es la única que lo admite. La API sigue una clara separación con puntos finales públicos para que los invitados naveguen por las bebidas y realicen pedidos, puntos finales de administración para gestionar el menú. Cada uno tiene diferentes requisitos de autenticación y limitación.
Puntos finales públicos (solo API Key):
GET /drinks - Listar todas las bebidas
GET /drinks/{id} - Obtener detalles de la bebida
GET /sections - Listar secciones del menú
POST /orders - Realizar un pedido
GET /orders/{id} - Verificar el estado del pedido
Puntos finales de administración (API Key + JWT):
POST /admin/drinks - Crear bebida
PUT /admin/drinks/{id} - Actualizar bebida
DELETE /admin/drinks/{id} - Eliminar bebida
GET /admin/orders - Ver todos los pedidos
PUT /admin/orders/{id} - Actualizar estado del pedidoAutorizador Lambda para AuthN + AuthZ
API Gateway usa un autorizador Lambda para validar solicitudes. Aquí es donde ocurre tanto la autenticación como la autorización.
def handler(event, context):
"""Autorizador Lambda - maneja tanto AuthN como AuthZ."""
token = extract_bearer_token(event)
# Paso 1: Autenticación - verificar que el JWT es válido
claims = validate_token(token) # Verifica la firma, la expiración, el emisor
# Paso 2: Autorización - verificar si el usuario tiene el rol de administrador
if "/admin/" in event["methodArn"]:
if "admin" not in claims.get("cognito:groups", []):
return generate_policy(claims["sub"], "Deny", event["methodArn"])
# Ambas verificaciones pasaron
return generate_policy(claims["sub"], "Allow", event["methodArn"])La validación de JWT busca las claves públicas de Cognito (JWKS) y verifica.
- Firma - El token no fue manipulado
- Expiración - El token no ha expirado
- Emisor - El token vino de mi grupo de usuarios de Cognito
Si todas las verificaciones pasan, el autorizador mira la afirmación cognito:groups. Los puntos finales de administración requieren que el usuario esté en el grupo admin, la verificación de autorización.
¿Por qué un autorizador Lambda?
API Gateway tiene autorizadores de Cognito integrados, pero solo hacen autenticación. Verifican que el token es válido pero no pueden verificar la membresía del grupo. El autorizador Lambda me permite hacer ambas cosas en un solo paso.
El autorizador también pasa el contexto del usuario a las funciones Lambda descendentes.
return {
"principalId": claims["sub"],
"policyDocument": { ... },
"context": {
"userId": claims["sub"],
"email": claims["email"],
"role": "admin" if is_admin(claims) else "user"
}
}Este contexto está disponible en event["requestContext"]["authorizer"] para que las funciones Lambda sepan quién hizo la solicitud sin analizar el JWT nuevamente.
¿Por qué API Keys para los puntos finales públicos?
Incluso los puntos finales públicos requieren una API Key. Esto no es para autenticación, es para protección y control. Las API Keys se vinculan a planes de uso con límites de tasa, dando un control de limitación de grano fino. Las métricas de CloudWatch muestran el uso por clave, proporcionando visibilidad sobre quién está llamando a la API y con qué frecuencia. Y si algo sale mal, puedo deshabilitar una clave al instante como interruptor de emergencia.
La API Key está incrustada en el frontend, por lo que no es secreta. Pero me da control sobre quién puede llamar a la API y con qué frecuencia.
Planes de uso y limitación
Los diferentes puntos finales tienen diferentes límites de tasa basados en el uso esperado.
PublicUsagePlan:
Type: AWS::ApiGateway::UsagePlan
Properties:
ApiStages:
- ApiId: !Ref DrinkAssistantApi
Stage: v1
Throttle:
"/drinks/GET":
RateLimit: 100 # solicitudes por segundo
BurstLimit: 200 # límite de pico
"/orders/POST":
RateLimit: 50 # más lento - escribe en la base de datos
BurstLimit: 100Navegar por las bebidas puede ocurrir 100 veces por segundo. Realizar pedidos está limitado a 50/segundo ya que implica escrituras en la base de datos y publicación de eventos, así que quiero ser más conservador.
Para una fiesta en casa esto es un exceso masivo. Pero es un buen hábito, y si esto alguna vez se convirtiera en un producto real, la base está ahí.
Caché de API Gateway
El menú de bebidas rara vez cambia, pero los invitados lo navegan constantemente. Sin caché, cada carga de página golpea Lambda y DSQL. Con 20 invitados refrescando el menú cada pocos segundos, eso es una carga y un costo innecesarios.
API Gateway tiene una caché integrada que es sorprendentemente efectiva para este escenario.
ApiGateway:
Type: AWS::Serverless::Api
Properties:
StageName: prod
CacheClusterEnabled: true
CacheClusterSize: "0.5" # Tamaño más pequeño, ~$15/mes
MethodSettings:
- HttpMethod: GET
ResourcePath: /drinks
CachingEnabled: true
CacheTtlInSeconds: 3600 # 1 horaAhora GET /drinks devuelve respuestas en caché durante 1 hora. El menú no cambia tan a menudo, así que este es un compromiso razonable. Pero aquí está el problema, ¿qué pasa cuando agrego una nueva bebida? Los invitados estarían navegando por un menú desactualizado hasta que la caché expire naturalmente.
La solución es una invalidación agresiva. El Lambda createDrink vacía toda la caché de la API después de guardar en la base de datos.
def flush_api_cache():
"""Invalida la caché de API Gateway después de cambios de datos."""
client = boto3.client("apigateway")
client.flush_stage_cache(
restApiId=os.environ["API_ID"],
stageName="prod"
)Este patrón, caché agresivamente para lecturas, invalida inmediatamente en escrituras, te da lo mejor de ambos mundos: cargas de página rápidas para navegar, y actualizaciones instantáneas cuando el menú cambia. Ten en cuenta que este enfoque funciona bien para el punto final del menú porque los cambios de datos son poco frecuentes y controlados. Si estuviera cachéando el estado del pedido o datos en tiempo real, usaría un TTL más corto o saltaría la caché por completo. La clave es hacer coincidir la estrategia de caché con la frecuencia con la que tus datos realmente cambian.
Carga de imágenes con S3
Las fotos de bebidas van a S3 usando URLs pre-firmadas. Esto mantiene las cargas grandes fuera de Lambda, debido a que tiene un límite de solicitud de 10MB.
El flujo es sencillo. Cuando un administrador quiere cargar una foto de bebida, solicita una URL de carga a la API. La función Lambda genera una URL PUT pre-firmada que es válida por 10 minutos, dando al administrador un permiso temporal y limitado para cargar directamente en S3. El navegador del administrador luego carga la imagen directamente en S3 sin tocar Lambda en absoluto. Una vez cargada, CloudFront sirve la imagen a los invitados que navegan por el menú.
El CloudFormation del bucket S3.
ImageBucket:
Type: AWS::S3::Bucket
Properties:
BucketName: !Sub "${AWS::StackName}-images"
CorsConfiguration:
CorsRules:
- AllowedOrigins:
- "*"
AllowedMethods:
- PUT
- GET
AllowedHeaders:
- "*"
MaxAge: 3600
PublicAccessBlockConfiguration:
BlockPublicAcls: true
BlockPublicPolicy: true
IgnorePublicAcls: true
RestrictPublicBuckets: true
ImageBucketPolicy:
Type: AWS::S3::BucketPolicy
Properties:
Bucket: !Ref ImageBucket
PolicyDocument:
Statement:
- Effect: Allow
Principal:
Service: cloudfront.amazonaws.com
Action: s3:GetObject
Resource: !Sub "${ImageBucket.Arn}/*"
Condition:
StringEquals:
AWS:SourceArn: !Sub "arn:aws:cloudfront::${AWS::AccountId}:distribution/${CloudFrontDistribution}"Y el Lambda que genera URLs pre-firmadas.
import boto3
import json
import uuid
import os
from aws_lambda_powertools import Logger
logger = Logger()
s3_client = boto3.client("s3")
def handler(event, context):
"""Genera URL pre-firmada para carga de imagen."""
try:
body = json.loads(event["body"])
filename = body["filename"]
content_type = body.get("content_type", "image/jpeg")
# Genera clave única
s3_key = f"drink-images/{uuid.uuid4()}-{filename}"
# Genera URL pre-firmada (válida 10 minutos)
presigned_url = s3_client.generate_presigned_url(
"put_object",
Params={
"Bucket": os.environ["IMAGE_BUCKET"],
"Key": s3_key,
"ContentType": content_type
},
ExpiresIn=600
)
# URL de CloudFront para acceder a la imagen
cloudfront_url = f"https://{os.environ['CLOUDFRONT_DOMAIN']}/{s3_key}"
logger.info(f"Generada URL de carga para {filename}")
return {
"statusCode": 200,
"body": json.dumps({
"upload_url": presigned_url,
"image_url": cloudfront_url,
"key": s3_key
})
}
except Exception as e:
logger.exception("Error generando URL pre-firmada")
return {"statusCode": 500, "body": json.dumps({"error": str(e)})}Generación de indicaciones de imagen impulsada por eventos
Aquí es donde se pone interesante, y la parte y las soluciones que normalmente construyo y sobre las que escribo. Cuando agrego una nueva bebida, quiero una indicación de imagen generada por IA lista para cuando eventualmente cree la foto de la bebida. En lugar de escribir indicaciones manualmente, dejo que Amazon Bedrock las genere basándose en el nombre y los ingredientes de la bebida.
La clave es el desacoplamiento en su máxima expresión. Podría haber llamado a Bedrock directamente desde el Lambda createDrink, pero eso agregaría latencia a la respuesta de la API ya que el administrador tendría que esperar la generación de IA. EventBridge resuelve esto elegantemente. La API regresa inmediatamente después del guardado en la base de datos, manteniendo los tiempos de respuesta rápidos. El bus de eventos maneja la generación de indicaciones de forma asincrónica, y si Bedrock es lento o falla temporalmente, EventBridge reintenta automáticamente. Este acoplamiento suelto también significa que puedo extender fácilmente el sistema más adelante agregando más oyentes de eventos para notificaciones o análisis sin tocar el código de la API en absoluto.

¿Por qué un bus de eventos personalizado?
Uso un bus de eventos dedicado en lugar del bus predeterminado de AWS.
DrinkAssistantEventBus:
Type: AWS::Events::EventBus
Properties:
Name: drink-assistant-events¿Por qué no usar el bus predeterminado? Tres razones.
- Aislamiento - Los eventos de mi aplicación no se mezclan con los eventos de los servicios de AWS (CloudTrail, etc.)
- Permisos - Puedo otorgar a roles de IAM específicos acceso solo a este bus
- Filtrado - Las reglas solo ven eventos de mi aplicación, haciendo los patrones más simples
Para un proyecto pequeño esto puede parecer una exageración, pero es un buen hábito. Cuando tienes múltiples aplicaciones publicando eventos, un bus dedicado por dominio mantiene las cosas limpias.
El flujo de eventos

La función Lambda createDrink publica un evento después de guardar en la base de datos.
def publish_drink_created(drink: dict) -> bool:
"""Publica evento DrinkCreated en EventBridge."""
events_client = boto3.client("events")
event_detail = {
"metadata": {
"event_type": "DRINK_CREATED",
"version": "1.0",
"timestamp": datetime.utcnow().isoformat() + "Z",
},
"data": {
"drink_id": drink["id"],
"name": drink["name"],
"description": drink.get("description", ""),
"ingredients": drink.get("ingredients", []),
},
}
events_client.put_events(
Entries=[{
"Source": "drink-assistant.api",
"DetailType": "DrinkCreated",
"Detail": json.dumps(event_detail),
"EventBusName": os.environ["DRINK_EVENT_BUS_NAME"],
}]
)El evento es de fuego y olvido. Si falla, lo registramos pero no fallamos la llamada a la API. La bebida ya está guardada.
Generando la indicación con Bedrock
Aquí es donde la ingeniería de indicaciones se vuelve crítica. Inicialmente intenté una indicación de sistema genérica como "Crea una indicación de imagen para una fotografía de cóctel" y la dejé suelta. Los resultados fueron funcionales pero poco inspirados: descripciones genéricas de bebidas en vasos genéricos bajo iluminación genérica. No terrible, pero no alineado con mi visión de minimalismo nórdico.
Elegí Amazon Bedrock Nova Pro para esta tarea porque equilibra costo y calidad. Nova Pro es más barato que Claude mientras aún produce salidas coherentes y detalladas. El modelo no alucina salvajemente, y para indicaciones de imágenes, no necesito razonamiento de borde sangrante, necesito salidas consistentes y predecibles con las que un modelo de generación de imágenes pueda trabajar.
El verdadero trabajo está en las indicaciones. La indicación del sistema establece la personalidad y perspectiva del fotógrafo. La indicación del usuario proporciona restricciones específicas y guía estética. Aquí está la evolución:
Primer intento (demasiado genérico):
Sistema: "Eres un fotógrafo. Genera una indicación de imagen para un cóctel."
Usuario: "Cóctel: Negroni. Ingredientes: Campari, gin, vermouth."
Resultado: "Un cóctel Negroni en un vaso de cristal, adornado con una vuelta de naranja, fotografiado en un entorno de estudio con iluminación profesional."
Esto funciona, pero es genérico—podría describir cualquier foto de cóctel.
Segundo intento (más específico):
Sistema: "Eres un fotógrafo especializado en fotografía de bebidas. Crea una indicación detallada y fotorrealista para una fotografía de cóctel."
Usuario: "Cóctel: Negroni. Genera una indicación enfatizando la estética minimalista nórdica, iluminación de estudio profesional."
Resultado: "Un Negroni en un vaso de rocas, mínimamente adornado con una vuelta de naranja, contra un fondo gris suave. Iluminación de estudio limpia enfatizando el color rojo profundo y la claridad de la bebida. Composición minimalista. Fotografía profesional."
¡Mejor! Más específico, pero la estética nórdica todavía no está lo suficientemente clara.
Versión final (lo que realmente uso):
def generate_prompt(drink: dict) -> str:
"""Genera indicación de imagen usando Bedrock Nova Pro."""
bedrock = boto3.client("bedrock-runtime")
# La indicación del sistema establece la experiencia y estilo del fotógrafo
system_prompt = """Eres un fotógrafo experto especializado en
fotografía de bebidas para marcas de lujo. Creas indicaciones detalladas y fotorrealistas
para fotografías de estudio. Tu estilo enfatiza el minimalismo nórdico:
líneas limpias, iluminación suave, fondos neutros y adornos mínimos.
Cada elemento en el marco tiene un propósito."""
# La indicación del usuario proporciona información específica de la bebida y refuerza la estética
user_prompt = f"""
Cóctel: {drink['name']}
Descripción: {drink['description']}
Ingredientes: {', '.join(drink['ingredients'])}
Genera una indicación de imagen detallada (2-3 oraciones) para una fotografía fotorrealista
de estudio con estos requisitos:
- Cristalería: tipo apropiado para el estilo de este cóctel
- Color/claridad: describe la apariencia visual de la bebida
- Adorno: mínimo, elegante, con propósito
- Estética: minimalismo nórdico—sombras suaves, fondo neutro (blanco o gris suave),
iluminación de estudio profesional que muestra los verdaderos colores de la bebida
- Composición: centrada, limpia, sin desorden
Salida: Solo la indicación, sin preámbulo.
"""
response = bedrock.invoke_model(
modelId="eu.amazon.nova-pro-v1:0",
body=json.dumps({
"messages": [{"role": "user", "content": [{"text": user_prompt}]}],
"inferenceConfig": {
"temperature": 0.7, # Equilibrado: creativo pero consistente
"maxTokens": 300 # Suficiente para 2-3 oraciones
}
})
)
return json.loads(response["body"].read())["output"]["message"]["content"][0]["text"]La temperatura de 0.7 logra un equilibrio. Más alto (más cercano a 1.0) haría que cada indicación fuera más aleatoria y creativa, pero menos consistente entre bebidas. Más bajo (más cercano a 0) sería repetitivo. En 0.7, cada indicación es única pero lo suficientemente predecible como para que un modelo de generación de imágenes obtenga la estética correcta. El maxTokens: 300 se establece específicamente porque Nova Canvas, el modelo de generación de imágenes que probé durante el proyecto, tiene un límite de 300 tokens para indicaciones. Esto obliga a la generación de texto a mantenerse concisa mientras sigue siendo descriptiva.
Esta expansión de la indicación del sistema—explicando que quiero específicamente el minimalismo nórdico, describiendo lo que eso significa (sombras suaves, fondos neutros, adorno mínimo)—fue el avance clave. La primera vez que generé indicaciones con esta versión, los resultados finalmente se alinearon con mi visión, fotografía de cócteles limpia, minimalista y premium.
La indicación se guarda en S3 para su uso posterior cuando realmente genere la imagen (tal vez con Nova Canvas en una versión futura).
Extensibilidad
Este patrón escala bien. Cuando agrego generación de imágenes, es solo otra regla escuchando el mismo evento. No se necesitan cambios en la API.
Qué sigue
La base está hecha. Los invitados pueden navegar por el menú, los administradores pueden gestionar bebidas. Pero los invitados aún no pueden realizar pedidos, necesitan una forma de identificarse sin crear cuentas completas.
En la Parte 2, construiré el flujo de pedido completo.
- Registro de invitados con códigos de invitación y JWT autofirmados
- Colocación de pedidos con un autorizador Lambda personalizado
- Notificaciones en tiempo real usando AWS AppSync Events - cuando marco una bebida como lista, los invitados la ven al instante
¡Mantente atento y sígueme en LinkedIn para no perdértelo!
Palabras finales
Esta base puede no ser la parte más llamativa del proyecto, pero es el trabajo de base que hace posible todo lo demás. Base de datos, APIs, autenticación, flujos de trabajo impulsados por eventos. Todo está aquí, listo para apoyar la verdadera magia.
Porque esto es solo el comienzo. El asistente de cócteles de IA es el objetivo, y ahí es donde se pone interesante. Recomendaciones de IA basadas en preferencias de sabor, generación de imágenes para fotos de bebidas, actualizaciones de pedidos en tiempo real, tal vez incluso pedidos conversacionales. La base está hecha. Ahora viene la parte divertida.
Eso es lo que me encanta de los servicios sin servidor y gestionados como DSQL, Cognito, EventBridge y Bedrock. Manejan el trabajo pesado indiferenciado para que puedas avanzar rápidamente en la base y pasar tu tiempo en lo que realmente hace que el proyecto sea único. Las características de IA. La experiencia del usuario. Las cosas que hacen que la gente diga "eso es genial".
La Parte 2 llegará pronto. Registro de invitados, flujo de pedidos y notificaciones en tiempo real. Mantente atento.
Revisa mis otras publicaciones en jimmydqv.com y sígueme en X para más contenido sin servidor.
Como dice Werner, ¡Ahora ve y construye!
