UPDATE 12-ago-2025

This commit is contained in:
PAVEL PALMA
2025-08-12 16:09:32 -06:00
parent 55fcf3b7d6
commit 849095374f
74 changed files with 2656 additions and 669 deletions

View File

@@ -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": {},
}
```

View File

@@ -114,7 +114,11 @@
<groupId>com.fasterxml.jackson.module</groupId>
<artifactId>jackson-module-parameter-names</artifactId>
<version>2.19.0</version>
</dependency>
</dependency>
<dependency>
<groupId>com.google.api</groupId>
<artifactId>gax</artifactId>
</dependency>
</dependencies>
<build>
<plugins>

View File

@@ -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<ServerResponse> indexRouter(
@Value("classpath:/static/index.html") final Resource indexHtml) {
return route(GET("/"), request -> ok().contentType(MediaType.TEXT_HTML).bodyValue(indexHtml));
}
}

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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<String, ConversationSessionDTO> 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<ConversationSessionDTO> serializer =
new Jackson2JsonRedisSerializer<>(objectMapper, ConversationSessionDTO.class);
Jackson2JsonRedisSerializer<ConversationSessionDTO> serializer = new Jackson2JsonRedisSerializer<>(
objectMapper, ConversationSessionDTO.class);
return new ReactiveRedisTemplate<>(factory, RedisSerializationContext
.<String, ConversationSessionDTO>newSerializationContext(new StringRedisSerializer())
.value(serializer)
.build());
.<String, ConversationSessionDTO>newSerializationContext(new StringRedisSerializer())
.value(serializer)
.build());
}
@Bean
public ReactiveRedisTemplate<String, String> reactiveStringRedisTemplate(
ReactiveRedisConnectionFactory factory) {
ReactiveRedisConnectionFactory factory) {
return new ReactiveRedisTemplate<>(factory, RedisSerializationContext
.<String, String>newSerializationContext(new StringRedisSerializer())
.value(new StringRedisSerializer())
.build());
}
.<String, String>newSerializationContext(new StringRedisSerializer())
.value(new StringRedisSerializer())
.build());
}
@Bean
public ReactiveRedisTemplate<String, NotificationSessionDTO> reactiveNotificationRedisTemplate(
ReactiveRedisConnectionFactory factory) {
ObjectMapper notificationObjectMapper = new ObjectMapper();
notificationObjectMapper.registerModule(new JavaTimeModule());
notificationObjectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
StringRedisSerializer keySerializer = new StringRedisSerializer();
Jackson2JsonRedisSerializer<NotificationSessionDTO> valueSerializer = new Jackson2JsonRedisSerializer<>(
notificationObjectMapper, NotificationSessionDTO.class);
RedisSerializationContext.RedisSerializationContextBuilder<String, NotificationSessionDTO> builder = RedisSerializationContext
.newSerializationContext(keySerializer);
RedisSerializationContext<String, NotificationSessionDTO> context = builder.value(valueSerializer)
.build();
return new ReactiveRedisTemplate<>(factory, context);
}
}

View File

@@ -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<DetectIntentResponseDTO> detectIntent(@Valid @RequestBody DetectIntentRequestDTO request) {
logger.info("Received request for session: {}", request.sessionId());
@PostMapping("/detect-intent")
public Mono<DetectIntentResponseDTO> 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));
}
}

View File

@@ -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;

View File

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

View File

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

View File

@@ -1,8 +0,0 @@
package com.example.dto.base;
public record ConversationContext(
String userId,
String sessionId,
String userMessageText,
String primaryPhoneNumber
) {}

View File

@@ -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
) {
}
}

View File

@@ -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<String, Object> 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
);
}
}

View File

@@ -1,6 +0,0 @@
package com.example.dto.dialogflow;
public enum ConversationEntryType {
USER_MESSAGE,
AGENT_RESPONSE
}

View File

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

View File

@@ -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
) {}

View File

@@ -1,6 +0,0 @@
package com.example.dto.dialogflow;
public record IntentDTO(
String name,
String displayName
) {}

View File

@@ -1,3 +0,0 @@
package com.example.dto.dialogflow;
public record QueryInputDTO(TextInputDTO text, String languageCode) {}

View File

@@ -1,4 +0,0 @@
package com.example.dto.dialogflow;
import java.util.Map;
public record QueryParamsDTO(Map<String, Object> parameters) {}

View File

@@ -1,3 +0,0 @@
package com.example.dto.dialogflow;
public record TextInputDTO(String text) {}

View File

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

View File

@@ -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
) {}

View File

@@ -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
) {}

View File

@@ -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<String, Object> 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
);
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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;

View File

@@ -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 {}
}

View File

@@ -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
) {}

View File

@@ -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<String, Object> parameters) {
public QueryParamsDTO {
parameters = Objects.requireNonNullElseGet(parameters, HashMap::new);
parameters = new HashMap<>(parameters);
}
public QueryParamsDTO withSessionParameter(String key, Object value) {
Map<String, Object> updatedParams = new HashMap<>(this.parameters());
updatedParams.put(key, value);
return new QueryParamsDTO(updatedParams);
}
}

View File

@@ -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;

View File

@@ -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) {}

View File

@@ -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;

View File

@@ -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
) {}

View File

@@ -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 {
}
}

View File

@@ -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<String, Object> 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.");
}
}

View File

@@ -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<NotificationDTO> 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.");
}
}

View File

@@ -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<ConversationEntryType> type,
Optional<ConversationEntryEntity> type,
@JsonProperty("intentDisplayName") String intentDisplayName,
@JsonProperty("parameters") Map<String, Object> 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;

View File

@@ -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;

View File

@@ -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
) {}

View File

@@ -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;

View File

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

View File

@@ -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<String, Object> 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;
}
}

View File

@@ -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<String, Object> createUpdateMapForSingleEntry(ConversationEntryDTO newEntry) {
Map<String, Object> updates = new HashMap<>();
// Convert Instant to Firestore Timestamp
Map<String, Object> 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<String, Object> createNewSessionMapForSingleEntry(String sessionId, String userId, String telefono, ConversationEntryDTO initialEntry) {
Map<String, Object> 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<Map<String, Object>> entriesList = new ArrayList<>();
entriesList.add(toFirestoreEntryMap(initialEntry));
sessionMap.put("entries", entriesList);
return sessionMap;
}
private Map<String, Object> toFirestoreEntryMap(ConversationEntryDTO entry) {
Map<String, Object> 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<Map<String, Object>> rawEntries = (List<Map<String, Object>>) documentSnapshot.get("entries");
List<ConversationEntryDTO> 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<String, Object> 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<String, Object> parameters = (Map<String, Object>) 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);
}
}

View File

@@ -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<String, Object> 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;
}
}

View File

@@ -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 {

View File

@@ -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<String, Object> 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);
}
}

View File

@@ -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<String, Object> createUpdateMapForSingleEntry(ConversationEntryDTO newEntry) {
Map<String, Object> updates = new HashMap<>();
Map<String, Object> 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<String, Object> createNewSessionMapForSingleEntry(String sessionId, String userId, String telefono, ConversationEntryDTO initialEntry) {
Map<String, Object> 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<Map<String, Object>> entriesList = new ArrayList<>();
entriesList.add(toFirestoreEntryMap(initialEntry));
sessionMap.put(FIELD_MESSAGES, entriesList);
return sessionMap;
}
private Map<String, Object> toFirestoreEntryMap(ConversationEntryDTO entry) {
Map<String, Object> 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<Map<String, Object>> rawEntries = (List<Map<String, Object>>) documentSnapshot.get(FIELD_MESSAGES);
List<ConversationEntryDTO> 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<String, Object> 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<String, Object> parameters = (Map<String, Object>) entryMap.get(FIELD_MESSAGE_PARAMETERS);
String canal = (String) entryMap.get(FIELD_MESSAGE_CHANNEL);
return new ConversationEntryDTO(entity, type, timestamp, text, parameters, canal);
}
}

View File

@@ -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<String, Object> 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);
}
}

View File

@@ -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<String, Object> 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<String, Object> 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<Map<String, Object>>() {});
}
}

View File

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

View File

@@ -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<Void> saveNotification(NotificationDTO notification) {
Objects.requireNonNull(notification.idNotificacion(), "Notification ID cannot be null.");
return Mono.fromCallable((Callable<Void>) () -> {
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<NotificationDTO> getNotification(String notificationId) { // Renamed method
Objects.requireNonNull(notificationId, "Notification ID cannot be null for retrieval.");
return Mono.fromCallable((Callable<NotificationDTO>) () -> {
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));
}
}

View File

@@ -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.";
}
}
}

View File

@@ -0,0 +1,5 @@
package com.example.service.base;
public class ConvSessionCloserService {
}

View File

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

View File

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

View File

@@ -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;
}
}
}

View File

@@ -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<DetectIntentResponseDTO> manageConversation(DetectIntentRequestDTO request) {
public Mono<DetectIntentResponseDTO>manageConversation(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<ConversationSessionDTO> 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<Void> 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<String, Object> 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.");
}

View File

@@ -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<Void> 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<String, Object> 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<String, Object> 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);

View File

@@ -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<String, ConversationSessionDTO> redisTemplate;
private final ReactiveRedisTemplate<String, String> stringRedisTemplate;
@Autowired
public MemoryStoreConversationService(
ReactiveRedisTemplate<String, ConversationSessionDTO> redisTemplate,
@@ -28,34 +36,29 @@ public class MemoryStoreConversationService {
this.redisTemplate = redisTemplate;
this.stringRedisTemplate = stringRedisTemplate;
}
public Mono<Void> 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<ConversationSessionDTO> 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));
}
}
}

View File

@@ -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<Void> 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<String, Object> 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<String, Object> 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<String, Object> 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);
}
}

View File

@@ -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<String, NotificationSessionDTO> notificationRedisTemplate;
private final ReactiveRedisTemplate<String, String> 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<String, NotificationSessionDTO> notificationRedisTemplate,
ReactiveRedisTemplate<String, String> stringRedisTemplate,
ObjectMapper objectMapper) {
this.notificationRedisTemplate = notificationRedisTemplate;
this.stringRedisTemplate = stringRedisTemplate;
}
public Mono<Void> 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<NotificationDTO> 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<Boolean> 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<NotificationSessionDTO> 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<String> 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));
}
}

View File

@@ -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<DetectIntentResponseDTO> 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<Void> 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<ConversationSessionDTO> 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<String, Object> 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));
}
}

View File

@@ -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()) {

View File

@@ -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> T timeExecution(String operationName, Supplier<T> 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);
}
}
}

View File

@@ -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) ->

View File

@@ -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;
}
}

View File

@@ -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
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

View File

@@ -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:

View File

@@ -1,57 +0,0 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Spring Data Firestore Sample</title>
</head>
<style>
html * {
font-family: Roboto, Verdana, sans-serif;
}
.container {
max-width: 50em;
}
.panel {
margin: 1em;
padding: 1em;
border: 1px solid black;
border-radius: 5px;
}
</style>
<body>
<h1>Spring Data Firestore Sample</h1>
<div class="container">
<h2>Firestore Control Panel</h2>
<p>
This section allows you to read User entities in Firestore.
Some values are prefilled as an example of what you can type in.
</p>
<div class="panel">
<a href="/users">Show all users</a>
</div>
<div class="panel">
<b>Show all users with age</b>
<form action="/users/age" method="get">
Age: <input type="text" name="age" value="30">
<input type="submit" value="submit">
</form>
</div>
</div>
<div class="panel">
<a href="https://console.cloud.google.com/firestore/data">View your Firestore data in the Cloud Console</a>
</div>
</div>
</body>
</html>

View File

@@ -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<String> 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<String> 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));
}
}
}

View File

@@ -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<SessionsClient> 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);
});
}
}

View File

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

View File

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