From 404f152097fe2cf61800882e7ac6cec9c9964dc3 Mon Sep 17 00:00:00 2001 From: PAVEL PALMA Date: Thu, 21 Aug 2025 00:24:21 -0600 Subject: [PATCH] UPDATE code 20-Ago --- src/.DS_Store | Bin 0 -> 6148 bytes src/main/java/com/example/Orchestrator.java | 16 - .../java/com/example/config/RedisConfig.java | 15 + .../controller/QuickRepliesController.java | 38 ++ .../base/DetectIntentResponseDTO.java | 13 +- .../conversation/ConversationEntryDTO.java | 16 +- .../conversation/ConversationSessionDTO.java | 19 +- .../conversation/ExternalConvRequestDTO.java | 3 +- .../notification/NotificationDTO.java | 3 +- .../example/dto/quickreplies/QuestionDTO.java | 8 + .../dto/quickreplies/QuickReplyDTO.java | 9 + .../QuickReplyScreenRequestDTO.java | 14 + .../conversation/DialogflowRequestMapper.java | 7 +- .../FirestoreConversationMapper.java | 12 +- .../ConversationContextMapper.java | 68 ++++ .../NotificationContextMapper.java | 33 ++ .../FirestoreNotificationMapper.java | 13 +- .../FirestoreQuickReplyMapper.java | 149 ++++++++ .../repository/FirestoreBaseRepository.java | 221 ++++++----- .../FirestoreNotificationRepository.java | 133 ------- .../base/ConvSessionCloserService.java | 5 - .../ConversationManagerService.java | 355 +++++++++++------- .../FirestoreConversationService.java | 23 +- .../MemoryStoreConversationService.java | 28 +- .../FirestoreNotificationConvService.java | 107 ++++++ .../FirestoreNotificationService.java | 209 +++++++---- .../MemoryStoreNotificationService.java | 68 +++- .../NotificationExpirationListener.java | 37 ++ .../NotificationManagerService.java | 137 ++++--- .../quickreplies/FirestoreQRService.java | 88 +++++ .../quickreplies/MemoryStoreQRService.java | 80 ++++ .../QuickRepliesManagerService.java | 104 +++++ .../QuickReplyContentService.java | 69 ++++ .../example/util/FirestoreDataImporter.java | 80 ++++ src/main/resources/application-dev.properties | 62 +++ .../resources/application-prod.properties | 60 +++ src/main/resources/application-qa.properties | 60 +++ src/main/resources/application.properties | 65 +--- .../resources/quick-replies/capsulas.json | 1 + .../resources/quick-replies/descubre.json | 1 + .../resources/quick-replies/detalle-tdc.json | 1 + .../resources/quick-replies/detalle-tdd.json | 1 + .../resources/quick-replies/finanzas.json | 1 + src/main/resources/quick-replies/home.json | 1 + .../resources/quick-replies/inversiones.json | 1 + src/main/resources/quick-replies/lealtad.json | 1 + src/main/resources/quick-replies/pagos.json | 4 + .../resources/quick-replies/prestamos.json | 1 + .../quick-replies/retiro-sin-tarjeta.json | 1 + .../quick-replies/transferencia.json | 1 + .../MessageEntryFilterIntegrationTest.java | 2 +- .../DialogflowClientServiceTest.java | 2 +- .../GeminiClientServiceTest .java | 0 .../MessageEntryFilterTest.java | 2 +- 54 files changed, 1851 insertions(+), 597 deletions(-) create mode 100644 src/.DS_Store create mode 100644 src/main/java/com/example/controller/QuickRepliesController.java create mode 100644 src/main/java/com/example/dto/quickreplies/QuestionDTO.java create mode 100644 src/main/java/com/example/dto/quickreplies/QuickReplyDTO.java create mode 100644 src/main/java/com/example/dto/quickreplies/QuickReplyScreenRequestDTO.java create mode 100644 src/main/java/com/example/mapper/messagefilter/ConversationContextMapper.java create mode 100644 src/main/java/com/example/mapper/messagefilter/NotificationContextMapper.java create mode 100644 src/main/java/com/example/mapper/quickreplies/FirestoreQuickReplyMapper.java delete mode 100644 src/main/java/com/example/repository/FirestoreNotificationRepository.java delete mode 100644 src/main/java/com/example/service/base/ConvSessionCloserService.java create mode 100644 src/main/java/com/example/service/notification/FirestoreNotificationConvService.java create mode 100644 src/main/java/com/example/service/notification/NotificationExpirationListener.java create mode 100644 src/main/java/com/example/service/quickreplies/FirestoreQRService.java create mode 100644 src/main/java/com/example/service/quickreplies/MemoryStoreQRService.java create mode 100644 src/main/java/com/example/service/quickreplies/QuickRepliesManagerService.java create mode 100644 src/main/java/com/example/service/quickreplies/QuickReplyContentService.java create mode 100644 src/main/java/com/example/util/FirestoreDataImporter.java create mode 100644 src/main/resources/application-dev.properties create mode 100644 src/main/resources/application-prod.properties create mode 100644 src/main/resources/application-qa.properties create mode 100644 src/main/resources/quick-replies/capsulas.json create mode 100644 src/main/resources/quick-replies/descubre.json create mode 100644 src/main/resources/quick-replies/detalle-tdc.json create mode 100644 src/main/resources/quick-replies/detalle-tdd.json create mode 100644 src/main/resources/quick-replies/finanzas.json create mode 100644 src/main/resources/quick-replies/home.json create mode 100644 src/main/resources/quick-replies/inversiones.json create mode 100644 src/main/resources/quick-replies/lealtad.json create mode 100644 src/main/resources/quick-replies/pagos.json create mode 100644 src/main/resources/quick-replies/prestamos.json create mode 100644 src/main/resources/quick-replies/retiro-sin-tarjeta.json create mode 100644 src/main/resources/quick-replies/transferencia.json rename src/test/java/com/example/service/{integration-testing => integration_testing}/MessageEntryFilterIntegrationTest.java (99%) rename src/test/java/com/example/service/{unit-testing => unit_testing}/DialogflowClientServiceTest.java (99%) rename src/test/java/com/example/service/{unit-testing => unit_testing}/GeminiClientServiceTest .java (100%) rename src/test/java/com/example/service/{unit-testing => unit_testing}/MessageEntryFilterTest.java (99%) diff --git a/src/.DS_Store b/src/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..7b5c75f2e0a9c2dff938e1cb0d1b7044fac7b894 GIT binary patch literal 6148 zcmeHK%Sr=55Ukcc1ia+vael!+7()Dl{6GSV2)cqs&wI*u`Ds=^5R#4H#fwx!cg@sx z&#-ma-UeW+_q!Wl31C5Y#KFVd{JHzgE{bt9KjUNbIzGLO54oS^pCi0>z%zDu#CTFa zV2^veVSmD(J4c)pkOERb3P=Gd@H++Av)vX~iHcG{3P^!(1^oNa=#IT`N{mkjLyQ2# zfpi$xF-s7e6U1IPC6b|8mJ+knYQ(TCC*LZs7fy*;4vU+4PTg!Zp;+8bzC}8$Cn`z- zDR8X7Wp0<=|8MC(%>Tzl+DQQ^@UIlGVZB+e_)67VXD{cyw$ZnA&-tXgaUCTL(T<7H ij=Ax6d>u)d*L=Lp5a`I75)X#wHB9j7tt-u#C&KU~; literal 0 HcmV?d00001 diff --git a/src/main/java/com/example/Orchestrator.java b/src/main/java/com/example/Orchestrator.java index e9214d0..6e29e80 100644 --- a/src/main/java/com/example/Orchestrator.java +++ b/src/main/java/com/example/Orchestrator.java @@ -5,29 +5,13 @@ package com.example; -import com.google.api.gax.core.CredentialsProvider; -import com.google.api.gax.core.NoCredentialsProvider; import com.google.cloud.spring.data.firestore.repository.config.EnableReactiveFirestoreRepositories; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.context.annotation.Bean; -import org.springframework.transaction.annotation.EnableTransactionManagement; - @SpringBootApplication -@EnableTransactionManagement @EnableReactiveFirestoreRepositories(basePackages = "com.example.repository") public class Orchestrator { - - @Bean - @ConditionalOnProperty( - value = "spring.cloud.gcp.firestore.emulator.enabled", - havingValue = "true") - public CredentialsProvider googleCredentials() { - return NoCredentialsProvider.create(); - } - public static void main(String[] args) { SpringApplication.run(Orchestrator.class, args); } diff --git a/src/main/java/com/example/config/RedisConfig.java b/src/main/java/com/example/config/RedisConfig.java index ca28bc6..63011af 100644 --- a/src/main/java/com/example/config/RedisConfig.java +++ b/src/main/java/com/example/config/RedisConfig.java @@ -15,8 +15,13 @@ import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.connection.ReactiveRedisConnectionFactory; import org.springframework.data.redis.core.ReactiveRedisTemplate; import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; +import org.springframework.data.redis.listener.RedisMessageListenerContainer; +import org.springframework.data.redis.listener.PatternTopic; import org.springframework.data.redis.serializer.RedisSerializationContext; import org.springframework.data.redis.serializer.StringRedisSerializer; +import com.example.service.notification.NotificationExpirationListener; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.beans.factory.annotation.Autowired; /** * Spring configuration class for setting up Reactive Redis(Memorystore in GCP) @@ -74,4 +79,14 @@ public ReactiveRedisTemplate reactiveNotificatio .build(); return new ReactiveRedisTemplate<>(factory, context); } + +@Bean +public RedisMessageListenerContainer keyExpirationListenerContainer( + @Autowired RedisConnectionFactory connectionFactory, + @Autowired NotificationExpirationListener notificationExpirationListener) { + RedisMessageListenerContainer listenerContainer = new RedisMessageListenerContainer(); + listenerContainer.setConnectionFactory(connectionFactory); + listenerContainer.addMessageListener(notificationExpirationListener, new PatternTopic("__keyevent@*__:expired")); + return listenerContainer; +} } \ No newline at end of file diff --git a/src/main/java/com/example/controller/QuickRepliesController.java b/src/main/java/com/example/controller/QuickRepliesController.java new file mode 100644 index 0000000..88a0dbb --- /dev/null +++ b/src/main/java/com/example/controller/QuickRepliesController.java @@ -0,0 +1,38 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.controller; + +import com.example.dto.quickreplies.QuickReplyScreenRequestDTO; +import com.example.service.quickreplies.QuickRepliesManagerService; +import jakarta.validation.Valid; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Mono; + +@RestController +@RequestMapping("/api/v1/quick-replies") +public class QuickRepliesController { + + private static final Logger logger = LoggerFactory.getLogger(QuickRepliesController.class); + private final QuickRepliesManagerService quickRepliesManagerService; + + public QuickRepliesController(QuickRepliesManagerService quickRepliesManagerService) { + this.quickRepliesManagerService = quickRepliesManagerService; + } + + @PostMapping("/screen") + 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/dto/dialogflow/base/DetectIntentResponseDTO.java b/src/main/java/com/example/dto/dialogflow/base/DetectIntentResponseDTO.java index 9411f6d..ac16a2f 100644 --- a/src/main/java/com/example/dto/dialogflow/base/DetectIntentResponseDTO.java +++ b/src/main/java/com/example/dto/dialogflow/base/DetectIntentResponseDTO.java @@ -8,7 +8,16 @@ package com.example.dto.dialogflow.base; import com.example.dto.dialogflow.conversation.QueryResultDTO; import com.fasterxml.jackson.annotation.JsonProperty; +import com.example.dto.quickreplies.QuickReplyDTO; +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_NULL) public record DetectIntentResponseDTO( @JsonProperty("responseId") String responseId, - @JsonProperty("queryResult") QueryResultDTO queryResult -) {} \ No newline at end of file + @JsonProperty("queryResult") QueryResultDTO queryResult, + @JsonProperty("quick_replies") QuickReplyDTO quickReplies +) { + public DetectIntentResponseDTO(String responseId, QueryResultDTO queryResult) { + this(responseId, queryResult, null); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/dto/dialogflow/conversation/ConversationEntryDTO.java b/src/main/java/com/example/dto/dialogflow/conversation/ConversationEntryDTO.java index 2fedd96..3867c3f 100644 --- a/src/main/java/com/example/dto/dialogflow/conversation/ConversationEntryDTO.java +++ b/src/main/java/com/example/dto/dialogflow/conversation/ConversationEntryDTO.java @@ -30,17 +30,29 @@ public record ConversationEntryDTO( public static ConversationEntryDTO forAgent(QueryResultDTO agentQueryResult) { String fulfillmentText = (agentQueryResult != null && agentQueryResult.responseText() != null) ? agentQueryResult.responseText() : ""; + Map parameters = (agentQueryResult != null) ? agentQueryResult.parameters() : null; return new ConversationEntryDTO( ConversationEntryEntity.AGENTE, ConversationEntryType.CONVERSACION, Instant.now(), fulfillmentText, - agentQueryResult.parameters(), + parameters, null ); } - public static ConversationEntryDTO forSystem(String text) { + + public static ConversationEntryDTO forAgentWithMessage(String text) { + return new ConversationEntryDTO( + ConversationEntryEntity.AGENTE, + ConversationEntryType.CONVERSACION, + Instant.now(), + text, + null, + null + ); + } + public static ConversationEntryDTO forSystem(String text) { return new ConversationEntryDTO( ConversationEntryEntity.SISTEMA, ConversationEntryType.CONVERSACION, diff --git a/src/main/java/com/example/dto/dialogflow/conversation/ConversationSessionDTO.java b/src/main/java/com/example/dto/dialogflow/conversation/ConversationSessionDTO.java index d5cc2dd..d2d9f0d 100644 --- a/src/main/java/com/example/dto/dialogflow/conversation/ConversationSessionDTO.java +++ b/src/main/java/com/example/dto/dialogflow/conversation/ConversationSessionDTO.java @@ -6,44 +6,53 @@ package com.example.dto.dialogflow.conversation; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; import java.time.Instant; import java.util.ArrayList; import java.util.Collections; import java.util.List; @JsonIgnoreProperties(ignoreUnknown = true) +@JsonInclude(Include.NON_NULL) public record ConversationSessionDTO( String sessionId, String userId, String telefono, Instant createdAt, Instant lastModified, - List entries + List entries, + String pantallaContexto ) { - public ConversationSessionDTO(String sessionId, String userId, String telefono, Instant createdAt, Instant lastModified, List entries) { + public ConversationSessionDTO(String sessionId, String userId, String telefono, Instant createdAt, Instant lastModified, List entries, String pantallaContexto) { this.sessionId = sessionId; this.userId = userId; this.telefono = telefono; this.createdAt = createdAt; this.lastModified = lastModified; this.entries = Collections.unmodifiableList(new ArrayList<>(entries)); + this.pantallaContexto = pantallaContexto; } public static ConversationSessionDTO create(String sessionId, String userId, String telefono) { Instant now = Instant.now(); - return new ConversationSessionDTO(sessionId, userId, telefono, now, now, Collections.emptyList()); + return new ConversationSessionDTO(sessionId, userId, telefono, now, now, Collections.emptyList(), null); } public ConversationSessionDTO withAddedEntry(ConversationEntryDTO newEntry) { List updatedEntries = new ArrayList<>(this.entries); updatedEntries.add(newEntry); - return new ConversationSessionDTO(this.sessionId, this.userId, this.telefono, this.createdAt, Instant.now(), updatedEntries); + return new ConversationSessionDTO(this.sessionId, this.userId, this.telefono, this.createdAt, Instant.now(), updatedEntries, this.pantallaContexto); } public ConversationSessionDTO withTelefono(String newTelefono) { if (newTelefono != null && !newTelefono.equals(this.telefono)) { - return new ConversationSessionDTO(this.sessionId, this.userId, newTelefono, this.createdAt, this.lastModified, this.entries); + return new ConversationSessionDTO(this.sessionId, this.userId, newTelefono, this.createdAt, this.lastModified, this.entries, this.pantallaContexto); } return this; } + + public ConversationSessionDTO withPantallaContexto(String pantallaContexto) { + return new ConversationSessionDTO(this.sessionId, this.userId, this.telefono, this.createdAt, this.lastModified, this.entries, pantallaContexto); + } } \ No newline at end of file diff --git a/src/main/java/com/example/dto/dialogflow/conversation/ExternalConvRequestDTO.java b/src/main/java/com/example/dto/dialogflow/conversation/ExternalConvRequestDTO.java index 5b88f92..cece1e1 100644 --- a/src/main/java/com/example/dto/dialogflow/conversation/ExternalConvRequestDTO.java +++ b/src/main/java/com/example/dto/dialogflow/conversation/ExternalConvRequestDTO.java @@ -13,7 +13,8 @@ public record ExternalConvRequestDTO( @JsonProperty("mensaje") String message, @JsonProperty("usuario") UsuarioDTO user, @JsonProperty("canal") String channel, - @JsonProperty("tipo") ConversationEntryType tipo + @JsonProperty("tipo") ConversationEntryType tipo, + @JsonProperty("pantallaContexto") String pantallaContexto //optional field for quick-replies ) { public ExternalConvRequestDTO {} } \ No newline at end of file diff --git a/src/main/java/com/example/dto/dialogflow/notification/NotificationDTO.java b/src/main/java/com/example/dto/dialogflow/notification/NotificationDTO.java index 1aed0fe..7c3cb16 100644 --- a/src/main/java/com/example/dto/dialogflow/notification/NotificationDTO.java +++ b/src/main/java/com/example/dto/dialogflow/notification/NotificationDTO.java @@ -21,7 +21,8 @@ public record NotificationDTO( String texto, // 'texto' original de NotificationRequestDTO (si aplica) String nombreEventoDialogflow, // Nombre del evento enviado a Dialogflow (ej. "tu Estado de cuenta listo") String codigoIdiomaDialogflow, // Código de idioma usado para el evento - Map parametros // Parámetros de sesión finales después del procesamiento de// Dialogflow + Map parametros, // Parámetros de sesión finales después del procesamiento de// Dialogflow + String status ) { public NotificationDTO { Objects.requireNonNull(idNotificacion, "Notification ID cannot be null."); diff --git a/src/main/java/com/example/dto/quickreplies/QuestionDTO.java b/src/main/java/com/example/dto/quickreplies/QuestionDTO.java new file mode 100644 index 0000000..148eba9 --- /dev/null +++ b/src/main/java/com/example/dto/quickreplies/QuestionDTO.java @@ -0,0 +1,8 @@ +package com.example.dto.quickreplies; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record QuestionDTO( + @JsonProperty("titulo") String titulo, + @JsonProperty("descripcion") String descripcion +) {} diff --git a/src/main/java/com/example/dto/quickreplies/QuickReplyDTO.java b/src/main/java/com/example/dto/quickreplies/QuickReplyDTO.java new file mode 100644 index 0000000..465cff4 --- /dev/null +++ b/src/main/java/com/example/dto/quickreplies/QuickReplyDTO.java @@ -0,0 +1,9 @@ +package com.example.dto.quickreplies; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +public record QuickReplyDTO( + @JsonProperty("header") String header, + @JsonProperty("preguntas") List preguntas +) {} diff --git a/src/main/java/com/example/dto/quickreplies/QuickReplyScreenRequestDTO.java b/src/main/java/com/example/dto/quickreplies/QuickReplyScreenRequestDTO.java new file mode 100644 index 0000000..acb0105 --- /dev/null +++ b/src/main/java/com/example/dto/quickreplies/QuickReplyScreenRequestDTO.java @@ -0,0 +1,14 @@ +package com.example.dto.quickreplies; + +import com.example.dto.dialogflow.conversation.ConversationEntryType; +import com.example.dto.dialogflow.conversation.UsuarioDTO; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record QuickReplyScreenRequestDTO( + @JsonProperty("usuario") UsuarioDTO user, + @JsonProperty("canal") String channel, + @JsonProperty("tipo") ConversationEntryType tipo, + @JsonProperty("pantallaContexto") String pantallaContexto +) {} diff --git a/src/main/java/com/example/mapper/conversation/DialogflowRequestMapper.java b/src/main/java/com/example/mapper/conversation/DialogflowRequestMapper.java index d6b4bbf..adceff6 100644 --- a/src/main/java/com/example/mapper/conversation/DialogflowRequestMapper.java +++ b/src/main/java/com/example/mapper/conversation/DialogflowRequestMapper.java @@ -76,15 +76,14 @@ public class DialogflowRequestMapper { for (Map.Entry entry : requestDto.queryParams().parameters().entrySet()) { Value protobufValue = ProtobufUtil.convertJavaObjectToProtobufValue(entry.getValue()); paramsStructBuilder.putFields(entry.getKey(), protobufValue); - logger.info("Added session parameter from DTO queryParams: Key='{}', Value='{}'", entry.getKey(), - entry.getValue()); + logger.debug("Added session parameter from DTO queryParams: Key='{}', Value='{}'", + entry.getKey(),entry.getValue()); } } if (paramsStructBuilder.getFieldsCount() > 0) { queryParametersBuilder.setParameters(paramsStructBuilder.build()); - logger.debug( - "All custom session parameters (including telefono and canal if present) added to Protobuf request builder."); + logger.debug("All custom session parameters added to Protobuf request builder."); } else { logger.debug("No custom session parameters to add to Protobuf request."); } diff --git a/src/main/java/com/example/mapper/conversation/FirestoreConversationMapper.java b/src/main/java/com/example/mapper/conversation/FirestoreConversationMapper.java index 389801d..a9431bd 100644 --- a/src/main/java/com/example/mapper/conversation/FirestoreConversationMapper.java +++ b/src/main/java/com/example/mapper/conversation/FirestoreConversationMapper.java @@ -37,6 +37,7 @@ public class FirestoreConversationMapper { private static final String FIELD_CREATED_AT = "fechaCreacion"; private static final String FIELD_LAST_UPDATED = "ultimaActualizacion"; private static final String FIELD_MESSAGES = "mensajes"; + private static final String FIELD_PANTALLA_CONTEXTO = "pantallaContexto"; // Constants for fields within the 'mensajes' sub-documents private static final String FIELD_MESSAGE_ENTITY = "entidad"; @@ -56,6 +57,10 @@ public class FirestoreConversationMapper { } public Map createNewSessionMapForSingleEntry(String sessionId, String userId, String telefono, ConversationEntryDTO initialEntry) { + return createNewSessionMapForSingleEntry(sessionId, userId, telefono, initialEntry, null); + } + + public Map createNewSessionMapForSingleEntry(String sessionId, String userId, String telefono, ConversationEntryDTO initialEntry, String pantallaContexto) { Map sessionMap = new HashMap<>(); sessionMap.put(FIELD_SESSION_ID, sessionId); sessionMap.put(FIELD_USER_ID, userId); @@ -66,6 +71,10 @@ public class FirestoreConversationMapper { sessionMap.put(FIELD_PHONE_NUMBER, null); } + if (pantallaContexto != null) { + sessionMap.put(FIELD_PANTALLA_CONTEXTO, pantallaContexto); + } + sessionMap.put(FIELD_CREATED_AT, Timestamp.of(java.util.Date.from(Instant.now()))); sessionMap.put(FIELD_LAST_UPDATED, Timestamp.of(java.util.Date.from(Instant.now()))); @@ -100,6 +109,7 @@ public class FirestoreConversationMapper { String sessionId = documentSnapshot.getString(FIELD_SESSION_ID); String userId = documentSnapshot.getString(FIELD_USER_ID); String telefono = documentSnapshot.getString(FIELD_PHONE_NUMBER); + String pantallaContexto = documentSnapshot.getString(FIELD_PANTALLA_CONTEXTO); Timestamp createdAtFirestore = documentSnapshot.getTimestamp(FIELD_CREATED_AT); Instant createdAt = (createdAtFirestore != null) ? createdAtFirestore.toDate().toInstant() : null; @@ -115,7 +125,7 @@ public class FirestoreConversationMapper { .map(this::mapFirestoreEntryMapToConversationEntryDTO) .collect(Collectors.toList()); } - return new ConversationSessionDTO(sessionId, userId, telefono, createdAt, lastModified, entries); + return new ConversationSessionDTO(sessionId, userId, telefono, createdAt, lastModified, entries, pantallaContexto); } diff --git a/src/main/java/com/example/mapper/messagefilter/ConversationContextMapper.java b/src/main/java/com/example/mapper/messagefilter/ConversationContextMapper.java new file mode 100644 index 0000000..e6bd58c --- /dev/null +++ b/src/main/java/com/example/mapper/messagefilter/ConversationContextMapper.java @@ -0,0 +1,68 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.mapper.messagefilter; + +import com.example.dto.dialogflow.conversation.ConversationEntryDTO; +import com.example.dto.dialogflow.conversation.ConversationSessionDTO; +import org.springframework.stereotype.Component; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Collectors; + +@Component +public class ConversationContextMapper { + + private static final int MESSAGE_LIMIT = 60; + private static final int DAYS_LIMIT = 30; + + public String toText(ConversationSessionDTO session) { + if (session == null || session.entries() == null || session.entries().isEmpty()) { + return ""; + } + + return session.entries().stream() + .map(this::formatEntry) + .collect(Collectors.joining("")); + } + + public String toTextWithLimits(ConversationSessionDTO session) { + if (session == null || session.entries() == null || session.entries().isEmpty()) { + return ""; + } + + Instant thirtyDaysAgo = Instant.now().minus(DAYS_LIMIT, ChronoUnit.DAYS); + + List recentEntries = session.entries().stream() + .filter(entry -> entry.timestamp().isAfter(thirtyDaysAgo)) + .sorted(Comparator.comparing(ConversationEntryDTO::timestamp).reversed()) + .limit(MESSAGE_LIMIT) + .sorted(Comparator.comparing(ConversationEntryDTO::timestamp)) + .collect(Collectors.toList()); + + return recentEntries.stream() + .map(this::formatEntry) + .collect(Collectors.joining("")); + } + + private String formatEntry(ConversationEntryDTO entry) { + String prefix = "User: "; + if (entry.entity() != null) { + switch (entry.entity()) { + case AGENTE: + prefix = "Agent: "; + break; + case USUARIO: + default: + prefix = "User: "; + break; + } + } + return prefix + entry.text(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/mapper/messagefilter/NotificationContextMapper.java b/src/main/java/com/example/mapper/messagefilter/NotificationContextMapper.java new file mode 100644 index 0000000..a9c30fb --- /dev/null +++ b/src/main/java/com/example/mapper/messagefilter/NotificationContextMapper.java @@ -0,0 +1,33 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.mapper.messagefilter; + +import com.example.dto.dialogflow.notification.NotificationDTO; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.stream.Collectors; + +@Component +public class NotificationContextMapper { + + public String toText(NotificationDTO notification) { + if (notification == null || notification.texto() == null) { + return ""; + } + return notification.texto(); + } + + public String toText(List notifications) { + if (notifications == null || notifications.isEmpty()) { + return ""; + } + return notifications.stream() + .map(NotificationDTO::texto) + .filter(texto -> texto != null && !texto.isBlank()) + .collect(Collectors.joining("\n")); + } +} diff --git a/src/main/java/com/example/mapper/notification/FirestoreNotificationMapper.java b/src/main/java/com/example/mapper/notification/FirestoreNotificationMapper.java index b4945d0..78479e6 100644 --- a/src/main/java/com/example/mapper/notification/FirestoreNotificationMapper.java +++ b/src/main/java/com/example/mapper/notification/FirestoreNotificationMapper.java @@ -1,4 +1,7 @@ -// src/main/java/com/example/mapper/notification/FirestoreNotificationMapper.java +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ package com.example.mapper.notification; import com.example.dto.dialogflow.notification.NotificationDTO; @@ -22,7 +25,8 @@ public class FirestoreNotificationMapper { private static final String DEFAULT_LANGUAGE_CODE = "es"; private static final String FIXED_EVENT_NAME = "notificacion"; private final ObjectMapper objectMapper; - + private static final String DEFAULT_NOTIFICATION_STATUS="ACTIVE"; + public FirestoreNotificationMapper(ObjectMapper objectMapper) { this.objectMapper = objectMapper; } @@ -31,7 +35,7 @@ public class FirestoreNotificationMapper { String notificationId, String telephone, String notificationText, - Map parameters) { + Map parameters) { Objects.requireNonNull(notificationId, "Notification ID cannot be null for mapping."); Objects.requireNonNull(notificationText, "Notification text cannot be null for mapping."); @@ -44,7 +48,8 @@ public class FirestoreNotificationMapper { notificationText, FIXED_EVENT_NAME, DEFAULT_LANGUAGE_CODE, - parameters + parameters, + DEFAULT_NOTIFICATION_STATUS ); } diff --git a/src/main/java/com/example/mapper/quickreplies/FirestoreQuickReplyMapper.java b/src/main/java/com/example/mapper/quickreplies/FirestoreQuickReplyMapper.java new file mode 100644 index 0000000..f320bf5 --- /dev/null +++ b/src/main/java/com/example/mapper/quickreplies/FirestoreQuickReplyMapper.java @@ -0,0 +1,149 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.mapper.quickreplies; + +import com.example.dto.dialogflow.conversation.ConversationEntryDTO; +import com.example.dto.dialogflow.conversation.ConversationEntryEntity; +import com.example.dto.dialogflow.conversation.ConversationEntryType; +import com.example.dto.dialogflow.conversation.ConversationSessionDTO; +import com.google.cloud.Timestamp; +import com.google.cloud.firestore.FieldValue; +import com.google.cloud.firestore.DocumentSnapshot; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Component +public class FirestoreQuickReplyMapper { + + private static final Logger logger = LoggerFactory.getLogger(FirestoreQuickReplyMapper.class); + + private static final String FIELD_SESSION_ID = "session_id"; + private static final String FIELD_USER_ID = "usuario_id"; + private static final String FIELD_PHONE_NUMBER = "telefono"; + private static final String FIELD_CREATED_AT = "fechaCreacion"; + private static final String FIELD_LAST_UPDATED = "ultimaActualizacion"; + private static final String FIELD_MESSAGES = "mensajes"; + + private static final String FIELD_MESSAGE_ENTITY = "entidad"; + private static final String FIELD_MESSAGE_TYPE = "tipo"; + private static final String FIELD_MESSAGE_TEXT = "mensaje"; + private static final String FIELD_MESSAGE_TIMESTAMP = "tiempo"; + private static final String FIELD_MESSAGE_PARAMETERS = "parametros"; + private static final String FIELD_MESSAGE_CHANNEL = "canal"; + + + public Map createUpdateMapForSingleEntry(ConversationEntryDTO newEntry) { + Map updates = new HashMap<>(); + Map entryMap = toFirestoreEntryMap(newEntry); + updates.put(FIELD_MESSAGES, FieldValue.arrayUnion(entryMap)); + updates.put(FIELD_LAST_UPDATED, Timestamp.of(java.util.Date.from(Instant.now()))); + return updates; + } + + public Map createNewSessionMapForSingleEntry(String sessionId, String userId, String telefono, ConversationEntryDTO initialEntry) { + Map sessionMap = new HashMap<>(); + sessionMap.put(FIELD_SESSION_ID, sessionId); + sessionMap.put(FIELD_USER_ID, userId); + + if (telefono != null && !telefono.trim().isEmpty()) { + sessionMap.put(FIELD_PHONE_NUMBER, telefono); + } else { + sessionMap.put(FIELD_PHONE_NUMBER, null); + } + + sessionMap.put(FIELD_CREATED_AT, Timestamp.of(java.util.Date.from(Instant.now()))); + sessionMap.put(FIELD_LAST_UPDATED, Timestamp.of(java.util.Date.from(Instant.now()))); + + List> entriesList = new ArrayList<>(); + entriesList.add(toFirestoreEntryMap(initialEntry)); + sessionMap.put(FIELD_MESSAGES, entriesList); + + return sessionMap; + } + + private Map toFirestoreEntryMap(ConversationEntryDTO entry) { + Map entryMap = new HashMap<>(); + entryMap.put(FIELD_MESSAGE_ENTITY, entry.entity().name()); + entryMap.put(FIELD_MESSAGE_TYPE, entry.type().name()); + entryMap.put(FIELD_MESSAGE_TEXT, entry.text()); + entryMap.put(FIELD_MESSAGE_TIMESTAMP, Timestamp.of(java.util.Date.from(entry.timestamp()))); + + if (entry.parameters() != null && !entry.parameters().isEmpty()) { + entryMap.put(FIELD_MESSAGE_PARAMETERS, entry.parameters()); + } + if (entry.canal() != null) { + entryMap.put(FIELD_MESSAGE_CHANNEL, entry.canal()); + } + return entryMap; + } + + public ConversationSessionDTO mapFirestoreDocumentToConversationSessionDTO(DocumentSnapshot documentSnapshot) { + if (!documentSnapshot.exists()) { + return null; + } + + String sessionId = documentSnapshot.getString(FIELD_SESSION_ID); + String userId = documentSnapshot.getString(FIELD_USER_ID); + String telefono = documentSnapshot.getString(FIELD_PHONE_NUMBER); + + Timestamp createdAtFirestore = documentSnapshot.getTimestamp(FIELD_CREATED_AT); + Instant createdAt = (createdAtFirestore != null) ? createdAtFirestore.toDate().toInstant() : null; + + Timestamp lastModifiedFirestore = documentSnapshot.getTimestamp(FIELD_LAST_UPDATED); + Instant lastModified = (lastModifiedFirestore != null) ? lastModifiedFirestore.toDate().toInstant() : null; + + List> rawEntries = (List>) documentSnapshot.get(FIELD_MESSAGES); + + List entries = new ArrayList<>(); + if (rawEntries != null) { + entries = rawEntries.stream() + .map(this::mapFirestoreEntryMapToConversationEntryDTO) + .collect(Collectors.toList()); + } + return new ConversationSessionDTO(sessionId, userId, telefono, createdAt, lastModified, entries, null); + } + + +private ConversationEntryDTO mapFirestoreEntryMapToConversationEntryDTO(Map entryMap) { + ConversationEntryEntity entity = null; + Object entityObj = entryMap.get(FIELD_MESSAGE_ENTITY); + if (entityObj instanceof String) { + try { + entity = ConversationEntryEntity.valueOf((String) entityObj); + } catch (IllegalArgumentException e) { + logger.warn("Unknown ConversationEntryEntity encountered: {}. Setting entity to null.", entityObj); + } + } + + ConversationEntryType type = null; + Object typeObj = entryMap.get(FIELD_MESSAGE_TYPE); + if (typeObj instanceof String) { + try { + type = ConversationEntryType.valueOf((String) typeObj); + } catch (IllegalArgumentException e) { + logger.warn("Unknown ConversationEntryType encountered: {}. Setting type to null.", typeObj); + } + } + + String text = (String) entryMap.get(FIELD_MESSAGE_TEXT); + + Timestamp timestampFirestore = (Timestamp) entryMap.get(FIELD_MESSAGE_TIMESTAMP); + Instant timestamp = (timestampFirestore != null) ? timestampFirestore.toDate().toInstant() : null; + + Map parameters = (Map) entryMap.get(FIELD_MESSAGE_PARAMETERS); + String canal = (String) entryMap.get(FIELD_MESSAGE_CHANNEL); + + return new ConversationEntryDTO(entity, type, timestamp, text, parameters, canal); + } +} diff --git a/src/main/java/com/example/repository/FirestoreBaseRepository.java b/src/main/java/com/example/repository/FirestoreBaseRepository.java index 530af0a..4e05b4a 100644 --- a/src/main/java/com/example/repository/FirestoreBaseRepository.java +++ b/src/main/java/com/example/repository/FirestoreBaseRepository.java @@ -5,6 +5,8 @@ package com.example.repository; +import com.example.util.FirestoreTimestampDeserializer; +import com.example.util.FirestoreTimestampSerializer; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.databind.module.SimpleModule; @@ -14,129 +16,156 @@ import com.google.api.core.ApiFuture; import com.google.cloud.firestore.DocumentReference; import com.google.cloud.firestore.DocumentSnapshot; import com.google.cloud.firestore.Firestore; +import com.google.cloud.firestore.Query; +import com.google.cloud.firestore.QuerySnapshot; import com.google.cloud.firestore.WriteBatch; import com.google.cloud.firestore.WriteResult; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ExecutionException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Repository; - -import java.util.Map; -import java.util.Objects; -import java.util.concurrent.ExecutionException; - -import com.example.util.FirestoreTimestampDeserializer; -import com.example.util.FirestoreTimestampSerializer; +import reactor.core.publisher.Mono; /** - * A base repository for performing low-level operations with Firestore. - * It provides a generic interface for common data access tasks such as - * getting document references, performing reads, writes, and batched updates. - * This class also handles the serialization and deserialization of Java objects - * to and from Firestore documents using an `ObjectMapper`. + * A base repository for performing low-level operations with Firestore. It provides a generic + * interface for common data access tasks such as getting document references, performing reads, + * writes, and batched updates. This class also handles the serialization and deserialization of + * Java objects to and from Firestore documents using an `ObjectMapper`. */ @Repository public class FirestoreBaseRepository { - private static final Logger logger = LoggerFactory.getLogger(FirestoreBaseRepository.class); + private static final Logger logger = LoggerFactory.getLogger(FirestoreBaseRepository.class); - private final Firestore firestore; - private final ObjectMapper objectMapper; + private final Firestore firestore; + private final ObjectMapper objectMapper; - @Value("${app.id:default-app-id}") - private String appId; + @Value("${app.id:default-app-id}") + private String appId; - public FirestoreBaseRepository(Firestore firestore, ObjectMapper objectMapper) { - this.firestore = firestore; - this.objectMapper = objectMapper; + public FirestoreBaseRepository(Firestore firestore, ObjectMapper objectMapper) { + this.firestore = firestore; + this.objectMapper = objectMapper; - // Register JavaTimeModule for standard java.time handling - if (!ObjectMapper.findModules().stream().anyMatch(m -> m instanceof JavaTimeModule)) { - objectMapper.registerModule(new JavaTimeModule()); - } - objectMapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); + // Register JavaTimeModule for standard java.time handling + if (!ObjectMapper.findModules().stream().anyMatch(m -> m instanceof JavaTimeModule)) { + objectMapper.registerModule(new JavaTimeModule()); + } + objectMapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); - // Register ParameterNamesModule, crucial for Java Records and classes compiled with -parameters - if (!ObjectMapper.findModules().stream().anyMatch(m -> m instanceof ParameterNamesModule)) { - objectMapper.registerModule(new ParameterNamesModule()); - } - - // These specific Timestamp (Google Cloud) deserializers/serializers are for ObjectMapper - // to handle com.google.cloud.Timestamp objects when mapping other types. - // They are generally not the cause of the Redis deserialization error for Instant. - - SimpleModule firestoreTimestampModule = new SimpleModule(); - firestoreTimestampModule.addDeserializer(com.google.cloud.Timestamp.class, new FirestoreTimestampDeserializer()); - firestoreTimestampModule.addSerializer(com.google.cloud.Timestamp.class, new FirestoreTimestampSerializer()); - objectMapper.registerModule(firestoreTimestampModule); - - logger.info("FirestoreBaseRepository initialized with Firestore client and ObjectMapper. App ID will be: {}", appId); + // Register ParameterNamesModule, crucial for Java Records and classes compiled with -parameters + if (!ObjectMapper.findModules().stream().anyMatch(m -> m instanceof ParameterNamesModule)) { + objectMapper.registerModule(new ParameterNamesModule()); } - public DocumentReference getDocumentReference(String collectionPath, String documentId) { - Objects.requireNonNull(collectionPath, "Collection path cannot be null."); - Objects.requireNonNull(documentId, "Document ID cannot be null."); - return firestore.collection(collectionPath).document(documentId); - } + // These specific Timestamp (Google Cloud) deserializers/serializers are for ObjectMapper + // to handle com.google.cloud.Timestamp objects when mapping other types. + // They are generally not the cause of the Redis deserialization error for Instant. - public T getDocument(DocumentReference docRef, Class clazz) throws InterruptedException, ExecutionException { - Objects.requireNonNull(docRef, "DocumentReference cannot be null."); - Objects.requireNonNull(clazz, "Class for mapping cannot be null."); - ApiFuture future = docRef.get(); - DocumentSnapshot document = future.get(); - if (document.exists()) { - try { - logger.debug("FirestoreBaseRepository: Raw document data for {}: {}", docRef.getPath(), document.getData()); - T result = objectMapper.convertValue(document.getData(), clazz); - return result; - } catch (IllegalArgumentException e) { - logger.error("Failed to convert Firestore document data to {}: {}", clazz.getName(), e.getMessage(), e); - throw new RuntimeException("Failed to convert Firestore document data to " + clazz.getName(), e); - } - } - return null; - } + SimpleModule firestoreTimestampModule = new SimpleModule(); + firestoreTimestampModule.addDeserializer( + com.google.cloud.Timestamp.class, new FirestoreTimestampDeserializer()); + firestoreTimestampModule.addSerializer( + com.google.cloud.Timestamp.class, new FirestoreTimestampSerializer()); + objectMapper.registerModule(firestoreTimestampModule); - public DocumentSnapshot getDocumentSnapshot(DocumentReference docRef) throws ExecutionException, InterruptedException { - Objects.requireNonNull(docRef, "DocumentReference cannot be null."); - ApiFuture future = docRef.get(); - return future.get(); - } + logger.info( + "FirestoreBaseRepository initialized with Firestore client and ObjectMapper. App ID will be: {}", + appId); + } - public boolean documentExists(DocumentReference docRef) throws InterruptedException, ExecutionException { - Objects.requireNonNull(docRef, "DocumentReference cannot be null."); - ApiFuture future = docRef.get(); - return future.get().exists(); - } + public DocumentReference getDocumentReference(String collectionPath, String documentId) { + Objects.requireNonNull(collectionPath, "Collection path cannot be null."); + Objects.requireNonNull(documentId, "Document ID cannot be null."); + return firestore.collection(collectionPath).document(documentId); + } - public void setDocument(DocumentReference docRef, Object data) throws InterruptedException, ExecutionException { - Objects.requireNonNull(docRef, "DocumentReference cannot be null."); - Objects.requireNonNull(data, "Data for setting document cannot be null."); - ApiFuture future = docRef.set(data); - WriteResult writeResult = future.get(); - logger.debug("Document set: {} with update time: {}", docRef.getPath(), writeResult.getUpdateTime()); + public T getDocument(DocumentReference docRef, Class clazz) + throws InterruptedException, ExecutionException { + Objects.requireNonNull(docRef, "DocumentReference cannot be null."); + Objects.requireNonNull(clazz, "Class for mapping cannot be null."); + ApiFuture future = docRef.get(); + DocumentSnapshot document = future.get(); + if (document.exists()) { + try { + logger.debug( + "FirestoreBaseRepository: Raw document data for {}: {}", + docRef.getPath(), + document.getData()); + T result = objectMapper.convertValue(document.getData(), clazz); + return result; + } catch (IllegalArgumentException e) { + logger.error( + "Failed to convert Firestore document data to {}: {}", clazz.getName(), e.getMessage(), e); + throw new RuntimeException( + "Failed to convert Firestore document data to " + clazz.getName(), e); + } } + return null; + } - public void updateDocument(DocumentReference docRef, Map updates) throws InterruptedException, ExecutionException { - Objects.requireNonNull(docRef, "DocumentReference cannot be null."); - Objects.requireNonNull(updates, "Updates map cannot be null."); - ApiFuture future = docRef.update(updates); - WriteResult writeResult = future.get(); - logger.debug("Document updated: {} with update time: {}", docRef.getPath(), writeResult.getUpdateTime()); - } + public DocumentSnapshot getDocumentSnapshot(DocumentReference docRef) + throws ExecutionException, InterruptedException { + Objects.requireNonNull(docRef, "DocumentReference cannot be null."); + ApiFuture future = docRef.get(); + return future.get(); + } - public WriteBatch createBatch() { - return firestore.batch(); - } + public Mono getDocumentsByField( + String collectionPath, String fieldName, String value) { + return Mono.fromCallable( + () -> { + Query query = firestore.collection(collectionPath).whereEqualTo(fieldName, value); + ApiFuture future = query.get(); + QuerySnapshot querySnapshot = future.get(); + if (!querySnapshot.isEmpty()) { + return querySnapshot.getDocuments().get(0); + } + return null; + }); + } - public void commitBatch(WriteBatch batch) throws InterruptedException, ExecutionException { - Objects.requireNonNull(batch, "WriteBatch cannot be null."); - batch.commit().get(); - logger.debug("Batch committed successfully."); - } + public boolean documentExists(DocumentReference docRef) + throws InterruptedException, ExecutionException { + Objects.requireNonNull(docRef, "DocumentReference cannot be null."); + ApiFuture future = docRef.get(); + return future.get().exists(); + } - public String getAppId() { - return appId; - } + public void setDocument(DocumentReference docRef, Object data) + throws InterruptedException, ExecutionException { + Objects.requireNonNull(docRef, "DocumentReference cannot be null."); + Objects.requireNonNull(data, "Data for setting document cannot be null."); + ApiFuture future = docRef.set(data); + WriteResult writeResult = future.get(); + logger.debug( + "Document set: {} with update time: {}", docRef.getPath(), writeResult.getUpdateTime()); + } + public void updateDocument(DocumentReference docRef, Map updates) + throws InterruptedException, ExecutionException { + Objects.requireNonNull(docRef, "DocumentReference cannot be null."); + Objects.requireNonNull(updates, "Updates map cannot be null."); + ApiFuture future = docRef.update(updates); + WriteResult writeResult = future.get(); + logger.debug( + "Document updated: {} with update time: {}", docRef.getPath(), writeResult.getUpdateTime()); + } + + public WriteBatch createBatch() { + return firestore.batch(); + } + + public void commitBatch(WriteBatch batch) throws InterruptedException, ExecutionException { + Objects.requireNonNull(batch, "WriteBatch cannot be null."); + batch.commit().get(); + logger.debug("Batch committed successfully."); + } + + public String getAppId() { + return appId; + } } \ No newline at end of file diff --git a/src/main/java/com/example/repository/FirestoreNotificationRepository.java b/src/main/java/com/example/repository/FirestoreNotificationRepository.java deleted file mode 100644 index e4c9228..0000000 --- a/src/main/java/com/example/repository/FirestoreNotificationRepository.java +++ /dev/null @@ -1,133 +0,0 @@ -/* - * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. - * Your use of it is subject to your agreement with Google. - */ - -package com.example.repository; - -import com.example.dto.dialogflow.notification.NotificationDTO; -import com.example.util.FirestoreTimestampDeserializer; -import com.example.util.FirestoreTimestampSerializer; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.SerializationFeature; -import com.fasterxml.jackson.databind.module.SimpleModule; -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; -import com.fasterxml.jackson.module.paramnames.ParameterNamesModule; - -import com.google.cloud.firestore.Firestore; -import com.google.cloud.firestore.DocumentReference; -import com.google.cloud.firestore.DocumentSnapshot; -import com.google.cloud.firestore.SetOptions; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Repository; -import reactor.core.publisher.Mono; -import reactor.core.scheduler.Schedulers; - -import java.util.Objects; -import java.util.concurrent.Callable; - -/** - * Repository for managing `NotificationDTO` objects in Firestore. - * It provides reactive methods for saving and retrieving notification data, - * handling the serialization and deserialization of the DTOs and managing - * the document paths within a structured collection. - */ -@Repository -public class FirestoreNotificationRepository { - - private static final Logger logger = LoggerFactory.getLogger(FirestoreNotificationRepository.class); - - private final Firestore firestore; - private final ObjectMapper objectMapper; - - @Value("${app.id:default-app-id}") - private String appId; - - private final String BASE_COLLECTION_PATH_FORMAT = "artifacts/%s/notifications"; - - public FirestoreNotificationRepository(Firestore firestore, ObjectMapper objectMapper) { - this.firestore = firestore; - this.objectMapper = objectMapper; - - // Ensure ObjectMapper is configured for Java Time and Records, and Firestore - // Timestamps - if (!ObjectMapper.findModules().stream().anyMatch(m -> m instanceof JavaTimeModule)) { - objectMapper.registerModule(new JavaTimeModule()); - } - objectMapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); - - if (!ObjectMapper.findModules().stream().anyMatch(m -> m instanceof ParameterNamesModule)) { - objectMapper.registerModule(new ParameterNamesModule()); - } - - // These specific Timestamp (Google Cloud) deserializers/serializers are for ObjectMapper - // to handle com.google.cloud.Timestamp objects when mapping other types. - // They are generally not the cause of the Redis deserialization error for Instant. - - SimpleModule firestoreTimestampModule = new SimpleModule(); - firestoreTimestampModule.addDeserializer(com.google.cloud.Timestamp.class, - new FirestoreTimestampDeserializer()); - firestoreTimestampModule.addSerializer(com.google.cloud.Timestamp.class, new FirestoreTimestampSerializer()); - objectMapper.registerModule(firestoreTimestampModule); - - logger.info("FirestoreNotificationRepository initialized for project: {}", appId); - } - - private DocumentReference getDocumentReference(String notificationId) { - String collectionPath = String.format(BASE_COLLECTION_PATH_FORMAT, appId); - return firestore.collection(collectionPath).document(notificationId); - } - - public Mono saveNotification(NotificationDTO notification) { - Objects.requireNonNull(notification.idNotificacion(), "Notification ID cannot be null."); - - return Mono.fromCallable((Callable) () -> { - DocumentReference docRef = getDocumentReference(notification.idNotificacion()); - logger.debug("Attempting to save notification with ID {} to Firestore path: {}", - notification.idNotificacion(), docRef.getPath()); - - docRef.set(notification, SetOptions.merge()).get(); - logger.info("Notification with ID {} successfully saved to Firestore.", notification.idNotificacion()); - return null; - }).subscribeOn(Schedulers.boundedElastic()) - .doOnError(e -> logger.error("Failed to save notification with ID {} to Firestore: {}", - notification.idNotificacion(), e.getMessage(), e)); - } - - - public Mono getNotification(String notificationId) { // Renamed method - Objects.requireNonNull(notificationId, "Notification ID cannot be null for retrieval."); - - return Mono.fromCallable((Callable) () -> { - DocumentReference docRef = getDocumentReference(notificationId); - - logger.debug("Attempting to retrieve notification with ID {} from Firestore path: {}", notificationId, - docRef.getPath()); - - DocumentSnapshot document = docRef.get().get(); // Blocking call - if (document.exists()) { - try { - NotificationDTO notification = objectMapper.convertValue(document.getData(), NotificationDTO.class); - logger.info("Notification with ID {} successfully retrieved from Firestore.", notificationId); - return notification; - } catch (IllegalArgumentException e) { - logger.error( - "Failed to convert Firestore document data to Notification for ID {}: {}", - notificationId, e.getMessage(), e); - throw new RuntimeException( - "Failed to convert Firestore document data to Notification for ID " - + notificationId, - e); - } - } else { - logger.debug("Notification with ID {} not found in Firestore.", notificationId); - return null; - } - }).subscribeOn(Schedulers.boundedElastic()) - .doOnError(e -> logger.error("Failed to retrieve notification with ID {} from Firestore: {}", - notificationId, e.getMessage(), e)); - } -} \ No newline at end of file diff --git a/src/main/java/com/example/service/base/ConvSessionCloserService.java b/src/main/java/com/example/service/base/ConvSessionCloserService.java deleted file mode 100644 index 36851f1..0000000 --- a/src/main/java/com/example/service/base/ConvSessionCloserService.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.example.service.base; - -public class ConvSessionCloserService { - -} diff --git a/src/main/java/com/example/service/conversation/ConversationManagerService.java b/src/main/java/com/example/service/conversation/ConversationManagerService.java index e8b0e5e..0d8e43a 100644 --- a/src/main/java/com/example/service/conversation/ConversationManagerService.java +++ b/src/main/java/com/example/service/conversation/ConversationManagerService.java @@ -5,193 +5,290 @@ package com.example.service.conversation; -import com.example.mapper.conversation.ExternalConvRequestMapper; -import com.example.service.base.DialogflowClientService; -import com.example.util.SessionIdGenerator; import com.example.dto.dialogflow.base.DetectIntentRequestDTO; import com.example.dto.dialogflow.base.DetectIntentResponseDTO; import com.example.dto.dialogflow.conversation.ConversationContext; import com.example.dto.dialogflow.conversation.ConversationEntryDTO; +import com.example.dto.dialogflow.conversation.ConversationEntryEntity; +import com.example.dto.dialogflow.conversation.ConversationEntryType; import com.example.dto.dialogflow.conversation.ConversationSessionDTO; import com.example.dto.dialogflow.conversation.ExternalConvRequestDTO; - +import com.example.dto.dialogflow.notification.NotificationDTO; +import com.example.mapper.conversation.ExternalConvRequestMapper; +import com.example.mapper.messagefilter.ConversationContextMapper; +import com.example.mapper.messagefilter.NotificationContextMapper; +import com.example.service.base.DialogflowClientService; +import com.example.service.base.MessageEntryFilter; +import com.example.service.notification.MemoryStoreNotificationService; +import com.example.service.quickreplies.QuickRepliesManagerService; +import com.example.util.SessionIdGenerator; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import reactor.core.publisher.Mono; -import reactor.core.scheduler.Schedulers; import java.time.Duration; import java.time.Instant; import java.util.Collections; import java.util.Map; +import java.util.Objects; import java.util.Optional; -/** - * Service for orchestrating the end-to-end conversation flow. - * It manages user sessions, creating new ones or reusing existing ones - * based on a session reset threshold. The service handles the entire - * conversation turn, from mapping an external request to calling Dialogflow, - * and then persists both user and agent messages using a write-back strategy - * to a primary cache (Redis) and an asynchronous write to Firestore. - */ @Service public class ConversationManagerService { + private static final Logger logger = LoggerFactory.getLogger(ConversationManagerService.class); + private static final long SESSION_RESET_THRESHOLD_HOURS = 24; + private static final String CURRENT_PAGE_PARAM = "currentPage"; - private static final Logger logger = LoggerFactory.getLogger(ConversationManagerService.class); + private final ExternalConvRequestMapper externalRequestToDialogflowMapper; + private final DialogflowClientService dialogflowServiceClient; + private final FirestoreConversationService firestoreConversationService; + private final MemoryStoreConversationService memoryStoreConversationService; + private final QuickRepliesManagerService quickRepliesManagerService; + private final MessageEntryFilter messageEntryFilter; + private final MemoryStoreNotificationService memoryStoreNotificationService; + private final NotificationContextMapper notificationContextMapper; + private final ConversationContextMapper conversationContextMapper; - private static final long SESSION_RESET_THRESHOLD_HOURS = 24; - private static final String CURRENT_PAGE_PARAM = "currentPage"; - private final ExternalConvRequestMapper externalRequestToDialogflowMapper; - - private final DialogflowClientService dialogflowServiceClient; - private final FirestoreConversationService firestoreConversationService; - private final MemoryStoreConversationService memoryStoreConversationService; - - public ConversationManagerService( - DialogflowClientService dialogflowServiceClient, - FirestoreConversationService firestoreConversationService, - MemoryStoreConversationService memoryStoreConversationService, - ExternalConvRequestMapper externalRequestToDialogflowMapper) { + public ConversationManagerService( + DialogflowClientService dialogflowServiceClient, + FirestoreConversationService firestoreConversationService, + MemoryStoreConversationService memoryStoreConversationService, + ExternalConvRequestMapper externalRequestToDialogflowMapper, + QuickRepliesManagerService quickRepliesManagerService, + MessageEntryFilter messageEntryFilter, + MemoryStoreNotificationService memoryStoreNotificationService, + NotificationContextMapper notificationContextMapper, + ConversationContextMapper conversationContextMapper) { this.dialogflowServiceClient = dialogflowServiceClient; this.firestoreConversationService = firestoreConversationService; this.memoryStoreConversationService = memoryStoreConversationService; this.externalRequestToDialogflowMapper = externalRequestToDialogflowMapper; - } + this.quickRepliesManagerService = quickRepliesManagerService; + this.messageEntryFilter = messageEntryFilter; + this.memoryStoreNotificationService = memoryStoreNotificationService; + this.notificationContextMapper = notificationContextMapper; + this.conversationContextMapper = conversationContextMapper; +} - public MonomanageConversation(ExternalConvRequestDTO Externalrequest) { - final DetectIntentRequestDTO request; - try { - request = externalRequestToDialogflowMapper.mapExternalRequestToDetectIntentRequest(Externalrequest); - logger.debug("Successfully pre-mapped ExternalRequestDTO to DetectIntentRequestDTO"); - } catch (IllegalArgumentException e) { - logger.error("Error during pre-mapping: {}", e.getMessage()); - return Mono.error(new IllegalArgumentException("Failed to process external request due to mapping error: " + e.getMessage(), e)); + public Mono manageConversation(ExternalConvRequestDTO externalrequest) { + return memoryStoreConversationService.getSessionByTelefono(externalrequest.user().telefono()) + .flatMap(session -> { + if (session != null && !session.entries().isEmpty()) { + ConversationEntryDTO lastEntry = session.entries().get(session.entries().size() - 1); + if (lastEntry.entity() == ConversationEntryEntity.SISTEMA && lastEntry.type() == ConversationEntryType.INICIO) { + logger.info("Detected 'SISTEMA' and 'INICIO' values in last session entry. Delegating to QuickRepliesManagerService."); + ExternalConvRequestDTO updatedRequest = new ExternalConvRequestDTO( + externalrequest.message(), + externalrequest.user(), + externalrequest.channel(), + externalrequest.tipo(), + session.pantallaContexto() + ); + return quickRepliesManagerService.manageConversation(updatedRequest); + } + } + return continueManagingConversation(externalrequest); + }) + .switchIfEmpty(continueManagingConversation(externalrequest)); } - final ConversationContext context; - try { - context = resolveAndValidateRequest(request); - } catch (IllegalArgumentException e) { - logger.error("Validation error for incoming request: {}", e.getMessage()); - return Mono.error(e); + private Mono continueManagingConversation(ExternalConvRequestDTO externalrequest) { + final DetectIntentRequestDTO request; + try { + request = externalRequestToDialogflowMapper.mapExternalRequestToDetectIntentRequest(externalrequest); + logger.debug("Successfully pre-mapped ExternalRequestDTO to DetectIntentRequestDTO"); + } catch (IllegalArgumentException e) { + logger.error("Error during pre-mapping: {}", e.getMessage()); + return Mono.error(new IllegalArgumentException( + "Failed to process external request due to mapping error: " + e.getMessage(), e)); + } + + final ConversationContext context; + try { + context = resolveAndValidateRequest(request); + } catch (IllegalArgumentException e) { + logger.error("Validation error for incoming request: {}", e.getMessage()); + return Mono.error(e); + } + + return handleMessageClassification(context, request); } - + + private Mono handleMessageClassification(ConversationContext context, DetectIntentRequestDTO request) { + final String userPhoneNumber = context.primaryPhoneNumber(); + final String userMessageText = context.userMessageText(); + + 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); + return memoryStoreConversationService.getSessionByTelefono(userPhoneNumber) + .map(conversationContextMapper::toText) + .defaultIfEmpty("") + .flatMap(conversationHistory -> { + 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)); +} + + private Mono continueConversationFlow(ConversationContext context, DetectIntentRequestDTO request) { + final String userId = context.userId(); + final String userMessageText = context.userMessageText(); + final String userPhoneNumber = context.primaryPhoneNumber(); + + if (userPhoneNumber == null || userPhoneNumber.isBlank()) { + logger.warn("No phone number provided in request. Cannot manage conversation session without it."); + return Mono.error(new IllegalArgumentException("Phone number is required to manage conversation sessions.")); + } + + logger.info("Primary Check (MemoryStore): Looking up session for phone number: {}", userPhoneNumber); + return memoryStoreConversationService.getSessionByTelefono(userPhoneNumber) + .flatMap(session -> { + Instant now = Instant.now(); + if (Duration.between(session.lastModified(), now).toHours() < SESSION_RESET_THRESHOLD_HOURS) { + logger.info("Recent Session Found: Session {} is within the 24-hour threshold. Proceeding to Dialogflow.", session.sessionId()); + return processDialogflowRequest(session, request, userId, userMessageText, userPhoneNumber, false); + } else { + logger.info("Old Session Found: Session {} is older than the threshold. Proceeding to full lookup.", session.sessionId()); + return fullLookupAndProcess(session, request, userId, userMessageText, userPhoneNumber); + } + }) + .switchIfEmpty(Mono.defer(() -> { + logger.info("No session found in MemoryStore. Performing full lookup to Firestore."); + return fullLookupAndProcess(null, request, userId, userMessageText, userPhoneNumber); + })) + .onErrorResume(e -> { + logger.error("Overall error handling conversation in ConversationManagerService: {}", e.getMessage(), e); + return Mono.error(new RuntimeException("Failed to process conversation due to an internal error.", e)); + }); + } + + private Mono fullLookupAndProcess(ConversationSessionDTO oldSession, DetectIntentRequestDTO request, String userId, String userMessageText, String userPhoneNumber) { + return firestoreConversationService.getSessionByTelefono(userPhoneNumber) + .map(conversationContextMapper::toTextWithLimits) + .defaultIfEmpty("") + .flatMap(conversationHistory -> { + String newSessionId = SessionIdGenerator.generateStandardSessionId(); + logger.info("Creating new session {} after full lookup.", newSessionId); + ConversationSessionDTO newSession = ConversationSessionDTO.create(newSessionId, userId, userPhoneNumber); + DetectIntentRequestDTO newRequest = request.withParameter(CURRENT_PAGE_PARAM, conversationHistory); + return processDialogflowRequest(newSession, newRequest, userId, userMessageText, userPhoneNumber, true); + }); + } + + private Mono processDialogflowRequest(ConversationSessionDTO session, DetectIntentRequestDTO request, String userId, String userMessageText, String userPhoneNumber, boolean newSession) { + final String finalSessionId = session.sessionId(); + ConversationEntryDTO userEntry = ConversationEntryDTO.forUser(userMessageText); + + return this.persistConversationTurn(userId, finalSessionId, userEntry, userPhoneNumber) + .doOnSuccess(v -> logger.debug("User entry successfully persisted for session {}. Proceeding to Dialogflow...", finalSessionId)) + .doOnError(e -> logger.error("Error during user entry persistence for session {}: {}", finalSessionId, e.getMessage(), e)) + .then(Mono.defer(() -> dialogflowServiceClient.detectIntent(finalSessionId, request) + .flatMap(response -> { + logger.debug("Received Dialogflow CX response for session {}. Initiating agent response persistence.", finalSessionId); + ConversationEntryDTO agentEntry = ConversationEntryDTO.forAgent(response.queryResult()); + return persistConversationTurn(userId, finalSessionId, agentEntry, userPhoneNumber) + .thenReturn(response); + }) + .doOnError(error -> logger.error("Overall error during conversation management for session {}: {}", finalSessionId, error.getMessage(), error)) + )); + } + + private Mono startNotificationConversation(ConversationContext context, DetectIntentRequestDTO request, NotificationDTO notification) { final String userId = context.userId(); final String userMessageText = context.userMessageText(); final String userPhoneNumber = context.primaryPhoneNumber(); - Mono sessionMono; - if (userPhoneNumber != null && !userPhoneNumber.isBlank()) { - logger.info("Checking for existing session for phone number: {}", userPhoneNumber); - sessionMono = memoryStoreConversationService.getSessionByTelefono(userPhoneNumber) - .doOnNext(session -> logger.info("Found existing session {} for phone number {}", session.sessionId(), userPhoneNumber)) + return memoryStoreNotificationService.getSessionByTelefono(userPhoneNumber) .switchIfEmpty(Mono.defer(() -> { - String newSessionId = SessionIdGenerator.generateStandardSessionId(); - logger.info("No existing session found for phone number {}. Creating new session: {}", userPhoneNumber, newSessionId); - return Mono.just(ConversationSessionDTO.create(newSessionId, userId, userPhoneNumber)); - })); - } else { - logger.warn("No phone number provided in request. Cannot manage conversation session without it."); - return Mono.error(new IllegalArgumentException("Phone number is required to manage conversation sessions.")); + String newSessionId = SessionIdGenerator.generateStandardSessionId(); + logger.info("No existing notification session found for phone number {}. Creating new session: {}", + userPhoneNumber, newSessionId); + return Mono.just(ConversationSessionDTO.create(newSessionId, userId, userPhoneNumber)); + })) + .flatMap(session -> { + final String sessionId = session.sessionId(); + ConversationEntryDTO userEntry = ConversationEntryDTO.forUser(userMessageText); + return memoryStoreNotificationService.saveEntry(userId, sessionId, userEntry, userPhoneNumber) + .then(dialogflowServiceClient.detectIntent(sessionId, request) + .doOnSuccess(response -> { + ConversationEntryDTO agentEntry = ConversationEntryDTO.forAgent(response.queryResult()); + memoryStoreNotificationService.saveEntry(userId, sessionId, agentEntry, userPhoneNumber).subscribe(); + })); + }); } - return sessionMono.flatMap(session -> { - final String finalSessionId = session.sessionId(); + private Mono persistConversationTurn(String userId, String sessionId, ConversationEntryDTO entry,String userPhoneNumber) { + logger.debug("Starting Write-Back persistence for session {}. Type: {}. Writing to Redis first.", sessionId, + entry.type().name()); - logger.info("Managing conversation for resolved session: {}", finalSessionId); - ConversationEntryDTO userEntry = ConversationEntryDTO.forUser(userMessageText); - - final DetectIntentRequestDTO requestToDialogflow; - Instant currentInteractionTimestamp = userEntry.timestamp(); - if (session.lastModified() != null && - Duration.between(session.lastModified(), currentInteractionTimestamp).toHours() >= SESSION_RESET_THRESHOLD_HOURS) { - - logger.info("Session {} (last modified: {}) is older than {} hours. Adding '{}' parameter to Dialogflow request.", - session.sessionId(), session.lastModified(), SESSION_RESET_THRESHOLD_HOURS, CURRENT_PAGE_PARAM); - - requestToDialogflow = request.withParameter(CURRENT_PAGE_PARAM, true); - - } else { - requestToDialogflow = request; - } - - return this.persistConversationTurn(userId, finalSessionId, userEntry, userPhoneNumber) - .doOnSuccess(v -> logger.debug("User entry successfully persisted for session {}. Proceeding to Dialogflow...", finalSessionId)) - .doOnError(e -> logger.error("Error during user entry persistence for session {}: {}", finalSessionId, e.getMessage(), e)) - .then(Mono.defer(() -> { - return dialogflowServiceClient.detectIntent(finalSessionId, requestToDialogflow) - .doOnSuccess(response -> { - logger.debug("Received Dialogflow CX response for session {}. Initiating agent response persistence.", finalSessionId); - ConversationEntryDTO agentEntry = ConversationEntryDTO.forAgent(response.queryResult()); - this.persistConversationTurn(userId, finalSessionId, agentEntry, userPhoneNumber).subscribe( - v -> logger.debug("Background: Agent entry persistence initiated for session {}.", finalSessionId), - e -> logger.error("Background: Error during agent entry persistence for session {}: {}", finalSessionId, e.getMessage(), e) - ); - }) - .doOnError(error -> logger.error("Overall error during conversation management for session {}: {}", finalSessionId, error.getMessage(), error)); - })); - }) - .onErrorResume(e -> { - logger.error("Overall error handling conversation in ConversationManagerService: {}", e.getMessage(), e); - return Mono.error(new RuntimeException("Failed to process conversation due to an internal error.", e)); - }) - .subscribeOn(Schedulers.boundedElastic()); - } - - private Mono persistConversationTurn(String userId, String sessionId, ConversationEntryDTO entry, String userPhoneNumber) { - logger.debug("Starting Write-Back persistence for session {}. Type: {}. Writing to Redis first.", sessionId, entry.type().name()); - return memoryStoreConversationService.saveEntry(userId, sessionId, entry, userPhoneNumber) - .doOnSuccess(v -> { - logger.info("Entry saved to Redis for session {}. Type: {}. Kicking off async Firestore write-back.", sessionId, entry.type().name()); - firestoreConversationService.saveEntry(userId, sessionId, entry, userPhoneNumber) - .subscribe( - fsVoid -> logger.debug("Asynchronously (Write-Back): Entry successfully saved to Firestore for session {}. Type: {}.", - sessionId, entry.type().name()), - fsError -> logger.error("Asynchronously (Write-Back): Failed to save entry to Firestore for session {}. Type: {}: {}", - sessionId, entry.type().name(), fsError.getMessage(), fsError) - ); - }) - .doOnError(e -> logger.error("Error during primary Redis write for session {}. Type: {}: {}", sessionId, entry.type().name(), e.getMessage(), e)); - } + .doOnSuccess(v -> logger.info( + "Entry saved to Redis for session {}. Type: {}. Kicking off async Firestore write-back.", + sessionId, entry.type().name())) + .then(firestoreConversationService.saveEntry(userId, sessionId, entry, userPhoneNumber) + .doOnSuccess(fsVoid -> logger.debug( + "Asynchronously (Write-Back): Entry successfully saved to Firestore for session {}. Type: {}.", + sessionId, entry.type().name())) + .doOnError(fsError -> logger.error( + "Asynchronously (Write-Back): Failed to save entry to Firestore for session {}. Type: {}: {}", + sessionId, entry.type().name(), fsError.getMessage(), fsError))) + .doOnError(e -> logger.error("Error during primary Redis write for session {}. Type: {}: {}", sessionId, + entry.type().name(), e.getMessage(), e)); + } -private ConversationContext resolveAndValidateRequest(DetectIntentRequestDTO request) { + private ConversationContext resolveAndValidateRequest(DetectIntentRequestDTO request) { Map params = Optional.ofNullable(request.queryParams()) - .map(queryParamsDTO -> queryParamsDTO.parameters()) - .orElse(Collections.emptyMap()); + .map(queryParamsDTO -> queryParamsDTO.parameters()) + .orElse(Collections.emptyMap()); String primaryPhoneNumber = null; Object telefonoObj = params.get("telefono"); // Get from map if (telefonoObj instanceof String) { - primaryPhoneNumber = (String) telefonoObj; + primaryPhoneNumber = (String) telefonoObj; } else if (telefonoObj != null) { - logger.warn("Parameter 'telefono' in queryParams is not a String (type: {}). Expected String.", telefonoObj.getClass().getName()); + logger.warn("Parameter 'telefono' in queryParams is not a String (type: {}). Expected String.", + telefonoObj.getClass().getName()); } if (primaryPhoneNumber == null || primaryPhoneNumber.trim().isEmpty()) { - throw new IllegalArgumentException("Phone number (telefono) is required in query parameters for conversation management."); + throw new IllegalArgumentException( + "Phone number (telefono) is required in query parameters for conversation management."); } String resolvedUserId = null; Object userIdObj = params.get("usuario_id"); if (userIdObj instanceof String) { - resolvedUserId = (String) userIdObj; + resolvedUserId = (String) userIdObj; } else if (userIdObj != null) { - logger.warn("Parameter 'userId' in queryParams is not a String (type: {}). Expected String.", userIdObj.getClass().getName()); - } - - if (resolvedUserId == null || resolvedUserId.trim().isEmpty()) { - resolvedUserId = "user_by_phone_" + primaryPhoneNumber.replaceAll("[^0-9]", ""); - logger.warn("User ID not provided in query parameters. Using derived ID from phone number: {}", resolvedUserId); + logger.warn("Parameter 'userId' in query_params is not a String (type: {}). Expected String.", + userIdObj.getClass().getName()); } - if (request.queryInput() == null || request.queryInput().text() == null || - request.queryInput().text().text() == null || request.queryInput().text().text().trim().isEmpty()) { - throw new IllegalArgumentException("Dialogflow query input text is required."); + if (resolvedUserId == null || resolvedUserId.trim().isEmpty()) { + resolvedUserId = "user_by_phone_" + primaryPhoneNumber.replaceAll("[^0-9]", ""); + logger.warn("User ID not provided in query parameters. Using derived ID from phone number: {}", + resolvedUserId); + } + + if (request.queryInput() == null || request.queryInput().text() == null || + request.queryInput().text().text() == null || request.queryInput().text().text().trim().isEmpty()) { + throw new IllegalArgumentException("Dialogflow query input text is required."); } String userMessageText = request.queryInput().text().text(); return new ConversationContext(resolvedUserId, null, userMessageText, primaryPhoneNumber); - } +} } \ No newline at end of file diff --git a/src/main/java/com/example/service/conversation/FirestoreConversationService.java b/src/main/java/com/example/service/conversation/FirestoreConversationService.java index 9f2354f..3b36db0 100644 --- a/src/main/java/com/example/service/conversation/FirestoreConversationService.java +++ b/src/main/java/com/example/service/conversation/FirestoreConversationService.java @@ -43,6 +43,10 @@ public class FirestoreConversationService { } public Mono saveEntry(String userId, String sessionId, ConversationEntryDTO newEntry, String userPhoneNumber) { + return saveEntry(userId, sessionId, newEntry, userPhoneNumber, null); + } + + public Mono saveEntry(String userId, String sessionId, ConversationEntryDTO newEntry, String userPhoneNumber, String pantallaContexto) { logger.info("Attempting to save conversation entry to Firestore for session {}. Entity: {}", sessionId, newEntry.entity().name()); return Mono.fromRunnable(() -> { DocumentReference sessionDocRef = getSessionDocumentReference(sessionId); @@ -52,12 +56,15 @@ public class FirestoreConversationService { if (firestoreBaseRepository.documentExists(sessionDocRef)) { // Update: Append the new entry using arrayUnion and update lastModified Map updates = firestoreConversationMapper.createUpdateMapForSingleEntry(newEntry); + if (pantallaContexto != null) { + updates.put("pantallaContexto", pantallaContexto); + } batch.update(sessionDocRef, updates); logger.info("Appending entry to existing conversation session for user {} and session {}. Entity: {}", userId, sessionId, newEntry.entity().name()); } else { // Create: Start a new session with the first entry. // Pass userId and userPhoneNumber to the mapper to be stored as fields in the document. - Map newSessionMap = firestoreConversationMapper.createNewSessionMapForSingleEntry(sessionId, userId, userPhoneNumber, newEntry); + Map newSessionMap = firestoreConversationMapper.createNewSessionMapForSingleEntry(sessionId, userId, userPhoneNumber, newEntry, pantallaContexto); batch.set(sessionDocRef, newSessionMap); logger.info("Creating new conversation session with first entry for user {} and session {}. Entity: {}", userId, sessionId, newEntry.entity().name()); } @@ -94,6 +101,20 @@ public class FirestoreConversationService { }).subscribeOn(Schedulers.boundedElastic()); } + public Mono getSessionByTelefono(String userPhoneNumber) { + logger.info("Attempting to retrieve conversation session for phone number {}.", userPhoneNumber); + return firestoreBaseRepository.getDocumentsByField(getConversationCollectionPath(), "userPhoneNumber", userPhoneNumber) + .map(documentSnapshot -> { + if (documentSnapshot != null && documentSnapshot.exists()) { + ConversationSessionDTO sessionDTO = firestoreConversationMapper.mapFirestoreDocumentToConversationSessionDTO(documentSnapshot); + logger.info("Successfully retrieved and mapped conversation session for session {}.", sessionDTO.sessionId()); + return sessionDTO; + } + logger.info("Conversation session not found for phone number {}.", userPhoneNumber); + return null; + }); + } + private String getConversationCollectionPath() { return String.format(CONVERSATION_COLLECTION_PATH_FORMAT, firestoreBaseRepository.getAppId()); } diff --git a/src/main/java/com/example/service/conversation/MemoryStoreConversationService.java b/src/main/java/com/example/service/conversation/MemoryStoreConversationService.java index 33ade80..f9bad99 100644 --- a/src/main/java/com/example/service/conversation/MemoryStoreConversationService.java +++ b/src/main/java/com/example/service/conversation/MemoryStoreConversationService.java @@ -29,28 +29,48 @@ public class MemoryStoreConversationService { private static final Duration SESSION_TTL = Duration.ofHours(24); private final ReactiveRedisTemplate redisTemplate; private final ReactiveRedisTemplate stringRedisTemplate; + + @Autowired public MemoryStoreConversationService( ReactiveRedisTemplate redisTemplate, - ReactiveRedisTemplate stringRedisTemplate) { + ReactiveRedisTemplate stringRedisTemplate, + FirestoreConversationService firestoreConversationService) { this.redisTemplate = redisTemplate; this.stringRedisTemplate = stringRedisTemplate; } + + public Mono saveEntry(String userId, String sessionId, ConversationEntryDTO newEntry, String userPhoneNumber) { + return saveEntry(userId, sessionId, newEntry, userPhoneNumber, null); + } + + public Mono saveEntry(String userId, String sessionId, ConversationEntryDTO newEntry, String userPhoneNumber, String pantallaContexto) { String sessionKey = SESSION_KEY_PREFIX + sessionId; String phoneToSessionKey = PHONE_TO_SESSION_KEY_PREFIX + userPhoneNumber; + + logger.info("Attempting to save entry to Redis for session {}. Entity: {}", sessionId, newEntry.entity().name()); + + return redisTemplate.opsForValue().get(sessionKey) .defaultIfEmpty(ConversationSessionDTO.create(sessionId, userId, userPhoneNumber)) .flatMap(session -> { ConversationSessionDTO sessionWithUpdatedTelefono = session.withTelefono(userPhoneNumber); - ConversationSessionDTO updatedSession = sessionWithUpdatedTelefono.withAddedEntry(newEntry); + ConversationSessionDTO sessionWithPantallaContexto = (pantallaContexto != null) ? sessionWithUpdatedTelefono.withPantallaContexto(pantallaContexto) : sessionWithUpdatedTelefono; + ConversationSessionDTO updatedSession = sessionWithPantallaContexto.withAddedEntry(newEntry); + + logger.info("Attempting to set updated session {} with new entry entity {} in Redis.", sessionId, newEntry.entity().name()); + + return redisTemplate.opsForValue().set(sessionKey, updatedSession, SESSION_TTL) .then(stringRedisTemplate.opsForValue().set(phoneToSessionKey, sessionId, SESSION_TTL)) - .then(); // <--- ADD THIS .then() WITHOUT ARGUMENTS + .then(); + }) + .doOnSuccess(success -> { + logger.info("Successfully saved updated session and phone mapping to Redis for session {}. Entity Type: {}", sessionId, newEntry.entity().name()); }) - .doOnSuccess(success -> logger.info("Successfully saved updated session and phone mapping to Redis for session {}. Entity Type: {}", sessionId, newEntry.entity().name())) .doOnError(e -> logger.error("Error appending entry to Redis for session {}: {}", sessionId, e.getMessage(), e)); } public Mono getSessionByTelefono(String telefono) { diff --git a/src/main/java/com/example/service/notification/FirestoreNotificationConvService.java b/src/main/java/com/example/service/notification/FirestoreNotificationConvService.java new file mode 100644 index 0000000..be46ecd --- /dev/null +++ b/src/main/java/com/example/service/notification/FirestoreNotificationConvService.java @@ -0,0 +1,107 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.service.notification; + +import com.example.dto.dialogflow.conversation.ConversationEntryDTO; +import com.example.dto.dialogflow.conversation.ConversationSessionDTO; +import com.example.exception.FirestorePersistenceException; +import com.example.mapper.conversation.FirestoreConversationMapper; +import com.example.repository.FirestoreBaseRepository; +import com.google.cloud.firestore.DocumentReference; +import com.google.cloud.firestore.DocumentSnapshot; +import com.google.cloud.firestore.WriteBatch; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +import java.util.Map; +import java.util.concurrent.ExecutionException; + +/** + * Service for managing notification conversation sessions in Firestore. + * It handles the persistence of conversation entries, either by creating + * a new document for a new session or appending an entry to an existing + * session document using a Firestore batch. The service also provides + * methods for retrieving a complete conversation session from Firestore. + */ +@Service +public class FirestoreNotificationConvService { + + private static final Logger logger = LoggerFactory.getLogger(FirestoreNotificationConvService.class); + private static final String CONVERSATION_COLLECTION_PATH_FORMAT = "artifacts/%s/conversation-notifications"; + private final FirestoreBaseRepository firestoreBaseRepository; + private final FirestoreConversationMapper firestoreConversationMapper; + + public FirestoreNotificationConvService(FirestoreBaseRepository firestoreBaseRepository, FirestoreConversationMapper firestoreConversationMapper) { + this.firestoreBaseRepository = firestoreBaseRepository; + this.firestoreConversationMapper = firestoreConversationMapper; + } + + public Mono saveEntry(String userId, String sessionId, ConversationEntryDTO newEntry, String userPhoneNumber) { + logger.info("Attempting to save conversation entry to Firestore for session {}. Entity: {}", sessionId, newEntry.entity().name()); + return Mono.fromRunnable(() -> { + DocumentReference sessionDocRef = getSessionDocumentReference(sessionId); + // Synchronize on the session ID to prevent race conditions when creating a new session. + synchronized (sessionId.intern()) { + WriteBatch batch = firestoreBaseRepository.createBatch(); + try { + if (firestoreBaseRepository.documentExists(sessionDocRef)) { + // Update: Append the new entry using arrayUnion and update lastModified + Map updates = firestoreConversationMapper.createUpdateMapForSingleEntry(newEntry); + batch.update(sessionDocRef, updates); + logger.info("Appending entry to existing conversation session for user {} and session {}. Entity: {}", userId, sessionId, newEntry.entity().name()); + } else { + // Create: Start a new session with the first entry. + // Pass userId and userPhoneNumber to the mapper to be stored as fields in the document. + Map newSessionMap = firestoreConversationMapper.createNewSessionMapForSingleEntry(sessionId, userId, userPhoneNumber, newEntry); + batch.set(sessionDocRef, newSessionMap); + logger.info("Creating new conversation session with first entry for user {} and session {}. Entity: {}", userId, sessionId, newEntry.entity().name()); + } + firestoreBaseRepository.commitBatch(batch); + logger.info("Successfully committed batch for session {} to Firestore.", sessionId); + } catch (ExecutionException e) { + logger.error("Error saving conversation entry to Firestore for session {}: {}", sessionId, e.getMessage(), e); + throw new FirestorePersistenceException("Failed to save conversation entry to Firestore for session " + sessionId, e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + logger.error("Thread interrupted while saving conversation entry to Firestore for session {}: {}", sessionId, e.getMessage(), e); + throw new FirestorePersistenceException("Saving conversation entry was interrupted for session " + sessionId, e); + } + } + }).subscribeOn(Schedulers.boundedElastic()).then(); + } + + public Mono getConversationSession(String userId, String sessionId) { + logger.info("Attempting to retrieve conversation session for session {} (user ID {} for context).", sessionId, userId); + return Mono.fromCallable(() -> { + DocumentReference sessionDocRef = getSessionDocumentReference(sessionId); + try { + DocumentSnapshot documentSnapshot = firestoreBaseRepository.getDocumentSnapshot(sessionDocRef); + if (documentSnapshot != null && documentSnapshot.exists()) { + ConversationSessionDTO sessionDTO = firestoreConversationMapper.mapFirestoreDocumentToConversationSessionDTO(documentSnapshot); + logger.info("Successfully retrieved and mapped conversation session for session {}.", sessionId); + return sessionDTO; + } + logger.info("Conversation session not found for session {}.", sessionId); + return null; // Or Mono.empty() if this method returned Mono> + } catch (InterruptedException | ExecutionException e) { + logger.error("Error retrieving conversation session from Firestore for session {}: {}", sessionId, e.getMessage(), e); + throw new FirestorePersistenceException("Failed to retrieve conversation session from Firestore for session " + sessionId, e); + } + }).subscribeOn(Schedulers.boundedElastic()); + } + + private String getConversationCollectionPath() { + return String.format(CONVERSATION_COLLECTION_PATH_FORMAT, firestoreBaseRepository.getAppId()); + } + + private DocumentReference getSessionDocumentReference(String sessionId) { + String collectionPath = getConversationCollectionPath(); + return firestoreBaseRepository.getDocumentReference(collectionPath, sessionId); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/service/notification/FirestoreNotificationService.java b/src/main/java/com/example/service/notification/FirestoreNotificationService.java index b34e211..a159cad 100644 --- a/src/main/java/com/example/service/notification/FirestoreNotificationService.java +++ b/src/main/java/com/example/service/notification/FirestoreNotificationService.java @@ -1,3 +1,8 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + package com.example.service.notification; import com.example.dto.dialogflow.notification.NotificationDTO; @@ -7,91 +12,157 @@ import com.example.repository.FirestoreBaseRepository; import com.google.cloud.Timestamp; import com.google.cloud.firestore.DocumentReference; import com.google.cloud.firestore.FieldValue; +import java.time.Instant; +import java.util.Collections; +import java.util.Map; +import java.util.List; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.concurrent.ExecutionException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import reactor.core.publisher.Mono; import reactor.core.scheduler.Schedulers; -import java.time.Instant; -import java.util.Collections; -import java.util.Map; -import java.util.concurrent.ExecutionException; @Service public class FirestoreNotificationService { - private static final Logger logger = LoggerFactory.getLogger(FirestoreNotificationService.class); - private static final String NOTIFICATION_COLLECTION_PATH_FORMAT = "artifacts/%s/notifications"; - private static final String FIELD_MESSAGES = "notificaciones"; - private static final String FIELD_LAST_UPDATED = "ultimaActualizacion"; - private static final String FIELD_PHONE_NUMBER = "telefono"; - private static final String FIELD_NOTIFICATION_ID = "sessionId"; + private static final Logger logger = LoggerFactory.getLogger(FirestoreNotificationService.class); + private static final String NOTIFICATION_COLLECTION_PATH_FORMAT = "artifacts/%s/notifications"; + private static final String FIELD_MESSAGES = "notificaciones"; + private static final String FIELD_LAST_UPDATED = "ultimaActualizacion"; + private static final String FIELD_PHONE_NUMBER = "telefono"; + private static final String FIELD_NOTIFICATION_ID = "sessionId"; - private final FirestoreBaseRepository firestoreBaseRepository; - private final FirestoreNotificationMapper firestoreNotificationMapper; + private final FirestoreBaseRepository firestoreBaseRepository; + private final FirestoreNotificationMapper firestoreNotificationMapper; - public FirestoreNotificationService( - FirestoreBaseRepository firestoreBaseRepository, - FirestoreNotificationMapper firestoreNotificationMapper) { - this.firestoreBaseRepository = firestoreBaseRepository; - this.firestoreNotificationMapper = firestoreNotificationMapper; - } + public FirestoreNotificationService( + FirestoreBaseRepository firestoreBaseRepository, + FirestoreNotificationMapper firestoreNotificationMapper, + MemoryStoreNotificationService memoryStoreNotificationService) { + this.firestoreBaseRepository = firestoreBaseRepository; + this.firestoreNotificationMapper = firestoreNotificationMapper; + } + public Mono saveOrAppendNotificationEntry(NotificationDTO newEntry) { + return Mono.fromRunnable( + () -> { + String phoneNumber = newEntry.telefono(); + if (phoneNumber == null || phoneNumber.isBlank()) { + throw new IllegalArgumentException( + "Phone number is required to manage notification entries."); + } + // Use the phone number as the document ID for the session. + String notificationSessionId = phoneNumber; - public Mono saveOrAppendNotificationEntry(NotificationDTO newEntry) { - return Mono.fromRunnable(() -> { - String phoneNumber = newEntry.telefono(); - if (phoneNumber == null || phoneNumber.isBlank()) { - throw new IllegalArgumentException("Phone number is required to manage notification entries."); - } + // Synchronize on the notification session ID to prevent race conditions when creating a new session. + synchronized (notificationSessionId.intern()) { + DocumentReference notificationDocRef = + getNotificationDocumentReference(notificationSessionId); + Map entryMap = + firestoreNotificationMapper.mapNotificationDTOToMap(newEntry); + try { + // Check if the session document exists. + boolean docExists = firestoreBaseRepository.documentExists(notificationDocRef); - // Use the phone number as the document ID for the session. - String notificationSessionId = phoneNumber; - - DocumentReference notificationDocRef = getNotificationDocumentReference(notificationSessionId); - Map entryMap = firestoreNotificationMapper.mapNotificationDTOToMap(newEntry); - - try { - // Check if the session document exists. - boolean docExists = notificationDocRef.get().get().exists(); - - if (docExists) { + if (docExists) { // If the document exists, append the new entry to the 'notificaciones' array. - Map updates = Map.of( - FIELD_MESSAGES, FieldValue.arrayUnion(entryMap), - FIELD_LAST_UPDATED, Timestamp.of(java.util.Date.from(Instant.now())) - ); - notificationDocRef.update(updates).get(); - logger.info("Successfully appended new entry to notification session {} in Firestore.", notificationSessionId); - } else { + Map updates = + Map.of( + FIELD_MESSAGES, FieldValue.arrayUnion(entryMap), + FIELD_LAST_UPDATED, Timestamp.of(java.util.Date.from(Instant.now()))); + firestoreBaseRepository.updateDocument(notificationDocRef, updates); + logger.info( + "Successfully appended new entry to notification session {} in Firestore.", + notificationSessionId); + } else { // If the document does not exist, create a new session document. - Map newSessionData = Map.of( - FIELD_NOTIFICATION_ID, notificationSessionId, - FIELD_PHONE_NUMBER, phoneNumber, - "fechaCreacion", Timestamp.of(java.util.Date.from(Instant.now())), - FIELD_LAST_UPDATED, Timestamp.of(java.util.Date.from(Instant.now())), - FIELD_MESSAGES, Collections.singletonList(entryMap) - ); - notificationDocRef.set(newSessionData).get(); - logger.info("Successfully created a new notification session {} in Firestore.", notificationSessionId); + Map newSessionData = + Map.of( + FIELD_NOTIFICATION_ID, + notificationSessionId, + FIELD_PHONE_NUMBER, + phoneNumber, + "fechaCreacion", + Timestamp.of(java.util.Date.from(Instant.now())), + FIELD_LAST_UPDATED, + Timestamp.of(java.util.Date.from(Instant.now())), + FIELD_MESSAGES, + Collections.singletonList(entryMap)); + firestoreBaseRepository.setDocument(notificationDocRef, newSessionData); + logger.info( + "Successfully created a new notification session {} in Firestore.", + notificationSessionId); + } + } catch (ExecutionException e) { + logger.error( + "Error saving notification to Firestore for phone {}: {}", + phoneNumber, + e.getMessage(), + e); + throw new FirestorePersistenceException( + "Failed to save notification to Firestore for phone " + phoneNumber, e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + logger.error( + "Thread interrupted while saving notification to Firestore for phone {}: {}", + phoneNumber, + e.getMessage(), + e); + throw new FirestorePersistenceException( + "Saving notification was interrupted for phone " + phoneNumber, e); } - } catch (ExecutionException e) { - logger.error("Error saving notification to Firestore for phone {}: {}", phoneNumber, e.getMessage(), e); - throw new FirestorePersistenceException("Failed to save notification to Firestore for phone " + phoneNumber, e); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - logger.error("Thread interrupted while saving notification to Firestore for phone {}: {}", phoneNumber, e.getMessage(), e); - throw new FirestorePersistenceException("Saving notification was interrupted for phone " + phoneNumber, e); - } - }).subscribeOn(Schedulers.boundedElastic()).then(); - } - - private String getNotificationCollectionPath() { - return String.format(NOTIFICATION_COLLECTION_PATH_FORMAT, firestoreBaseRepository.getAppId()); - } + } + }) + .subscribeOn(Schedulers.boundedElastic()) + .then(); + } - private DocumentReference getNotificationDocumentReference(String notificationId) { - String collectionPath = getNotificationCollectionPath(); - return firestoreBaseRepository.getDocumentReference(collectionPath, notificationId); - } + private String getNotificationCollectionPath() { + return String.format(NOTIFICATION_COLLECTION_PATH_FORMAT, firestoreBaseRepository.getAppId()); + } + + private DocumentReference getNotificationDocumentReference(String notificationId) { + String collectionPath = getNotificationCollectionPath(); + return firestoreBaseRepository.getDocumentReference(collectionPath, notificationId); + } + + @SuppressWarnings("unchecked") + public Mono updateNotificationStatus(String sessionId, String status) { + return Mono.fromRunnable(() -> { + DocumentReference notificationDocRef = getNotificationDocumentReference(sessionId); + try { + Map sessionData = firestoreBaseRepository.getDocument(notificationDocRef, Map.class); + if (sessionData != null) { + List> notifications = (List>) sessionData.get(FIELD_MESSAGES); + if (notifications != null) { + List> updatedNotifications = new ArrayList<>(); + for (Map notification : notifications) { + Map updatedNotification = new HashMap<>(notification); + updatedNotification.put("status", status); + updatedNotifications.add(updatedNotification); + } + Map updates = new HashMap<>(); + updates.put(FIELD_MESSAGES, updatedNotifications); + updates.put(FIELD_LAST_UPDATED, Timestamp.of(java.util.Date.from(Instant.now()))); + firestoreBaseRepository.updateDocument(notificationDocRef, updates); + logger.info("Successfully updated notification status to '{}' for session {} in Firestore.", status, sessionId); + } + } else { + logger.warn("Notification session {} not found in Firestore. Cannot update status.", sessionId); + } + } catch (ExecutionException e) { + logger.error("Error updating notification status in Firestore for session {}: {}", sessionId, e.getMessage(), e); + throw new FirestorePersistenceException("Failed to update notification status in Firestore for session " + sessionId, e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + logger.error("Thread interrupted while updating notification status in Firestore for session {}: {}", sessionId, e.getMessage(), e); + throw new FirestorePersistenceException("Updating notification status was interrupted for session " + sessionId, e); + } + }) + .subscribeOn(Schedulers.boundedElastic()) + .then(); + } } \ No newline at end of file diff --git a/src/main/java/com/example/service/notification/MemoryStoreNotificationService.java b/src/main/java/com/example/service/notification/MemoryStoreNotificationService.java index 928ba07..611412b 100644 --- a/src/main/java/com/example/service/notification/MemoryStoreNotificationService.java +++ b/src/main/java/com/example/service/notification/MemoryStoreNotificationService.java @@ -1,5 +1,7 @@ package com.example.service.notification; +import com.example.dto.dialogflow.conversation.ConversationEntryDTO; +import com.example.dto.dialogflow.conversation.ConversationSessionDTO; import com.example.dto.dialogflow.notification.NotificationDTO; import com.example.dto.dialogflow.notification.NotificationSessionDTO; import com.fasterxml.jackson.databind.ObjectMapper; @@ -19,17 +21,25 @@ public class MemoryStoreNotificationService { private static final Logger logger = LoggerFactory.getLogger(MemoryStoreNotificationService.class); private final ReactiveRedisTemplate notificationRedisTemplate; + private final ReactiveRedisTemplate conversationRedisTemplate; private final ReactiveRedisTemplate stringRedisTemplate; + private final FirestoreNotificationConvService firestoreNotificationConvService; private static final String NOTIFICATION_KEY_PREFIX = "notification:"; - private static final String PHONE_TO_SESSION_KEY_PREFIX = "notification:phone_to_notification:"; - private final Duration notificationTtl = Duration.ofHours(24); + private static final String PHONE_TO_NOTIFICATION_SESSION_KEY_PREFIX = "notification:phone_to_notification:"; + private static final String CONVERSATION_SESSION_KEY_PREFIX = "conversation-notification:session:"; + private static final String PHONE_TO_CONVERSATION_SESSION_KEY_PREFIX = "conversation-notification:phone_to_session:"; + private final Duration notificationTtl = Duration.ofMinutes(5); public MemoryStoreNotificationService( ReactiveRedisTemplate notificationRedisTemplate, + ReactiveRedisTemplate conversationRedisTemplate, ReactiveRedisTemplate stringRedisTemplate, + FirestoreNotificationConvService firestoreNotificationConvService, ObjectMapper objectMapper) { this.notificationRedisTemplate = notificationRedisTemplate; + this.conversationRedisTemplate = conversationRedisTemplate; this.stringRedisTemplate = stringRedisTemplate; + this.firestoreNotificationConvService = firestoreNotificationConvService; } @@ -38,7 +48,6 @@ public class MemoryStoreNotificationService { if (phoneNumber == null || phoneNumber.isBlank()) { return Mono.error(new IllegalArgumentException("Phone number is required to manage notification entries.")); } - //noote: Use the phone number as the session ID for notifications String notificationSessionId = phoneNumber; @@ -73,7 +82,7 @@ public class MemoryStoreNotificationService { private Mono cacheNotificationSession(NotificationSessionDTO session) { String key = NOTIFICATION_KEY_PREFIX + session.sessionId(); - String phoneToSessionKey = PHONE_TO_SESSION_KEY_PREFIX + session.telefono(); + String phoneToSessionKey = PHONE_TO_NOTIFICATION_SESSION_KEY_PREFIX + session.telefono(); return notificationRedisTemplate.opsForValue().set(key, session, notificationTtl) .then(stringRedisTemplate.opsForValue().set(phoneToSessionKey, session.sessionId(), notificationTtl)); @@ -93,7 +102,7 @@ public class MemoryStoreNotificationService { } public Mono getNotificationIdForPhone(String phone) { - String key = PHONE_TO_SESSION_KEY_PREFIX + phone; + String key = PHONE_TO_NOTIFICATION_SESSION_KEY_PREFIX + phone; return stringRedisTemplate.opsForValue().get(key) .doOnSuccess(sessionId -> { if (sessionId != null) { @@ -105,4 +114,53 @@ public class MemoryStoreNotificationService { .doOnError(e -> logger.error("Error retrieving session ID for phone {} from MemoryStore: {}", phone, e.getMessage(), e)); } + + public Mono saveEntry(String userId, String sessionId, ConversationEntryDTO newEntry, String userPhoneNumber) { + String sessionKey = CONVERSATION_SESSION_KEY_PREFIX + sessionId; + String phoneToSessionKey = PHONE_TO_CONVERSATION_SESSION_KEY_PREFIX + userPhoneNumber; + logger.info("Attempting to save entry to Redis for session {}. Entity: {}", sessionId, newEntry.entity().name()); + return conversationRedisTemplate.opsForValue().get(sessionKey) + .defaultIfEmpty(ConversationSessionDTO.create(sessionId, userId, userPhoneNumber)) + .flatMap(session -> { + ConversationSessionDTO sessionWithUpdatedTelefono = session.withTelefono(userPhoneNumber); + ConversationSessionDTO updatedSession = sessionWithUpdatedTelefono.withAddedEntry(newEntry); + logger.info("Attempting to set updated session {} with new entry entity {} in Redis.", sessionId, newEntry.entity().name()); + return conversationRedisTemplate.opsForValue().set(sessionKey, updatedSession, notificationTtl) + .then(stringRedisTemplate.opsForValue().set(phoneToSessionKey, sessionId, notificationTtl)) + .then(); + }) + .doOnSuccess(success ->{ + logger.info("Successfully saved updated session and phone mapping to Redis for session {}. Entity Type: {}", sessionId, newEntry.entity().name()); + firestoreNotificationConvService.saveEntry(userId, sessionId, newEntry, userPhoneNumber) + .subscribe( + fsVoid -> logger.debug( + "Asynchronously (Write-Back): Entry successfully saved to Firestore for session {}. Type: {}.", + sessionId, newEntry.type().name()), + fsError -> logger.error( + "Asynchronously (Write-Back): Failed to save entry to Firestore for session {}. Type: {}: {}", + sessionId, newEntry.type().name(), fsError.getMessage(), fsError)); + }) + .doOnError(e -> logger.error("Error appending entry to Redis for session {}: {}", sessionId, e.getMessage(), e)); + } + + public Mono getSessionByTelefono(String telefono) { + if (telefono == null || telefono.isBlank()) { + return Mono.empty(); + } + String phoneToSessionKey = PHONE_TO_CONVERSATION_SESSION_KEY_PREFIX + telefono; + logger.debug("Attempting to retrieve session ID for phone number {} from Redis.", telefono); + return stringRedisTemplate.opsForValue().get(phoneToSessionKey) + .flatMap(sessionId -> { + logger.debug("Found session ID {} for phone number {}. Retrieving session data.", sessionId, telefono); + return conversationRedisTemplate.opsForValue().get(CONVERSATION_SESSION_KEY_PREFIX + sessionId); + }) + .doOnSuccess(session -> { + if (session != null) { + logger.info("Successfully retrieved session {} by phone number {}.", session.sessionId(), telefono); + } else { + logger.info("No session found in Redis for phone number {}.", telefono); + } + }) + .doOnError(e -> logger.error("Error retrieving session by phone number {}: {}", telefono, e.getMessage(), e)); + } } \ No newline at end of file diff --git a/src/main/java/com/example/service/notification/NotificationExpirationListener.java b/src/main/java/com/example/service/notification/NotificationExpirationListener.java new file mode 100644 index 0000000..5a5f4d2 --- /dev/null +++ b/src/main/java/com/example/service/notification/NotificationExpirationListener.java @@ -0,0 +1,37 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.service.notification; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.redis.connection.Message; +import org.springframework.data.redis.connection.MessageListener; +import org.springframework.stereotype.Service; + +@Service +public class NotificationExpirationListener implements MessageListener { + + private static final Logger logger = LoggerFactory.getLogger(NotificationExpirationListener.class); + private final FirestoreNotificationService firestoreNotificationService; + private static final String NOTIFICATION_KEY_PREFIX = "notification:"; + + public NotificationExpirationListener(FirestoreNotificationService firestoreNotificationService) { + this.firestoreNotificationService = firestoreNotificationService; + } + + @Override + public void onMessage(Message message, byte[] pattern) { + String expiredKey = new String(message.getBody()); + logger.info("Expired key: " + expiredKey); + if (expiredKey.startsWith(NOTIFICATION_KEY_PREFIX)) { + String sessionId = expiredKey.substring(NOTIFICATION_KEY_PREFIX.length()); + firestoreNotificationService.updateNotificationStatus(sessionId, "inactive") + .doOnSuccess(v -> logger.info("Notification status updated to inactive for session: " + sessionId)) + .doOnError(e -> logger.error("Error updating notification status for session: " + sessionId, e)) + .subscribe(); + } + } +} diff --git a/src/main/java/com/example/service/notification/NotificationManagerService.java b/src/main/java/com/example/service/notification/NotificationManagerService.java index d3adc9d..92ddf31 100644 --- a/src/main/java/com/example/service/notification/NotificationManagerService.java +++ b/src/main/java/com/example/service/notification/NotificationManagerService.java @@ -3,12 +3,12 @@ package com.example.service.notification; import com.example.dto.dialogflow.notification.ExternalNotRequestDTO; import com.example.dto.dialogflow.base.DetectIntentRequestDTO; import com.example.dto.dialogflow.base.DetectIntentResponseDTO; +import com.example.dto.dialogflow.conversation.ConversationEntryDTO; import com.example.dto.dialogflow.conversation.ConversationSessionDTO; import com.example.dto.dialogflow.conversation.QueryInputDTO; import com.example.dto.dialogflow.conversation.QueryParamsDTO; import com.example.dto.dialogflow.notification.NotificationDTO; import com.example.service.base.DialogflowClientService; -import com.example.service.conversation.MemoryStoreConversationService; import com.example.util.SessionIdGenerator; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -26,85 +26,118 @@ import com.example.dto.dialogflow.conversation.TextInputDTO; @Service public class NotificationManagerService { - private static final Logger logger = LoggerFactory.getLogger(NotificationManagerService.class); - private static final String NOTIFICATION_TEXT_PARAM = "notificationText"; - private static final String eventName = "notificacion"; +private static final Logger logger = LoggerFactory.getLogger(NotificationManagerService.class); +private static final String NOTIFICATION_TEXT_PARAM = "notificationText"; +private static final String eventName = "notificacion"; +private final DialogflowClientService dialogflowClientService; +private final FirestoreNotificationService firestoreNotificationService; +private final MemoryStoreNotificationService memoryStoreNotificationService; +private final FirestoreNotificationConvService firestoreConversationService; - private final DialogflowClientService dialogflowClientService; - private final FirestoreNotificationService firestoreNotificationService; - private final MemoryStoreNotificationService memoryStoreNotificationService; - private final MemoryStoreConversationService memoryStoreConversationService; +@Value("${dialogflow.default-language-code:es}") +private String defaultLanguageCode; - @Value("${dialogflow.default-language-code:es}") - private String defaultLanguageCode; - - public NotificationManagerService( - DialogflowClientService dialogflowClientService, - FirestoreNotificationService firestoreNotificationService, - MemoryStoreNotificationService memoryStoreNotificationService, - MemoryStoreConversationService memoryStoreConversationService) { +public NotificationManagerService( + DialogflowClientService dialogflowClientService, + FirestoreNotificationService firestoreNotificationService, + MemoryStoreNotificationService memoryStoreNotificationService, + FirestoreNotificationConvService firestoreConversationService) { this.dialogflowClientService = dialogflowClientService; this.firestoreNotificationService = firestoreNotificationService; this.memoryStoreNotificationService = memoryStoreNotificationService; - this.memoryStoreConversationService = memoryStoreConversationService; - } + this.firestoreConversationService = firestoreConversationService; +} - public Mono processNotification(ExternalNotRequestDTO externalRequest) { +public Mono processNotification(ExternalNotRequestDTO externalRequest) { Objects.requireNonNull(externalRequest, "ExternalNotRequestDTO cannot be null."); String telefono = externalRequest.phoneNumber(); if (telefono == null || telefono.isBlank()) { - logger.warn("No phone number provided in ExternalNotRequestDTO. Cannot process notification."); - return Mono.error(new IllegalArgumentException("Phone number is required.")); + logger.warn("No phone number provided in ExternalNotRequestDTO. Cannot process notification."); + return Mono.error(new IllegalArgumentException("Phone number is required.")); } // 1. Persist the incoming notification entry - String newNotificationId = SessionIdGenerator.generateStandardSessionId(); - NotificationDTO newNotificationEntry = new NotificationDTO(newNotificationId,telefono, Instant.now(), - externalRequest.text(),eventName, defaultLanguageCode,Collections.emptyMap() -); + String newNotificationId = SessionIdGenerator.generateStandardSessionId(); + NotificationDTO newNotificationEntry = new NotificationDTO(newNotificationId, telefono, Instant.now(), + externalRequest.text(), eventName, defaultLanguageCode, Collections.emptyMap(), "active"); Mono persistenceMono = memoryStoreNotificationService.saveOrAppendNotificationEntry(newNotificationEntry) - .doOnSuccess(v -> { - logger.info("Notification for phone {} cached. Kicking off async Firestore write-back.", telefono); - firestoreNotificationService.saveOrAppendNotificationEntry(newNotificationEntry) - .subscribe( - ignored -> logger.debug("Background: Notification entry persistence initiated for phone {} in Firestore.", telefono), - e -> logger.error("Background: Error during notification entry persistence for phone {} in Firestore: {}", telefono, e.getMessage(), e) - ); - }); + .doOnSuccess(v -> { + logger.info("Notification for phone {} cached. Kicking off async Firestore write-back.", telefono); + firestoreNotificationService.saveOrAppendNotificationEntry(newNotificationEntry) + .subscribe( + ignored -> logger.debug( + "Background: Notification entry persistence initiated for phone {} in Firestore.",telefono), + e -> logger.error( + "Background: Error during notification entry persistence for phone {} in Firestore: {}", + telefono, e.getMessage(), e)); + }); // 2. Resolve or create a conversation session - Mono sessionMono = memoryStoreConversationService.getSessionByTelefono(telefono) - .doOnNext(session -> logger.info("Found existing conversation session {} for phone number {}", session.sessionId(), telefono)) - .switchIfEmpty(Mono.defer(() -> { + Mono sessionMono = memoryStoreNotificationService.getSessionByTelefono(telefono) + .doOnNext(session -> logger.info("Found existing conversation session {} for phone number {}", + session.sessionId(), telefono)) + .flatMap(session -> { + ConversationEntryDTO systemEntry = ConversationEntryDTO.forSystem(externalRequest.text()); + return persistConversationTurn(session.userId(), session.sessionId(), systemEntry, telefono) + .thenReturn(session); + }) + .switchIfEmpty(Mono.defer(() -> { String newSessionId = SessionIdGenerator.generateStandardSessionId(); - logger.info("No existing conversation session found for phone number {}. Creating new session: {}", telefono, newSessionId); - return Mono.just(ConversationSessionDTO.create(newSessionId, "user_by_phone_" + telefono, telefono)); - })); + logger.info("No existing conversation session found for phone number {}. Creating new session: {}",telefono, newSessionId); + String userId = "user_by_phone_" + telefono; + ConversationEntryDTO systemEntry = ConversationEntryDTO.forSystem(externalRequest.text()); + return persistConversationTurn(userId, newSessionId, systemEntry, telefono) + .then(Mono.just(ConversationSessionDTO.create(newSessionId, userId, telefono))); + })); - // 3. Send notification text to Dialogflow using the resolved conversation session + // 3. Send notification text to Dialogflow using the resolved conversation + // session return persistenceMono.then(sessionMono) - .flatMap(session -> { + .flatMap(session -> { final String sessionId = session.sessionId(); logger.info("Sending notification text to Dialogflow using conversation session: {}", sessionId); Map parameters = new HashMap<>(); parameters.put("telefono", telefono); parameters.put(NOTIFICATION_TEXT_PARAM, newNotificationEntry.texto()); - - // Use a TextInputDTO to correctly build the QueryInputDTO + + // Use a TextInputDTO to correctly build the QueryInputDTO TextInputDTO textInput = new TextInputDTO(newNotificationEntry.texto()); QueryInputDTO queryInput = new QueryInputDTO(textInput, null, defaultLanguageCode); - + DetectIntentRequestDTO detectIntentRequest = new DetectIntentRequestDTO( - queryInput, - new QueryParamsDTO(parameters) - ); + queryInput, + new QueryParamsDTO(parameters)); return dialogflowClientService.detectIntent(sessionId, detectIntentRequest); - }) - .doOnSuccess(response -> logger.info("Finished processing notification. Dialogflow response received for phone {}.", telefono)) - .doOnError(e -> logger.error("Overall error in NotificationManagerService: {}", e.getMessage(), e)); - } -} \ No newline at end of file + }) + .doOnSuccess(response -> logger + .info("Finished processing notification. Dialogflow response received for phone {}.", telefono)) + .doOnError(e -> logger.error("Overall error in NotificationManagerService: {}", e.getMessage(), e)); + } + + private Mono persistConversationTurn(String userId, String sessionId, ConversationEntryDTO entry, + String userPhoneNumber) { + logger.debug("Starting Write-Back persistence for session {}. Type: {}. Writing to Redis first.", sessionId, + entry.type().name()); + + return memoryStoreNotificationService.saveEntry(userId, sessionId, entry, userPhoneNumber) + .doOnSuccess(v -> { + logger.info( + "Entry saved to Redis for session {}. Type: {}. Kicking off async Firestore write-back.", + sessionId, entry.type().name()); + firestoreConversationService.saveEntry(userId, sessionId, entry, userPhoneNumber) + .subscribe( + fsVoid -> logger.debug( + "Asynchronously (Write-Back): Entry successfully saved to Firestore for session {}. Type: {}.", + sessionId, entry.type().name()), + fsError -> logger.error( + "Asynchronously (Write-Back): Failed to save entry to Firestore for session {}. Type: {}: {}", + sessionId, entry.type().name(), fsError.getMessage(), fsError)); + }) + .doOnError(e -> logger.error("Error during primary Redis write for session {}. Type: {}: {}", sessionId, + entry.type().name(), e.getMessage(), e)); + } +} diff --git a/src/main/java/com/example/service/quickreplies/FirestoreQRService.java b/src/main/java/com/example/service/quickreplies/FirestoreQRService.java new file mode 100644 index 0000000..f95a5fd --- /dev/null +++ b/src/main/java/com/example/service/quickreplies/FirestoreQRService.java @@ -0,0 +1,88 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.service.quickreplies; + +import com.example.dto.dialogflow.conversation.ConversationEntryDTO; +import com.example.dto.dialogflow.conversation.ConversationSessionDTO; +import com.example.exception.FirestorePersistenceException; +import com.example.mapper.quickreplies.FirestoreQuickReplyMapper; +import com.example.repository.FirestoreBaseRepository; +import com.google.cloud.firestore.DocumentReference; +import com.google.cloud.firestore.WriteBatch; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +import java.util.Map; +import java.util.concurrent.ExecutionException; + +@Service +public class FirestoreQRService { + + private static final Logger logger = LoggerFactory.getLogger(FirestoreQRService.class); + private static final String CONVERSATION_COLLECTION_PATH_FORMAT = "artifacts/%s/quick-replies-conversations"; + private final FirestoreBaseRepository firestoreBaseRepository; + private final FirestoreQuickReplyMapper firestoreQuickReplyMapper; + + public FirestoreQRService(FirestoreBaseRepository firestoreBaseRepository, FirestoreQuickReplyMapper firestoreQuickReplyMapper) { + this.firestoreBaseRepository = firestoreBaseRepository; + this.firestoreQuickReplyMapper = firestoreQuickReplyMapper; + } + + public Mono saveEntry(String userId, String sessionId, ConversationEntryDTO newEntry, String userPhoneNumber) { + logger.info("Attempting to save quick reply entry to Firestore for session {}. Entity: {}", sessionId, newEntry.entity().name()); + return Mono.fromRunnable(() -> { + DocumentReference sessionDocRef = getSessionDocumentReference(sessionId); + WriteBatch batch = firestoreBaseRepository.createBatch(); + + try { + if (firestoreBaseRepository.documentExists(sessionDocRef)) { + Map updates = firestoreQuickReplyMapper.createUpdateMapForSingleEntry(newEntry); + batch.update(sessionDocRef, updates); + logger.info("Appending entry to existing quick reply session for user {} and session {}. Entity: {}", userId, sessionId, newEntry.entity().name()); + } else { + Map newSessionMap = firestoreQuickReplyMapper.createNewSessionMapForSingleEntry(sessionId, userId, userPhoneNumber, newEntry); + batch.set(sessionDocRef, newSessionMap); + logger.info("Creating new quick reply session with first entry for user {} and session {}. Entity: {}", userId, sessionId, newEntry.entity().name()); + } + firestoreBaseRepository.commitBatch(batch); + logger.info("Successfully committed batch for session {} to Firestore.", sessionId); + } catch (ExecutionException e) { + logger.error("Error saving quick reply entry to Firestore for session {}: {}", sessionId, e.getMessage(), e); + throw new FirestorePersistenceException("Failed to save quick reply entry to Firestore for session " + sessionId, e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + logger.error("Thread interrupted while saving quick reply entry to Firestore for session {}: {}", sessionId, e.getMessage(), e); + throw new FirestorePersistenceException("Saving quick reply entry was interrupted for session " + sessionId, e); + } + }).subscribeOn(Schedulers.boundedElastic()).then(); + } + + public Mono getSessionByTelefono(String userPhoneNumber) { + logger.info("Attempting to retrieve quick reply session for phone number {}.", userPhoneNumber); + return firestoreBaseRepository.getDocumentsByField(getConversationCollectionPath(), "userPhoneNumber", userPhoneNumber) + .map(documentSnapshot -> { + if (documentSnapshot != null && documentSnapshot.exists()) { + ConversationSessionDTO sessionDTO = firestoreQuickReplyMapper.mapFirestoreDocumentToConversationSessionDTO(documentSnapshot); + logger.info("Successfully retrieved and mapped quick reply session for session {}.", sessionDTO.sessionId()); + return sessionDTO; + } + logger.info("Quick reply session not found for phone number {}.", userPhoneNumber); + return null; + }); + } + + private String getConversationCollectionPath() { + return String.format(CONVERSATION_COLLECTION_PATH_FORMAT, firestoreBaseRepository.getAppId()); + } + + private DocumentReference getSessionDocumentReference(String sessionId) { + String collectionPath = getConversationCollectionPath(); + return firestoreBaseRepository.getDocumentReference(collectionPath, sessionId); + } +} diff --git a/src/main/java/com/example/service/quickreplies/MemoryStoreQRService.java b/src/main/java/com/example/service/quickreplies/MemoryStoreQRService.java new file mode 100644 index 0000000..dc3410b --- /dev/null +++ b/src/main/java/com/example/service/quickreplies/MemoryStoreQRService.java @@ -0,0 +1,80 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.service.quickreplies; + +import com.example.dto.dialogflow.conversation.ConversationEntryDTO; +import com.example.dto.dialogflow.conversation.ConversationSessionDTO; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.ReactiveRedisTemplate; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; + +import java.time.Duration; + +@Service +public class MemoryStoreQRService { + private static final Logger logger = LoggerFactory.getLogger(MemoryStoreQRService.class); + private static final String SESSION_KEY_PREFIX = "qr:session:"; + private static final String PHONE_TO_SESSION_KEY_PREFIX = "qr:phone_to_session:"; + private static final Duration SESSION_TTL = Duration.ofHours(24); + private final ReactiveRedisTemplate redisTemplate; + private final ReactiveRedisTemplate stringRedisTemplate; + + @Autowired + public MemoryStoreQRService( + ReactiveRedisTemplate redisTemplate, + ReactiveRedisTemplate stringRedisTemplate) { + this.redisTemplate = redisTemplate; + this.stringRedisTemplate = stringRedisTemplate; + } + + public Mono saveEntry(String userId, String sessionId, ConversationEntryDTO newEntry, String userPhoneNumber) { + String sessionKey = SESSION_KEY_PREFIX + sessionId; + String phoneToSessionKey = PHONE_TO_SESSION_KEY_PREFIX + userPhoneNumber; + + logger.info("Attempting to save entry to Redis for quick reply session {}. Entity: {}", sessionId, newEntry.entity().name()); + + return redisTemplate.opsForValue().get(sessionKey) + .defaultIfEmpty(ConversationSessionDTO.create(sessionId, userId, userPhoneNumber)) + .flatMap(session -> { + ConversationSessionDTO sessionWithUpdatedTelefono = session.withTelefono(userPhoneNumber); + ConversationSessionDTO updatedSession = sessionWithUpdatedTelefono.withAddedEntry(newEntry); + + logger.info("Attempting to set updated quick reply session {} with new entry entity {} in Redis.", sessionId, newEntry.entity().name()); + + return redisTemplate.opsForValue().set(sessionKey, updatedSession, SESSION_TTL) + .then(stringRedisTemplate.opsForValue().set(phoneToSessionKey, sessionId, SESSION_TTL)) + .then(); + }) + .doOnSuccess(success -> { + logger.info("Successfully saved updated quick reply session and phone mapping to Redis for session {}. Entity Type: {}", sessionId, newEntry.entity().name()); + }) + .doOnError(e -> logger.error("Error appending entry to Redis for quick reply session {}: {}", sessionId, e.getMessage(), e)); + } + + public Mono getSessionByTelefono(String telefono) { + if (telefono == null || telefono.isBlank()) { + return Mono.empty(); + } + String phoneToSessionKey = PHONE_TO_SESSION_KEY_PREFIX + telefono; + logger.debug("Attempting to retrieve quick reply session ID for phone number {} from Redis.", telefono); + return stringRedisTemplate.opsForValue().get(phoneToSessionKey) + .flatMap(sessionId -> { + logger.debug("Found quick reply session ID {} for phone number {}. Retrieving session data.", sessionId, telefono); + return redisTemplate.opsForValue().get(SESSION_KEY_PREFIX + sessionId); + }) + .doOnSuccess(session -> { + if (session != null) { + logger.info("Successfully retrieved quick reply session {} by phone number {}.", session.sessionId(), telefono); + } else { + logger.info("No quick reply session found in Redis for phone number {}.", telefono); + } + }) + .doOnError(e -> logger.error("Error retrieving quick reply session by phone number {}: {}", telefono, e.getMessage(), e)); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/service/quickreplies/QuickRepliesManagerService.java b/src/main/java/com/example/service/quickreplies/QuickRepliesManagerService.java new file mode 100644 index 0000000..44f0443 --- /dev/null +++ b/src/main/java/com/example/service/quickreplies/QuickRepliesManagerService.java @@ -0,0 +1,104 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.service.quickreplies; + +import com.example.dto.dialogflow.base.DetectIntentResponseDTO; +import com.example.dto.dialogflow.conversation.ConversationEntryDTO; +import com.example.dto.dialogflow.conversation.ConversationEntryEntity; +import com.example.dto.dialogflow.conversation.ConversationEntryType; +import com.example.dto.dialogflow.conversation.ExternalConvRequestDTO; +import com.example.dto.quickreplies.QuickReplyScreenRequestDTO; +import com.example.service.conversation.FirestoreConversationService; +import com.example.service.conversation.MemoryStoreConversationService; +import com.example.util.SessionIdGenerator; + +import java.time.Instant; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; + +@Service +public class QuickRepliesManagerService { + + private static final Logger logger = LoggerFactory.getLogger(QuickRepliesManagerService.class); + + private final MemoryStoreConversationService memoryStoreConversationService; + private final FirestoreConversationService firestoreConversationService; + private final QuickReplyContentService quickReplyContentService; + + public QuickRepliesManagerService( + MemoryStoreConversationService memoryStoreConversationService, + FirestoreConversationService firestoreConversationService, + QuickReplyContentService quickReplyContentService) { + this.memoryStoreConversationService = memoryStoreConversationService; + this.firestoreConversationService = firestoreConversationService; + this.quickReplyContentService = quickReplyContentService; + } + + public Mono startQuickReplySession(QuickReplyScreenRequestDTO externalRequest) { + String userPhoneNumber = externalRequest.user().telefono(); + if (userPhoneNumber == null || userPhoneNumber.isBlank()) { + logger.warn("No phone number provided in request. Cannot manage conversation session without it."); + return Mono.error(new IllegalArgumentException("Phone number is required to manage conversation sessions.")); + } + + return memoryStoreConversationService.getSessionByTelefono(userPhoneNumber) + .flatMap(session -> Mono.just(session.sessionId())) + .switchIfEmpty(Mono.fromCallable(SessionIdGenerator::generateStandardSessionId)) + .flatMap(sessionId -> { + String userId = "user_by_phone_" + userPhoneNumber.replaceAll("[^0-9]", ""); + ConversationEntryDTO systemEntry = new ConversationEntryDTO( + ConversationEntryEntity.SISTEMA, + ConversationEntryType.INICIO, + Instant.now(), + "Pantalla :" + externalRequest.pantallaContexto() + " Agregada a la conversacion :", + null, + null + ); + + return persistConversationTurn(userId, sessionId, systemEntry, userPhoneNumber, externalRequest.pantallaContexto()) + .then(quickReplyContentService.getQuickReplies(externalRequest.pantallaContexto())) + .map(quickReplyDTO -> new DetectIntentResponseDTO(sessionId, null, quickReplyDTO)); + }); + } + + public Mono manageConversation(ExternalConvRequestDTO externalRequest) { + String userPhoneNumber = externalRequest.user().telefono(); + if (userPhoneNumber == null || userPhoneNumber.isBlank()) { + logger.warn("No phone number provided in request. Cannot manage conversation session without it."); + return Mono.error(new IllegalArgumentException("Phone number is required to manage conversation sessions.")); + } + + return memoryStoreConversationService.getSessionByTelefono(userPhoneNumber) + .switchIfEmpty(Mono.error(new IllegalStateException("No quick reply session found for phone number: " + userPhoneNumber))) + .flatMap(session -> { + String userId = session.userId(); + String sessionId = session.sessionId(); + ConversationEntryDTO userEntry = ConversationEntryDTO.forUser(externalRequest.message()); + + return persistConversationTurn(userId, sessionId, userEntry, userPhoneNumber, session.pantallaContexto()) + .then(quickReplyContentService.getQuickReplies(session.pantallaContexto())) + .flatMap(quickReplyDTO -> { + ConversationEntryDTO agentEntry = ConversationEntryDTO.forAgentWithMessage(quickReplyDTO.toString()); + return persistConversationTurn(userId, sessionId, agentEntry, userPhoneNumber, session.pantallaContexto()) + .thenReturn(new DetectIntentResponseDTO(sessionId, null, quickReplyDTO)); + }); + }); + } + + private Mono persistConversationTurn(String userId, String sessionId, ConversationEntryDTO entry, String userPhoneNumber, String pantallaContexto) { + logger.debug("Starting Write-Back persistence for quick reply session {}. Type: {}. Writing to Redis first.", sessionId, entry.type().name()); + + return memoryStoreConversationService.saveEntry(userId, sessionId, entry, userPhoneNumber, pantallaContexto) + .doOnSuccess(v -> logger.info("Entry saved to Redis for quick reply session {}. Type: {}. Kicking off async Firestore write-back.", sessionId, entry.type().name())) + .then(firestoreConversationService.saveEntry(userId, sessionId, entry, userPhoneNumber, pantallaContexto) + .doOnSuccess(fsVoid -> logger.debug("Asynchronously (Write-Back): Entry successfully saved to Firestore for quick reply session {}. Type: {}.", sessionId, entry.type().name())) + .doOnError(fsError -> logger.error("Asynchronously (Write-Back): Failed to save entry to Firestore for quick reply session {}. Type: {}: {}", sessionId, entry.type().name(), fsError.getMessage(), fsError))) + .doOnError(e -> logger.error("Error during primary Redis write for quick reply session {}. Type: {}: {}", sessionId, entry.type().name(), e.getMessage(), e)); + } +} diff --git a/src/main/java/com/example/service/quickreplies/QuickReplyContentService.java b/src/main/java/com/example/service/quickreplies/QuickReplyContentService.java new file mode 100644 index 0000000..972b271 --- /dev/null +++ b/src/main/java/com/example/service/quickreplies/QuickReplyContentService.java @@ -0,0 +1,69 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.service.quickreplies; + +import com.example.dto.quickreplies.QuestionDTO; +import com.example.dto.quickreplies.QuickReplyDTO; +import com.google.cloud.firestore.DocumentSnapshot; +import com.google.cloud.firestore.Firestore; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; + +import java.util.Collections; +import java.util.List; + +@Service +public class QuickReplyContentService { + + private static final Logger logger = LoggerFactory.getLogger(QuickReplyContentService.class); + + private final Firestore firestore; + + public QuickReplyContentService(Firestore firestore) { + this.firestore = firestore; + } + + public Mono getQuickReplies(String collectionId) { + logger.info("Fetching quick replies from Firestore for document: {}", collectionId); + + if (collectionId == null || collectionId.isBlank()) { + logger.warn("collectionId is null or empty. Returning empty quick replies."); + return Mono.just(new QuickReplyDTO("empty", Collections.emptyList())); + } + + return Mono.fromCallable(() -> { + try { + return firestore.collection("artifacts") + .document("default-app-id") + .collection("quick-replies") + .document(collectionId) + .get() + .get(); + } catch (Exception e) { + throw new RuntimeException(e); + } + }) + .filter(DocumentSnapshot::exists) + .map(document -> { + QuestionDTO pregunta = new QuestionDTO(document.getString("titulo"), document.getString("descripcion")); + return new QuickReplyDTO("preguntas sobre " + collectionId, List.of(pregunta)); + }) + .doOnSuccess(quickReplyDTO -> { + if (quickReplyDTO != null) { + logger.info("Successfully fetched {} quick replies for document: {}", quickReplyDTO.preguntas().size(), collectionId); + } else { + logger.info("No quick reply document found for id: {}", collectionId); + } + }) + .doOnError(error -> logger.error("Error fetching quick replies from Firestore for document: {}", collectionId, error)) + .switchIfEmpty(Mono.defer(() -> { + logger.info("No quick reply document found for id: {}", collectionId); + return Mono.empty(); + })); + } +} diff --git a/src/main/java/com/example/util/FirestoreDataImporter.java b/src/main/java/com/example/util/FirestoreDataImporter.java new file mode 100644 index 0000000..185b13f --- /dev/null +++ b/src/main/java/com/example/util/FirestoreDataImporter.java @@ -0,0 +1,80 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.util; + +import com.example.repository.FirestoreBaseRepository; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.cloud.firestore.DocumentReference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import jakarta.annotation.PostConstruct; +import java.io.IOException; +import java.io.InputStream; +import java.util.Map; +import java.util.concurrent.ExecutionException; + +@Component +public class FirestoreDataImporter { + + private static final Logger logger = LoggerFactory.getLogger(FirestoreDataImporter.class); + private static final String QUICK_REPLIES_COLLECTION_PATH_FORMAT = "artifacts/%s/quick-replies"; + + @Autowired + private FirestoreBaseRepository firestoreBaseRepository; + + @Autowired + private ObjectMapper objectMapper; + + @PostConstruct + public void importDataOnStartup() { + try { + importQuickReplies(); + } catch (Exception e) { + logger.error("Failed to import data to Firestore on startup", e); + } + } + + private void importQuickReplies() throws IOException, ExecutionException, InterruptedException { + String collectionPath = String.format(QUICK_REPLIES_COLLECTION_PATH_FORMAT, firestoreBaseRepository.getAppId()); + + importJson(collectionPath, "home"); + importJson(collectionPath, "pagos"); + importJson(collectionPath, "finanzas"); + importJson(collectionPath, "lealtad"); + importJson(collectionPath, "descubre"); + importJson(collectionPath, "detalle-tdc"); + importJson(collectionPath, "detalle-tdd"); + importJson(collectionPath, "transferencia"); + importJson(collectionPath, "retiro-sin-tarjeta"); + importJson(collectionPath, "capsulas"); + importJson(collectionPath, "inversiones"); + importJson(collectionPath, "prestamos"); + + logger.info("All JSON files were imported successfully."); + } + + private void importJson(String collectionPath, String documentId) throws IOException, ExecutionException, InterruptedException { + String resourcePath = "/quick-replies/" + documentId + ".json"; + try (InputStream inputStream = getClass().getResourceAsStream(resourcePath)) { + if (inputStream == null) { + logger.warn("Resource not found: {}", resourcePath); + return; + } + Map data = objectMapper.readValue(inputStream, new TypeReference>() {}); + DocumentReference docRef = firestoreBaseRepository.getDocumentReference(collectionPath, documentId); + if (!firestoreBaseRepository.documentExists(docRef)) { + firestoreBaseRepository.setDocument(docRef, data); + logger.debug("Successfully imported {} to Firestore.", documentId); + } else { + logger.debug("{} already exists in Firestore. Skipping import.", documentId); + } + } + } +} diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties new file mode 100644 index 0000000..ecc6fef --- /dev/null +++ b/src/main/resources/application-dev.properties @@ -0,0 +1,62 @@ +# Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. +# Your use of it is subject to your agreement with Google. + +# ========================================= +# Spring Boot Configuration Template +# ========================================= +# This file serves as a reference template for all application configuration properties. + +# Best Practices: +# - Use Spring Profiles (e.g., application-dev.properties, application-prod.properties) +# to manage environment-specific settings. +# - Do not store in PROD sensitive information (e.g., API keys, passwords) directly here. +# Use environment variables or a configuration server for production environments. +# - This template can be adapted for logging configuration, database connections, +# and other external service settings. + +# ========================================================= +# Orchestrator general Configuration +# ========================================================= +spring.cloud.gcp.project-id=bnt-orquestador-cognitivo-dev +# ========================================================= +# Google Firestore Configuration +# ========================================================= +spring.cloud.gcp.firestore.project-id=bnt-orquestador-cognitivo-dev +spring.cloud.gcp.firestore.database-id=bnt-orquestador-cognitivo-firestore-bdo-dev +spring.cloud.gcp.firestore.host=firestore.googleapis.com +spring.cloud.gcp.firestore.port=443 +# ========================================================= +# Google Memorystore(Redis) Configuration +# ========================================================= +spring.data.redis.host=10.33.22.4 +spring.data.redis.port=6379 +spring.redis.jedis.pool.enabled=true +spring.redis.notify-keyspace-events=Ex +#spring.data.redis.password=23cb4c76-9d96-4c74-b8c0-778fb364877a +#spring.data.redis.username=default + +# SSL Configuration (if using SSL) +# spring.data.redis.ssl=true +# spring.data.redis.ssl.key-store=classpath:keystore.p12 +# spring.data.redis.ssl.key-store-password=your-keystore-password +# ========================================================= +# Google Conversational Agents Configuration +# ========================================================= +dialogflow.cx.project-id=bnt-orquestador-cognitivo-dev +dialogflow.cx.location=us-central1 +dialogflow.cx.agent-id=5590ff1d-1f66-4777-93f5-1a608f1900ac +dialogflow.default-language-code=es +# ========================================================= +# Google Generative AI (Gemini) Configuration +# ========================================================= +google.cloud.project=bnt-orquestador-cognitivo-dev +google.cloud.location=us-central1 +gemini.model.name=gemini-2.0-flash-001 +# ========================================================= +# (Gemini) MessageFilter Configuration +# ========================================================= +messagefilter.geminimodel=gemini-2.0-flash-001 +messagefilter.temperature=0.1f +messagefilter.maxOutputTokens=800 +messagefilter.topP= 0.1f +messagefilter.prompt=prompts/message_filter_prompt.txt \ No newline at end of file diff --git a/src/main/resources/application-prod.properties b/src/main/resources/application-prod.properties new file mode 100644 index 0000000..937530a --- /dev/null +++ b/src/main/resources/application-prod.properties @@ -0,0 +1,60 @@ +# Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. +# Your use of it is subject to your agreement with Google. + +# ========================================= +# Spring Boot Configuration Template +# ========================================= +# This file serves as a reference template for all application configuration properties. + +# Best Practices: +# - Use Spring Profiles (e.g., application-dev.properties, application-prod.properties) +# to manage environment-specific settings. +# - Do not store in PROD sensitive information (e.g., API keys, passwords) directly here. +# Use environment variables or a configuration server for production environments. +# - This template can be adapted for logging configuration, database connections, +# and other external service settings. + +# ========================================================= +# Orchestrator general Configuration +# ========================================================= +spring.cloud.gcp.project-id=${GCP_PROJECT_ID} +# ========================================================= +# Google Firestore Configuration +# ========================================================= +spring.cloud.gcp.firestore.project-id=${GCP_PROJECT_ID} +spring.cloud.gcp.firestore.database-id=${GCP_FIRESTORE_DATABASE_ID} +spring.cloud.gcp.firestore.host=${GCP_FIRESTORE_HOST} +spring.cloud.gcp.firestore.port=${GCP_FIRESTORE_PORT} +# ========================================================= +# Google Memorystore(Redis) Configuration +# ========================================================= +spring.data.redis.host=${REDIS_HOST} +spring.data.redis.port=${REDIS_PORT} +#spring.data.redis.password=23cb4c76-9d96-4c74-b8c0-778fb364877a +#spring.data.redis.username=default + +# SSL Configuration (if using SSL) +# spring.data.redis.ssl=true +# spring.data.redis.ssl.key-store=classpath:keystore.p12 +# spring.data.redis.ssl.key-store-password=your-keystore-password +# ========================================================= +# Google Conversational Agents Configuration +# ========================================================= +dialogflow.cx.project-id=${DIALOGFLOW_CX_PROJECT_ID} +dialogflow.cx.location=${DIALOGFLOW_CX_LOCATION} +dialogflow.cx.agent-id=${DIALOGFLOW_CX_AGENT_ID} +dialogflow.default-language-code=${DIALOGFLOW_DEFAULT_LANGUAGE_CODE} +# ========================================================= +# Google Generative AI (Gemini) Configuration +# ========================================================= +google.cloud.project=${GCP_PROJECT_ID} +google.cloud.location=${GCP_LOCATION} +gemini.model.name=${GEMINI_MODEL_NAME} +# ========================================================= +# (Gemini) MessageFilter Configuration +# ========================================================= +messagefilter.geminimodel=${MESSAGE_FILTER_GEMINI_MODEL} +messagefilter.temperature=${MESSAGE_FILTER_TEMPERATURE} +messagefilter.maxOutputTokens=${MESSAGE_FILTER_MAX_OUTPUT_TOKENS} +messagefilter.topP=${MESSAGE_FILTER_TOP_P} +messagefilter.prompt=prompts/message_filter_prompt.txt diff --git a/src/main/resources/application-qa.properties b/src/main/resources/application-qa.properties new file mode 100644 index 0000000..937530a --- /dev/null +++ b/src/main/resources/application-qa.properties @@ -0,0 +1,60 @@ +# Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. +# Your use of it is subject to your agreement with Google. + +# ========================================= +# Spring Boot Configuration Template +# ========================================= +# This file serves as a reference template for all application configuration properties. + +# Best Practices: +# - Use Spring Profiles (e.g., application-dev.properties, application-prod.properties) +# to manage environment-specific settings. +# - Do not store in PROD sensitive information (e.g., API keys, passwords) directly here. +# Use environment variables or a configuration server for production environments. +# - This template can be adapted for logging configuration, database connections, +# and other external service settings. + +# ========================================================= +# Orchestrator general Configuration +# ========================================================= +spring.cloud.gcp.project-id=${GCP_PROJECT_ID} +# ========================================================= +# Google Firestore Configuration +# ========================================================= +spring.cloud.gcp.firestore.project-id=${GCP_PROJECT_ID} +spring.cloud.gcp.firestore.database-id=${GCP_FIRESTORE_DATABASE_ID} +spring.cloud.gcp.firestore.host=${GCP_FIRESTORE_HOST} +spring.cloud.gcp.firestore.port=${GCP_FIRESTORE_PORT} +# ========================================================= +# Google Memorystore(Redis) Configuration +# ========================================================= +spring.data.redis.host=${REDIS_HOST} +spring.data.redis.port=${REDIS_PORT} +#spring.data.redis.password=23cb4c76-9d96-4c74-b8c0-778fb364877a +#spring.data.redis.username=default + +# SSL Configuration (if using SSL) +# spring.data.redis.ssl=true +# spring.data.redis.ssl.key-store=classpath:keystore.p12 +# spring.data.redis.ssl.key-store-password=your-keystore-password +# ========================================================= +# Google Conversational Agents Configuration +# ========================================================= +dialogflow.cx.project-id=${DIALOGFLOW_CX_PROJECT_ID} +dialogflow.cx.location=${DIALOGFLOW_CX_LOCATION} +dialogflow.cx.agent-id=${DIALOGFLOW_CX_AGENT_ID} +dialogflow.default-language-code=${DIALOGFLOW_DEFAULT_LANGUAGE_CODE} +# ========================================================= +# Google Generative AI (Gemini) Configuration +# ========================================================= +google.cloud.project=${GCP_PROJECT_ID} +google.cloud.location=${GCP_LOCATION} +gemini.model.name=${GEMINI_MODEL_NAME} +# ========================================================= +# (Gemini) MessageFilter Configuration +# ========================================================= +messagefilter.geminimodel=${MESSAGE_FILTER_GEMINI_MODEL} +messagefilter.temperature=${MESSAGE_FILTER_TEMPERATURE} +messagefilter.maxOutputTokens=${MESSAGE_FILTER_MAX_OUTPUT_TOKENS} +messagefilter.topP=${MESSAGE_FILTER_TOP_P} +messagefilter.prompt=prompts/message_filter_prompt.txt diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 65c48e5..257b306 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,64 +1 @@ -# Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. -# Your use of it is subject to your agreement with Google. - -# ========================================= -# Spring Boot Configuration Template -# ========================================= -# This file serves as a reference template for all application configuration properties. - -# Best Practices: -# - Use Spring Profiles (e.g., application-dev.properties, application-prod.properties) -# to manage environment-specific settings. -# - Do not store in PROD sensitive information (e.g., API keys, passwords) directly here. -# Use environment variables or a configuration server for production environments. -# - This template can be adapted for logging configuration, database connections, -# and other external service settings. - -# ========================================================= -# Orchestrator general Configuration -# ========================================================= -spring.cloud.gcp.project-id=bnt-orquestador-cognitivo-dev - -# Firestore Emulator Configuration (for local development) -#spring.cloud.gcp.firestore.emulator-host=localhost:8080 -spring.cloud.gcp.firestore.emulator.enabled=false -# ========================================================= -# Google Firestore Configuration -# ========================================================= -spring.cloud.gcp.firestore.project-id=bnt-orquestador-cognitivo-dev -spring.cloud.gcp.firestore.database-id=bnt-orquestador-cognitivo-firestore-bdo-dev -spring.cloud.gcp.firestore.host=firestore.googleapis.com -spring.cloud.gcp.firestore.port=443 -# ========================================================= -# Google Memorystore(Redis) Configuration -# ========================================================= -spring.data.redis.host=10.33.22.4 -spring.data.redis.port=6379 -#spring.data.redis.password=23cb4c76-9d96-4c74-b8c0-778fb364877a -#spring.data.redis.username=default - -# SSL Configuration (if using SSL) -# spring.data.redis.ssl=true -# spring.data.redis.ssl.key-store=classpath:keystore.p12 -# spring.data.redis.ssl.key-store-password=your-keystore-password -# ========================================================= -# Google Conversational Agents Configuration -# ========================================================= -dialogflow.cx.project-id=bnt-orquestador-cognitivo-dev -dialogflow.cx.location=us-central1 -dialogflow.cx.agent-id=5590ff1d-1f66-4777-93f5-1a608f1900ac -dialogflow.default-language-code=es -# ========================================================= -# Google Generative AI (Gemini) Configuration -# ========================================================= -google.cloud.project=bnt-orquestador-cognitivo-dev -google.cloud.location=us-central1 -gemini.model.name=gemini-2.0-flash-001 -# ========================================================= -# (Gemini) MessageFilter Configuration -# ========================================================= -messagefilter.geminimodel=gemini-2.0-flash-001 -messagefilter.temperature=0.1f -messagefilter.maxOutputTokens=800 -messagefilter.topP= 0.1f -messagefilter.prompt=prompts/message_filter_prompt.txt +spring.profiles.active=dev \ No newline at end of file diff --git a/src/main/resources/quick-replies/capsulas.json b/src/main/resources/quick-replies/capsulas.json new file mode 100644 index 0000000..be8a192 --- /dev/null +++ b/src/main/resources/quick-replies/capsulas.json @@ -0,0 +1 @@ +{"titulo": "Capsulas"} \ No newline at end of file diff --git a/src/main/resources/quick-replies/descubre.json b/src/main/resources/quick-replies/descubre.json new file mode 100644 index 0000000..cf46565 --- /dev/null +++ b/src/main/resources/quick-replies/descubre.json @@ -0,0 +1 @@ +{"titulo": "Descubre"} \ No newline at end of file diff --git a/src/main/resources/quick-replies/detalle-tdc.json b/src/main/resources/quick-replies/detalle-tdc.json new file mode 100644 index 0000000..42689ea --- /dev/null +++ b/src/main/resources/quick-replies/detalle-tdc.json @@ -0,0 +1 @@ +{"titulo": "Detalle TDC"} \ No newline at end of file diff --git a/src/main/resources/quick-replies/detalle-tdd.json b/src/main/resources/quick-replies/detalle-tdd.json new file mode 100644 index 0000000..dc2490c --- /dev/null +++ b/src/main/resources/quick-replies/detalle-tdd.json @@ -0,0 +1 @@ +{"titulo": "Detalle TDD"} \ No newline at end of file diff --git a/src/main/resources/quick-replies/finanzas.json b/src/main/resources/quick-replies/finanzas.json new file mode 100644 index 0000000..920a72f --- /dev/null +++ b/src/main/resources/quick-replies/finanzas.json @@ -0,0 +1 @@ +{"titulo": "Finanzas"} \ No newline at end of file diff --git a/src/main/resources/quick-replies/home.json b/src/main/resources/quick-replies/home.json new file mode 100644 index 0000000..ac41a8c --- /dev/null +++ b/src/main/resources/quick-replies/home.json @@ -0,0 +1 @@ +{"titulo": "Home"} \ No newline at end of file diff --git a/src/main/resources/quick-replies/inversiones.json b/src/main/resources/quick-replies/inversiones.json new file mode 100644 index 0000000..c5b5b36 --- /dev/null +++ b/src/main/resources/quick-replies/inversiones.json @@ -0,0 +1 @@ +{"titulo": "Inversiones"} \ No newline at end of file diff --git a/src/main/resources/quick-replies/lealtad.json b/src/main/resources/quick-replies/lealtad.json new file mode 100644 index 0000000..1dfcb56 --- /dev/null +++ b/src/main/resources/quick-replies/lealtad.json @@ -0,0 +1 @@ +{"titulo": "Lealtad"} \ No newline at end of file diff --git a/src/main/resources/quick-replies/pagos.json b/src/main/resources/quick-replies/pagos.json new file mode 100644 index 0000000..b6a0351 --- /dev/null +++ b/src/main/resources/quick-replies/pagos.json @@ -0,0 +1,4 @@ +{ + "titulo": "Payment History", + "descripcion": "View your recent payments" +} diff --git a/src/main/resources/quick-replies/prestamos.json b/src/main/resources/quick-replies/prestamos.json new file mode 100644 index 0000000..9d1989d --- /dev/null +++ b/src/main/resources/quick-replies/prestamos.json @@ -0,0 +1 @@ +{"titulo": "Prestamos"} \ No newline at end of file diff --git a/src/main/resources/quick-replies/retiro-sin-tarjeta.json b/src/main/resources/quick-replies/retiro-sin-tarjeta.json new file mode 100644 index 0000000..6794c55 --- /dev/null +++ b/src/main/resources/quick-replies/retiro-sin-tarjeta.json @@ -0,0 +1 @@ +{"titulo": "Retiro sin tarjeta"} \ No newline at end of file diff --git a/src/main/resources/quick-replies/transferencia.json b/src/main/resources/quick-replies/transferencia.json new file mode 100644 index 0000000..0d10be4 --- /dev/null +++ b/src/main/resources/quick-replies/transferencia.json @@ -0,0 +1 @@ +{"titulo": "Transferencia"} \ No newline at end of file diff --git a/src/test/java/com/example/service/integration-testing/MessageEntryFilterIntegrationTest.java b/src/test/java/com/example/service/integration_testing/MessageEntryFilterIntegrationTest.java similarity index 99% rename from src/test/java/com/example/service/integration-testing/MessageEntryFilterIntegrationTest.java rename to src/test/java/com/example/service/integration_testing/MessageEntryFilterIntegrationTest.java index afc3ea6..a44a682 100644 --- a/src/test/java/com/example/service/integration-testing/MessageEntryFilterIntegrationTest.java +++ b/src/test/java/com/example/service/integration_testing/MessageEntryFilterIntegrationTest.java @@ -2,7 +2,7 @@ * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. * Your use of it is subject to your agreement with Google. */ -package com.example.service; +package com.example.service.integration_testing; import com.example.service.base.MessageEntryFilter; import com.example.util.PerformanceTimer; diff --git a/src/test/java/com/example/service/unit-testing/DialogflowClientServiceTest.java b/src/test/java/com/example/service/unit_testing/DialogflowClientServiceTest.java similarity index 99% rename from src/test/java/com/example/service/unit-testing/DialogflowClientServiceTest.java rename to src/test/java/com/example/service/unit_testing/DialogflowClientServiceTest.java index 21cebca..f5517a2 100644 --- a/src/test/java/com/example/service/unit-testing/DialogflowClientServiceTest.java +++ b/src/test/java/com/example/service/unit_testing/DialogflowClientServiceTest.java @@ -1,4 +1,4 @@ -package com.example.service; +package com.example.service.unit_testing; import com.example.dto.dialogflow.base.DetectIntentRequestDTO; import com.example.dto.dialogflow.base.DetectIntentResponseDTO; diff --git a/src/test/java/com/example/service/unit-testing/GeminiClientServiceTest .java b/src/test/java/com/example/service/unit_testing/GeminiClientServiceTest .java similarity index 100% rename from src/test/java/com/example/service/unit-testing/GeminiClientServiceTest .java rename to src/test/java/com/example/service/unit_testing/GeminiClientServiceTest .java diff --git a/src/test/java/com/example/service/unit-testing/MessageEntryFilterTest.java b/src/test/java/com/example/service/unit_testing/MessageEntryFilterTest.java similarity index 99% rename from src/test/java/com/example/service/unit-testing/MessageEntryFilterTest.java rename to src/test/java/com/example/service/unit_testing/MessageEntryFilterTest.java index dd410c8..a6cbb3e 100644 --- a/src/test/java/com/example/service/unit-testing/MessageEntryFilterTest.java +++ b/src/test/java/com/example/service/unit_testing/MessageEntryFilterTest.java @@ -2,7 +2,7 @@ * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. * Your use of it is subject to your agreement with Google. */ -package com.example.service; +package com.example.service.unit_testing; import com.example.service.base.GeminiClientService; import com.example.service.base.MessageEntryFilter;