diff --git a/README.md b/README.md index 9392ea1..c38b0a3 100644 --- a/README.md +++ b/README.md @@ -4,3 +4,233 @@ * *Spring Boot Version:* `3.2.5` (defined in the parent POM) * *Spring Cloud GCP Version:* `5.3.0` (managed via `spring-cloud-gcp-dependencies`) * *Spring Cloud Version:* `2023.0.0` (managed via `spring-cloud-dependencies`) + + +This project is a **Spring Boot Service Orchestrator** running on **Java 21**. + +Here is step-by-step guide to getting this deployed locally in your IDE. + +----- + +### Step 1: Ensure Prerequisites + +Before we touch the code, we need to make sure your local machine matches the project requirements found in the `pom.xml` and `Dockerfile`. + +1. **Install Java 21 JDK:** The project explicitly requires Java 21. + * *Check:* Run `java -version` in your terminal. If it doesn't say "21", you need to install it. +2. **Install Maven:** This is used to build the project dependencies. +3. **Install the "Extension Pack for Java" in VS Code:** This includes tools for Maven, debugging, and IntelliSense. +4. **Install Docker (Desktop or Engine):** We will need this to run a local Redis instance. + +----- + +### Step 2: The "Redis Gotcha" (Local Infrastructure) + +If you look at `src/main/resources/application-dev.properties`, you will see this line: +`spring.data.redis.host=localhost`. + + +1. **Start Redis in Docker:** + Open your terminal and run: + ```bash + docker run --name local-redis -p 6379:6379 -d redis + ``` +2. **Verify it's running:** + Run `docker ps`. You should see redis running on port `6379`. + +----- + +### Step 3: Google Cloud Authentication + +This application connects to **Firestore**, **Dialogflow CX**, and **Vertex AI (Gemini)**. It uses the "Application Default Credentials" strategy. + +1. **Install the Google Cloud CLI (`gcloud`)** if you haven't already. +2. **Login:** + In your terminal, run: + ```bash + gcloud auth application-default login + ``` + *This will open a browser window. Log in with your Google account that has access to the `app-jovenes` project.* + +----- + +### Step 4: Configure Local Properties + +We need to tell the application to look at your *local* Redis instead of the cloud one. + +1. Open `src/main/resources/application.properties`. + +2. Ensure the active profile is set to `dev`: + + ```properties + spring.profiles.active=dev + ``` + +----- + +### Step 5: Build the Project + +Now let's download all the dependencies defined in the `pom.xml`. + +1. Open the Command Palette (Ctrl+Shift+P or Cmd+Shift+P). +2. Type **"Maven: Execute Commands"** -\> select the project -\> **"install"**. + * *Alternative:* Open the built-in terminal and run: + ```bash + mvn clean install -DskipTests + ``` + * *Why skip tests?* The tests might try to connect to real cloud services or check specific configs that might fail on the first local run. Let's just get it compiling first. + +----- + +### Step 6: Run the Application + +1. Navigate to `src/main/java/com/example/Orchestrator.java`. +2. You should see a small "Run | Debug" button appear just above the `public static void main` line. +3. Click **Run**. + +**What to watch for in the Console:** + + * You want to see the Spring Boot logo. + * Look for `Started Orchestrator in X seconds`. + * Look for `Netty started on port 8080` (since this is a WebFlux app). + +----- + +### Step 7: Verify it's working + +Since this is an API, let's test the health or a simple endpoint. + +1. The app runs on port **8080** (defined in Dockerfile). +2. The API has Swagger documentation configured. +3. Open your browser and go to: + `http://localhost:8080/webjars/swagger-ui/index.html` . + * *Note:* If Swagger isn't loading, check the console logs for the exact context path. + +### Summary Checklist for you: + + * [ ] Java 21 Installed? + * [ ] Docker running Redis on localhost:6379? + * [ ] `gcloud auth application-default login` run? + * [ ] `application-dev.properties` updated to use `localhost` for Redis? + +### Examples of endpoint call + +### 1\. The Standard Conversation (Dialogflow) + +This is the most common flow. It simulates a user sending a message like "Hola" to the bot. The orchestrator will route this to Dialogflow CX. + +**Request:** + +```bash +curl -X POST http://localhost:8080/api/v1/dialogflow/detect-intent \ +-H "Content-Type: application/json" \ +-d '{ + "mensaje": "Hola, ¿quien eres?", + "usuario": { + "telefono": "5550001234", + "nickname": "DiegoLocal" + }, + "canal": "whatsapp", + "tipo": "INICIO" +}' +``` + +**What to expect:** + + * **Status:** `200 OK` + * **Response:** A JSON object containing `responseText` (the answer from Dialogflow) and `responseId`. + * **Logs:** Check your VS Code terminal. You should see logs like `Initiating detectIntent for session...`. + +----- + +### 2\. The "Smart" Notification Flow (Gemini Router) + +This is the cool part. We will first "push" a notification to the user, and then simulate the user asking a question about it. + +**Step A: Push the Notification** +This tells the system: *"Hey, user 5550001234 just received this alert."* + +```bash +curl -X POST http://localhost:8080/api/v1/dialogflow/notification \ +-H "Content-Type: application/json" \ +-d '{ + "texto": "Tu tarjeta *1234 ha sido bloqueada por seguridad.", + "telefono": "5550001234", + "parametrosOcultos": { + "motivo": "intento_fraude_detectado", + "ubicacion": "CDMX", + "fecha": "Hoy" + } +}' +``` + + * **Check Logs:** You should see `Notification for phone 5550001234 cached`. + +**Step B: User asks a follow-up (The Test)** +Now, ask a question that requires context from that notification. + +```bash +curl -X POST http://localhost:8080/api/v1/dialogflow/detect-intent \ +-H "Content-Type: application/json" \ +-d '{ + "mensaje": "¿Por qué fue bloqueada?", + "usuario": { + "telefono": "5550001234" + }, + "canal": "whatsapp", + "tipo": "CONVERSACION" +}' +``` + + * **What happens internally:** The `MessageEntryFilter` (Gemini) will see the previous notification in the history and classify this as a `NOTIFICATION` follow-up, routing it to the LLM instead of standard Dialogflow. + +----- + +### 3\. Quick Replies (Static Content) + +This tests the `QuickRepliesManagerService`. It fetches a JSON screen definition from your local files (e.g., `home.json`). + +**Request:** + +```bash +curl -X POST http://localhost:8080/api/v1/quick-replies/screen \ +-H "Content-Type: application/json" \ +-d '{ + "usuario": { + "telefono": "5550001234" + }, + "canal": "app", + "tipo": "INICIO", + "pantallaContexto": "pagos" +}' +``` + +**What to expect:** + + * **Response:** A JSON object with a `quick_replies` field containing the title "Home" (loaded from `home.json`). + +----- + +### 4\. Reset Everything (Purge) + +If you want to start fresh (clear the cache and history for "Local"), run this: + +```bash +curl -X DELETE http://localhost:8080/api/v1/data-purge/all +``` + + * **Logs:** You'll see `Starting Redis data purge` and `Starting Firestore data purge`. + +### 5\. Optional testing the llm response with uuid + +```bash +/api/v1/llm/tune-response +{ + "sessionInfo": { + "parameters": { + "uuid": "21270589-184e-4a1a-922d-fb48464211e8" + } + } +} +``` + diff --git a/pom.xml b/pom.xml index 83982fd..aecdb7a 100644 --- a/pom.xml +++ b/pom.xml @@ -166,10 +166,6 @@ com.fasterxml.jackson.datatype jackson-datatype-jsr310 - - com.fasterxml.jackson.module - jackson-module-parameter-names - com.fasterxml.jackson.module jackson-module-parameter-names diff --git a/src/main/java/com/example/controller/QuickRepliesController.java b/src/main/java/com/example/controller/QuickRepliesController.java index 88a0dbb..497afc4 100644 --- a/src/main/java/com/example/controller/QuickRepliesController.java +++ b/src/main/java/com/example/controller/QuickRepliesController.java @@ -5,6 +5,7 @@ package com.example.controller; +import com.example.dto.dialogflow.base.DetectIntentResponseDTO; import com.example.dto.quickreplies.QuickReplyScreenRequestDTO; import com.example.service.quickreplies.QuickRepliesManagerService; import jakarta.validation.Valid; @@ -29,9 +30,8 @@ public class QuickRepliesController { } @PostMapping("/screen") - public Mono> startSessionAndGetReplies(@Valid @RequestBody QuickReplyScreenRequestDTO request) { + public Mono startSessionAndGetReplies(@Valid @RequestBody QuickReplyScreenRequestDTO request) { return quickRepliesManagerService.startQuickReplySession(request) - .map(response -> Map.of("responseId", response.responseId())) .doOnSuccess(response -> logger.info("Successfully processed quick reply request")) .doOnError(error -> logger.error("Error processing quick reply request: {}", error.getMessage(), error)); } diff --git a/src/main/java/com/example/mapper/messagefilter/ConversationContextMapper.java b/src/main/java/com/example/mapper/messagefilter/ConversationContextMapper.java index 70340cc..39448a1 100644 --- a/src/main/java/com/example/mapper/messagefilter/ConversationContextMapper.java +++ b/src/main/java/com/example/mapper/messagefilter/ConversationContextMapper.java @@ -23,6 +23,8 @@ public class ConversationContextMapper { private static final int MAX_HISTORY_BYTES = 50 * 1024; // 50 KB + private static final String NOTIFICATION_TEXT_PARAM = "notification_text"; + public String toText(ConversationSessionDTO session, List messages) { if (messages == null || messages.isEmpty()) { return ""; @@ -75,23 +77,34 @@ public class ConversationContextMapper { private String formatEntry(ConversationMessageDTO entry) { String prefix = "User: "; + String content = entry.text(); if (entry.type() != null) { switch (entry.type()) { - case MessageType.AGENT: + case AGENT: prefix = "Agent: "; break; - case MessageType.SYSTEM: + case SYSTEM: prefix = "System: "; + // fix: add notification in the conversation. + if (entry.parameters() != null && entry.parameters().containsKey(NOTIFICATION_TEXT_PARAM)) { + Object paramText = entry.parameters().get(NOTIFICATION_TEXT_PARAM); + if (paramText != null && !paramText.toString().isBlank()) { + content = paramText.toString(); + } + } break; - case MessageType.USER: + case LLM: + prefix = "System: "; + break; + case USER: default: prefix = "User: "; break; } } - String text = prefix + entry.text(); + String text = prefix + content; if (entry.type() == MessageType.AGENT) { text = cleanAgentMessage(text); diff --git a/src/main/java/com/example/service/base/MessageEntryFilter.java b/src/main/java/com/example/service/base/MessageEntryFilter.java index b84e126..171812b 100644 --- a/src/main/java/com/example/service/base/MessageEntryFilter.java +++ b/src/main/java/com/example/service/base/MessageEntryFilter.java @@ -119,7 +119,6 @@ public class MessageEntryFilter { yield CATEGORY_UNKNOWN; } }; - resultCategory = CATEGORY_CONVERSATION; return resultCategory; } catch (Exception e) { logger.error("An error occurred during Gemini content generation for message classification.", e); diff --git a/src/main/java/com/example/service/conversation/ConversationManagerService.java b/src/main/java/com/example/service/conversation/ConversationManagerService.java index da10103..611f86d 100644 --- a/src/main/java/com/example/service/conversation/ConversationManagerService.java +++ b/src/main/java/com/example/service/conversation/ConversationManagerService.java @@ -161,40 +161,7 @@ public class ConversationManagerService { String userMessageText = request.queryInput().text().text(); final ConversationContext context = new ConversationContext(resolvedUserId, null, userMessageText, primaryPhoneNumber); - return handleMessageClassification(context, request); - } - - private Mono handleMessageClassification(ConversationContext context, - DetectIntentRequestDTO request) { - final String userPhoneNumber = context.primaryPhoneNumber(); - final String userMessageText = context.userMessageText(); - - return memoryStoreConversationService.getSessionByTelefono(userPhoneNumber) - .flatMap(session -> memoryStoreConversationService.getMessages(session.sessionId()).collectList() - .map(conversationContextMapper::toTextFromMessages) - .defaultIfEmpty("") - .flatMap(conversationHistory -> { - return memoryStoreNotificationService.getNotificationIdForPhone(userPhoneNumber) - .flatMap(notificationId -> memoryStoreNotificationService - .getCachedNotificationSession(notificationId)) - .map(notificationSession -> notificationSession.notificaciones().stream() - .filter(notification -> "active".equalsIgnoreCase(notification.status())) - .max(java.util.Comparator.comparing(NotificationDTO::timestampCreacion)) - .orElse(null)) - .filter(Objects::nonNull) - .flatMap((NotificationDTO notification) -> { - String notificationText = notificationContextMapper.toText(notification); - String classification = messageEntryFilter.classifyMessage(userMessageText, - notificationText, conversationHistory); - if (MessageEntryFilter.CATEGORY_NOTIFICATION.equals(classification)) { - return startNotificationConversation(context, request, notification); - } else { - return continueConversationFlow(context, request); - } - }) - .switchIfEmpty(continueConversationFlow(context, request)); - })) - .switchIfEmpty(continueConversationFlow(context, request)); + return continueConversationFlow(context, request); } private Mono continueConversationFlow(ConversationContext context, @@ -357,7 +324,6 @@ public class ConversationManagerService { String resolvedContext = notificationContextResolver.resolveContext(userMessageText, notificationText, conversationHistory, filteredParams.toString(), userId, sessionId, userPhoneNumber); - if (!resolvedContext.trim().toUpperCase().contains(NotificationContextResolver.CATEGORY_DIALOGFLOW)) { String uuid = UUID.randomUUID().toString(); llmResponseTunerService.setValue(uuid, resolvedContext).subscribe(); diff --git a/src/test/java/com/example/mapper/messagefilter/ConversationContextMapperTest.java b/src/test/java/com/example/mapper/messagefilter/ConversationContextMapperTest.java index 4e90cd7..4cce3c8 100644 --- a/src/test/java/com/example/mapper/messagefilter/ConversationContextMapperTest.java +++ b/src/test/java/com/example/mapper/messagefilter/ConversationContextMapperTest.java @@ -7,6 +7,7 @@ import java.lang.reflect.Method; import java.time.Instant; import java.util.ArrayList; import java.util.List; +import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -118,4 +119,30 @@ public class ConversationContextMapperTest { private ConversationMessageDTO createMessage(String text, MessageType type) { return new ConversationMessageDTO(type, Instant.now(), text, null, null); } + + + @Test + public void testToTextFromMessages_SystemNotification_ShouldUseParamText() { + ConversationContextMapper mapper = new ConversationContextMapper(); + + Map params = new java.util.HashMap<>(); + params.put("notification_text", "Tu estado de cuenta está listo"); + + ConversationMessageDTO systemMessage = new ConversationMessageDTO( + MessageType.SYSTEM, + Instant.now(), + "NOTIFICATION", + params, + "whatsapp" + ); + + List messages = new java.util.ArrayList<>(); + messages.add(systemMessage); + + // WHEN + String result = mapper.toTextFromMessages(messages); + System.out.println(result); + // THEN + assertEquals("System: Tu estado de cuenta está listo", result); + } }