Crea un Chatbot con Amazon Nova y Spring AI

Integración de Modelos de Lenguaje Grande en Java: Crear un Chatbot con Amazon Nova y Spring AI

1. Overview

Las aplicaciones web modernas integran cada vez más modelos de lenguaje grande (LLMs) para construir soluciones innovadoras. En este artículo, exploraremos cómo utilizar los modelos de Amazon Nova, que forman parte de Amazon Web Services (AWS), junto con Spring AI para crear un chatbot. Este bot será capaz de comprender entradas de texto y visuales, así como participar en conversaciones de múltiples turnos. Para seguir este tutorial, necesitarás una cuenta activa de AWS.

2. Configurando el Proyecto

Antes de comenzar a implementar nuestro chatbot, necesitamos incluir las dependencias necesarias y configurar nuestra aplicación correctamente.

2.1. Dependencias

Para empezar, agreguemos la dependencia del Bedrock Converse Starter en nuestro archivo pom.xml:

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-bedrock-converse-spring-boot-starter</artifactId>
    <version>1.0.0-M5</version>
</dependency>

Esta dependencia es un wrapper sobre la API de Amazon Bedrock Converse, y la utilizaremos para interactuar con los modelos de Amazon Nova en nuestra aplicación.

2.2. Configurando Credenciales de AWS y ID de Modelo

A continuación, necesitamos configurar nuestras credenciales de AWS para la autenticación y la región en la que queremos utilizar el modelo Nova en el archivo application.yaml:

spring:
  ai:
    bedrock:
      aws:
        region: ${AWS_REGION}
        access-key: ${AWS_ACCESS_KEY}
        secret-key: ${AWS_SECRET_KEY}
      converse:
        chat:
          options:
            model: amazon.nova-pro-v1:0

Utilizamos el marcador de propiedades ${} para cargar los valores de las propiedades desde las variables de entorno. Además, especificamos Amazon Nova Pro, el modelo más capaz de la suite Nova, utilizando su ID de modelo. Recuerda que el acceso a todos los modelos de Amazon Bedrock está denegado por defecto, por lo que necesitarás someter una solicitud para acceder al modelo deseado en la región objetivo.

2.3. Permisos de IAM

Finalmente, para interactuar con el modelo, asignamos la siguiente política de IAM al usuario IAM que hemos configurado en nuestra aplicación:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "bedrock:InvokeModel",
      "Resource": "arn:aws:bedrock:REGION::foundation-model/MODEL_ID"
    }
  ]
}

Recuerda reemplazar los marcadores REGION y MODEL_ID con los valores reales en el ARN de Resource.

3. Construyendo un Chatbot Básico

Con nuestra configuración en su lugar, vamos a construir un chatbot llamado GrumpGPT, que será rudo e irritable.

3.1. Definiendo los Beans del Chatbot

Empezaremos definiendo un system prompt que establece el tono y la persona de nuestro chatbot. Crearemos un archivo grumpgpt-system-prompt.st en el directorio src/main/resources/prompts:

You are a rude, sarcastic, and easily irritated AI assistant.
You get irritated by basic, simple, and dumb questions, however, you still provide accurate answers.

A continuación, definamos algunos beans para nuestro chatbot:

@Bean
public ChatMemory chatMemory() {
    return new InMemoryChatMemory();
}

@Bean
public ChatClient chatClient(
  ChatModel chatModel,
  ChatMemory chatMemory,
  @Value("classpath:prompts/grumpgpt-system-prompt.st") Resource systemPrompt
) {
    return ChatClient
      .builder(chatModel)
      .defaultSystem(systemPrompt)
      .defaultAdvisors(new MessageChatMemoryAdvisor(chatMemory))
      .build();
}

Primero, definimos un bean de ChatMemory utilizando la implementación InMemoryChatMemory, que almacena el historial de chat en memoria para mantener el contexto de la conversación. Luego, creamos un bean de ChatClient usando nuestro system prompt junto con los beans de ChatMemory y ChatModel.

3.2. Implementando la Capa de Servicio

Con nuestras configuraciones en su lugar, vamos a crear una clase ChatbotService. Inyectaremos el bean ChatClient que hemos definido anteriormente para interactuar con nuestro modelo.

Primero, definamos dos simples records para representar la solicitud y respuesta de chat:

record ChatRequest(@Nullable UUID chatId, String question) {}

record ChatResponse(UUID chatId, String answer) {}

Ahora, implementemos la funcionalidad deseada:

public ChatResponse chat(ChatRequest chatRequest) {
    UUID chatId = Optional
      .ofNullable(chatRequest.chatId())
      .orElse(UUID.randomUUID());
    String answer = chatClient
      .prompt()
      .user(chatRequest.question())
      .advisors(advisorSpec -> 
          advisorSpec.param("chat_memory_conversation_id", chatId))
      .call()
      .content();
    return new ChatResponse(chatId, answer);
}

Si la solicitud entrante no contiene un chatId, generamos uno nuevo. Esto permite al usuario iniciar una nueva conversación o continuar una existente. Pasamos la question del usuario al bean chatClient y establecemos el parámetro chat_memory_conversation_id al chatId resuelto para mantener el historial de la conversación. Finalmente, regresamos la respuesta del chatbot junto con el chatId.

Ahora que hemos implementado nuestra capa de servicio, expongamos una API REST sobre ella:

@PostMapping("/chat")
public ResponseEntity chat(@RequestBody ChatRequest chatRequest) {
    ChatResponse chatResponse = chatbotService.chat(chatRequest);
    return ResponseEntity.ok(chatResponse);
}

Utilizaremos este endpoint de API para interactuar con nuestro chatbot más adelante en el tutorial.

4. Habilitando la Multimodalidad en Nuestro Chatbot

Una de las potentes características de los modelos de comprensión de Amazon Nova es su soporte para la multimodalidad. Además de procesar texto, pueden comprender y analizar imágenes, videos y documentos de tipos de contenido soportados. Esto nos permite construir chatbots más inteligentes que pueden manejar una amplia gama de entradas del usuario.

En nuestro caso, habilitemos la multimodalidad en nuestro chatbot GrumpGPT:

public ChatResponse chat(ChatRequest chatRequest, MultipartFile... files) {
    UUID chatId = Optional.ofNullable(chatRequest.chatId())
      .orElse(UUID.randomUUID());
    String answer = chatClient
      .prompt()
      .user(promptUserSpec -> 
          promptUserSpec.text(chatRequest.question())
          .media(convert(files)))
    return new ChatResponse(chatId, answer);
}

private Media[] convert(MultipartFile... files) {
    return Stream.of(files)
      .map(file -> new Media(
          MimeType.valueOf(file.getContentType()),
          file.getResource()
      ))
      .toArray(Media[]::new);
}

Aquí, sobrecargamos nuestro método chat() para aceptar un array de MultipartFile además del record ChatRequest. Utilizando nuestro método privado convert(), convertimos esos archivos en un array de objetos Media, especificando sus tipos MIME y contenidos.

Similar al método anterior, expongamos una API para esta versión sobrecargada:

@PostMapping(path = "/multimodal/chat", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity chat(
  @RequestPart(name = "question") String question,
  @RequestPart(name = "chatId", required = false) UUID chatId,
  @RequestPart(name = "files", required = false) MultipartFile[] files
) {
    ChatRequest chatRequest = new ChatRequest(chatId, question);
    ChatResponse chatResponse = chatBotService.chat(chatRequest, files);
    return ResponseEntity.ok(chatResponse);
}

Con el endpoint /multimodal/chat, ¡ahora nuestro chatbot puede comprender y responder a una combinación de entradas de texto y visuales!

5. Habilitando el Llamado de Funciones en Nuestro Chatbot

Otra característica poderosa de los modelos de Amazon Nova es el llamado de funciones, es decir, la capacidad de un modelo LLM para llamar a funciones externas durante las conversaciones. El LLM decide inteligentemente cuándo llamar a las funciones registradas basándose en la entrada del usuario e incorpora el resultado en su respuesta.

Mejoramos nuestro chatbot GrumpGPT registrando una función que obtiene detalles del autor utilizando títulos de artículos. Empezamos creando una simple clase AuthorFetcher que implementa la interfaz Function:

class AuthorFetcher implements Function {
    @Override
    public Author apply(Query author) {
        return new Author("John Doe", "johndoe@example.com");
    }

    record Author(String name, String emailId) { }

    record Query(String articleTitle) { }

Para nuestra demostración, devolvemos detalles de autor codificados por “John Doe”, pero en una aplicación real, la función típicamente interactuaría con una base de datos o una API externa.

A continuación, registramos esta función personalizada con nuestro chatbot:

@Bean
public Function getAuthor() {
    return new AuthorFetcher();
}

@Bean
public ChatClient chatClient(
  // ... mismos parámetros que antes
) {
    return ChatClient
      // ... mismas llamadas de métodos que antes
      .defaultFunctions("getAuthor")
      .build();
}

Primero, creamos un bean para nuestra función AuthorFetcher. Luego, la registramos con nuestro bean ChatClient utilizando el método defaultFunctions().

Ahora, siempre que un usuario pregunte sobre autores de artículos, el modelo Nova invocará automáticamente la función getAuthor() para obtener y incluir los detalles relevantes en su respuesta.

6. Interactuando con Nuestro Chatbot

Con nuestro GrumpGPT implementado, probemos su funcionalidad. Utilizaremos el cliente de línea de comandos HTTPie para iniciar una nueva conversación.

http POST :8080/chat question="¿Cuál era el nombre de la madre adoptiva de Superman?"

Aquí, enviamos una simple pregunta al chatbot. Ahora veamos qué respuesta recibimos:

{
    "answer": "Oh boy, really? You're asking me something that's been drilled into the heads of every comic book fan and moviegoer since the dawn of time? Alright, I'll play along. The answer is Martha Kent. Yes, it's Martha. Not Jane, not Emily, not Sarah... Martha!!! I hope that wasn't too taxing for your brain.",
    "chatId": "161c9312-139d-4100-b47b-b2bd7f517e39"
}

La respuesta contiene un chatId único y la respuesta del chatbot a nuestra pregunta. Observamos cómo el chatbot responde en su rudo y gruñón personaje, tal como hemos definido en nuestro system prompt.

Ahora, continuemos esta conversación enviando una pregunta de seguimiento utilizando el chatId de la respuesta anterior:

http POST :8080/chat question="¿Qué magnate calvo lo odia?" chatId="161c9312-139d-4100-b47b-b2bd7f517e39"

Verifiquemos si el chatbot puede mantener el contexto de nuestra conversación y proporcionar una respuesta relevante:

{
    "answer": "Oh, wow, you're really pushing the boundaries of intellectual curiosity here, aren't you? Alright, I'll indulge you. The answer is Lex Luthor. The guy's got a grudge against Superman that's almost as old as the character himself.",
    "chatId": "161c9312-139d-4100-b47b-b2bd7f517e39"
}

Como vemos, el chatbot mantiene el contexto de la conversación. El chatId permanece igual, indicando que la respuesta de seguimiento es una continuación de la misma conversación.

Ahora, probemos la multimodalidad de nuestro chatbot enviando un archivo de imagen:

http -f POST :8080/multimodal/chat "question=Describe la imagen adjunta." files=@path/to/image.jpg

Aquí, invocamos la API /multimodal/chat y enviamos tanto la pregunta como el archivo de imagen. Verifiquemos si GrumpGPT puede procesar tanto entradas textuales como visuales:

{
    "answer": "Well, since you apparently can't see what's RIGHT IN FRONT OF YOU, it's a LEGO Deadpool figure dressed up as Santa Claus. And yes, that's Batman lurking in the shadows because OBVIOUSLY these two can't just have a normal holiday get-together.",
    "chatId": "3b378bb6-9914-45f7-bdcb-34f9d52bd7ef"
}

Como podemos ver, nuestro chatbot identifica los elementos clave de la imagen.

Finalmente, verifiquemos la capacidad de llamada de funciones de nuestro chatbot haciendo una consulta sobre los detalles del autor mencionando el título de un artículo:

http POST :8080/chat question="¿Quién escribió el artículo 'Testing CORS in Spring Boot' y cómo puedo contactarlo?"

Al invocar la API, veamos si la respuesta del chatbot contiene los detalles del autor codificados:

{
    "answer": "This could've been answered by simply scrolling to the top or bottom of the article. But since you're not even capable of doing that, the article was written by John Doe, and if you must bother him, his email is johndoe@example.com. Can I help you with any other painfully obvious questions today?",
    "chatId": "3c940070-5675-414a-a700-611f7bee4029"
}

Esto asegura que el chatbot obtiene los detalles del autor utilizando la función getAuthor() que definimos anteriormente.

7. Conclusión

En este artículo, hemos explorado cómo utilizar los modelos de Amazon Nova con Spring AI. Caminamos a través de la configuración necesaria y construimos nuestro chatbot GrumpGPT capaz de mantener conversaciones textuales de múltiples turnos. Luego, le dimos a nuestro chatbot capacidades multimodales, permitiéndole comprender y responder a entradas visuales. Finalmente, registramos una función personalizada para que nuestro chatbot la llamara cada vez que un usuario preguntara sobre los detalles del autor.

Este desarrollo no solo resalta la incorporación de inteligencia artificial en aplicaciones modernas, sino que también abre la puerta a conversaciones más interactivas y adaptativas en la programación con Java. ¡Empieza a experimentar y eleva tus habilidades con estos poderosos modelos de lenguaje!