UPDATE fix quick replies

This commit is contained in:
PAVEL PALMA
2025-12-16 19:07:49 -06:00
parent a15af59668
commit 1e7829d433
6 changed files with 720 additions and 17 deletions

15
Dockerfile 2 Normal file
View File

@@ -0,0 +1,15 @@
# Java 21.0.6
# 'jammy' refers to Ubuntu 22.04 LTS, which is a stable and widely used base.
# FROM maven:3.9.6-eclipse-temurin-21 AS builder
# FROM quay.ocp.banorte.com/base/openjdk-21:maven_3.8 AS builder
# WORKDIR /app
# COPY pom.xml .
# COPY src ./src
# RUN mvn -B clean install -DskipTests -Dmaven.javadoc.skip=true
# FROM eclipse-temurin:21.0.3_9-jre-jammy
FROM quay.ocp.banorte.com/golden/openjdk-21:latest
# COPY --from=builder /app/target/app-jovenes-service-orchestrator-0.0.1-SNAPSHOT.jar app.jar
COPY target/app-jovenes-service-orchestrator-0.0.1-SNAPSHOT.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

236
README 2.md Normal file
View File

@@ -0,0 +1,236 @@
*Key Versions & Management:*
* *Java Version:* `21`
* *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"
}
}
}
```

241
pom 2.xml Normal file
View File

@@ -0,0 +1,241 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.11</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>app-jovenes-service-orchestrator</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>app-jovenes-service-orchestrator</name>
<description>This serivce handle conversations over Dialogflow and multiple Storage GCP services</description>
<properties>
<java.version>21</java.version>
<spring-cloud-gcp.version>5.4.0</spring-cloud-gcp.version>
<spring-cloud.version>2023.0.0</spring-cloud.version>
<lettuce.version>6.4.0.RELEASE</lettuce.version>
<spring-framework.version>6.1.21</spring-framework.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>com.google.cloud</groupId>
<artifactId>spring-cloud-gcp-dependencies</artifactId>
<version>${spring-cloud-gcp.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>com.google.cloud</groupId>
<artifactId>libraries-bom</artifactId>
<version>26.40.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-bom</artifactId>
<version>2024.0.8</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webflux-ui</artifactId>
<version>2.5.0</version>
<exclusions>
<exclusion>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.google.cloud</groupId>
<artifactId>spring-cloud-gcp-starter-data-firestore</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.google.cloud</groupId>
<artifactId>spring-cloud-gcp-data-firestore</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.google.cloud</groupId>
<artifactId>spring-cloud-gcp-starter-storage</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis-reactive</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.google.cloud</groupId>
<artifactId>google-cloud-dialogflow-cx</artifactId>
</dependency>
<dependency>
<groupId>com.google.genai</groupId>
<artifactId>google-genai</artifactId>
<version>1.14.0</version>
</dependency>
<dependency>
<groupId>com.google.protobuf</groupId>
<artifactId>protobuf-java-util</artifactId>
</dependency>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.module</groupId>
<artifactId>jackson-module-parameter-names</artifactId>
<version>2.19.0</version>
</dependency>
<dependency>
<groupId>com.google.api</groupId>
<artifactId>gax</artifactId>
</dependency>
<dependency>
<groupId>com.google.cloud</groupId>
<artifactId>google-cloud-dlp</artifactId>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-codec-http2</artifactId>
<version>4.1.125.Final</version>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-handler</artifactId>
<version>4.1.125.Final</version>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-common</artifactId>
<version>4.1.125.Final</version>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-codec-http</artifactId>
<version>4.1.125.Final</version>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-codec</artifactId>
<version>4.1.125.Final</version>
</dependency>
<dependency>
<groupId>com.google.protobuf</groupId>
<artifactId>protobuf-java</artifactId>
<version>3.25.5</version>
</dependency>
<dependency>
<groupId>net.minidev</groupId>
<artifactId>json-smart</artifactId>
<version>2.5.2</version>
</dependency>
<dependency>
<groupId>org.xmlunit</groupId>
<artifactId>xmlunit-core</artifactId>
<version>2.10.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.18.0</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@@ -63,6 +63,7 @@ public class ConversationManagerService {
private static final Logger logger = LoggerFactory.getLogger(ConversationManagerService.class);
private static final long SESSION_RESET_THRESHOLD_MINUTES = 30;
private static final long SCREEN_CONTEXT_TIMEOUT_MINUTES = 10; // fix for the quick replies screen
private static final String CONV_HISTORY_PARAM = "conversation_history";
private final ExternalConvRequestMapper externalRequestToDialogflowMapper;
private final DialogflowClientService dialogflowServiceClient;
@@ -123,12 +124,25 @@ public class ConversationManagerService {
externalrequest.pantallaContexto());
return memoryStoreConversationService.getSessionByTelefono(externalrequest.user().telefono())
.flatMap(session -> {
if (session != null && session.pantallaContexto() != null
&& !session.pantallaContexto().isBlank()) {
logger.info(
"Detected 'pantallaContexto' in session. Delegating to QuickRepliesManagerService.");
return quickRepliesManagerService.manageConversation(obfuscatedRequest);
}
boolean isContextStale = false;
if (session.lastModified() != null) {
long minutesSinceLastUpdate = java.time.Duration.between(session.lastModified(), java.time.Instant.now()).toMinutes();
if (minutesSinceLastUpdate > SCREEN_CONTEXT_TIMEOUT_MINUTES) {
isContextStale = true;
}
}
if (session != null && session.pantallaContexto() != null
&& !session.pantallaContexto().isBlank()
&& !isContextStale) {
logger.info("Detected active 'pantallaContexto'. Delegating to QuickRepliesManagerService.");
return quickRepliesManagerService.manageConversation(obfuscatedRequest);
}
// Remove the old QR and continue as normal conversation.
if (isContextStale && session.pantallaContexto() != null) {
logger.info("Detected STALE 'pantallaContexto'. Ignoring and proceeding with normal flow.");
}
return continueManagingConversation(obfuscatedRequest);
})
.switchIfEmpty(continueManagingConversation(obfuscatedRequest));

View File

@@ -22,7 +22,7 @@ import java.util.stream.IntStream;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import com.example.dto.dialogflow.conversation.QueryResultDTO;
import com.example.service.conversation.ConversationManagerService;
import org.springframework.context.annotation.Lazy;
import reactor.core.publisher.Mono;
@@ -109,16 +109,36 @@ public class QuickRepliesManagerService {
userMessagesCount = 0;
}
if (userMessagesCount == 0) { // Is the first user message in the Quick-Replies flow
// This is the second message of the flow. Return the full list.
return persistConversationTurn(session, userEntry)
.then(quickReplyContentService.getQuickReplies(session.pantallaContexto()))
.flatMap(quickReplyDTO -> {
ConversationEntryDTO agentEntry = ConversationEntryDTO
.forAgentWithMessage(quickReplyDTO.toString());
return persistConversationTurn(session, agentEntry)
.thenReturn(new DetectIntentResponseDTO(session.sessionId(), null, quickReplyDTO));
});
if (userMessagesCount == 0) {
// El usuario acaba de responder al menú.
// Validamos si lo que escribió coincide con alguna opción del menú.
return quickReplyContentService.getQuickReplies(session.pantallaContexto())
.flatMap(quickReplyDTO -> {
// Verificamos si el texto del usuario coincide con algún título de las preguntas
boolean esOpcionValida = quickReplyDTO.preguntas().stream()
.anyMatch(p -> p.titulo().equalsIgnoreCase(externalRequest.message().trim()));
if (esOpcionValida) {
// Si coincide, guardamos el mensaje y procesamos la respuesta (lógica existente para respuesta automática)
String respuesta = quickReplyDTO.preguntas().stream()
.filter(p -> p.titulo().equalsIgnoreCase(externalRequest.message().trim()))
.findFirst().get().respuesta();
QueryResultDTO queryResult = new QueryResultDTO(respuesta, null);
DetectIntentResponseDTO response = new DetectIntentResponseDTO(session.sessionId(), queryResult, null);
return persistConversationTurn(session, userEntry) // Guardamos el "input" del usuario
.then(memoryStoreConversationService.updateSession(session.withPantallaContexto(null))) // Limpiamos contexto
.then(persistConversationTurn(session, ConversationEntryDTO.forAgentWithMessage(respuesta))) // Guardamos la respuesta
.thenReturn(response);
} else {
// Si el usuario escribe algo que no es del menu valida y manda a Dialog
logger.info("El input del usuario no coincide con las Quick Replies. Redirigiendo a Dialogflow normal.");
return memoryStoreConversationService.updateSession(session.withPantallaContexto(null))
.then(conversationManagerService.manageConversation(externalRequest));
}
});
} else if (userMessagesCount == 1) { // Is the second user message in the QR flow
// This is the third message of the flow. Filter and end.
return persistConversationTurn(session, userEntry)

View File

@@ -0,0 +1,177 @@
package com.example.service.unit_testing;
import com.example.dto.dialogflow.base.DetectIntentResponseDTO;
import com.example.dto.dialogflow.conversation.*;
import com.example.dto.quickreplies.QuestionDTO;
import com.example.dto.quickreplies.QuickReplyDTO;
import com.example.mapper.conversation.ConversationEntryMapper;
import com.example.service.conversation.ConversationManagerService;
import com.example.service.conversation.FirestoreConversationService;
import com.example.service.conversation.MemoryStoreConversationService;
import com.example.service.quickreplies.QuickRepliesManagerService;
import com.example.service.quickreplies.QuickReplyContentService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import java.time.Instant;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class QuickRepliesManagerServiceTest {
@Mock private MemoryStoreConversationService memoryStoreConversationService;
@Mock private FirestoreConversationService firestoreConversationService;
@Mock private QuickReplyContentService quickReplyContentService;
@Mock private ConversationManagerService conversationManagerService;
@Mock private ConversationEntryMapper conversationEntryMapper;
private QuickRepliesManagerService quickRepliesManagerService;
// Test Data
private final String PHONE = "5555555555";
private final String SESSION_ID = "session-123";
private final String USER_ID = "user_by_phone_5555555555";
private final String CONTEXTO = "pagos";
@BeforeEach
void setUp() {
quickRepliesManagerService = new QuickRepliesManagerService(
conversationManagerService,
memoryStoreConversationService,
firestoreConversationService,
quickReplyContentService,
conversationEntryMapper
);
}
@Test
@DisplayName("manageConversation - Count 0 - NO MATCH: Should clear context and delegate to Dialogflow")
void manageConversation_Count0_NoMatch_ShouldDelegate() {
// 1. SETUP: User typed "Hola", but context "pagos" is active.
ExternalConvRequestDTO request = new ExternalConvRequestDTO("Hola", new UsuarioDTO(PHONE, "Nick"), "whatsapp", ConversationEntryType.CONVERSACION, null);
ConversationSessionDTO session = new ConversationSessionDTO(SESSION_ID, USER_ID, PHONE, Instant.now(), Instant.now(), "last", CONTEXTO);
// Mock Session Retrieval
when(memoryStoreConversationService.getSessionByTelefono(PHONE)).thenReturn(Mono.just(session));
// Mock History: Only the SYSTEM message (The Menu) exists. Count = 0.
ConversationMessageDTO sysMsg = new ConversationMessageDTO(MessageType.SYSTEM, Instant.now(), "Menu...", null, "whatsapp");
when(memoryStoreConversationService.getMessages(SESSION_ID)).thenReturn(Flux.just(sysMsg));
// Mock QR Content: The menu has options, but "Hola" is NOT one of them.
QuestionDTO q1 = new QuestionDTO("Ver Saldo", "desc", "Tu saldo is 10");
QuickReplyDTO qrDto = new QuickReplyDTO("Header", "Body", "Btn", "Section", List.of(q1));
when(quickReplyContentService.getQuickReplies(CONTEXTO)).thenReturn(Mono.just(qrDto));
// Mock Orchestrator Delegation (The expected outcome)
DetectIntentResponseDTO delegatedResponse = new DetectIntentResponseDTO("df-response", new QueryResultDTO("Hola soy Beto", null));
when(memoryStoreConversationService.updateSession(any())).thenReturn(Mono.empty()); // Clearing context
when(conversationManagerService.manageConversation(request)).thenReturn(Mono.just(delegatedResponse));
// 2. EXECUTE
StepVerifier.create(quickRepliesManagerService.manageConversation(request))
.expectNext(delegatedResponse)
.verifyComplete();
// 3. VERIFY
// Ensure we cleared the context
verify(memoryStoreConversationService).updateSession(argThat(s -> s.pantallaContexto() == null));
// Ensure we called the normal conversation manager
verify(conversationManagerService).manageConversation(request);
}
@Test
@DisplayName("manageConversation - Count 0 - MATCH: Should return QR Answer")
void manageConversation_Count0_Match_ShouldAnswer() {
// 1. SETUP: User typed "Ver Saldo" (Valid Option)
ExternalConvRequestDTO request = new ExternalConvRequestDTO("Ver Saldo", new UsuarioDTO(PHONE, "Nick"), "whatsapp", ConversationEntryType.CONVERSACION, null);
ConversationSessionDTO session = new ConversationSessionDTO(SESSION_ID, USER_ID, PHONE, Instant.now(), Instant.now(), "last", CONTEXTO);
when(memoryStoreConversationService.getSessionByTelefono(PHONE)).thenReturn(Mono.just(session));
// Count 0 (Last message was System)
ConversationMessageDTO sysMsg = new ConversationMessageDTO(MessageType.SYSTEM, Instant.now(), "Menu...", null, "whatsapp");
when(memoryStoreConversationService.getMessages(SESSION_ID)).thenReturn(Flux.just(sysMsg));
// Valid Option exists
QuestionDTO q1 = new QuestionDTO("Ver Saldo", "desc", "Tu saldo es 100 pesos");
QuickReplyDTO qrDto = new QuickReplyDTO("Header", "Body", "Btn", "Section", List.of(q1));
when(quickReplyContentService.getQuickReplies(CONTEXTO)).thenReturn(Mono.just(qrDto));
// Mocks for saving the conversation turn
mockPersistence();
when(memoryStoreConversationService.updateSession(any())).thenReturn(Mono.empty());
// 2. EXECUTE
StepVerifier.create(quickRepliesManagerService.manageConversation(request))
.assertNext(response -> {
// Expect the pre-defined answer from the JSON
assert response.queryResult().responseText().equals("Tu saldo es 100 pesos");
})
.verifyComplete();
// Verify we did NOT delegate
verify(conversationManagerService, never()).manageConversation(any());
}
@Test
@DisplayName("manageConversation - Count 1 - NO MATCH: Should save User Msg, Clear, and Delegate")
void manageConversation_Count1_NoMatch_ShouldDelegate() {
// 1. SETUP: User typed "Gracias" (Not an option)
ExternalConvRequestDTO request = new ExternalConvRequestDTO("Gracias", new UsuarioDTO(PHONE, "Nick"), "whatsapp", ConversationEntryType.CONVERSACION, null);
ConversationSessionDTO session = new ConversationSessionDTO(SESSION_ID, USER_ID, PHONE, Instant.now(), Instant.now(), "last", CONTEXTO);
when(memoryStoreConversationService.getSessionByTelefono(PHONE)).thenReturn(Mono.just(session));
// Mock History: System msg -> User msg (Invalid) -> Now this is the 2nd user msg (Count 1 logic)
// Wait, logic says count is messages AFTER last init.
// Sys (Init) -> User("Hola" - ignored previously) -> Current Request("Gracias")
ConversationMessageDTO sysMsg = new ConversationMessageDTO(MessageType.SYSTEM, Instant.now(), "Menu...", null, "whatsapp");
ConversationMessageDTO userMsg1 = new ConversationMessageDTO(MessageType.USER, Instant.now(), "Hola", null, "whatsapp");
when(memoryStoreConversationService.getMessages(SESSION_ID)).thenReturn(Flux.just(sysMsg, userMsg1));
QuestionDTO q1 = new QuestionDTO("Ver Saldo", "desc", "Tu saldo es 10");
QuickReplyDTO qrDto = new QuickReplyDTO("Header", "Body", "Btn", "Section", List.of(q1));
when(quickReplyContentService.getQuickReplies(CONTEXTO)).thenReturn(Mono.just(qrDto));
// Mock persistence for the user message (Manual save in the else block)
mockPersistence();
// Mock Delegation
DetectIntentResponseDTO delegatedResponse = new DetectIntentResponseDTO("df-response", new QueryResultDTO("De nada", null));
when(memoryStoreConversationService.updateSession(any())).thenReturn(Mono.empty());
when(conversationManagerService.manageConversation(request)).thenReturn(Mono.just(delegatedResponse));
// 2. EXECUTE
StepVerifier.create(quickRepliesManagerService.manageConversation(request))
.expectNext(delegatedResponse)
.verifyComplete();
// 3. VERIFY
// Ensure manual save was called (verifying flow of `persistConversationTurn`)
verify(memoryStoreConversationService, times(1)).saveMessage(eq(SESSION_ID), any());
verify(memoryStoreConversationService).updateSession(argThat(s -> s.pantallaContexto() == null));
verify(conversationManagerService).manageConversation(request);
}
// Helper to mock the complex save chain
private void mockPersistence() {
when(conversationEntryMapper.toConversationMessageDTO(any())).thenReturn(new ConversationMessageDTO(MessageType.USER, Instant.now(), "text", null, "wa"));
when(memoryStoreConversationService.saveSession(any())).thenReturn(Mono.empty());
when(memoryStoreConversationService.saveMessage(anyString(), any())).thenReturn(Mono.empty());
when(firestoreConversationService.saveSession(any())).thenReturn(Mono.empty());
when(firestoreConversationService.saveMessage(anyString(), any())).thenReturn(Mono.empty());
}
}