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

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());
}
}