diff --git a/README.md b/README.md index e725a4a..9392ea1 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,3 @@ * *Spring Boot Version:* `3.2.5` (defined in the parent POM) * *Spring Cloud GCP Version:* `5.3.0` (managed via `spring-cloud-gcp-dependencies`) * *Spring Cloud Version:* `2023.0.0` (managed via `spring-cloud-dependencies`) - - -Response Body Development: -```json -"responseId": "e582a35c-157c-4fb0-b96f-be4a0272ee33", - "queryResult": { - "responseText": "¡Hola! Soy Beto, tu asesor financiero de Banorte. ¿En qué puedo ayudarte hoy?", - "parameters": {}, - } -``` - diff --git a/pom.xml b/pom.xml index 339d537..d762ca8 100644 --- a/pom.xml +++ b/pom.xml @@ -114,7 +114,11 @@ com.fasterxml.jackson.module jackson-module-parameter-names 2.19.0 - + + + com.google.api + gax + diff --git a/src/main/java/com/example/Orchestrator.java b/src/main/java/com/example/Orchestrator.java index c078aaf..e9214d0 100644 --- a/src/main/java/com/example/Orchestrator.java +++ b/src/main/java/com/example/Orchestrator.java @@ -1,22 +1,18 @@ -package com.example; +/* + * 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. + */ -import static org.springframework.web.reactive.function.server.RequestPredicates.GET; -import static org.springframework.web.reactive.function.server.RouterFunctions.route; -import static org.springframework.web.reactive.function.server.ServerResponse.ok; +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.beans.factory.annotation.Value; 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.core.io.Resource; -import org.springframework.http.MediaType; import org.springframework.transaction.annotation.EnableTransactionManagement; -import org.springframework.web.reactive.function.server.RouterFunction; -import org.springframework.web.reactive.function.server.ServerResponse; @SpringBootApplication @@ -35,10 +31,4 @@ public class Orchestrator { public static void main(String[] args) { SpringApplication.run(Orchestrator.class, args); } - - @Bean - public RouterFunction indexRouter( - @Value("classpath:/static/index.html") final Resource indexHtml) { - return route(GET("/"), request -> ok().contentType(MediaType.TEXT_HTML).bodyValue(indexHtml)); - } } diff --git a/src/main/java/com/example/config/VertexAIConfig.java b/src/main/java/com/example/config/GeminiConfig.java similarity index 63% rename from src/main/java/com/example/config/VertexAIConfig.java rename to src/main/java/com/example/config/GeminiConfig.java index 1310acf..6121eb7 100644 --- a/src/main/java/com/example/config/VertexAIConfig.java +++ b/src/main/java/com/example/config/GeminiConfig.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.config; import com.google.genai.Client; @@ -9,10 +14,16 @@ import org.springframework.context.annotation.Configuration; import java.io.IOException; +/** + * Spring configuration class for initializing the Google Gen AI Client. + * It uses properties from the application's configuration to create a + * singleton `Client` bean for interacting with the Gemini model, ensuring + * proper resource management by specifying a destroy method. + */ @Configuration -public class VertexAIConfig { +public class GeminiConfig { - private static final Logger logger = LoggerFactory.getLogger(VertexAIConfig.class); + private static final Logger logger = LoggerFactory.getLogger(GeminiConfig.class); @Value("${google.cloud.project}") private String projectId; diff --git a/src/main/java/com/example/config/OpenApiConfig.java b/src/main/java/com/example/config/OpenApiConfig.java index 9a02397..09dc794 100644 --- a/src/main/java/com/example/config/OpenApiConfig.java +++ b/src/main/java/com/example/config/OpenApiConfig.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.config; import io.swagger.v3.oas.models.OpenAPI; @@ -6,6 +11,11 @@ import io.swagger.v3.oas.models.info.License; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +/** + * Spring configuration class for customizing OpenAPI (Swagger) documentation. + * It defines a single bean to configure the API's title, version, description, + * and license, providing a structured and user-friendly documentation page. + */ @Configuration public class OpenApiConfig { diff --git a/src/main/java/com/example/config/RedisConfig.java b/src/main/java/com/example/config/RedisConfig.java index ea1c100..ca28bc6 100644 --- a/src/main/java/com/example/config/RedisConfig.java +++ b/src/main/java/com/example/config/RedisConfig.java @@ -1,6 +1,12 @@ +/* + * 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.config; -import com.example.dto.dialogflow.ConversationSessionDTO; +import com.example.dto.dialogflow.conversation.ConversationSessionDTO; +import com.example.dto.dialogflow.notification.NotificationSessionDTO; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; @@ -12,32 +18,60 @@ import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.RedisSerializationContext; import org.springframework.data.redis.serializer.StringRedisSerializer; +/** + * Spring configuration class for setting up Reactive Redis(Memorystore in GCP) + * templates. + * It defines and customizes `ReactiveRedisTemplate` beans for different data + * types + * like `ConversationSessionDTO` and `NotificationDTO`, using Jackson for JSON + * serialization and ensuring proper handling of Java 8 and higher date/time + * objects. + */ @Configuration public class RedisConfig { @Bean public ReactiveRedisTemplate reactiveConversationRedisTemplate( - ReactiveRedisConnectionFactory factory) { + ReactiveRedisConnectionFactory factory) { ObjectMapper objectMapper = new ObjectMapper(); objectMapper.registerModule(new JavaTimeModule()); - objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); - Jackson2JsonRedisSerializer serializer = - new Jackson2JsonRedisSerializer<>(objectMapper, ConversationSessionDTO.class); + Jackson2JsonRedisSerializer serializer = new Jackson2JsonRedisSerializer<>( + objectMapper, ConversationSessionDTO.class); return new ReactiveRedisTemplate<>(factory, RedisSerializationContext - .newSerializationContext(new StringRedisSerializer()) - .value(serializer) - .build()); + .newSerializationContext(new StringRedisSerializer()) + .value(serializer) + .build()); } @Bean public ReactiveRedisTemplate reactiveStringRedisTemplate( - ReactiveRedisConnectionFactory factory) { + ReactiveRedisConnectionFactory factory) { return new ReactiveRedisTemplate<>(factory, RedisSerializationContext - .newSerializationContext(new StringRedisSerializer()) - .value(new StringRedisSerializer()) - .build()); - } + .newSerializationContext(new StringRedisSerializer()) + .value(new StringRedisSerializer()) + .build()); +} + +@Bean +public ReactiveRedisTemplate reactiveNotificationRedisTemplate( + ReactiveRedisConnectionFactory factory) { + ObjectMapper notificationObjectMapper = new ObjectMapper(); + notificationObjectMapper.registerModule(new JavaTimeModule()); + notificationObjectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + StringRedisSerializer keySerializer = new StringRedisSerializer(); + Jackson2JsonRedisSerializer valueSerializer = new Jackson2JsonRedisSerializer<>( + notificationObjectMapper, NotificationSessionDTO.class); + + RedisSerializationContext.RedisSerializationContextBuilder builder = RedisSerializationContext + .newSerializationContext(keySerializer); + + RedisSerializationContext context = builder.value(valueSerializer) + .build(); + return new ReactiveRedisTemplate<>(factory, context); +} } \ No newline at end of file diff --git a/src/main/java/com/example/controller/ConversationController.java b/src/main/java/com/example/controller/ConversationController.java index 0638944..6e707ba 100644 --- a/src/main/java/com/example/controller/ConversationController.java +++ b/src/main/java/com/example/controller/ConversationController.java @@ -1,8 +1,14 @@ +/* + * 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.dialogflow.DetectIntentRequestDTO; -import com.example.dto.dialogflow.DetectIntentResponseDTO; -import com.example.service.ConversationManagerService; +import com.example.dto.dialogflow.base.DetectIntentResponseDTO; +import com.example.dto.dialogflow.conversation.ExternalConvRequestDTO; +import com.example.mapper.conversation.ExternalConvRequestMapper; +import com.example.service.conversation.ConversationManagerService; import jakarta.validation.Valid; @@ -16,24 +22,21 @@ import org.slf4j.LoggerFactory; @RestController -@RequestMapping("/api/v1") +@RequestMapping("/api/v1/dialogflow") public class ConversationController { private static final Logger logger = LoggerFactory.getLogger(ConversationController.class); private final ConversationManagerService conversationManagerService; - public ConversationController(ConversationManagerService conversationManagerService) { + public ConversationController(ConversationManagerService conversationManagerService, + ExternalConvRequestMapper externalRequestToDialogflowMapper) { this.conversationManagerService = conversationManagerService; } - @PostMapping("/dialogflow/detect-intent") - public Mono detectIntent(@Valid @RequestBody DetectIntentRequestDTO request) { - logger.info("Received request for session: {}", request.sessionId()); + @PostMapping("/detect-intent") + public Mono detectIntent(@Valid @RequestBody ExternalConvRequestDTO request) { return conversationManagerService.manageConversation(request) - .doOnSuccess(response -> logger.info("Successfully processed direct Dialogflow request for session: {}", request.sessionId())) - .doOnError(error -> logger.error("Error processing direct Dialogflow request for session {}: {}", request.sessionId(), error.getMessage(), error)); + .doOnSuccess(response -> logger.info("Successfully processed direct Dialogflow request")) + .doOnError(error -> logger.error("Error processing direct Dialogflow request: {}", error.getMessage(), error)); } - - - } \ No newline at end of file diff --git a/src/main/java/com/example/controller/ConversationSummaryController.java b/src/main/java/com/example/controller/ConversationSummaryController.java index a32ead0..7e54a94 100644 --- a/src/main/java/com/example/controller/ConversationSummaryController.java +++ b/src/main/java/com/example/controller/ConversationSummaryController.java @@ -1,10 +1,16 @@ +/* + * 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.gemini.ConversationSummaryRequest; import com.example.dto.gemini.ConversationSummaryResponse; +import com.example.service.summary.ConversationSummaryService; + import jakarta.validation.Valid; -import com.example.service.ConversationSummaryService; + import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; diff --git a/src/main/java/com/example/controller/NotificationController.java b/src/main/java/com/example/controller/NotificationController.java new file mode 100644 index 0000000..e091274 --- /dev/null +++ b/src/main/java/com/example/controller/NotificationController.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.dialogflow.notification.ExternalNotRequestDTO; +import com.example.service.notification.NotificationManagerService; + +import jakarta.validation.Valid; +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; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@RestController +@RequestMapping("/api/v1/dialogflow") +public class NotificationController { + + private static final Logger logger = LoggerFactory.getLogger(ConversationController.class); + private final NotificationManagerService notificationManagerService; + + public NotificationController(NotificationManagerService notificationManagerService) { + this.notificationManagerService = notificationManagerService; + } + + @PostMapping("/notification") + public Mono processNotification(@Valid @RequestBody ExternalNotRequestDTO request) { + return notificationManagerService.processNotification(request) + .doOnSuccess(response -> logger.info("Successfully processed direct Dialogflow request")) + .doOnError(error -> logger.error("Error processing direct Dialogflow request: {}", error.getMessage(), error)) + .then(); + } +} diff --git a/src/main/java/com/example/dto/base/BaseRequest.java b/src/main/java/com/example/dto/base/BaseRequest.java deleted file mode 100644 index f298c6d..0000000 --- a/src/main/java/com/example/dto/base/BaseRequest.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.example.dto.base; - -import com.fasterxml.jackson.annotation.JsonSubTypes; -import com.fasterxml.jackson.annotation.JsonTypeInfo; - -@JsonTypeInfo( - use = JsonTypeInfo.Id.NAME, - include = JsonTypeInfo.As.PROPERTY, - property = "type" -) -@JsonSubTypes({ - @JsonSubTypes.Type(value = NotificationRequest.class, name = "NOTIFICATION"), -}) -public sealed interface BaseRequest - permits NotificationRequest{ - String type(); -} \ No newline at end of file diff --git a/src/main/java/com/example/dto/base/ConversationContext.java b/src/main/java/com/example/dto/base/ConversationContext.java deleted file mode 100644 index 37de5e5..0000000 --- a/src/main/java/com/example/dto/base/ConversationContext.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.example.dto.base; - -public record ConversationContext( - String userId, - String sessionId, - String userMessageText, - String primaryPhoneNumber -) {} \ No newline at end of file diff --git a/src/main/java/com/example/dto/base/NotificationRequest.java b/src/main/java/com/example/dto/base/NotificationRequest.java deleted file mode 100644 index 6d9b613..0000000 --- a/src/main/java/com/example/dto/base/NotificationRequest.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.example.dto.base; -import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.annotation.JsonProperty; -import jakarta.validation.Valid; - -@JsonInclude(JsonInclude.Include.NON_NULL) -public record NotificationRequest( - @JsonProperty("requestId") String requestId, - @JsonProperty("sessionId") String sessionId, - @JsonProperty("mensaje") String message, - @JsonProperty("SIC") String SIC, - @Valid Usuario usuario, - @JsonProperty("pantalla_contexto") String pantallaContexto, - @JsonProperty("canal") String canal -) implements BaseRequest { - @Override - public String type() { - return "NOTIFICATION"; - } - @JsonInclude(JsonInclude.Include.NON_NULL) - public record Usuario( - String telefono, - String nickname - ) { - } -} \ No newline at end of file diff --git a/src/main/java/com/example/dto/dialogflow/ConversationEntryDTO.java b/src/main/java/com/example/dto/dialogflow/ConversationEntryDTO.java deleted file mode 100644 index 4302c19..0000000 --- a/src/main/java/com/example/dto/dialogflow/ConversationEntryDTO.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.example.dto.dialogflow; - -import com.fasterxml.jackson.annotation.JsonInclude; -import java.time.Instant; -import java.util.Map; - -@JsonInclude(JsonInclude.Include.NON_NULL) -public record ConversationEntryDTO( - ConversationEntryType type, - Instant timestamp, - String text, - String intentDisplayName, - Map parameters, - String webhookStatus, - String canal -) { - public static ConversationEntryDTO forUser(String text) { - return new ConversationEntryDTO(ConversationEntryType.USER_MESSAGE, Instant.now(), - text, null, null, null, null); - } - - public static ConversationEntryDTO forAgent(QueryResultDTO agentQueryResult) { - String fulfillmentText = (agentQueryResult != null && agentQueryResult.responseText() != null) ? agentQueryResult.responseText() : ""; - - return new ConversationEntryDTO( - ConversationEntryType.AGENT_RESPONSE, - Instant.now(), - fulfillmentText, - null, - agentQueryResult.parameters(), - null, - null - ); - } -} \ No newline at end of file diff --git a/src/main/java/com/example/dto/dialogflow/ConversationEntryType.java b/src/main/java/com/example/dto/dialogflow/ConversationEntryType.java deleted file mode 100644 index ca130da..0000000 --- a/src/main/java/com/example/dto/dialogflow/ConversationEntryType.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.example.dto.dialogflow; - -public enum ConversationEntryType { - USER_MESSAGE, - AGENT_RESPONSE -} \ No newline at end of file diff --git a/src/main/java/com/example/dto/dialogflow/DetectIntentRequestDTO.java b/src/main/java/com/example/dto/dialogflow/DetectIntentRequestDTO.java deleted file mode 100644 index 147fe1f..0000000 --- a/src/main/java/com/example/dto/dialogflow/DetectIntentRequestDTO.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.example.dto.dialogflow; - -import com.example.dto.base.UsuarioDTO; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.annotation.JsonProperty; - -@JsonIgnoreProperties(ignoreUnknown = true) -public record DetectIntentRequestDTO( - @JsonProperty("session") String sessionId, - @JsonProperty("queryInput") QueryInputDTO queryInput, - @JsonProperty("queryParams") QueryParamsDTO queryParams, - @JsonProperty("usuario") UsuarioDTO usuario, - String userId -) { - - public DetectIntentRequestDTO withSessionId(String newSessionId) { - return new DetectIntentRequestDTO( - newSessionId, - this.queryInput(), - this.queryParams(), - this.usuario(), - this.userId() - ); - } -} \ No newline at end of file diff --git a/src/main/java/com/example/dto/dialogflow/DetectIntentResponseDTO.java b/src/main/java/com/example/dto/dialogflow/DetectIntentResponseDTO.java deleted file mode 100644 index 7eac05e..0000000 --- a/src/main/java/com/example/dto/dialogflow/DetectIntentResponseDTO.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.example.dto.dialogflow; - -import com.fasterxml.jackson.annotation.JsonProperty; - -public record DetectIntentResponseDTO( - @JsonProperty("responseId") String responseId, - @JsonProperty("queryResult") QueryResultDTO queryResult -) {} \ No newline at end of file diff --git a/src/main/java/com/example/dto/dialogflow/IntentDTO.java b/src/main/java/com/example/dto/dialogflow/IntentDTO.java deleted file mode 100644 index 26aa0f4..0000000 --- a/src/main/java/com/example/dto/dialogflow/IntentDTO.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.example.dto.dialogflow; - -public record IntentDTO( - String name, - String displayName -) {} \ No newline at end of file diff --git a/src/main/java/com/example/dto/dialogflow/QueryInputDTO.java b/src/main/java/com/example/dto/dialogflow/QueryInputDTO.java deleted file mode 100644 index 037f9a1..0000000 --- a/src/main/java/com/example/dto/dialogflow/QueryInputDTO.java +++ /dev/null @@ -1,3 +0,0 @@ -package com.example.dto.dialogflow; - -public record QueryInputDTO(TextInputDTO text, String languageCode) {} \ No newline at end of file diff --git a/src/main/java/com/example/dto/dialogflow/QueryParamsDTO.java b/src/main/java/com/example/dto/dialogflow/QueryParamsDTO.java deleted file mode 100644 index ce54ea9..0000000 --- a/src/main/java/com/example/dto/dialogflow/QueryParamsDTO.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.example.dto.dialogflow; - -import java.util.Map; -public record QueryParamsDTO(Map parameters) {} diff --git a/src/main/java/com/example/dto/dialogflow/TextInputDTO.java b/src/main/java/com/example/dto/dialogflow/TextInputDTO.java deleted file mode 100644 index 12f93a9..0000000 --- a/src/main/java/com/example/dto/dialogflow/TextInputDTO.java +++ /dev/null @@ -1,3 +0,0 @@ -package com.example.dto.dialogflow; - -public record TextInputDTO(String text) {} \ No newline at end of file diff --git a/src/main/java/com/example/dto/dialogflow/base/DetectIntentRequestDTO.java b/src/main/java/com/example/dto/dialogflow/base/DetectIntentRequestDTO.java new file mode 100644 index 0000000..f26c861 --- /dev/null +++ b/src/main/java/com/example/dto/dialogflow/base/DetectIntentRequestDTO.java @@ -0,0 +1,29 @@ +/* + * 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.dto.dialogflow.base; + +import com.example.dto.dialogflow.conversation.QueryInputDTO; +import com.example.dto.dialogflow.conversation.QueryParamsDTO; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record DetectIntentRequestDTO( + @JsonProperty("queryInput") QueryInputDTO queryInput, + @JsonProperty("queryParams") QueryParamsDTO queryParams +) { + +public DetectIntentRequestDTO withParameter(String key, Object value) { + // Create a new QueryParamsDTO with the updated session parameter + QueryParamsDTO updatedQueryParams = this.queryParams().withSessionParameter(key, value); + + // Return a new DetectIntentRequestDTO instance with the updated QueryParamsDTO + return new DetectIntentRequestDTO( + this.queryInput(), + updatedQueryParams + ); +} +} \ No newline at end of file diff --git a/src/main/java/com/example/dto/dialogflow/base/DetectIntentResponseDTO.java b/src/main/java/com/example/dto/dialogflow/base/DetectIntentResponseDTO.java new file mode 100644 index 0000000..9411f6d --- /dev/null +++ b/src/main/java/com/example/dto/dialogflow/base/DetectIntentResponseDTO.java @@ -0,0 +1,14 @@ +/* + * 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.dto.dialogflow.base; + +import com.example.dto.dialogflow.conversation.QueryResultDTO; +import com.fasterxml.jackson.annotation.JsonProperty; + +public record DetectIntentResponseDTO( + @JsonProperty("responseId") String responseId, + @JsonProperty("queryResult") QueryResultDTO queryResult +) {} \ No newline at end of file diff --git a/src/main/java/com/example/dto/dialogflow/conversation/ConversationContext.java b/src/main/java/com/example/dto/dialogflow/conversation/ConversationContext.java new file mode 100644 index 0000000..def56e3 --- /dev/null +++ b/src/main/java/com/example/dto/dialogflow/conversation/ConversationContext.java @@ -0,0 +1,13 @@ +/* + * 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.dto.dialogflow.conversation; + +public record ConversationContext( + String userId, + String sessionId, + String userMessageText, + String primaryPhoneNumber +) {} \ 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 new file mode 100644 index 0000000..2fedd96 --- /dev/null +++ b/src/main/java/com/example/dto/dialogflow/conversation/ConversationEntryDTO.java @@ -0,0 +1,53 @@ +/* + * 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.dto.dialogflow.conversation; + +import com.fasterxml.jackson.annotation.JsonInclude; +import java.time.Instant; +import java.util.Map; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record ConversationEntryDTO( + ConversationEntryEntity entity, + ConversationEntryType type, + Instant timestamp, + String text, + Map parameters, + String canal +) { + public static ConversationEntryDTO forUser(String text) { + return new ConversationEntryDTO( + ConversationEntryEntity.USUARIO, + ConversationEntryType.CONVERSACION, + Instant.now(), + text, + null, + null); + } + + public static ConversationEntryDTO forAgent(QueryResultDTO agentQueryResult) { + String fulfillmentText = (agentQueryResult != null && agentQueryResult.responseText() != null) ? agentQueryResult.responseText() : ""; + + return new ConversationEntryDTO( + ConversationEntryEntity.AGENTE, + ConversationEntryType.CONVERSACION, + Instant.now(), + fulfillmentText, + agentQueryResult.parameters(), + null + ); + } + public static ConversationEntryDTO forSystem(String text) { + return new ConversationEntryDTO( + ConversationEntryEntity.SISTEMA, + ConversationEntryType.CONVERSACION, + Instant.now(), + text, + null, + null + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/dto/dialogflow/conversation/ConversationEntryEntity.java b/src/main/java/com/example/dto/dialogflow/conversation/ConversationEntryEntity.java new file mode 100644 index 0000000..17f442a --- /dev/null +++ b/src/main/java/com/example/dto/dialogflow/conversation/ConversationEntryEntity.java @@ -0,0 +1,12 @@ +/* + * 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.dto.dialogflow.conversation; + +public enum ConversationEntryEntity { + USUARIO, + AGENTE, + SISTEMA +} \ No newline at end of file diff --git a/src/main/java/com/example/dto/dialogflow/conversation/ConversationEntryType.java b/src/main/java/com/example/dto/dialogflow/conversation/ConversationEntryType.java new file mode 100644 index 0000000..0f1efdd --- /dev/null +++ b/src/main/java/com/example/dto/dialogflow/conversation/ConversationEntryType.java @@ -0,0 +1,11 @@ +/* + * 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.dto.dialogflow.conversation; + +public enum ConversationEntryType { + INICIO, + CONVERSACION +} \ No newline at end of file diff --git a/src/main/java/com/example/dto/dialogflow/ConversationSessionDTO.java b/src/main/java/com/example/dto/dialogflow/conversation/ConversationSessionDTO.java similarity index 88% rename from src/main/java/com/example/dto/dialogflow/ConversationSessionDTO.java rename to src/main/java/com/example/dto/dialogflow/conversation/ConversationSessionDTO.java index 458c693..d5cc2dd 100644 --- a/src/main/java/com/example/dto/dialogflow/ConversationSessionDTO.java +++ b/src/main/java/com/example/dto/dialogflow/conversation/ConversationSessionDTO.java @@ -1,4 +1,9 @@ -package com.example.dto.dialogflow; +/* + * 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.dto.dialogflow.conversation; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import java.time.Instant; diff --git a/src/main/java/com/example/dto/dialogflow/conversation/ExternalConvRequestDTO.java b/src/main/java/com/example/dto/dialogflow/conversation/ExternalConvRequestDTO.java new file mode 100644 index 0000000..5b88f92 --- /dev/null +++ b/src/main/java/com/example/dto/dialogflow/conversation/ExternalConvRequestDTO.java @@ -0,0 +1,19 @@ +/* + * 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.dto.dialogflow.conversation; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record ExternalConvRequestDTO( + @JsonProperty("mensaje") String message, + @JsonProperty("usuario") UsuarioDTO user, + @JsonProperty("canal") String channel, + @JsonProperty("tipo") ConversationEntryType tipo +) { + public ExternalConvRequestDTO {} + } \ No newline at end of file diff --git a/src/main/java/com/example/dto/dialogflow/conversation/QueryInputDTO.java b/src/main/java/com/example/dto/dialogflow/conversation/QueryInputDTO.java new file mode 100644 index 0000000..4545b1a --- /dev/null +++ b/src/main/java/com/example/dto/dialogflow/conversation/QueryInputDTO.java @@ -0,0 +1,14 @@ +/* + * 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.dto.dialogflow.conversation; + +import com.example.dto.dialogflow.notification.EventInputDTO; + +public record QueryInputDTO( + TextInputDTO text, // Can be null if using event + EventInputDTO event, + String languageCode // REQUIRED for both text and event inputs +) {} \ No newline at end of file diff --git a/src/main/java/com/example/dto/dialogflow/conversation/QueryParamsDTO.java b/src/main/java/com/example/dto/dialogflow/conversation/QueryParamsDTO.java new file mode 100644 index 0000000..8ab4b6d --- /dev/null +++ b/src/main/java/com/example/dto/dialogflow/conversation/QueryParamsDTO.java @@ -0,0 +1,28 @@ +/* + * 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.dto.dialogflow.conversation; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record QueryParamsDTO( + @JsonProperty("parameters") Map parameters) { + + public QueryParamsDTO { + parameters = Objects.requireNonNullElseGet(parameters, HashMap::new); + parameters = new HashMap<>(parameters); + } + + public QueryParamsDTO withSessionParameter(String key, Object value) { + Map updatedParams = new HashMap<>(this.parameters()); + updatedParams.put(key, value); + return new QueryParamsDTO(updatedParams); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/dto/dialogflow/QueryResultDTO.java b/src/main/java/com/example/dto/dialogflow/conversation/QueryResultDTO.java similarity index 50% rename from src/main/java/com/example/dto/dialogflow/QueryResultDTO.java rename to src/main/java/com/example/dto/dialogflow/conversation/QueryResultDTO.java index a8f1c46..485d893 100644 --- a/src/main/java/com/example/dto/dialogflow/QueryResultDTO.java +++ b/src/main/java/com/example/dto/dialogflow/conversation/QueryResultDTO.java @@ -1,4 +1,9 @@ -package com.example.dto.dialogflow; +/* + * 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.dto.dialogflow.conversation; import com.fasterxml.jackson.annotation.JsonProperty; import java.util.Map; diff --git a/src/main/java/com/example/dto/dialogflow/conversation/TextInputDTO.java b/src/main/java/com/example/dto/dialogflow/conversation/TextInputDTO.java new file mode 100644 index 0000000..b4546ee --- /dev/null +++ b/src/main/java/com/example/dto/dialogflow/conversation/TextInputDTO.java @@ -0,0 +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.dto.dialogflow.conversation; + +public record TextInputDTO(String text) {} \ No newline at end of file diff --git a/src/main/java/com/example/dto/base/UsuarioDTO.java b/src/main/java/com/example/dto/dialogflow/conversation/UsuarioDTO.java similarity index 50% rename from src/main/java/com/example/dto/base/UsuarioDTO.java rename to src/main/java/com/example/dto/dialogflow/conversation/UsuarioDTO.java index f101439..11bc796 100644 --- a/src/main/java/com/example/dto/base/UsuarioDTO.java +++ b/src/main/java/com/example/dto/dialogflow/conversation/UsuarioDTO.java @@ -1,4 +1,9 @@ -package com.example.dto.base; +/* + * 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.dto.dialogflow.conversation; import com.fasterxml.jackson.annotation.JsonProperty; import jakarta.validation.constraints.NotBlank; diff --git a/src/main/java/com/example/dto/dialogflow/notification/EventInputDTO.java b/src/main/java/com/example/dto/dialogflow/notification/EventInputDTO.java new file mode 100644 index 0000000..0501fcb --- /dev/null +++ b/src/main/java/com/example/dto/dialogflow/notification/EventInputDTO.java @@ -0,0 +1,10 @@ +/* + * 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.dto.dialogflow.notification; + +public record EventInputDTO( + String event +) {} diff --git a/src/main/java/com/example/dto/dialogflow/notification/ExternalNotRequestDTO.java b/src/main/java/com/example/dto/dialogflow/notification/ExternalNotRequestDTO.java new file mode 100644 index 0000000..3113deb --- /dev/null +++ b/src/main/java/com/example/dto/dialogflow/notification/ExternalNotRequestDTO.java @@ -0,0 +1,16 @@ +/* + * 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.dto.dialogflow.notification; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record ExternalNotRequestDTO( + @JsonProperty("texto") String text, + @JsonProperty("telefono") String phoneNumber) { + public ExternalNotRequestDTO { + } +} \ 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 new file mode 100644 index 0000000..1aed0fe --- /dev/null +++ b/src/main/java/com/example/dto/dialogflow/notification/NotificationDTO.java @@ -0,0 +1,32 @@ +/* + * 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.dto.dialogflow.notification; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.time.Instant; +import java.util.Map; +import java.util.Objects; + +/** + * Represents a notification record to be stored in Firestore and cached in + * Redis. + */ +@JsonIgnoreProperties(ignoreUnknown = true) // Ignorar campos adicionales durante la deserialización +public record NotificationDTO( + String idNotificacion, // ID único para esta notificación (ej. el sessionId usado con Dialogflow) + String telefono, + Instant timestampCreacion, // Momento en que la notificación fue procesada + 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 +) { + public NotificationDTO { + Objects.requireNonNull(idNotificacion, "Notification ID cannot be null."); + Objects.requireNonNull(timestampCreacion, "Notification timestamp cannot be null."); + Objects.requireNonNull(nombreEventoDialogflow, "Dialogflow event name cannot be null."); + Objects.requireNonNull(codigoIdiomaDialogflow, "Dialogflow language code cannot be null."); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/dto/dialogflow/notification/NotificationSessionDTO.java b/src/main/java/com/example/dto/dialogflow/notification/NotificationSessionDTO.java new file mode 100644 index 0000000..8169ddb --- /dev/null +++ b/src/main/java/com/example/dto/dialogflow/notification/NotificationSessionDTO.java @@ -0,0 +1,24 @@ +// src/main/java/com/example/dto/dialogflow/notification/NotificationSessionDTO.java +package com.example.dto.dialogflow.notification; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.time.Instant; +import java.util.List; +import java.util.Objects; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record NotificationSessionDTO( + String sessionId, // The unique session identifier (e.g., the phone number) + String telefono, // The phone number for this session + Instant fechaCreacion, // When the session was first created + Instant ultimaActualizacion, // When the session was last updated + List notificaciones // List of individual notification events +) { + public NotificationSessionDTO { + Objects.requireNonNull(sessionId, "Session ID cannot be null."); + Objects.requireNonNull(telefono, "Phone number cannot be null."); + Objects.requireNonNull(fechaCreacion, "Creation timestamp cannot be null."); + Objects.requireNonNull(ultimaActualizacion, "Last updated timestamp cannot be null."); + Objects.requireNonNull(notificaciones, "Notifications list cannot be null."); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/dto/gemini/ConversationEntrySummaryDTO.java b/src/main/java/com/example/dto/gemini/ConversationEntrySummaryDTO.java index 87e8e93..a98acec 100644 --- a/src/main/java/com/example/dto/gemini/ConversationEntrySummaryDTO.java +++ b/src/main/java/com/example/dto/gemini/ConversationEntrySummaryDTO.java @@ -1,6 +1,11 @@ +/* + * 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.dto.gemini; -import com.example.dto.dialogflow.ConversationEntryType; +import com.example.dto.dialogflow.conversation.ConversationEntryEntity; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; @@ -12,7 +17,7 @@ import java.util.Optional; public record ConversationEntrySummaryDTO( @JsonProperty("text") String text, @JsonProperty("timestamp") Timestamp timestamp, - Optional type, + Optional type, @JsonProperty("intentDisplayName") String intentDisplayName, @JsonProperty("parameters") Map parameters, @JsonProperty("webhookStatus") String webhookStatus, @@ -33,7 +38,7 @@ public record ConversationEntrySummaryDTO( timestamp, Optional.ofNullable(typeString).map(t -> { try { - return ConversationEntryType.valueOf(t); + return ConversationEntryEntity.valueOf(t); } catch (IllegalArgumentException e) { System.err.println("Warning: Invalid ConversationEntryType string during deserialization: " + t); return null; diff --git a/src/main/java/com/example/dto/gemini/ConversationSessionSummaryDTO.java b/src/main/java/com/example/dto/gemini/ConversationSessionSummaryDTO.java index 3e4ec56..5168cba 100644 --- a/src/main/java/com/example/dto/gemini/ConversationSessionSummaryDTO.java +++ b/src/main/java/com/example/dto/gemini/ConversationSessionSummaryDTO.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.dto.gemini; import com.fasterxml.jackson.annotation.JsonCreator; diff --git a/src/main/java/com/example/dto/gemini/ConversationSummaryRequest.java b/src/main/java/com/example/dto/gemini/ConversationSummaryRequest.java index 4c0f29b..7e71f5f 100644 --- a/src/main/java/com/example/dto/gemini/ConversationSummaryRequest.java +++ b/src/main/java/com/example/dto/gemini/ConversationSummaryRequest.java @@ -1,7 +1,12 @@ +/* + * 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.dto.gemini; -import jakarta.validation.constraints.Max; -import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; import jakarta.validation.constraints.NotBlank; public record ConversationSummaryRequest( @@ -9,11 +14,16 @@ public record ConversationSummaryRequest( String sessionId, @NotBlank(message = "Prompt for summarization is required.") String prompt, - @Min(value = 0, message = "Temperature must be between 0.0 and 1.0.") - @Max(value = 1, message = "Temperature must be between 0.0 and 1.0.") + @DecimalMin(value = "0.0", message = "Temperature must be between 0.0 and 1.0.") + @DecimalMax(value = "0.1", message = "Temperature must be between 0.0 and 1.0.") Float temperature, - @Min(value = 1, message = "Max Output Tokens must be at least 1.") + @DecimalMin(value = "0.1", message = "Max Output Tokens must be at least 1.") Integer maxOutputTokens, @NotBlank(message = "model is required.") - String modelName + String modelName, + @NotBlank(message = "topP is required.") + @DecimalMin(value = "0.0", message = "topP must be between 0.0 and 1.0.") + Float top_P + + ) {} \ No newline at end of file diff --git a/src/main/java/com/example/dto/gemini/ConversationSummaryResponse.java b/src/main/java/com/example/dto/gemini/ConversationSummaryResponse.java index 918f636..b7d0a2f 100644 --- a/src/main/java/com/example/dto/gemini/ConversationSummaryResponse.java +++ b/src/main/java/com/example/dto/gemini/ConversationSummaryResponse.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.dto.gemini; import jakarta.validation.constraints.NotBlank; diff --git a/src/main/java/com/example/exception/GeminiClientException.java b/src/main/java/com/example/exception/GeminiClientException.java new file mode 100644 index 0000000..6e76ee2 --- /dev/null +++ b/src/main/java/com/example/exception/GeminiClientException.java @@ -0,0 +1,12 @@ +package com.example.exception; + +public class GeminiClientException extends Exception { + + public GeminiClientException(String message) { + super(message); + } + + public GeminiClientException(String message, Throwable cause) { + super(message, cause); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/mapper/DialogflowRequestMapper.java b/src/main/java/com/example/mapper/DialogflowRequestMapper.java deleted file mode 100644 index 7a50ad0..0000000 --- a/src/main/java/com/example/mapper/DialogflowRequestMapper.java +++ /dev/null @@ -1,79 +0,0 @@ -package com.example.mapper; - -import com.example.dto.dialogflow.DetectIntentRequestDTO; -import com.example.util.ProtobufUtil; - -import com.google.cloud.dialogflow.cx.v3.DetectIntentRequest; -import com.google.cloud.dialogflow.cx.v3.QueryInput; -import com.google.cloud.dialogflow.cx.v3.QueryParameters; -import com.google.cloud.dialogflow.cx.v3.TextInput; -import com.google.protobuf.Struct; -import com.google.protobuf.Value; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.stereotype.Component; - -import java.util.Map; -import java.util.Objects; - -@Component -public class DialogflowRequestMapper { - - private static final Logger logger = LoggerFactory.getLogger(DialogflowRequestMapper.class); - - @org.springframework.beans.factory.annotation.Value("${dialogflow.default-language-code:es}") String defaultLanguageCode; - - public DetectIntentRequest.Builder mapToDetectIntentRequestBuilder(DetectIntentRequestDTO requestDto) { - Objects.requireNonNull(requestDto, "DetectIntentRequestDTO cannot be null for mapping."); - - logger.debug("Building partial Dialogflow CX DetectIntentRequest Protobuf Builder from DTO (only QueryInput and QueryParams)."); - - // Build QueryInput from the DTO's text and language code - QueryInput.Builder queryInputBuilder = QueryInput.newBuilder(); - if (requestDto.queryInput() != null && requestDto.queryInput().text() != null && - requestDto.queryInput().text().text() != null && !requestDto.queryInput().text().text().trim().isEmpty()) { - - queryInputBuilder.setText(TextInput.newBuilder() - .setText(requestDto.queryInput().text().text()) - .build()); - queryInputBuilder.setLanguageCode(requestDto.queryInput().languageCode() != null ? requestDto.queryInput().languageCode() : defaultLanguageCode); - } else { - logger.error("Dialogflow query input text is required for building Protobuf query input."); - throw new IllegalArgumentException("Dialogflow query input text is required."); - } - - // Build QueryParameters from the DTO's parameters map - QueryParameters.Builder queryParametersBuilder = QueryParameters.newBuilder(); - Struct.Builder paramsStructBuilder = Struct.newBuilder(); - // Add existing parameters from DTO's queryParams - if (requestDto.queryParams() != null && requestDto.queryParams().parameters() != null) { - for (Map.Entry entry : requestDto.queryParams().parameters().entrySet()) { - Value protobufValue = ProtobufUtil.convertJavaObjectToProtobufValue(entry.getValue()); - paramsStructBuilder.putFields(entry.getKey(), protobufValue); - } - } - - if (requestDto.usuario() != null && requestDto.usuario().telefono() != null && !requestDto.usuario().telefono().trim().isEmpty()) { - paramsStructBuilder.putFields("telefono", Value.newBuilder().setStringValue(requestDto.usuario().telefono()).build()); - logger.debug("Added 'telefono' as a query parameter: {}", requestDto.usuario().telefono()); - } - - if (paramsStructBuilder.getFieldsCount() > 0) { - queryParametersBuilder.setParameters(paramsStructBuilder.build()); - logger.debug("Custom parameters (including telefono if present) added to Protobuf request builder."); - } else { - logger.debug("No custom parameters provided in DTO (or no telefono in usuario)."); - } - - DetectIntentRequest.Builder detectIntentRequestBuilder = DetectIntentRequest.newBuilder() - .setQueryInput(queryInputBuilder.build()); - - if (queryParametersBuilder.hasParameters()) { - detectIntentRequestBuilder.setQueryParams(queryParametersBuilder.build()); - } - - logger.debug("Finished building partial DetectIntentRequest Protobuf Builder."); - return detectIntentRequestBuilder; - } -} \ No newline at end of file diff --git a/src/main/java/com/example/mapper/FirestoreConversationMapper.java b/src/main/java/com/example/mapper/FirestoreConversationMapper.java deleted file mode 100644 index 20f0979..0000000 --- a/src/main/java/com/example/mapper/FirestoreConversationMapper.java +++ /dev/null @@ -1,132 +0,0 @@ -package com.example.mapper; - -import com.example.dto.dialogflow.ConversationEntryDTO; -import com.example.dto.dialogflow.ConversationEntryType; -import com.example.dto.dialogflow.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 FirestoreConversationMapper { - - private static final Logger logger = LoggerFactory.getLogger(FirestoreConversationMapper.class); - - public Map createUpdateMapForSingleEntry(ConversationEntryDTO newEntry) { - Map updates = new HashMap<>(); - // Convert Instant to Firestore Timestamp - Map entryMap = toFirestoreEntryMap(newEntry); - updates.put("entries", FieldValue.arrayUnion(entryMap)); - // Convert Instant to Firestore Timestamp - updates.put("lastModified", 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("sessionId", sessionId); - sessionMap.put("userId", userId); - - if (telefono != null && !telefono.trim().isEmpty()) { - sessionMap.put("telefono", telefono); - } else { - sessionMap.put("telefono", null); - } - - // Convert Instant to Firestore Timestamp - sessionMap.put("createdAt", Timestamp.of(java.util.Date.from(Instant.now()))); - sessionMap.put("lastModified", Timestamp.of(java.util.Date.from(Instant.now()))); - - List> entriesList = new ArrayList<>(); - entriesList.add(toFirestoreEntryMap(initialEntry)); - sessionMap.put("entries", entriesList); - - return sessionMap; - } - - private Map toFirestoreEntryMap(ConversationEntryDTO entry) { - Map entryMap = new HashMap<>(); - entryMap.put("type", entry.type().name()); - entryMap.put("text", entry.text()); - // Convert Instant to Firestore Timestamp for storage - entryMap.put("timestamp", Timestamp.of(java.util.Date.from(entry.timestamp()))); - - if (entry.intentDisplayName() != null) { - entryMap.put("intentDisplayName", entry.intentDisplayName()); - } - if (entry.parameters() != null && !entry.parameters().isEmpty()) { - entryMap.put("parameters", entry.parameters()); - } - if (entry.webhookStatus() != null) { - entryMap.put("webhookStatus", entry.webhookStatus()); - } - if (entry.canal() != null) { - entryMap.put("canal", entry.canal()); - } - return entryMap; - } - - public ConversationSessionDTO mapFirestoreDocumentToConversationSessionDTO(DocumentSnapshot documentSnapshot) { - if (!documentSnapshot.exists()) { - return null; - } - - String sessionId = documentSnapshot.getString("sessionId"); - String userId = documentSnapshot.getString("userId"); - String telefono = documentSnapshot.getString("telefono"); - - // Convert Firestore Timestamp to Instant - Timestamp createdAtFirestore = documentSnapshot.getTimestamp("createdAt"); - Instant createdAt = (createdAtFirestore != null) ? createdAtFirestore.toDate().toInstant() : null; - - // Convert Firestore Timestamp to Instant - Timestamp lastModifiedFirestore = documentSnapshot.getTimestamp("lastModified"); - Instant lastModified = (lastModifiedFirestore != null) ? lastModifiedFirestore.toDate().toInstant() : null; - - List> rawEntries = (List>) documentSnapshot.get("entries"); - 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); - } - - - private ConversationEntryDTO mapFirestoreEntryMapToConversationEntryDTO(Map entryMap) { - ConversationEntryType type = null; - Object typeObj = entryMap.get("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("text"); - - // Convert Firestore Timestamp to Instant - Timestamp timestampFirestore = (Timestamp) entryMap.get("timestamp"); - Instant timestamp = (timestampFirestore != null) ? timestampFirestore.toDate().toInstant() : null; - - String intentDisplayName = (String) entryMap.get("intentDisplayName"); - Map parameters = (Map) entryMap.get("parameters"); - String webhookStatus = (String) entryMap.get("webhookStatus"); - String canal = (String) entryMap.get("canal"); - - return new ConversationEntryDTO(type, timestamp, text, intentDisplayName, parameters, webhookStatus, canal); - } - -} \ No newline at end of file diff --git a/src/main/java/com/example/mapper/conversation/DialogflowRequestMapper.java b/src/main/java/com/example/mapper/conversation/DialogflowRequestMapper.java new file mode 100644 index 0000000..d6b4bbf --- /dev/null +++ b/src/main/java/com/example/mapper/conversation/DialogflowRequestMapper.java @@ -0,0 +1,102 @@ +/* + * 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.conversation; + +import com.example.dto.dialogflow.base.DetectIntentRequestDTO; +import com.example.dto.dialogflow.conversation.QueryInputDTO; +import com.example.util.ProtobufUtil; +import com.google.cloud.dialogflow.cx.v3.DetectIntentRequest; +import com.google.cloud.dialogflow.cx.v3.EventInput; +import com.google.cloud.dialogflow.cx.v3.QueryInput; +import com.google.cloud.dialogflow.cx.v3.QueryParameters; +import com.google.cloud.dialogflow.cx.v3.TextInput; +import com.google.protobuf.Struct; +import com.google.protobuf.Value; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import java.util.Map; +import java.util.Objects; + +/** + * Spring component responsible for mapping a custom `DetectIntentRequestDTO` + * into a Dialogflow CX `DetectIntentRequest.Builder`. It handles the conversion + * of user text or event inputs and the serialization of custom session parameters, + * ensuring the data is in the correct Protobuf format for API communication. + */ +@Component +public class DialogflowRequestMapper { + + private static final Logger logger = LoggerFactory.getLogger(DialogflowRequestMapper.class); + + @org.springframework.beans.factory.annotation.Value("${dialogflow.default-language-code:es}") + String defaultLanguageCode; + + public DetectIntentRequest.Builder mapToDetectIntentRequestBuilder(DetectIntentRequestDTO requestDto) { + Objects.requireNonNull(requestDto, "DetectIntentRequestDTO cannot be null for mapping."); + + logger.debug( + "Building partial Dialogflow CX DetectIntentRequest Protobuf Builder from DTO (only QueryInput and QueryParams)."); + QueryInput.Builder queryInputBuilder = QueryInput.newBuilder(); + QueryInputDTO queryInputDTO = requestDto.queryInput(); + + String languageCodeToSet = (queryInputDTO.languageCode() != null + && !queryInputDTO.languageCode().trim().isEmpty()) + ? queryInputDTO.languageCode() + : defaultLanguageCode; + queryInputBuilder.setLanguageCode(languageCodeToSet); + logger.debug("Setting languageCode for QueryInput to: {}", languageCodeToSet); + + if (queryInputDTO.text() != null && queryInputDTO.text().text() != null + && !queryInputDTO.text().text().trim().isEmpty()) { + queryInputBuilder.setText(TextInput.newBuilder() + .setText(queryInputDTO.text().text()) + .build()); + logger.debug("Mapped text input for QueryInput: '{}'", queryInputDTO.text().text()); + + } else if (queryInputDTO.event() != null && queryInputDTO.event().event() != null + && !queryInputDTO.event().event().trim().isEmpty()) { + queryInputBuilder.setEvent(EventInput.newBuilder() + .setEvent(queryInputDTO.event().event()) + .build()); + logger.debug("Mapped event input for QueryInput: '{}'", queryInputDTO.event().event()); + + } else { + logger.error("Dialogflow query input (either text or event) is required and must not be empty."); + throw new IllegalArgumentException("Dialogflow query input (either text or event) is required."); + } + + QueryParameters.Builder queryParametersBuilder = QueryParameters.newBuilder(); + Struct.Builder paramsStructBuilder = Struct.newBuilder(); + + if (requestDto.queryParams() != null && requestDto.queryParams().parameters() != null) { + 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()); + } + } + + 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."); + } else { + logger.debug("No custom session parameters to add to Protobuf request."); + } + + DetectIntentRequest.Builder detectIntentRequestBuilder = DetectIntentRequest.newBuilder() + .setQueryInput(queryInputBuilder.build()); + + if (queryParametersBuilder.hasParameters()) { + detectIntentRequestBuilder.setQueryParams(queryParametersBuilder.build()); + } + + logger.debug("Finished building partial DetectIntentRequest Protobuf Builder."); + return detectIntentRequestBuilder; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/mapper/DialogflowResponseMapper.java b/src/main/java/com/example/mapper/conversation/DialogflowResponseMapper.java similarity index 82% rename from src/main/java/com/example/mapper/DialogflowResponseMapper.java rename to src/main/java/com/example/mapper/conversation/DialogflowResponseMapper.java index 9e77d43..63359db 100644 --- a/src/main/java/com/example/mapper/DialogflowResponseMapper.java +++ b/src/main/java/com/example/mapper/conversation/DialogflowResponseMapper.java @@ -1,10 +1,15 @@ -package com.example.mapper; +/* + * 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.conversation; -import com.example.dto.dialogflow.DetectIntentResponseDTO; -import com.example.dto.dialogflow.QueryResultDTO; import com.google.cloud.dialogflow.cx.v3.QueryResult; import com.google.cloud.dialogflow.cx.v3.ResponseMessage; import com.google.cloud.dialogflow.cx.v3.DetectIntentResponse; +import com.example.dto.dialogflow.base.DetectIntentResponseDTO; +import com.example.dto.dialogflow.conversation.QueryResultDTO; import com.example.util.ProtobufUtil; import java.util.Collections; @@ -16,6 +21,13 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; +/** + * Spring component responsible for mapping a Dialogflow CX API response + * (`DetectIntentResponse`) to a simplified custom DTO (`DetectIntentResponseDTO`). + * It extracts and consolidates the fulfillment text, and converts Protobuf + * session parameters into standard Java objects, providing a clean and + * decoupled interface for consuming Dialogflow results. + */ @Component public class DialogflowResponseMapper { diff --git a/src/main/java/com/example/mapper/conversation/ExternalConvRequestMapper.java b/src/main/java/com/example/mapper/conversation/ExternalConvRequestMapper.java new file mode 100644 index 0000000..5cd0876 --- /dev/null +++ b/src/main/java/com/example/mapper/conversation/ExternalConvRequestMapper.java @@ -0,0 +1,86 @@ +/* + * 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.conversation; + +import com.example.dto.dialogflow.base.DetectIntentRequestDTO; +import com.example.dto.dialogflow.conversation.ExternalConvRequestDTO; +import com.example.dto.dialogflow.conversation.QueryInputDTO; +import com.example.dto.dialogflow.conversation.QueryParamsDTO; +import com.example.dto.dialogflow.conversation.TextInputDTO; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +/** + * Spring component responsible for mapping a simplified, external API request + * into a structured `DetectIntentRequestDTO` for Dialogflow. It processes + * user messages and relevant context, such as phone numbers and channel information, + * and populates the `QueryInputDTO` and `QueryParamsDTO` fields required for + * a Dialogflow API call. + */ +@Component +public class ExternalConvRequestMapper { + + private static final Logger logger = LoggerFactory.getLogger(ExternalConvRequestMapper.class); + private static final String DEFAULT_LANGUAGE_CODE = "es"; + + public DetectIntentRequestDTO mapExternalRequestToDetectIntentRequest(ExternalConvRequestDTO externalRequest) { + Objects.requireNonNull(externalRequest, "ExternalRequestDTO cannot be null for mapping."); + + if (externalRequest.message() == null || externalRequest.message().isBlank()) { + throw new IllegalArgumentException("External request 'mensaje' (message) is required."); + } + TextInputDTO textInput = new TextInputDTO(externalRequest.message()); + QueryInputDTO queryInputDTO = new QueryInputDTO(textInput,null,DEFAULT_LANGUAGE_CODE); + + // 2. Map ALL relevant external fields into QueryParamsDTO.parameters + Map parameters = new HashMap<>(); + + String primaryPhoneNumber = null; + if (externalRequest.user() != null && externalRequest.user().telefono() != null + && !externalRequest.user().telefono().isBlank()) { + primaryPhoneNumber = externalRequest.user().telefono(); + parameters.put("telefono", primaryPhoneNumber); + logger.debug("Mapped 'telefono' from external request: {}", primaryPhoneNumber); + } + + if (primaryPhoneNumber == null || primaryPhoneNumber.isBlank()) { + throw new IllegalArgumentException( + "Phone number (telefono) is required in the 'usuario' field for conversation management."); + } + + String resolvedUserId = null; + // Derive from phone number if not provided by 'userId' parameter + resolvedUserId = "user_by_phone_" + primaryPhoneNumber.replaceAll("[^0-9]", ""); + parameters.put("usuario_id", resolvedUserId); // Ensure derived ID is also in params + logger.warn("User ID not provided in external request. Using derived ID from phone number: {}", resolvedUserId); + + + if (externalRequest.channel() != null && !externalRequest.channel().isBlank()) { + parameters.put("canal", externalRequest.channel()); + logger.debug("Mapped 'canal' from external request: {}", externalRequest.channel()); + } + + if (externalRequest.user() != null && externalRequest.user().nickname() != null + && !externalRequest.user().nickname().isBlank()) { + parameters.put("nickname", externalRequest.user().nickname()); + logger.debug("Mapped 'nickname' from external request: {}", externalRequest.user().nickname()); + } + if (externalRequest.tipo() != null) { + parameters.put("tipo", externalRequest.tipo()); + logger.debug("Mapped 'tipo' from external request: {}", externalRequest.tipo()); + } + + QueryParamsDTO queryParamsDTO = new QueryParamsDTO(parameters); + + // 3. Construct the final DetectIntentRequestDTO + return new DetectIntentRequestDTO(queryInputDTO, queryParamsDTO); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/mapper/conversation/FirestoreConversationMapper.java b/src/main/java/com/example/mapper/conversation/FirestoreConversationMapper.java new file mode 100644 index 0000000..389801d --- /dev/null +++ b/src/main/java/com/example/mapper/conversation/FirestoreConversationMapper.java @@ -0,0 +1,153 @@ +package com.example.mapper.conversation; + +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; + +/** + * Spring component for mapping data between a `ConversationSessionDTO` and Firestore documents. + * It provides methods to convert a DTO into a format suitable for Firestore storage + * (creating new documents or updating existing ones) and to deserialize a Firestore + * `DocumentSnapshot` back into a `ConversationSessionDTO`, handling data types + * and nested collections correctly. + */ +@Component +public class FirestoreConversationMapper { + + private static final Logger logger = LoggerFactory.getLogger(FirestoreConversationMapper.class); + + // Class-level constants for Firestore field names at the session level + 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"; + + // Constants for fields within the 'mensajes' sub-documents + 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); + } + + +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); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/mapper/notification/ExternalNotRequestMapper.java b/src/main/java/com/example/mapper/notification/ExternalNotRequestMapper.java new file mode 100644 index 0000000..756a070 --- /dev/null +++ b/src/main/java/com/example/mapper/notification/ExternalNotRequestMapper.java @@ -0,0 +1,54 @@ +/* + * 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.EventInputDTO; +import com.example.dto.dialogflow.notification.ExternalNotRequestDTO; +import com.example.dto.dialogflow.base.DetectIntentRequestDTO; +import com.example.dto.dialogflow.conversation.QueryInputDTO; +import com.example.dto.dialogflow.conversation.QueryParamsDTO; + +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +/** + * Spring component for mapping an external notification request to a Dialogflow `DetectIntentRequestDTO`. + * This class takes a simplified `ExternalNotRequestDTO` and converts it into the structured + * DTO required for a Dialogflow API call, specifically for triggering a notification event. + * It ensures required parameters like the phone number are present and populates the + * request with event-specific details. + */ +@Component +public class ExternalNotRequestMapper { + private static final String EVENT_NAME = "notificacion"; + private static final String LANGUAGE_CODE = "es"; + private static final String TELEPHONE_PARAM_NAME = "telefono"; + + public DetectIntentRequestDTO map(ExternalNotRequestDTO request) { + Objects.requireNonNull(request, "NotificationRequestDTO cannot be null for mapping."); + + if (request.phoneNumber() == null || request.phoneNumber().isEmpty()) { + throw new IllegalArgumentException("List of 'telefonos' (phone numbers) is required and cannot be empty in NotificationRequestDTO."); + } + String phoneNumber = request.phoneNumber(); + EventInputDTO eventInput = new EventInputDTO(EVENT_NAME); + QueryInputDTO queryInput = new QueryInputDTO(null,eventInput, LANGUAGE_CODE); + + Map parameters = new HashMap<>(); + parameters.put(TELEPHONE_PARAM_NAME, phoneNumber); + + if (request.text() != null && !request.text().trim().isEmpty()) { + parameters.put("notification_text", request.text()); + } + + QueryParamsDTO queryParams = new QueryParamsDTO(parameters); + + return new DetectIntentRequestDTO(queryInput, queryParams); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/mapper/notification/FirestoreNotificationMapper.java b/src/main/java/com/example/mapper/notification/FirestoreNotificationMapper.java new file mode 100644 index 0000000..b4945d0 --- /dev/null +++ b/src/main/java/com/example/mapper/notification/FirestoreNotificationMapper.java @@ -0,0 +1,68 @@ +// src/main/java/com/example/mapper/notification/FirestoreNotificationMapper.java +package com.example.mapper.notification; + +import com.example.dto.dialogflow.notification.NotificationDTO; +import org.springframework.stereotype.Component; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.cloud.firestore.DocumentSnapshot; + +import java.time.Instant; +import java.util.Map; +import java.util.Objects; + +/** + * Spring component for mapping notification data between application DTOs and Firestore documents. + * This class handles the transformation of Dialogflow event details and notification metadata + * into a `NotificationDTO` for persistence and provides methods to serialize and deserialize + * this DTO to and from Firestore-compatible data structures. + */ +@Component +public class FirestoreNotificationMapper { + + private static final String DEFAULT_LANGUAGE_CODE = "es"; + private static final String FIXED_EVENT_NAME = "notificacion"; + private final ObjectMapper objectMapper; + + public FirestoreNotificationMapper(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + public NotificationDTO mapToFirestoreNotification( + String notificationId, + String telephone, + String notificationText, + Map parameters) { + + Objects.requireNonNull(notificationId, "Notification ID cannot be null for mapping."); + Objects.requireNonNull(notificationText, "Notification text cannot be null for mapping."); + Objects.requireNonNull(parameters, "Dialogflow parameters map cannot be null."); + + return new NotificationDTO( + notificationId, + telephone, + Instant.now(), + notificationText, + FIXED_EVENT_NAME, + DEFAULT_LANGUAGE_CODE, + parameters + ); + } + + public NotificationDTO mapFirestoreDocumentToNotificationDTO(DocumentSnapshot documentSnapshot) { + Objects.requireNonNull(documentSnapshot, "DocumentSnapshot cannot be null for mapping."); + if (!documentSnapshot.exists()) { + throw new IllegalArgumentException("DocumentSnapshot does not exist."); + } + try { + return objectMapper.convertValue(documentSnapshot.getData(), NotificationDTO.class); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException( + "Failed to convert Firestore document data to NotificationDTO for ID " + documentSnapshot.getId(), e); + } + } + + public Map mapNotificationDTOToMap(NotificationDTO notificationDTO) { + Objects.requireNonNull(notificationDTO, "NotificationDTO cannot be null for mapping to map."); + return objectMapper.convertValue(notificationDTO, new com.fasterxml.jackson.core.type.TypeReference>() {}); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/repository/FirestoreBaseRepository.java b/src/main/java/com/example/repository/FirestoreBaseRepository.java index 4b62078..530af0a 100644 --- a/src/main/java/com/example/repository/FirestoreBaseRepository.java +++ b/src/main/java/com/example/repository/FirestoreBaseRepository.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.repository; import com.fasterxml.jackson.databind.ObjectMapper; @@ -23,6 +28,13 @@ import java.util.concurrent.ExecutionException; import com.example.util.FirestoreTimestampDeserializer; import com.example.util.FirestoreTimestampSerializer; +/** + * 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 { @@ -39,13 +51,13 @@ public class FirestoreBaseRepository { this.objectMapper = objectMapper; // Register JavaTimeModule for standard java.time handling - if (!objectMapper.findModules().stream().anyMatch(m -> m instanceof JavaTimeModule)) { + 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)) { + if (!ObjectMapper.findModules().stream().anyMatch(m -> m instanceof ParameterNamesModule)) { objectMapper.registerModule(new ParameterNamesModule()); } diff --git a/src/main/java/com/example/repository/FirestoreNotificationRepository.java b/src/main/java/com/example/repository/FirestoreNotificationRepository.java new file mode 100644 index 0000000..e4c9228 --- /dev/null +++ b/src/main/java/com/example/repository/FirestoreNotificationRepository.java @@ -0,0 +1,133 @@ +/* + * 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/GeminiClientService.java b/src/main/java/com/example/service/GeminiClientService.java deleted file mode 100644 index 84e9ffc..0000000 --- a/src/main/java/com/example/service/GeminiClientService.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.example.service; - -import com.google.genai.Client; -import com.google.genai.errors.GenAiIOException; -import com.google.genai.types.Content; -import com.google.genai.types.GenerateContentConfig; -import com.google.genai.types.GenerateContentResponse; -import com.google.genai.types.Part; -import org.springframework.stereotype.Service; - -@Service -public class GeminiClientService { - - private final Client geminiClient; - - public GeminiClientService(Client geminiClient) { - this.geminiClient = geminiClient; - } - - public String generateContent(String prompt, Float temperature, Integer maxOutputTokens, String modelName) { - try { - Content content = Content.fromParts(Part.fromText(prompt)); - GenerateContentConfig config = GenerateContentConfig.builder() - .temperature(temperature) - .maxOutputTokens(maxOutputTokens) - .build(); - - GenerateContentResponse response = geminiClient.models.generateContent(modelName, content, config); - - if (response != null && response.text() != null) { - return response.text(); - } else { - return "No content generated or unexpected response structure."; - } - } catch (GenAiIOException e) { - System.err.println("Gemini API I/O error: " + e.getMessage()); - e.printStackTrace(); - return "Error: An API communication issue occurred: " + e.getMessage(); - } catch (Exception e) { - System.err.println("An unexpected error occurred during Gemini content generation: " + e.getMessage()); - e.printStackTrace(); - return "Error: An unexpected issue occurred during content generation."; - } - } -} \ 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 new file mode 100644 index 0000000..36851f1 --- /dev/null +++ b/src/main/java/com/example/service/base/ConvSessionCloserService.java @@ -0,0 +1,5 @@ +package com.example.service.base; + +public class ConvSessionCloserService { + +} diff --git a/src/main/java/com/example/service/DialogflowClientService.java b/src/main/java/com/example/service/base/DialogflowClientService.java similarity index 85% rename from src/main/java/com/example/service/DialogflowClientService.java rename to src/main/java/com/example/service/base/DialogflowClientService.java index 541d8ca..b6e5412 100644 --- a/src/main/java/com/example/service/DialogflowClientService.java +++ b/src/main/java/com/example/service/base/DialogflowClientService.java @@ -1,27 +1,34 @@ -package com.example.service; +/* + * 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. + */ -import com.example.dto.dialogflow.DetectIntentRequestDTO; -import com.example.dto.dialogflow.DetectIntentResponseDTO; -import com.example.mapper.DialogflowRequestMapper; -import com.example.mapper.DialogflowResponseMapper; +package com.example.service.base; + +import com.example.mapper.conversation.DialogflowRequestMapper; +import com.example.mapper.conversation.DialogflowResponseMapper; +import com.example.dto.dialogflow.base.DetectIntentRequestDTO; +import com.example.dto.dialogflow.base.DetectIntentResponseDTO; import com.example.exception.DialogflowClientException; - import com.google.api.gax.rpc.ApiException; import com.google.cloud.dialogflow.cx.v3.DetectIntentRequest; import com.google.cloud.dialogflow.cx.v3.SessionsClient; import com.google.cloud.dialogflow.cx.v3.SessionName; import com.google.cloud.dialogflow.cx.v3.SessionsSettings; - import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import reactor.core.publisher.Mono; - import javax.annotation.PreDestroy; - import java.io.IOException; import java.util.Objects; +/** + * Service for interacting with the Dialogflow CX API to detect user DetectIntent. + * It encapsulates the low-level API calls, handling request mapping from DTOs, + * managing the `SessionsClient`, and translating API responses into DTOs, + * all within a reactive programming context. + */ @Service public class DialogflowClientService { @@ -57,6 +64,7 @@ public class DialogflowClientService { .build(); this.sessionsClient = SessionsClient.create(sessionsSettings); logger.info("Dialogflow CX SessionsClient initialized successfully for endpoint: {}", regionalEndpoint); + logger.info("Dialogflow CX SessionsClient initialized successfully for agent : {}", dialogflowCxAgentId); } catch (IOException e) { logger.error("Failed to create Dialogflow CX SessionsClient: {}", e.getMessage(), e); throw e; @@ -100,7 +108,6 @@ public class DialogflowClientService { // Build the final DetectIntentRequest Protobuf object DetectIntentRequest detectIntentRequest = detectIntentRequestBuilder.build(); - return Mono.fromCallable(() -> { logger.debug("Calling Dialogflow CX detectIntent for session: {}", sessionId); return sessionsClient.detectIntent(detectIntentRequest); @@ -111,10 +118,6 @@ public class DialogflowClientService { return new DialogflowClientException( "Dialogflow CX API error: " + e.getStatusCode().getCode() + " - " + e.getMessage(), e); }) - .onErrorMap(IOException.class, e -> { - logger.error("IO error when calling Dialogflow CX for session {}: {}", sessionId, e.getMessage(),e); - return new RuntimeException("IO error with Dialogflow CX API: " + e.getMessage(), e); - }) .map(dfResponse -> this.dialogflowResponseMapper.mapFromDialogflowResponse(dfResponse, sessionId)); } } \ No newline at end of file diff --git a/src/main/java/com/example/service/base/GeminiClientService.java b/src/main/java/com/example/service/base/GeminiClientService.java new file mode 100644 index 0000000..ef46ec1 --- /dev/null +++ b/src/main/java/com/example/service/base/GeminiClientService.java @@ -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. + */ + +package com.example.service.base; + +import com.example.exception.GeminiClientException; +import com.google.genai.Client; +import com.google.genai.errors.GenAiIOException; +import com.google.genai.types.Content; +import com.google.genai.types.GenerateContentConfig; +import com.google.genai.types.GenerateContentResponse; +import com.google.genai.types.Part; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +/** + * Service for interacting with the Gemini API to generate content. + * It encapsulates the low-level API calls, handling prompt configuration, + * and error management to provide a clean and robust content generation interface. + */ +@Service +public class GeminiClientService { + + private static final Logger logger = LoggerFactory.getLogger(GeminiClientService.class); + private final Client geminiClient; + + public GeminiClientService(Client geminiClient) { + this.geminiClient = geminiClient; + } + + public String generateContent(String prompt, Float temperature, Integer maxOutputTokens, String modelName,Float topP) throws GeminiClientException { + try { + Content content = Content.fromParts(Part.fromText(prompt)); + GenerateContentConfig config = GenerateContentConfig.builder() + .temperature(temperature) + .maxOutputTokens(maxOutputTokens) + .topP(topP) + .build(); + + logger.debug("Sending request to Gemini model '{}'", modelName); + GenerateContentResponse response = geminiClient.models.generateContent(modelName, content, config); + + if (response != null && response.text() != null) { + return response.text(); + } else { + logger.warn("Gemini returned no content or an unexpected response structure for model '{}'.", modelName); + throw new GeminiClientException("No content generated or unexpected response structure."); + } + } catch (GenAiIOException e) { + logger.error("Gemini API I/O error while calling model '{}': {}", modelName, e.getMessage(), e); + throw new GeminiClientException("An API communication issue occurred: " + e.getMessage(), e); + } catch (Exception e) { + logger.error("An unexpected error occurred during Gemini content generation for model '{}': {}", modelName, e.getMessage(), e); + throw new GeminiClientException("An unexpected issue occurred during content generation.", e); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/example/service/base/MessageEntryFilter.java b/src/main/java/com/example/service/base/MessageEntryFilter.java new file mode 100644 index 0000000..47f3cbb --- /dev/null +++ b/src/main/java/com/example/service/base/MessageEntryFilter.java @@ -0,0 +1,125 @@ +/* + * 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.base; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Service; +import org.springframework.util.FileCopyUtils; +import jakarta.annotation.PostConstruct; +import java.io.IOException; +import java.io.Reader; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.Objects; + +/** + * Service to classify message entries (user text input from DetectIntent) into predefined categories + * like "CONVERSATION" or "NOTIFICATION" using Gemini model. + */ +@Service +public class MessageEntryFilter { + + private static final Logger logger = LoggerFactory.getLogger(MessageEntryFilter.class); + private final GeminiClientService geminiService; + + @Value("${messagefilter.geminimodel:gemini-2.0-flash-001}") + private String geminiModelNameClassifier; + + @Value("${messagefilter.temperature:0.1f}") + private Float classifierTemperature; + + @Value("${messagefilter.maxOutputTokens:10}") + private Integer classifierMaxOutputTokens; + + @Value("${messagefilter.topP:0.1f}") + private Float classifierTopP; + + @Value("${messagefilter.prompt:prompts/message_filter_prompt.txt}") + private String promptFilePath; + + public static final String CATEGORY_CONVERSATION = "CONVERSATION"; + public static final String CATEGORY_NOTIFICATION = "NOTIFICATION"; + public static final String CATEGORY_UNKNOWN = "UNKNOWN"; + public static final String CATEGORY_ERROR = "ERROR"; + + private String promptTemplate; + + public MessageEntryFilter(GeminiClientService geminiService) { + this.geminiService = Objects.requireNonNull(geminiService, "GeminiClientService cannot be null for MessageEntryFilter."); + } + + @PostConstruct + public void loadPromptTemplate() { + try { + ClassPathResource resource = new ClassPathResource(promptFilePath); + try (Reader reader = new InputStreamReader(resource.getInputStream(), StandardCharsets.UTF_8)) { + this.promptTemplate = FileCopyUtils.copyToString(reader); + } + logger.info("Successfully loaded prompt template from '{}'.", promptFilePath); + } catch (IOException e) { + logger.error("Failed to load prompt template from '{}'. Please ensure the file exists.", promptFilePath, e); + throw new IllegalStateException("Could not load prompt template.", e); + } + } + + public String classifyMessage(String queryInputText, String notificationsJson, String conversationJson) { + if (queryInputText == null || queryInputText.isBlank()) { + logger.warn("Query input text for classification is null or blank. Returning {}.", CATEGORY_UNKNOWN); + return CATEGORY_UNKNOWN; + } + + String interruptingNotification = (notificationsJson != null && !notificationsJson.isBlank()) ? + notificationsJson : "No interrupting notification."; + + String conversationHistory = (conversationJson != null && !conversationJson.isBlank()) ? + conversationJson : "No conversation history."; + + String classificationPrompt = String.format( + this.promptTemplate, + CATEGORY_NOTIFICATION, CATEGORY_NOTIFICATION, CATEGORY_CONVERSATION, CATEGORY_CONVERSATION, + CATEGORY_CONVERSATION, CATEGORY_CONVERSATION, CATEGORY_CONVERSATION, CATEGORY_NOTIFICATION, + CATEGORY_NOTIFICATION, CATEGORY_CONVERSATION, CATEGORY_CONVERSATION, + conversationHistory, + interruptingNotification, + queryInputText + ); + + logger.info("Sending classification request to Gemini for input (first 100 chars): '{}'...", + queryInputText.substring(0, Math.min(queryInputText.length(), 100))); + + try { + String geminiResponse = geminiService.generateContent( + classificationPrompt, + classifierTemperature, + classifierMaxOutputTokens, + geminiModelNameClassifier, + classifierTopP + ); + + String resultCategory = switch (geminiResponse != null ? geminiResponse.trim().toUpperCase() : "") { + case CATEGORY_CONVERSATION -> { + logger.info("Classified as {}. Input: '{}'", CATEGORY_CONVERSATION, queryInputText); + yield CATEGORY_CONVERSATION; + } + case CATEGORY_NOTIFICATION -> { + logger.info("Classified as {}. Input: '{}'", CATEGORY_NOTIFICATION, queryInputText); + yield CATEGORY_NOTIFICATION; + } + default -> { + logger.warn("Gemini returned an unrecognised classification or was null/blank: '{}'. Expected '{}' or '{}'. Input: '{}'. Returning {}.", + geminiResponse, CATEGORY_CONVERSATION, CATEGORY_NOTIFICATION, queryInputText, CATEGORY_UNKNOWN); + yield CATEGORY_UNKNOWN; + } + }; + return resultCategory; + } catch (Exception e) { + logger.error("An error occurred during Gemini content generation for message classification.", e); + return CATEGORY_UNKNOWN; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/example/service/ConversationManagerService.java b/src/main/java/com/example/service/conversation/ConversationManagerService.java similarity index 56% rename from src/main/java/com/example/service/ConversationManagerService.java rename to src/main/java/com/example/service/conversation/ConversationManagerService.java index df55580..e8b0e5e 100644 --- a/src/main/java/com/example/service/ConversationManagerService.java +++ b/src/main/java/com/example/service/conversation/ConversationManagerService.java @@ -1,25 +1,49 @@ -package com.example.service; +/* + * 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.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.ConversationSessionDTO; +import com.example.dto.dialogflow.conversation.ExternalConvRequestDTO; -import com.example.dto.dialogflow.DetectIntentRequestDTO; -import com.example.dto.dialogflow.DetectIntentResponseDTO; -import com.example.dto.base.ConversationContext; -import com.example.dto.dialogflow.ConversationEntryDTO; -import com.example.dto.dialogflow.ConversationSessionDTO; -import com.example.dto.base.UsuarioDTO; 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.UUID; +import java.time.Duration; +import java.time.Instant; +import java.util.Collections; +import java.util.Map; 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 final ExternalConvRequestMapper externalRequestToDialogflowMapper; + private final DialogflowClientService dialogflowServiceClient; private final FirestoreConversationService firestoreConversationService; private final MemoryStoreConversationService memoryStoreConversationService; @@ -27,13 +51,24 @@ public class ConversationManagerService { public ConversationManagerService( DialogflowClientService dialogflowServiceClient, FirestoreConversationService firestoreConversationService, - MemoryStoreConversationService memoryStoreConversationService) { + MemoryStoreConversationService memoryStoreConversationService, + ExternalConvRequestMapper externalRequestToDialogflowMapper) { this.dialogflowServiceClient = dialogflowServiceClient; this.firestoreConversationService = firestoreConversationService; this.memoryStoreConversationService = memoryStoreConversationService; + this.externalRequestToDialogflowMapper = externalRequestToDialogflowMapper; } - public Mono manageConversation(DetectIntentRequestDTO request) { + 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)); + } + final ConversationContext context; try { context = resolveAndValidateRequest(request); @@ -47,40 +82,48 @@ public class ConversationManagerService { final String userPhoneNumber = context.primaryPhoneNumber(); Mono sessionMono; - if (userPhoneNumber != null && !userPhoneNumber.trim().isEmpty()) { + 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)) .switchIfEmpty(Mono.defer(() -> { - String newSessionId = UUID.randomUUID().toString(); + 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 { - String newSessionId = UUID.randomUUID().toString(); - logger.warn("No phone number provided in request. Creating new session: {}", newSessionId); + 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 sessionMono.flatMap(session -> { final String finalSessionId = session.sessionId(); + logger.info("Managing conversation for resolved session: {}", finalSessionId); - ConversationEntryDTO userEntry = ConversationEntryDTO.forUser(userMessageText); - DetectIntentRequestDTO updatedRequest = request.withSessionId(finalSessionId); + + 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)) - // After user entry persistence is complete (Mono emits 'onComplete'), - // then proceed to call Dialogflow. - .then(Mono.defer(() -> { // Use Mono.defer to ensure Dialogflow call is subscribed AFTER persistence - // Call Dialogflow. - return dialogflowServiceClient.detectIntent(finalSessionId, updatedRequest) + .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()); - // Agent entry persistence can still be backgrounded via .subscribe() - // if its completion isn't strictly required before returning the Dialogflow response. 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) @@ -113,20 +156,38 @@ public class ConversationManagerService { .doOnError(e -> logger.error("Error during primary Redis write for session {}. Type: {}: {}", sessionId, entry.type().name(), e.getMessage(), e)); } - private ConversationContext resolveAndValidateRequest(DetectIntentRequestDTO request) { - String primaryPhoneNumber = Optional.ofNullable(request.usuario()) - .map(UsuarioDTO::telefono) - .orElse(null); - +private ConversationContext resolveAndValidateRequest(DetectIntentRequestDTO request) { + Map params = Optional.ofNullable(request.queryParams()) + .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; + } else if (telefonoObj != null) { + 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 the 'usuario' field for conversation management."); + throw new IllegalArgumentException("Phone number (telefono) is required in query parameters for conversation management."); } - String resolvedUserId = request.userId(); + + String resolvedUserId = null; + Object userIdObj = params.get("usuario_id"); + if (userIdObj instanceof String) { + 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]", ""); // Derive from phone number - logger.warn("User ID not provided in request. Using derived ID from phone number: {}", resolvedUserId); + 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()) { + + 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."); } diff --git a/src/main/java/com/example/service/FirestoreConversationService.java b/src/main/java/com/example/service/conversation/FirestoreConversationService.java similarity index 83% rename from src/main/java/com/example/service/FirestoreConversationService.java rename to src/main/java/com/example/service/conversation/FirestoreConversationService.java index 26e7995..9f2354f 100644 --- a/src/main/java/com/example/service/FirestoreConversationService.java +++ b/src/main/java/com/example/service/conversation/FirestoreConversationService.java @@ -1,9 +1,14 @@ -package com.example.service; +/* + * 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. + */ -import com.example.dto.dialogflow.ConversationEntryDTO; -import com.example.dto.dialogflow.ConversationSessionDTO; +package com.example.service.conversation; + +import com.example.dto.dialogflow.conversation.ConversationEntryDTO; +import com.example.dto.dialogflow.conversation.ConversationSessionDTO; import com.example.exception.FirestorePersistenceException; -import com.example.mapper.FirestoreConversationMapper; +import com.example.mapper.conversation.FirestoreConversationMapper; import com.example.repository.FirestoreBaseRepository; import com.google.cloud.firestore.DocumentReference; import com.google.cloud.firestore.DocumentSnapshot; @@ -17,6 +22,13 @@ import reactor.core.scheduler.Schedulers; import java.util.Map; import java.util.concurrent.ExecutionException; +/** + * Service for managing 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 FirestoreConversationService { @@ -31,7 +43,7 @@ public class FirestoreConversationService { } public Mono saveEntry(String userId, String sessionId, ConversationEntryDTO newEntry, String userPhoneNumber) { - logger.info("Attempting to save conversation entry to Firestore for session {}. Type: {}", sessionId, newEntry.type().name()); + logger.info("Attempting to save conversation entry to Firestore for session {}. Entity: {}", sessionId, newEntry.entity().name()); return Mono.fromRunnable(() -> { DocumentReference sessionDocRef = getSessionDocumentReference(sessionId); WriteBatch batch = firestoreBaseRepository.createBatch(); @@ -41,13 +53,13 @@ public class FirestoreConversationService { // 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 {}. Type: {}", userId, sessionId, newEntry.type().name()); + 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 {}. Type: {}", userId, sessionId, newEntry.type().name()); + 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); diff --git a/src/main/java/com/example/service/MemoryStoreConversationService.java b/src/main/java/com/example/service/conversation/MemoryStoreConversationService.java similarity index 74% rename from src/main/java/com/example/service/MemoryStoreConversationService.java rename to src/main/java/com/example/service/conversation/MemoryStoreConversationService.java index 5c81fc6..33ade80 100644 --- a/src/main/java/com/example/service/MemoryStoreConversationService.java +++ b/src/main/java/com/example/service/conversation/MemoryStoreConversationService.java @@ -1,26 +1,34 @@ -package com.example.service; +/* + * 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. + */ -import com.example.dto.dialogflow.ConversationEntryDTO; -import com.example.dto.dialogflow.ConversationSessionDTO; +package com.example.service.conversation; 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 com.example.dto.dialogflow.conversation.ConversationEntryDTO; +import com.example.dto.dialogflow.conversation.ConversationSessionDTO; import reactor.core.publisher.Mono; import java.time.Duration; +/** + * Service for managing conversation sessions using a memory store (Redis). + * It caches and retrieves `ConversationSessionDTO` objects, maintaining a mapping + * from a user's phone number to their active session ID. This service uses + * a time-to-live (TTL) to manage session expiration and provides a fast + * reactive interface for persisting new conversation entries and fetching sessions. + */ @Service -public class MemoryStoreConversationService { - +public class MemoryStoreConversationService { private static final Logger logger = LoggerFactory.getLogger(MemoryStoreConversationService.class); private static final String SESSION_KEY_PREFIX = "conversation:session:"; private static final String PHONE_TO_SESSION_KEY_PREFIX = "conversation:phone_to_session:"; private static final Duration SESSION_TTL = Duration.ofHours(24); - private final ReactiveRedisTemplate redisTemplate; private final ReactiveRedisTemplate stringRedisTemplate; - @Autowired public MemoryStoreConversationService( ReactiveRedisTemplate redisTemplate, @@ -28,34 +36,29 @@ public class MemoryStoreConversationService { 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 session {}. Type: {}", sessionId, newEntry.type().name()); - + 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); - - logger.info("Attempting to set updated session {} with new entry type {} in Redis.", sessionId, newEntry.type().name()); + 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(stringRedisTemplate.opsForValue().set(phoneToSessionKey, sessionId, SESSION_TTL)) + .then(); // <--- ADD THIS .then() WITHOUT ARGUMENTS }) - .doOnSuccess(success -> logger.info("Successfully saved updated session and phone mapping to Redis for session {}. Entry Type: {}", sessionId, newEntry.type().name())) - .doOnError(e -> logger.error("Error appending entry to Redis for session {}: {}", sessionId, e.getMessage(), e)) - .then(); + .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) { - if (telefono == null || telefono.trim().isEmpty()) { + if (telefono == null || telefono.isBlank()) { return Mono.empty(); } String phoneToSessionKey = PHONE_TO_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); @@ -70,4 +73,4 @@ public class MemoryStoreConversationService { }) .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/FirestoreNotificationService.java b/src/main/java/com/example/service/notification/FirestoreNotificationService.java new file mode 100644 index 0000000..b34e211 --- /dev/null +++ b/src/main/java/com/example/service/notification/FirestoreNotificationService.java @@ -0,0 +1,97 @@ +package com.example.service.notification; + +import com.example.dto.dialogflow.notification.NotificationDTO; +import com.example.exception.FirestorePersistenceException; +import com.example.mapper.notification.FirestoreNotificationMapper; +import com.example.repository.FirestoreBaseRepository; +import com.google.cloud.Timestamp; +import com.google.cloud.firestore.DocumentReference; +import com.google.cloud.firestore.FieldValue; +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 final FirestoreBaseRepository firestoreBaseRepository; + private final FirestoreNotificationMapper firestoreNotificationMapper; + + public FirestoreNotificationService( + FirestoreBaseRepository firestoreBaseRepository, + FirestoreNotificationMapper firestoreNotificationMapper) { + 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; + + 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 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 { + // 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); + } + } 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()); + } + + private DocumentReference getNotificationDocumentReference(String notificationId) { + String collectionPath = getNotificationCollectionPath(); + return firestoreBaseRepository.getDocumentReference(collectionPath, notificationId); + } +} \ 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 new file mode 100644 index 0000000..928ba07 --- /dev/null +++ b/src/main/java/com/example/service/notification/MemoryStoreNotificationService.java @@ -0,0 +1,108 @@ +package com.example.service.notification; + +import com.example.dto.dialogflow.notification.NotificationDTO; +import com.example.dto.dialogflow.notification.NotificationSessionDTO; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.redis.core.ReactiveRedisTemplate; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.time.Instant; + +@Service +public class MemoryStoreNotificationService { + + private static final Logger logger = LoggerFactory.getLogger(MemoryStoreNotificationService.class); + private final ReactiveRedisTemplate notificationRedisTemplate; + private final ReactiveRedisTemplate stringRedisTemplate; + 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); + + public MemoryStoreNotificationService( + ReactiveRedisTemplate notificationRedisTemplate, + ReactiveRedisTemplate stringRedisTemplate, + ObjectMapper objectMapper) { + this.notificationRedisTemplate = notificationRedisTemplate; + this.stringRedisTemplate = stringRedisTemplate; + + } + + public Mono saveOrAppendNotificationEntry(NotificationDTO newEntry) { + String phoneNumber = newEntry.telefono(); + 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; + + return getCachedNotificationSession(notificationSessionId) + .flatMap(existingSession -> { + // Session exists, append the new entry + List updatedEntries = new ArrayList<>(existingSession.notificaciones()); + updatedEntries.add(newEntry); + NotificationSessionDTO updatedSession = new NotificationSessionDTO( + notificationSessionId, + phoneNumber, + existingSession.fechaCreacion(), + Instant.now(), + updatedEntries + ); + return Mono.just(updatedSession); + }) + .switchIfEmpty(Mono.defer(() -> { + // No session found, create a new one + NotificationSessionDTO newSession = new NotificationSessionDTO( + notificationSessionId, + phoneNumber, + Instant.now(), + Instant.now(), + Collections.singletonList(newEntry) + ); + return Mono.just(newSession); + })) + .flatMap(this::cacheNotificationSession) + .then(); + } + + private Mono cacheNotificationSession(NotificationSessionDTO session) { + String key = NOTIFICATION_KEY_PREFIX + session.sessionId(); + String phoneToSessionKey = PHONE_TO_SESSION_KEY_PREFIX + session.telefono(); + + return notificationRedisTemplate.opsForValue().set(key, session, notificationTtl) + .then(stringRedisTemplate.opsForValue().set(phoneToSessionKey, session.sessionId(), notificationTtl)); + } + + public Mono getCachedNotificationSession(String sessionId) { + String key = NOTIFICATION_KEY_PREFIX + sessionId; + return notificationRedisTemplate.opsForValue().get(key) + .doOnSuccess(notification -> { + if (notification != null) { + logger.info("Notification session with ID {} retrieved from MemoryStore.", sessionId); + } else { + logger.debug("Notification session with ID {} not found in MemoryStore.", sessionId); + } + }) + .doOnError(e -> logger.error("Error retrieving notification session with ID {} from MemoryStore: {}", sessionId, e.getMessage(), e)); + } + + public Mono getNotificationIdForPhone(String phone) { + String key = PHONE_TO_SESSION_KEY_PREFIX + phone; + return stringRedisTemplate.opsForValue().get(key) + .doOnSuccess(sessionId -> { + if (sessionId != null) { + logger.info("Session ID {} found for phone {}.", sessionId, phone); + } else { + logger.debug("Session ID not found for phone {}.", phone); + } + }) + .doOnError(e -> logger.error("Error retrieving session ID for phone {} from MemoryStore: {}", phone, + e.getMessage(), e)); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/service/notification/NotificationManagerService.java b/src/main/java/com/example/service/notification/NotificationManagerService.java new file mode 100644 index 0000000..d3adc9d --- /dev/null +++ b/src/main/java/com/example/service/notification/NotificationManagerService.java @@ -0,0 +1,110 @@ +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.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; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; + +import java.time.Instant; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +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 final DialogflowClientService dialogflowClientService; + private final FirestoreNotificationService firestoreNotificationService; + private final MemoryStoreNotificationService memoryStoreNotificationService; + private final MemoryStoreConversationService memoryStoreConversationService; + + @Value("${dialogflow.default-language-code:es}") + private String defaultLanguageCode; + + public NotificationManagerService( + DialogflowClientService dialogflowClientService, + FirestoreNotificationService firestoreNotificationService, + MemoryStoreNotificationService memoryStoreNotificationService, + MemoryStoreConversationService memoryStoreConversationService) { + this.dialogflowClientService = dialogflowClientService; + this.firestoreNotificationService = firestoreNotificationService; + this.memoryStoreNotificationService = memoryStoreNotificationService; + this.memoryStoreConversationService = memoryStoreConversationService; + } + + 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.")); + } + + // 1. Persist the incoming notification entry + String newNotificationId = SessionIdGenerator.generateStandardSessionId(); + NotificationDTO newNotificationEntry = new NotificationDTO(newNotificationId,telefono, Instant.now(), + externalRequest.text(),eventName, defaultLanguageCode,Collections.emptyMap() +); + 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) + ); + }); + + // 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(() -> { + 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)); + })); + + // 3. Send notification text to Dialogflow using the resolved conversation session + return persistenceMono.then(sessionMono) + .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 + TextInputDTO textInput = new TextInputDTO(newNotificationEntry.texto()); + QueryInputDTO queryInput = new QueryInputDTO(textInput, null, defaultLanguageCode); + + DetectIntentRequestDTO detectIntentRequest = new DetectIntentRequestDTO( + 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 diff --git a/src/main/java/com/example/service/ConversationSummaryService.java b/src/main/java/com/example/service/summary/ConversationSummaryService.java similarity index 92% rename from src/main/java/com/example/service/ConversationSummaryService.java rename to src/main/java/com/example/service/summary/ConversationSummaryService.java index 372f900..ed853c1 100644 --- a/src/main/java/com/example/service/ConversationSummaryService.java +++ b/src/main/java/com/example/service/summary/ConversationSummaryService.java @@ -1,10 +1,16 @@ -package com.example.service; +/* + * 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.summary; import com.example.dto.gemini.ConversationSummaryRequest; import com.example.dto.gemini.ConversationSummaryResponse; import com.example.dto.gemini.ConversationSessionSummaryDTO; import com.example.dto.gemini.ConversationEntrySummaryDTO; import com.example.repository.FirestoreBaseRepository; +import com.example.service.base.GeminiClientService; import com.google.cloud.firestore.DocumentReference; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -26,6 +32,8 @@ public class ConversationSummaryService { private static final String DEFAULT_GEMINI_MODEL_NAME = "gemini-2.0-flash-001"; private static final Float DEFAULT_TEMPERATURE = 0.7f; private static final Integer DEFAULT_MAX_OUTPUT_TOKENS = 800; + private static final Float DEFAULT_tOPP = 0.1f; + public ConversationSummaryService(GeminiClientService geminiService, FirestoreBaseRepository firestoreBaseRepository) { this.geminiService = geminiService; @@ -55,6 +63,8 @@ public class ConversationSummaryService { ? request.temperature() : DEFAULT_TEMPERATURE; Integer actualMaxOutputTokens = (request.maxOutputTokens() != null) ? request.maxOutputTokens() : DEFAULT_MAX_OUTPUT_TOKENS; + Float actualTopP = (request.top_P() != null) + ? request.top_P() : DEFAULT_tOPP; String collectionPath = String.format(CONVERSATION_COLLECTION_PATH_FORMAT, firestoreBaseRepository.getAppId()); String documentId = sessionId; @@ -99,7 +109,8 @@ public class ConversationSummaryService { fullPromptForGemini, actualTemperature, actualMaxOutputTokens, - actualModelName + actualModelName, + actualTopP ); if (summaryText == null || summaryText.trim().isEmpty()) { diff --git a/src/main/java/com/example/util/PerformanceTimer.java b/src/main/java/com/example/util/PerformanceTimer.java new file mode 100644 index 0000000..6b82a9a --- /dev/null +++ b/src/main/java/com/example/util/PerformanceTimer.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.util; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.function.Supplier; + +/** + * A utility class to measure and log the execution time of a given operation. + * It uses the Supplier functional interface to wrap the code block to be timed. + */ +public class PerformanceTimer { + private static final Logger logger = LoggerFactory.getLogger(PerformanceTimer.class); + + + public static T timeExecution(String operationName, Supplier operation) { + long startTime = System.nanoTime(); + try { + T result = operation.get(); + long endTime = System.nanoTime(); + long durationNanos = endTime - startTime; + double durationMillis = durationNanos / 1_000_000.0; + logger.info("Operation '{}' completed in {} ms.", operationName, String.format("%.2f", durationMillis)); + return result; + } catch (Exception e) { + long endTime = System.nanoTime(); + long durationNanos = endTime - startTime; + double durationMillis = durationNanos / 1_000_000.0; + logger.error("Operation '{}' failed in {} ms: {}", operationName, String.format("%.2f", durationMillis), e.getMessage(), e); + throw new RuntimeException("Error during timed operation: " + operationName, e); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/example/util/ProtobufUtil.java b/src/main/java/com/example/util/ProtobufUtil.java index abd8da2..1a7f25d 100644 --- a/src/main/java/com/example/util/ProtobufUtil.java +++ b/src/main/java/com/example/util/ProtobufUtil.java @@ -22,6 +22,7 @@ public class ProtobufUtil { * Maps will be converted to Protobuf Structs. * Lists will be converted to Protobuf ListValues. */ + @SuppressWarnings("rawtypes") public static Value convertJavaObjectToProtobufValue(Object obj) { if (obj == null) { return Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build(); @@ -35,6 +36,8 @@ public class ProtobufUtil { return Value.newBuilder().setNumberValue((Double) obj).build(); } else if (obj instanceof String) { return Value.newBuilder().setStringValue((String) obj).build(); + } else if (obj instanceof Enum) { + return Value.newBuilder().setStringValue(((Enum) obj).name()).build(); } else if (obj instanceof Map) { Struct.Builder structBuilder = Struct.newBuilder(); ((Map) obj).forEach((key, val) -> diff --git a/src/main/java/com/example/util/SessionIdGenerator.java b/src/main/java/com/example/util/SessionIdGenerator.java new file mode 100644 index 0000000..984e73c --- /dev/null +++ b/src/main/java/com/example/util/SessionIdGenerator.java @@ -0,0 +1,63 @@ +package com.example.util; + +import java.util.UUID; +import java.util.Base64; + +/** + * A utility class for generating consistent and formatted session IDs. + * Centralizing ID generation ensures all parts of the application use the same + * logic and format. + */ +public final class SessionIdGenerator { + + // Private constructor to prevent instantiation of the utility class. + private SessionIdGenerator() {} + + /** + * Generates a standard, version 4 (random) UUID as a string. + * This is the most common and robust approach for general-purpose unique IDs. + * The UUID is a 36-character string with hyphens (e.g., "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"). + * + * @return a new, randomly generated UUID string. + */ + public static String generateStandardSessionId() { + return UUID.randomUUID().toString(); + } + + /** + * Generates a more compact session ID by removing the hyphens from a standard UUID. + * This is useful for contexts where a shorter or URL-friendly ID is needed. + * + * @return a 32-character UUID string without hyphens. + */ + public static String generateCompactSessionId() { + return UUID.randomUUID().toString().replace("-", ""); + } + + /** + * Generates a base64-encoded, URL-safe session ID from a UUID. + * This provides a very compact, yet robust, representation of the UUID. + * It's ideal for use in URLs, cookies, or other contexts where size matters. + * + * @return a new, base64-encoded UUID string. + */ + public static String generateUrlSafeSessionId() { + UUID uuid = UUID.randomUUID(); + byte[] uuidBytes = toBytes(uuid); + return Base64.getUrlEncoder().withoutPadding().encodeToString(uuidBytes); + } + + // Helper method to convert UUID to a byte array + private static byte[] toBytes(UUID uuid) { + long mostSignificantBits = uuid.getMostSignificantBits(); + long leastSignificantBits = uuid.getLeastSignificantBits(); + byte[] bytes = new byte[16]; + for (int i = 0; i < 8; i++) { + bytes[i] = (byte) (mostSignificantBits >>> (8 * (7 - i))); + } + for (int i = 0; i < 8; i++) { + bytes[8 + i] = (byte) (leastSignificantBits >>> (8 * (7 - i))); + } + return bytes; + } +} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 4996b35..4825ed4 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,85 +1,64 @@ -# Firestore Configuration Properties -# -------------------------------- +# 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. -# Project ID Configuration -# Use this setting if you want to manually specify a GCP Project instead of inferring -# from your machine's environment. - spring.cloud.gcp.firestore.project-id=app-jovenes +# ========================================= +# Spring Boot Configuration Template +# ========================================= +# This file serves as a reference template for all application configuration properties. -# Credentials Configuration -# Use this setting if you want to manually specify service account credentials instead of inferring -# from the machine's environment for firestore. - #spring.cloud.gcp.firestore.credentials.location=file:{PATH_TO_YOUR_CREDENTIALS_FILE} +# 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=app-jovenes # Firestore Emulator Configuration (for local development) #spring.cloud.gcp.firestore.emulator-host=localhost:8080 spring.cloud.gcp.firestore.emulator.enabled=false - -# Firestore Database Configuration -# ------------------------------------------ - spring.cloud.gcp.firestore.database-id=app-jovenes-cache-database - spring.cloud.gcp.firestore.host=firestore.googleapis.com - spring.cloud.gcp.firestore.port=443 - -# Memorystore (Redis) Configuration Properties -# ------------------------------------------ - -# Basic Connection Settings -#Secret Manager recomendation for credentials - spring.data.redis.host=10.241.0.11 - spring.data.redis.port=6379 - #spring.data.redis.password=23cb4c76-9d96-4c74-b8c0-778fb364877a - #spring.data.redis.username=default - -# Connection Pool Settings -# spring.data.redis.lettuce.pool.max-active=8 -# spring.data.redis.lettuce.pool.max-idle=8 -# spring.data.redis.lettuce.pool.min-idle=0 -# spring.data.redis.lettuce.pool.max-wait=-1ms +# ========================================================= +# Google Firestore Configuration +# ========================================================= +spring.cloud.gcp.firestore.project-id=app-jovenes +spring.cloud.gcp.firestore.database-id=app-jovenes-cache-database +spring.cloud.gcp.firestore.host=firestore.googleapis.com +spring.cloud.gcp.firestore.port=443 +# ========================================================= +# Google Memorystore(Redis) Configuration +# ========================================================= +spring.data.redis.host=10.241.0.11 +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 - -# Timeout Settings -# spring.data.redis.timeout=2000ms -# spring.data.redis.lettuce.shutdown-timeout=100ms - -# Cluster Configuration (if using Redis Cluster) -# spring.data.redis.cluster.nodes=localhost:6379,localhost:6380,localhost:6381 -# spring.data.redis.cluster.max-redirects=3 - -# Sentinel Configuration (if using Redis Sentinel) -# spring.data.redis.sentinel.master=mymaster -# spring.data.redis.sentinel.nodes=localhost:26379,localhost:26380,localhost:26381 - -# Additional Redis Settings -# spring.data.redis.database=0 -# spring.data.redis.client-type=lettuce -# spring.data.redis.lettuce.cluster.refresh.period=1000ms - -# Google Cloud StorageConfiguration -# ------------------------------------------ -gcs.bucket.name=app-jovenes-bucket -spring.cloud.gcp.project-id=app-jovenes - -# Dialogflow CX Configuration -# ------------------------------------------ +# ========================================================= +# Google Conversational Agents Configuration +# ========================================================= dialogflow.cx.project-id=app-jovenes dialogflow.cx.location=us-central1 -dialogflow.cx.agent-id=3b9f2354-8556-4363-9e70-fa8283582a3e +dialogflow.cx.agent-id=ff65fd2a-99d1-477f-9d10-4d75461f9568 dialogflow.default-language-code=es - # ========================================================= # Google Generative AI (Gemini) Configuration # ========================================================= -# Your Google Cloud Project ID where the Vertex AI service is enabled. google.cloud.project=app-jovenes - -# The Google Cloud region where you want to access the Gemini model. -# Common regions: us-central1, europe-west1, asia-northeast1 etc. google.cloud.location=us-central1 - -# The name of the Gemini model to use for summarization. -gemini.model.name=gemini-2.0-flash-001 \ No newline at end of file +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 diff --git a/src/main/resources/prompts/message_filter_prompt.txt b/src/main/resources/prompts/message_filter_prompt.txt new file mode 100644 index 0000000..ac95ac7 --- /dev/null +++ b/src/main/resources/prompts/message_filter_prompt.txt @@ -0,0 +1,54 @@ +You are an expert AI classification agent. +Your task is to analyze a user's final message after an external notification interrupts an ongoing conversation. + +You will receive three pieces of information: +1. `CONVERSATION_HISTORY`: The dialogue between the agent and the user *before* the notification. +2. `INTERRUPTING_NOTIFICATION`: The specific alert that appeared. +3. `USER_FINAL_INPUT`: The user's message that you must classify. + +Your goal is to determine if the `USER_FINAL_INPUT` is a reaction to the `INTERRUPTING_NOTIFICATION` or a continuation of the `CONVERSATION_HISTORY`. + +**Classification Rules:** + +* **`%s`**: Classify as `%s` if the `USER_FINAL_INPUT` is a direct question, comment, or reaction related to the `INTERRUPTING_NOTIFICATION`. The user has switched their focus to the notification. +* **`%s`**: Classify as `%s` if the `USER_FINAL_INPUT` ignores the notification and continues the topic from the `CONVERSATION_HISTORY`. +* **Ambiguity Rule**: If the input is ambiguous (e.g., "ok, thanks"), default to `%s`. It's safer to assume the user is concluding the original topic. +* **Acknowledgement Rule**: If the user briefly acknowledges the notification but immediately pivots back to the original conversation (e.g., "Okay thank you, but back to my question about loans..."), classify it as `%s`. The PRIMARY INTENT is to continue the original dialogue. + +Your response must be a single word: `%s` or `%s`. Do not add any other text, punctuation, or explanations. + +--- +**Examples (Few-Shot Learning):** + +**Example 1:** +`CONVERSATION_HISTORY`: +Agent: Claro, para un crédito de vehículo, las tasas actuales inician en el 1.2%% mensual. +User: Entiendo, ¿y el plazo máximo de cuánto sería? +`INTERRUPTING_NOTIFICATION`: Tu pago de la tarjeta de crédito por $1,500.00 ha sido procesado. +`USER_FINAL_INPUT`: ¡Perfecto! Justo de eso quería saber, ¿ese pago ya se ve reflejado en el cupo disponible? +Classification: %s + +**Example 2:** +`CONVERSATION_HISTORY`: +Agent: No es necesario, puedes completar todo el proceso para abrir tu cuenta desde nuestra app. +User: Ok, suena fácil. +`INTERRUPTING_NOTIFICATION`: Tu estado de cuenta de Julio ya está disponible. +`USER_FINAL_INPUT`: Bueno, y qué documentos necesito tener a la mano para hacerlo en la app? +Classification: %s + +**Example 3:** +`CONVERSATION_HISTORY`: +Agent: Ese fondo de inversión tiene un perfil de alto riesgo, pero históricamente ha dado un rendimiento superior al 15%% anual. +User: Suena interesante... +`INTERRUPTING_NOTIFICATION`: Alerta: Tu cuenta de ahorros tiene un saldo bajo de $50.00. +`USER_FINAL_INPUT`: Umm, ok gracias cuando fue el ultimo retiro?. Pero volviendo al fondo, ¿cuál es la inversión mínima para entrar? +Classification: %s +--- + +**Task:** + +`CONVERSATION_HISTORY`: +%s +`INTERRUPTING_NOTIFICATION`: %s +`USER_FINAL_INPUT`: %s +Classification: \ No newline at end of file diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html deleted file mode 100644 index 3498164..0000000 --- a/src/main/resources/static/index.html +++ /dev/null @@ -1,57 +0,0 @@ - - - - Spring Data Firestore Sample - - - - - - -

Spring Data Firestore Sample

- -
- -

Firestore Control Panel

- -

- This section allows you to read User entities in Firestore. - Some values are prefilled as an example of what you can type in. -

- - - -
- Show all users with age -
- Age: - -
-
- -
- - - - - - \ 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 new file mode 100644 index 0000000..afc3ea6 --- /dev/null +++ b/src/test/java/com/example/service/integration-testing/MessageEntryFilterIntegrationTest.java @@ -0,0 +1,121 @@ +/* + * 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; + +import com.example.service.base.MessageEntryFilter; +import com.example.util.PerformanceTimer; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@SpringBootTest +@ActiveProfiles("test") +@DisplayName("MessageEntryFilter Integration Tests") +public class MessageEntryFilterIntegrationTest { + + @Autowired + private MessageEntryFilter messageEntryFilter; + + private static final String NOTIFICATION_JSON_EXAMPLE = + "[{\"texto\": \"Tu estado de cuenta de Agosto esta listo\"}," + + "{\"texto\": \"Tu pago ha sido procesado\"}]"; + + private static final String CONVERSATION_JSON_EXAMPLE = + "{\"sessionId\":\"ec9f3731-59ac-4bd0-849e-f45fcc18436d\"," + + "\"userId\":\"user_by_phone_0102030405060708\"," + + "\"telefono\":\"0102030405060708\"," + + "\"createdAt\":\"2025-08-06T20:35:05.123699404Z\"," + + "\"lastModified\":\"2025-08-06T20:35:05.984574281Z\"," + + "\"entries\":[{" + + "\"type\":\"USUARIO\"," + + "\"timestamp\":\"2025-08-06T20:35:05.123516916Z\"," + + "\"text\":\"Hola que tal\"" + + "},{" + + "\"type\":\"SISTEMA\"," + + "\"timestamp\":\"2025-08-06T20:35:05.967828173Z\"," + + "\"text\":\"\\Hola! Bienvenido a Banorte, te saluda Beto. \\En que te puedo ayudar? \"," + + "\"parameters\":{" + + "\"canal\":\"banortec\"," + + "\"telefono\":\"0102030405060708\"," + + "\"pantalla_contexto\":\"transferencias\"," + + "\"usuario_id\":\"user_by_phone_0102030405060708\"," + + "\"nickname\":\"John Doe\"" + + "}" + + "}]" + + "}"; + + private static final List CONVERSATION_QUERIES = Arrays.asList( + "Hola, ¿cómo estás?", + "Qué tal, ¿qué hay de nuevo?", + "¿Cuál es el pronóstico del tiempo para hoy?", + "Me gustaría saber más sobre otro servicio", + "Tengo una pregunta general" + ); + + private static final List NOTIFICATION_QUERIES = Arrays.asList( + "¿Dónde puedo ver mi estado de cuenta?", + //"Quiero saber mas", + "Muéstrame mi estado de cuenta de este mes", + "¿Qué dice la notificación del 1 de agosto?" + ); + + @Test + @DisplayName("Gemini should classify various conversational queries as CONVERSATION") + void classifyMessage_integrationTest_shouldClassifyVariousQueriesAsConversation() { + for (int i = 0; i < CONVERSATION_QUERIES.size(); i++) { + String query = CONVERSATION_QUERIES.get(i); + String testName = String.format("Gemini (CONVERSATION) - Query %d", i + 1); + + String result = PerformanceTimer.timeExecution( + testName, + () -> messageEntryFilter.classifyMessage(query, null,null) + ); + + assertEquals(MessageEntryFilter.CATEGORY_CONVERSATION, result, + String.format("Assertion failed for query: '%s'", query)); + } + } + + @Test + @DisplayName("Gemini should classify various notification queries as NOTIFICATION with context") + void classifyMessage_integrationTest_shouldClassifyVariousQueriesAsNotificationWithContext() { + for (int i = 0; i < NOTIFICATION_QUERIES.size(); i++) { + String query = NOTIFICATION_QUERIES.get(i); + String testName = String.format("Gemini (NOTIFICATION with context) - Query %d", i + 1); + + String result = PerformanceTimer.timeExecution( + testName, + () -> messageEntryFilter.classifyMessage(query, NOTIFICATION_JSON_EXAMPLE,CONVERSATION_JSON_EXAMPLE) + ); + + assertEquals(MessageEntryFilter.CATEGORY_NOTIFICATION, result, + String.format("Assertion failed for query: '%s'", query)); + } + } + + @Test + @DisplayName("Gemini should classify various conversational queries as CONVERSATION even with context") + void classifyMessage_integrationTest_shouldClassifyVariousConversationalQueriesWithContext() { + for (int i = 0; i < CONVERSATION_QUERIES.size(); i++) { + String query = CONVERSATION_QUERIES.get(i); + String testName = String.format("Gemini (CONVERSATION with context) - Query %d", i + 1); + + String result = PerformanceTimer.timeExecution( + testName, + () -> messageEntryFilter.classifyMessage(query, NOTIFICATION_JSON_EXAMPLE,CONVERSATION_JSON_EXAMPLE) + ); + + assertEquals(MessageEntryFilter.CATEGORY_CONVERSATION, result, + String.format("Assertion failed for query: '%s'", query)); + } + } +} \ No newline at end of file diff --git a/src/test/java/com/example/service/unit-testing/DialogflowClientServiceTest.java b/src/test/java/com/example/service/unit-testing/DialogflowClientServiceTest.java new file mode 100644 index 0000000..21cebca --- /dev/null +++ b/src/test/java/com/example/service/unit-testing/DialogflowClientServiceTest.java @@ -0,0 +1,167 @@ +package com.example.service; + +import com.example.dto.dialogflow.base.DetectIntentRequestDTO; +import com.example.dto.dialogflow.base.DetectIntentResponseDTO; +import com.example.exception.DialogflowClientException; +import com.example.mapper.conversation.DialogflowRequestMapper; +import com.example.mapper.conversation.DialogflowResponseMapper; +import com.example.service.base.DialogflowClientService; +import com.google.api.gax.rpc.ApiException; +import com.google.api.gax.rpc.StatusCode; +import com.google.cloud.dialogflow.cx.v3.DetectIntentRequest; +import com.google.cloud.dialogflow.cx.v3.DetectIntentResponse; +import com.google.cloud.dialogflow.cx.v3.SessionsClient; +import com.google.cloud.dialogflow.cx.v3.SessionsSettings; + +import io.grpc.Status; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import reactor.test.StepVerifier; + +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class DialogflowClientServiceTest { + + private static final String PROJECT_ID = "test-project"; + private static final String LOCATION = "us-central1"; + private static final String AGENT_ID = "test-agent"; + private static final String SESSION_ID = "test-session-123"; + + @Mock + private DialogflowRequestMapper mockRequestMapper; + @Mock + private DialogflowResponseMapper mockResponseMapper; + @Mock + private SessionsClient mockSessionsClient; + + private MockedStatic mockedStaticSessionsClient; + + private DialogflowClientService dialogflowClientService; + + @BeforeEach + void setUp() throws IOException { + mockedStaticSessionsClient = Mockito.mockStatic(SessionsClient.class); + mockedStaticSessionsClient.when(() -> SessionsClient.create(any(SessionsSettings.class))) + .thenReturn(mockSessionsClient); + + dialogflowClientService = new DialogflowClientService( + PROJECT_ID, + LOCATION, + AGENT_ID, + mockRequestMapper, + mockResponseMapper + ); + } + + @AfterEach + void tearDown() { + mockedStaticSessionsClient.close(); + } + + @Test + void constructor_shouldInitializeClientSuccessfully() { + assertNotNull(dialogflowClientService); + mockedStaticSessionsClient.verify(() -> SessionsClient.create(any(SessionsSettings.class))); + } + + @Test + void closeSessionsClient_shouldCloseClient() { + dialogflowClientService.closeSessionsClient(); + verify(mockSessionsClient, times(1)).close(); + } + + @Test + void detectIntent_whenSuccess_shouldReturnMappedResponse() { + // Arrange + DetectIntentRequestDTO requestDTO = mock(DetectIntentRequestDTO.class); + DetectIntentRequest.Builder requestBuilder = DetectIntentRequest.newBuilder(); + DetectIntentRequest finalRequest = DetectIntentRequest.newBuilder() + .setSession(String.format("projects/%s/locations/%s/agents/%s/sessions/%s", PROJECT_ID, LOCATION, AGENT_ID, SESSION_ID)) + .build(); + DetectIntentResponse dfResponse = DetectIntentResponse.newBuilder().build(); + DetectIntentResponseDTO expectedResponseDTO = mock(DetectIntentResponseDTO.class); + + when(mockRequestMapper.mapToDetectIntentRequestBuilder(requestDTO)).thenReturn(requestBuilder); + when(mockSessionsClient.detectIntent(any(DetectIntentRequest.class))).thenReturn(dfResponse); + when(mockResponseMapper.mapFromDialogflowResponse(dfResponse, SESSION_ID)).thenReturn(expectedResponseDTO); + + // Act & Assert + StepVerifier.create(dialogflowClientService.detectIntent(SESSION_ID, requestDTO)) + .expectNext(expectedResponseDTO) + .verifyComplete(); + + verify(mockSessionsClient).detectIntent(finalRequest); + verify(mockResponseMapper).mapFromDialogflowResponse(dfResponse, SESSION_ID); + } + + @Test + void detectIntent_whenRequestMapperFails_shouldReturnError() { + DetectIntentRequestDTO requestDTO = mock(DetectIntentRequestDTO.class); + when(mockRequestMapper.mapToDetectIntentRequestBuilder(requestDTO)) + .thenThrow(new IllegalArgumentException("Invalid mapping")); + StepVerifier.create(dialogflowClientService.detectIntent(SESSION_ID, requestDTO)) + .expectError(IllegalArgumentException.class) + .verify(); + + verify(mockSessionsClient, never()).detectIntent(any(DetectIntentRequest.class)); + } + + @Test + void detectIntent_whenDialogflowApiThrowsApiException_shouldReturnDialogflowClientException() { + DetectIntentRequestDTO requestDTO = mock(DetectIntentRequestDTO.class); + DetectIntentRequest.Builder requestBuilder = DetectIntentRequest.newBuilder(); + + ApiException apiException = new ApiException( + "API Error", + null, + new StatusCode() { + @Override + public Code getCode() { + return Code.UNAVAILABLE; + } + + @Override + public Object getTransportCode() { + return Status.Code.UNAVAILABLE; + } + }, + false + ); + + when(mockRequestMapper.mapToDetectIntentRequestBuilder(requestDTO)).thenReturn(requestBuilder); + when(mockSessionsClient.detectIntent(any(DetectIntentRequest.class))).thenThrow(apiException); + + StepVerifier.create(dialogflowClientService.detectIntent(SESSION_ID, requestDTO)) + .expectError(DialogflowClientException.class) + .verify(); + } + + @Test + void detectIntent_withNullSessionId_shouldThrowNullPointerException() { + DetectIntentRequestDTO requestDTO = mock(DetectIntentRequestDTO.class); + + assertThrows(NullPointerException.class, () -> { + dialogflowClientService.detectIntent(null, requestDTO); + }); + } + + @Test + void detectIntent_withNullRequest_shouldThrowNullPointerException() { + assertThrows(NullPointerException.class, () -> { + dialogflowClientService.detectIntent(SESSION_ID, null); + }); + } +} \ No newline at end of file diff --git a/src/test/java/com/example/service/unit-testing/GeminiClientServiceTest .java b/src/test/java/com/example/service/unit-testing/GeminiClientServiceTest .java new file mode 100644 index 0000000..d01aca3 --- /dev/null +++ b/src/test/java/com/example/service/unit-testing/GeminiClientServiceTest .java @@ -0,0 +1,120 @@ +package com.example.service; + +import com.example.exception.GeminiClientException; +import com.example.service.base.GeminiClientService; +import com.google.genai.Client; +import com.google.genai.errors.GenAiIOException; +import com.google.genai.types.Content; +import com.google.genai.types.GenerateContentConfig; +import com.google.genai.types.GenerateContentResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Answers; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class GeminiClientServiceTest { + + +@Mock(answer = Answers.RETURNS_DEEP_STUBS) +private Client geminiClient; + +@InjectMocks +private GeminiClientService geminiClientService; + +private String prompt; +private Float temperature; +private Integer maxOutputTokens; +private String modelName; +private Float top_P; + +@BeforeEach +void setUp() { + prompt = "Test prompt"; + temperature = 0.5f; + maxOutputTokens = 100; + modelName = "gemini-test-model"; + top_P=0.85f; + +} + +@Test +void generateContent_whenApiSucceeds_returnsGeneratedText() throws GeminiClientException { + // Arrange + String expectedText = "This is the generated content."; + GenerateContentResponse mockResponse = mock(GenerateContentResponse.class); + when(mockResponse.text()).thenReturn(expectedText); + when(geminiClient.models.generateContent(anyString(), any(Content.class), any(GenerateContentConfig.class))) + .thenReturn(mockResponse); + + String actualText = geminiClientService.generateContent(prompt, temperature, maxOutputTokens, modelName,top_P); + assertEquals(expectedText, actualText); +} + +@Test +void generateContent_whenApiResponseIsNull_throwsGeminiClientException() { + // Arrange + when(geminiClient.models.generateContent(anyString(), any(Content.class), any(GenerateContentConfig.class))) + .thenReturn(null); + + GeminiClientException exception = assertThrows(GeminiClientException.class, () -> + geminiClientService.generateContent(prompt, temperature, maxOutputTokens, modelName,top_P) + ); + + assertEquals("No content generated or unexpected response structure.", exception.getMessage()); +} + +@Test +void generateContent_whenResponseTextIsNull_throwsGeminiClientException() { + GenerateContentResponse mockResponse = mock(GenerateContentResponse.class); + when(mockResponse.text()).thenReturn(null); + when(geminiClient.models.generateContent(anyString(), any(Content.class), any(GenerateContentConfig.class))) + .thenReturn(mockResponse); + + GeminiClientException exception = assertThrows(GeminiClientException.class, () -> + geminiClientService.generateContent(prompt, temperature, maxOutputTokens, modelName,top_P) + ); + + assertEquals("No content generated or unexpected response structure.", exception.getMessage()); +} + +@Test +void generateContent_whenGenAiIOExceptionOccurs_throwsGeminiClientException() { + // Arrange + String errorMessage = "Network issue"; + when(geminiClient.models.generateContent(anyString(), any(Content.class), any(GenerateContentConfig.class))) + .thenThrow(new GenAiIOException(errorMessage, new IOException())); + + GeminiClientException exception = assertThrows(GeminiClientException.class, () -> + geminiClientService.generateContent(prompt, temperature, maxOutputTokens, modelName,top_P) + ); + + assertTrue(exception.getMessage().startsWith("An API communication issue occurred:")); + assertTrue(exception.getMessage().contains(errorMessage)); +} + +@Test +void generateContent_whenUnexpectedExceptionOccurs_throwsGeminiClientException() { + when(geminiClient.models.generateContent(anyString(), any(Content.class), any(GenerateContentConfig.class))) + .thenThrow(new RuntimeException("Something went wrong")); + + GeminiClientException exception = assertThrows(GeminiClientException.class, () -> + geminiClientService.generateContent(prompt, temperature, maxOutputTokens, modelName,top_P) + ); + + assertEquals("An unexpected issue occurred during content generation.", exception.getMessage()); +} +} \ No newline at end of file diff --git a/src/test/java/com/example/service/unit-testing/MessageEntryFilterTest.java b/src/test/java/com/example/service/unit-testing/MessageEntryFilterTest.java new file mode 100644 index 0000000..dd410c8 --- /dev/null +++ b/src/test/java/com/example/service/unit-testing/MessageEntryFilterTest.java @@ -0,0 +1,262 @@ +/* + * 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; + +import com.example.service.base.GeminiClientService; +import com.example.service.base.MessageEntryFilter; +import com.example.util.PerformanceTimer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.slf4j.LoggerFactory; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyFloat; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.times; + +@ExtendWith(MockitoExtension.class) +@DisplayName("MessageEntryFilter Unit Tests") +public class MessageEntryFilterTest { + + @Mock + private GeminiClientService geminiService; + + @InjectMocks + private MessageEntryFilter messageEntryFilter; + + private ListAppender listAppender; + private static final String NOTIFICATION_JSON_EXAMPLE = + "{\"idNotificacion\":\"4c2992d3-539d-4b28-8d52-cdea02cd1c75\"," + + "\"timestampCreacion\":\"2025-08-01T16:14:02.301671204Z\"," + + "\"texto\":\"Tu estado de cuenta de Agosto esta listo\"," + + "\"nombreEventoDialogflow\":\"notificacion\"," + + "\"codigoIdiomaDialogflow\":\"es\"," + + "\"parametros\":{\"notificacion_texto\":\"Tu estado de cuenta de Agosto esta listo\",\"telefono\":\"555555555\"}}"; + + private static final String CONVERSATION_JSON_EXAMPLE = + "{\"sessionId\":\"ec9f3731-59ac-4bd0-849e-f45fcc18436d\"," + + "\"userId\":\"user_by_phone_0102030405060708\"," + + "\"telefono\":\"0102030405060708\"," + + "\"createdAt\":\"2025-08-06T20:35:05.123699404Z\"," + + "\"lastModified\":\"2025-08-06T20:35:05.984574281Z\"," + + "\"entries\":[{" + + "\"type\":\"USUARIO\"," + + "\"timestamp\":\"2025-08-06T20:35:05.123516916Z\"," + + "\"text\":\"Hola que tal\"" + + "},{" + + "\"type\":\"SISTEMA\"," + + "\"timestamp\":\"2025-08-06T20:35:05.967828173Z\"," + + "\"text\":\"\\u00a1Hola! Bienvenido a Banorte, te saluda Beto. \\u00bfEn qu\\u00e9 te puedo ayudar? \\uD83D\\uDE0A\"," + + "\"parameters\":{" + + "\"canal\":\"banortec\"," + + "\"telefono\":\"0102030405060708\"," + + "\"pantalla_contexto\":\"transferencias\"," + + "\"usuario_id\":\"user_by_phone_0102030405060708\"," + + "\"nickname\":\"John Doe\"" + + "}" + + "}]" + + "}"; + + @BeforeEach + void setUp() { + Logger logger = (Logger) LoggerFactory.getLogger(MessageEntryFilter.class); + listAppender = new ListAppender<>(); + listAppender.start(); + logger.addAppender(listAppender); + } + + private List getLogMessages() { + return listAppender.list.stream() + .map(ILoggingEvent::getFormattedMessage) + .collect(java.util.stream.Collectors.toList()); + } + + @Test + @DisplayName("Should classify as CONVERSATION when Gemini responds with 'CONVERSATION'") + void classifyMessage_shouldReturnConversation_whenGeminiRespondsConversation() throws Exception { + String query = "Hola,como estas?"; + when(geminiService.generateContent(any(String.class), anyFloat(), anyInt(), any(String.class), anyFloat())) + .thenReturn("CONVERSATION"); + + String result = PerformanceTimer.timeExecution("ClassifyConversationTest", + () -> messageEntryFilter.classifyMessage(query, null,null)); + + assertEquals(MessageEntryFilter.CATEGORY_CONVERSATION, result); + + verify(geminiService, times(1)).generateContent(any(String.class), anyFloat(), anyInt(), any(String.class), anyFloat()); + + List logMessages = getLogMessages(); + assertNotNull(logMessages.stream() + .filter(m -> m.contains("Classified as CONVERSATION. Input: 'Hola,como estas?'")) + .findFirst() + .orElse(null), "Log message for successful classification not found."); + + } + + + @Test + @DisplayName("Should classify as NOTIFICATION when Gemini responds with 'NOTIFICATION' (with context)") + void classifyMessage_shouldReturnNotification_whenGeminiRespondsNotificationWithContext() throws Exception { + String query = "Donde puedo descargar mi estado de cuenta"; + when(geminiService.generateContent(any(String.class), anyFloat(), anyInt(), any(String.class), anyFloat())) + .thenReturn("NOTIFICATION"); + + String result = PerformanceTimer.timeExecution("ClassifyNotificationTest", + () -> messageEntryFilter.classifyMessage(query, NOTIFICATION_JSON_EXAMPLE,CONVERSATION_JSON_EXAMPLE)); + + assertEquals(MessageEntryFilter.CATEGORY_NOTIFICATION, result); + + verify(geminiService, times(1)).generateContent(any(String.class), anyFloat(), anyInt(), any(String.class), anyFloat()); + + List logMessages = getLogMessages(); + assertNotNull(logMessages.stream() + .filter(m -> m.contains("Classified as NOTIFICATION") && m.contains(query)) + .findFirst() + .orElse(null), "Log message for successful classification not found."); + + } + + + @Test + @DisplayName("Should return UNKNOWN if queryInputText is null") + void classifyMessage_shouldReturnUnknown_whenQueryInputTextIsNull() throws Exception { + String result = messageEntryFilter.classifyMessage(null, null,null); + assertEquals(MessageEntryFilter.CATEGORY_UNKNOWN, result); + verify(geminiService, times(0)).generateContent(any(), any(), any(), any(), any()); + assertNotNull(getLogMessages().stream().filter(m -> m.contains("Query input text for classification is null or blank")).findFirst().orElse(null)); + } + + @Test + @DisplayName("Should return UNKNOWN if queryInputText is blank") + void classifyMessage_shouldReturnUnknown_whenQueryInputTextIsBlank() throws Exception { + String result = messageEntryFilter.classifyMessage(" ", null,null); + assertEquals(MessageEntryFilter.CATEGORY_UNKNOWN, result); + verify(geminiService, times(0)).generateContent(any(), any(), any(), any(), any()); + assertNotNull(getLogMessages().stream().filter(m -> m.contains("Query input text for classification is null or blank")).findFirst().orElse(null)); + } + + // --- + + @Test + @DisplayName("Should return UNKNOWN if Gemini returns null") + void classifyMessage_shouldReturnUnknown_whenGeminiReturnsNull() throws Exception { + String query = "Any valid query"; + when(geminiService.generateContent(any(), any(), any(), any(), any())).thenReturn(null); + + String result = messageEntryFilter.classifyMessage(query, null,null); + assertEquals(MessageEntryFilter.CATEGORY_UNKNOWN, result); + assertNotNull(getLogMessages().stream().filter(m -> m.contains("Gemini returned an unrecognised classification or was null/blank")).findFirst().orElse(null)); + } + + @Test + @DisplayName("Should return UNKNOWN if Gemini returns blank") + void classifyMessage_shouldReturnUnknown_whenGeminiReturnsBlank() throws Exception { + String query = "Any valid query"; + when(geminiService.generateContent(any(), any(), any(), any(), any())).thenReturn(" "); + + String result = messageEntryFilter.classifyMessage(query, null, null); + assertEquals(MessageEntryFilter.CATEGORY_UNKNOWN, result); + assertNotNull(getLogMessages().stream().filter(m -> m.contains("Gemini returned an unrecognised classification or was null/blank")).findFirst().orElse(null)); + } + + @Test + @DisplayName("Should return UNKNOWN if Gemini returns an unexpected string") + void classifyMessage_shouldReturnUnknown_whenGeminiReturnsUnexpectedString() throws Exception { + String query = "Any valid query"; + when(geminiService.generateContent(any(), any(), any(), any(), any())).thenReturn("INVALID_RESPONSE"); + + String result = messageEntryFilter.classifyMessage(query, null,null); + assertEquals(MessageEntryFilter.CATEGORY_UNKNOWN, result); + assertNotNull(getLogMessages().stream().filter(m -> m.contains("Gemini returned an unrecognised classification")).findFirst().orElse(null)); + } + + @Test + @DisplayName("Should return ERROR if Gemini service throws an exception") + void classifyMessage_shouldReturnError_whenGeminiServiceThrowsException() throws Exception { + String query = "Query causing error"; + when(geminiService.generateContent(any(), any(), any(), any(), any())) + .thenThrow(new RuntimeException("Gemini API error")); + + String result = messageEntryFilter.classifyMessage(query, null,null); + assertEquals(MessageEntryFilter.CATEGORY_ERROR, result); + assertNotNull(getLogMessages().stream().filter(m -> m.contains("Error during Gemini classification")).findFirst().orElse(null)); + } + + @Test + @DisplayName("Should include notification context in prompt when provided and not blank") + void classifyMessage_shouldIncludeNotificationContextInPrompt() throws Exception { + String query = "What's up?"; + + when(geminiService.generateContent(any(String.class), anyFloat(), anyInt(), any(String.class), anyFloat())) + .thenReturn("CONVERSATION"); + + messageEntryFilter.classifyMessage(query, NOTIFICATION_JSON_EXAMPLE,CONVERSATION_JSON_EXAMPLE); + + verify(geminiService, times(1)).generateContent( + org.mockito.ArgumentMatchers.argThat(prompt -> + prompt.contains("Recent Notifications Context:") && + prompt.contains(NOTIFICATION_JSON_EXAMPLE) && + prompt.contains("User Input: 'What's up?'") + ), + anyFloat(), anyInt(), any(String.class), anyFloat() + ); + } + + @Test + @DisplayName("Should NOT include notification context in prompt when provided but blank") + void classifyMessage_shouldNotIncludeNotificationContextInPromptWhenBlank() throws Exception { + String query = "What's up?"; + String notifications = " "; + String conversations =" "; + + when(geminiService.generateContent(any(String.class), anyFloat(), anyInt(), any(String.class), anyFloat())) + .thenReturn("CONVERSATION"); + + messageEntryFilter.classifyMessage(query, notifications,conversations); + + verify(geminiService, times(1)).generateContent( + org.mockito.ArgumentMatchers.argThat(prompt -> + !prompt.contains("Recent Notifications Context:") && + prompt.contains("User Input: 'What's up?'") + ), + anyFloat(), anyInt(), any(String.class), anyFloat() + ); + } + + @Test + @DisplayName("Should NOT include notification context in prompt when null") + void classifyMessage_shouldNotIncludeNotificationContextInPromptWhenNull() throws Exception { + String query = "What's up?"; + String notifications = null; + String conversations = null; + + when(geminiService.generateContent(any(String.class), anyFloat(), anyInt(), any(String.class), anyFloat())) + .thenReturn("CONVERSATION"); + + messageEntryFilter.classifyMessage(query, notifications, conversations); + + verify(geminiService, times(1)).generateContent( + org.mockito.ArgumentMatchers.argThat(prompt -> + !prompt.contains("Recent Notifications Context:") && + prompt.contains("User Input: 'What's up?'") + ), + anyFloat(), anyInt(), any(String.class), anyFloat() + ); + } +} \ No newline at end of file