UPDATE 12-ago-2025
This commit is contained in:
11
README.md
11
README.md
@@ -4,14 +4,3 @@
|
|||||||
* *Spring Boot Version:* `3.2.5` (defined in the parent POM)
|
* *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 GCP Version:* `5.3.0` (managed via `spring-cloud-gcp-dependencies`)
|
||||||
* *Spring Cloud Version:* `2023.0.0` (managed via `spring-cloud-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": {},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|||||||
6
pom.xml
6
pom.xml
@@ -114,7 +114,11 @@
|
|||||||
<groupId>com.fasterxml.jackson.module</groupId>
|
<groupId>com.fasterxml.jackson.module</groupId>
|
||||||
<artifactId>jackson-module-parameter-names</artifactId>
|
<artifactId>jackson-module-parameter-names</artifactId>
|
||||||
<version>2.19.0</version>
|
<version>2.19.0</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.google.api</groupId>
|
||||||
|
<artifactId>gax</artifactId>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
<build>
|
<build>
|
||||||
<plugins>
|
<plugins>
|
||||||
|
|||||||
@@ -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;
|
package com.example;
|
||||||
import static org.springframework.web.reactive.function.server.RouterFunctions.route;
|
|
||||||
import static org.springframework.web.reactive.function.server.ServerResponse.ok;
|
|
||||||
|
|
||||||
import com.google.api.gax.core.CredentialsProvider;
|
import com.google.api.gax.core.CredentialsProvider;
|
||||||
import com.google.api.gax.core.NoCredentialsProvider;
|
import com.google.api.gax.core.NoCredentialsProvider;
|
||||||
import com.google.cloud.spring.data.firestore.repository.config.EnableReactiveFirestoreRepositories;
|
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.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
import org.springframework.context.annotation.Bean;
|
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.transaction.annotation.EnableTransactionManagement;
|
||||||
import org.springframework.web.reactive.function.server.RouterFunction;
|
|
||||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
|
||||||
|
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
@@ -35,10 +31,4 @@ public class Orchestrator {
|
|||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
SpringApplication.run(Orchestrator.class, 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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
package com.example.config;
|
||||||
|
|
||||||
import com.google.genai.Client;
|
import com.google.genai.Client;
|
||||||
@@ -9,10 +14,16 @@ import org.springframework.context.annotation.Configuration;
|
|||||||
|
|
||||||
import java.io.IOException;
|
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
|
@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}")
|
@Value("${google.cloud.project}")
|
||||||
private String projectId;
|
private String projectId;
|
||||||
@@ -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;
|
package com.example.config;
|
||||||
|
|
||||||
import io.swagger.v3.oas.models.OpenAPI;
|
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.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
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
|
@Configuration
|
||||||
public class OpenApiConfig {
|
public class OpenApiConfig {
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
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.ObjectMapper;
|
||||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
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.RedisSerializationContext;
|
||||||
import org.springframework.data.redis.serializer.StringRedisSerializer;
|
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
|
@Configuration
|
||||||
public class RedisConfig {
|
public class RedisConfig {
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public ReactiveRedisTemplate<String, ConversationSessionDTO> reactiveConversationRedisTemplate(
|
public ReactiveRedisTemplate<String, ConversationSessionDTO> reactiveConversationRedisTemplate(
|
||||||
ReactiveRedisConnectionFactory factory) {
|
ReactiveRedisConnectionFactory factory) {
|
||||||
|
|
||||||
ObjectMapper objectMapper = new ObjectMapper();
|
ObjectMapper objectMapper = new ObjectMapper();
|
||||||
objectMapper.registerModule(new JavaTimeModule());
|
objectMapper.registerModule(new JavaTimeModule());
|
||||||
objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
|
objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
|
||||||
|
|
||||||
Jackson2JsonRedisSerializer<ConversationSessionDTO> serializer =
|
Jackson2JsonRedisSerializer<ConversationSessionDTO> serializer = new Jackson2JsonRedisSerializer<>(
|
||||||
new Jackson2JsonRedisSerializer<>(objectMapper, ConversationSessionDTO.class);
|
objectMapper, ConversationSessionDTO.class);
|
||||||
|
|
||||||
return new ReactiveRedisTemplate<>(factory, RedisSerializationContext
|
return new ReactiveRedisTemplate<>(factory, RedisSerializationContext
|
||||||
.<String, ConversationSessionDTO>newSerializationContext(new StringRedisSerializer())
|
.<String, ConversationSessionDTO>newSerializationContext(new StringRedisSerializer())
|
||||||
.value(serializer)
|
.value(serializer)
|
||||||
.build());
|
.build());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public ReactiveRedisTemplate<String, String> reactiveStringRedisTemplate(
|
public ReactiveRedisTemplate<String, String> reactiveStringRedisTemplate(
|
||||||
ReactiveRedisConnectionFactory factory) {
|
ReactiveRedisConnectionFactory factory) {
|
||||||
return new ReactiveRedisTemplate<>(factory, RedisSerializationContext
|
return new ReactiveRedisTemplate<>(factory, RedisSerializationContext
|
||||||
.<String, String>newSerializationContext(new StringRedisSerializer())
|
.<String, String>newSerializationContext(new StringRedisSerializer())
|
||||||
.value(new StringRedisSerializer())
|
.value(new StringRedisSerializer())
|
||||||
.build());
|
.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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
package com.example.controller;
|
||||||
|
|
||||||
import com.example.dto.dialogflow.DetectIntentRequestDTO;
|
import com.example.dto.dialogflow.base.DetectIntentResponseDTO;
|
||||||
import com.example.dto.dialogflow.DetectIntentResponseDTO;
|
import com.example.dto.dialogflow.conversation.ExternalConvRequestDTO;
|
||||||
import com.example.service.ConversationManagerService;
|
import com.example.mapper.conversation.ExternalConvRequestMapper;
|
||||||
|
import com.example.service.conversation.ConversationManagerService;
|
||||||
|
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
|
|
||||||
@@ -16,24 +22,21 @@ import org.slf4j.LoggerFactory;
|
|||||||
|
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1")
|
@RequestMapping("/api/v1/dialogflow")
|
||||||
public class ConversationController {
|
public class ConversationController {
|
||||||
|
|
||||||
private static final Logger logger = LoggerFactory.getLogger(ConversationController.class);
|
private static final Logger logger = LoggerFactory.getLogger(ConversationController.class);
|
||||||
private final ConversationManagerService conversationManagerService;
|
private final ConversationManagerService conversationManagerService;
|
||||||
|
|
||||||
public ConversationController(ConversationManagerService conversationManagerService) {
|
public ConversationController(ConversationManagerService conversationManagerService,
|
||||||
|
ExternalConvRequestMapper externalRequestToDialogflowMapper) {
|
||||||
this.conversationManagerService = conversationManagerService;
|
this.conversationManagerService = conversationManagerService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/dialogflow/detect-intent")
|
@PostMapping("/detect-intent")
|
||||||
public Mono<DetectIntentResponseDTO> detectIntent(@Valid @RequestBody DetectIntentRequestDTO request) {
|
public Mono<DetectIntentResponseDTO> detectIntent(@Valid @RequestBody ExternalConvRequestDTO request) {
|
||||||
logger.info("Received request for session: {}", request.sessionId());
|
|
||||||
return conversationManagerService.manageConversation(request)
|
return conversationManagerService.manageConversation(request)
|
||||||
.doOnSuccess(response -> logger.info("Successfully processed direct Dialogflow request for session: {}", request.sessionId()))
|
.doOnSuccess(response -> logger.info("Successfully processed direct Dialogflow request"))
|
||||||
.doOnError(error -> logger.error("Error processing direct Dialogflow request for session {}: {}", request.sessionId(), error.getMessage(), error));
|
.doOnError(error -> logger.error("Error processing direct Dialogflow request: {}", error.getMessage(), error));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -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;
|
package com.example.controller;
|
||||||
|
|
||||||
import com.example.dto.gemini.ConversationSummaryRequest;
|
import com.example.dto.gemini.ConversationSummaryRequest;
|
||||||
import com.example.dto.gemini.ConversationSummaryResponse;
|
import com.example.dto.gemini.ConversationSummaryResponse;
|
||||||
|
import com.example.service.summary.ConversationSummaryService;
|
||||||
|
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import com.example.service.ConversationSummaryService;
|
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
package com.example.dto.base;
|
|
||||||
|
|
||||||
public record ConversationContext(
|
|
||||||
String userId,
|
|
||||||
String sessionId,
|
|
||||||
String userMessageText,
|
|
||||||
String primaryPhoneNumber
|
|
||||||
) {}
|
|
||||||
@@ -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
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
package com.example.dto.dialogflow;
|
|
||||||
|
|
||||||
public enum ConversationEntryType {
|
|
||||||
USER_MESSAGE,
|
|
||||||
AGENT_RESPONSE
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
) {}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
package com.example.dto.dialogflow;
|
|
||||||
|
|
||||||
public record IntentDTO(
|
|
||||||
String name,
|
|
||||||
String displayName
|
|
||||||
) {}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
package com.example.dto.dialogflow;
|
|
||||||
|
|
||||||
public record QueryInputDTO(TextInputDTO text, String languageCode) {}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
package com.example.dto.dialogflow;
|
|
||||||
|
|
||||||
import java.util.Map;
|
|
||||||
public record QueryParamsDTO(Map<String, Object> parameters) {}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
package com.example.dto.dialogflow;
|
|
||||||
|
|
||||||
public record TextInputDTO(String text) {}
|
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
) {}
|
||||||
@@ -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
|
||||||
|
) {}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
@@ -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 {}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
) {}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -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) {}
|
||||||
@@ -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 com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
@@ -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
|
||||||
|
) {}
|
||||||
@@ -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 {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
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.JsonCreator;
|
||||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
@@ -12,7 +17,7 @@ import java.util.Optional;
|
|||||||
public record ConversationEntrySummaryDTO(
|
public record ConversationEntrySummaryDTO(
|
||||||
@JsonProperty("text") String text,
|
@JsonProperty("text") String text,
|
||||||
@JsonProperty("timestamp") Timestamp timestamp,
|
@JsonProperty("timestamp") Timestamp timestamp,
|
||||||
Optional<ConversationEntryType> type,
|
Optional<ConversationEntryEntity> type,
|
||||||
@JsonProperty("intentDisplayName") String intentDisplayName,
|
@JsonProperty("intentDisplayName") String intentDisplayName,
|
||||||
@JsonProperty("parameters") Map<String, Object> parameters,
|
@JsonProperty("parameters") Map<String, Object> parameters,
|
||||||
@JsonProperty("webhookStatus") String webhookStatus,
|
@JsonProperty("webhookStatus") String webhookStatus,
|
||||||
@@ -33,7 +38,7 @@ public record ConversationEntrySummaryDTO(
|
|||||||
timestamp,
|
timestamp,
|
||||||
Optional.ofNullable(typeString).map(t -> {
|
Optional.ofNullable(typeString).map(t -> {
|
||||||
try {
|
try {
|
||||||
return ConversationEntryType.valueOf(t);
|
return ConversationEntryEntity.valueOf(t);
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
System.err.println("Warning: Invalid ConversationEntryType string during deserialization: " + t);
|
System.err.println("Warning: Invalid ConversationEntryType string during deserialization: " + t);
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -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;
|
package com.example.dto.gemini;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonCreator;
|
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||||
|
|||||||
@@ -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;
|
package com.example.dto.gemini;
|
||||||
|
|
||||||
import jakarta.validation.constraints.Max;
|
import jakarta.validation.constraints.DecimalMax;
|
||||||
import jakarta.validation.constraints.Min;
|
import jakarta.validation.constraints.DecimalMin;
|
||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
public record ConversationSummaryRequest(
|
public record ConversationSummaryRequest(
|
||||||
@@ -9,11 +14,16 @@ public record ConversationSummaryRequest(
|
|||||||
String sessionId,
|
String sessionId,
|
||||||
@NotBlank(message = "Prompt for summarization is required.")
|
@NotBlank(message = "Prompt for summarization is required.")
|
||||||
String prompt,
|
String prompt,
|
||||||
@Min(value = 0, message = "Temperature must be between 0.0 and 1.0.")
|
@DecimalMin(value = "0.0", message = "Temperature must be between 0.0 and 1.0.")
|
||||||
@Max(value = 1, 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,
|
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,
|
Integer maxOutputTokens,
|
||||||
@NotBlank(message = "model is required.")
|
@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
|
||||||
|
|
||||||
|
|
||||||
) {}
|
) {}
|
||||||
@@ -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;
|
package com.example.dto.gemini;
|
||||||
|
|
||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.QueryResult;
|
||||||
import com.google.cloud.dialogflow.cx.v3.ResponseMessage;
|
import com.google.cloud.dialogflow.cx.v3.ResponseMessage;
|
||||||
import com.google.cloud.dialogflow.cx.v3.DetectIntentResponse;
|
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 com.example.util.ProtobufUtil;
|
||||||
|
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
@@ -16,6 +21,13 @@ import org.slf4j.Logger;
|
|||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.stereotype.Component;
|
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
|
@Component
|
||||||
public class DialogflowResponseMapper {
|
public class DialogflowResponseMapper {
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>>() {});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
package com.example.repository;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
@@ -23,6 +28,13 @@ import java.util.concurrent.ExecutionException;
|
|||||||
import com.example.util.FirestoreTimestampDeserializer;
|
import com.example.util.FirestoreTimestampDeserializer;
|
||||||
import com.example.util.FirestoreTimestampSerializer;
|
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
|
@Repository
|
||||||
public class FirestoreBaseRepository {
|
public class FirestoreBaseRepository {
|
||||||
|
|
||||||
@@ -39,13 +51,13 @@ public class FirestoreBaseRepository {
|
|||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
|
|
||||||
// Register JavaTimeModule for standard java.time handling
|
// 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.registerModule(new JavaTimeModule());
|
||||||
}
|
}
|
||||||
objectMapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
|
objectMapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
|
||||||
|
|
||||||
// Register ParameterNamesModule, crucial for Java Records and classes compiled with -parameters
|
// 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());
|
objectMapper.registerModule(new ParameterNamesModule());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package com.example.service.base;
|
||||||
|
|
||||||
|
public class ConvSessionCloserService {
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
package com.example.service.base;
|
||||||
import com.example.dto.dialogflow.DetectIntentResponseDTO;
|
|
||||||
import com.example.mapper.DialogflowRequestMapper;
|
import com.example.mapper.conversation.DialogflowRequestMapper;
|
||||||
import com.example.mapper.DialogflowResponseMapper;
|
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.example.exception.DialogflowClientException;
|
||||||
|
|
||||||
import com.google.api.gax.rpc.ApiException;
|
import com.google.api.gax.rpc.ApiException;
|
||||||
import com.google.cloud.dialogflow.cx.v3.DetectIntentRequest;
|
import com.google.cloud.dialogflow.cx.v3.DetectIntentRequest;
|
||||||
import com.google.cloud.dialogflow.cx.v3.SessionsClient;
|
import com.google.cloud.dialogflow.cx.v3.SessionsClient;
|
||||||
import com.google.cloud.dialogflow.cx.v3.SessionName;
|
import com.google.cloud.dialogflow.cx.v3.SessionName;
|
||||||
import com.google.cloud.dialogflow.cx.v3.SessionsSettings;
|
import com.google.cloud.dialogflow.cx.v3.SessionsSettings;
|
||||||
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
import javax.annotation.PreDestroy;
|
import javax.annotation.PreDestroy;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.Objects;
|
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
|
@Service
|
||||||
public class DialogflowClientService {
|
public class DialogflowClientService {
|
||||||
|
|
||||||
@@ -57,6 +64,7 @@ public class DialogflowClientService {
|
|||||||
.build();
|
.build();
|
||||||
this.sessionsClient = SessionsClient.create(sessionsSettings);
|
this.sessionsClient = SessionsClient.create(sessionsSettings);
|
||||||
logger.info("Dialogflow CX SessionsClient initialized successfully for endpoint: {}", regionalEndpoint);
|
logger.info("Dialogflow CX SessionsClient initialized successfully for endpoint: {}", regionalEndpoint);
|
||||||
|
logger.info("Dialogflow CX SessionsClient initialized successfully for agent : {}", dialogflowCxAgentId);
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
logger.error("Failed to create Dialogflow CX SessionsClient: {}", e.getMessage(), e);
|
logger.error("Failed to create Dialogflow CX SessionsClient: {}", e.getMessage(), e);
|
||||||
throw e;
|
throw e;
|
||||||
@@ -100,7 +108,6 @@ public class DialogflowClientService {
|
|||||||
|
|
||||||
// Build the final DetectIntentRequest Protobuf object
|
// Build the final DetectIntentRequest Protobuf object
|
||||||
DetectIntentRequest detectIntentRequest = detectIntentRequestBuilder.build();
|
DetectIntentRequest detectIntentRequest = detectIntentRequestBuilder.build();
|
||||||
|
|
||||||
return Mono.fromCallable(() -> {
|
return Mono.fromCallable(() -> {
|
||||||
logger.debug("Calling Dialogflow CX detectIntent for session: {}", sessionId);
|
logger.debug("Calling Dialogflow CX detectIntent for session: {}", sessionId);
|
||||||
return sessionsClient.detectIntent(detectIntentRequest);
|
return sessionsClient.detectIntent(detectIntentRequest);
|
||||||
@@ -111,10 +118,6 @@ public class DialogflowClientService {
|
|||||||
return new DialogflowClientException(
|
return new DialogflowClientException(
|
||||||
"Dialogflow CX API error: " + e.getStatusCode().getCode() + " - " + e.getMessage(), e);
|
"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));
|
.map(dfResponse -> this.dialogflowResponseMapper.mapFromDialogflowResponse(dfResponse, sessionId));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
125
src/main/java/com/example/service/base/MessageEntryFilter.java
Normal file
125
src/main/java/com/example/service/base/MessageEntryFilter.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
import reactor.core.scheduler.Schedulers;
|
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;
|
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
|
@Service
|
||||||
public class ConversationManagerService {
|
public class ConversationManagerService {
|
||||||
|
|
||||||
private static final Logger logger = LoggerFactory.getLogger(ConversationManagerService.class);
|
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 DialogflowClientService dialogflowServiceClient;
|
||||||
private final FirestoreConversationService firestoreConversationService;
|
private final FirestoreConversationService firestoreConversationService;
|
||||||
private final MemoryStoreConversationService memoryStoreConversationService;
|
private final MemoryStoreConversationService memoryStoreConversationService;
|
||||||
@@ -27,13 +51,24 @@ public class ConversationManagerService {
|
|||||||
public ConversationManagerService(
|
public ConversationManagerService(
|
||||||
DialogflowClientService dialogflowServiceClient,
|
DialogflowClientService dialogflowServiceClient,
|
||||||
FirestoreConversationService firestoreConversationService,
|
FirestoreConversationService firestoreConversationService,
|
||||||
MemoryStoreConversationService memoryStoreConversationService) {
|
MemoryStoreConversationService memoryStoreConversationService,
|
||||||
|
ExternalConvRequestMapper externalRequestToDialogflowMapper) {
|
||||||
this.dialogflowServiceClient = dialogflowServiceClient;
|
this.dialogflowServiceClient = dialogflowServiceClient;
|
||||||
this.firestoreConversationService = firestoreConversationService;
|
this.firestoreConversationService = firestoreConversationService;
|
||||||
this.memoryStoreConversationService = memoryStoreConversationService;
|
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;
|
final ConversationContext context;
|
||||||
try {
|
try {
|
||||||
context = resolveAndValidateRequest(request);
|
context = resolveAndValidateRequest(request);
|
||||||
@@ -47,40 +82,48 @@ public class ConversationManagerService {
|
|||||||
final String userPhoneNumber = context.primaryPhoneNumber();
|
final String userPhoneNumber = context.primaryPhoneNumber();
|
||||||
|
|
||||||
Mono<ConversationSessionDTO> sessionMono;
|
Mono<ConversationSessionDTO> sessionMono;
|
||||||
if (userPhoneNumber != null && !userPhoneNumber.trim().isEmpty()) {
|
if (userPhoneNumber != null && !userPhoneNumber.isBlank()) {
|
||||||
logger.info("Checking for existing session for phone number: {}", userPhoneNumber);
|
logger.info("Checking for existing session for phone number: {}", userPhoneNumber);
|
||||||
sessionMono = memoryStoreConversationService.getSessionByTelefono(userPhoneNumber)
|
sessionMono = memoryStoreConversationService.getSessionByTelefono(userPhoneNumber)
|
||||||
.doOnNext(session -> logger.info("Found existing session {} for phone number {}", session.sessionId(), userPhoneNumber))
|
.doOnNext(session -> logger.info("Found existing session {} for phone number {}", session.sessionId(), userPhoneNumber))
|
||||||
.switchIfEmpty(Mono.defer(() -> {
|
.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);
|
logger.info("No existing session found for phone number {}. Creating new session: {}", userPhoneNumber, newSessionId);
|
||||||
return Mono.just(ConversationSessionDTO.create(newSessionId, userId, userPhoneNumber));
|
return Mono.just(ConversationSessionDTO.create(newSessionId, userId, userPhoneNumber));
|
||||||
}));
|
}));
|
||||||
} else {
|
} else {
|
||||||
String newSessionId = UUID.randomUUID().toString();
|
logger.warn("No phone number provided in request. Cannot manage conversation session without it.");
|
||||||
logger.warn("No phone number provided in request. Creating new session: {}", newSessionId);
|
|
||||||
return Mono.error(new IllegalArgumentException("Phone number is required to manage conversation sessions."));
|
return Mono.error(new IllegalArgumentException("Phone number is required to manage conversation sessions."));
|
||||||
}
|
}
|
||||||
|
|
||||||
return sessionMono.flatMap(session -> {
|
return sessionMono.flatMap(session -> {
|
||||||
final String finalSessionId = session.sessionId();
|
final String finalSessionId = session.sessionId();
|
||||||
logger.info("Managing conversation for resolved session: {}", finalSessionId);
|
|
||||||
|
|
||||||
|
logger.info("Managing conversation for resolved session: {}", finalSessionId);
|
||||||
ConversationEntryDTO userEntry = ConversationEntryDTO.forUser(userMessageText);
|
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)
|
return this.persistConversationTurn(userId, finalSessionId, userEntry, userPhoneNumber)
|
||||||
.doOnSuccess(v -> logger.debug("User entry successfully persisted for session {}. Proceeding to Dialogflow...", finalSessionId))
|
.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))
|
.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(Mono.defer(() -> {
|
||||||
// then proceed to call Dialogflow.
|
return dialogflowServiceClient.detectIntent(finalSessionId, requestToDialogflow)
|
||||||
.then(Mono.defer(() -> { // Use Mono.defer to ensure Dialogflow call is subscribed AFTER persistence
|
|
||||||
// Call Dialogflow.
|
|
||||||
return dialogflowServiceClient.detectIntent(finalSessionId, updatedRequest)
|
|
||||||
.doOnSuccess(response -> {
|
.doOnSuccess(response -> {
|
||||||
logger.debug("Received Dialogflow CX response for session {}. Initiating agent response persistence.", finalSessionId);
|
logger.debug("Received Dialogflow CX response for session {}. Initiating agent response persistence.", finalSessionId);
|
||||||
ConversationEntryDTO agentEntry = ConversationEntryDTO.forAgent(response.queryResult());
|
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(
|
this.persistConversationTurn(userId, finalSessionId, agentEntry, userPhoneNumber).subscribe(
|
||||||
v -> logger.debug("Background: Agent entry persistence initiated for session {}.", finalSessionId),
|
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)
|
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));
|
.doOnError(e -> logger.error("Error during primary Redis write for session {}. Type: {}: {}", sessionId, entry.type().name(), e.getMessage(), e));
|
||||||
}
|
}
|
||||||
|
|
||||||
private ConversationContext resolveAndValidateRequest(DetectIntentRequestDTO request) {
|
private ConversationContext resolveAndValidateRequest(DetectIntentRequestDTO request) {
|
||||||
String primaryPhoneNumber = Optional.ofNullable(request.usuario())
|
Map<String, Object> params = Optional.ofNullable(request.queryParams())
|
||||||
.map(UsuarioDTO::telefono)
|
.map(queryParamsDTO -> queryParamsDTO.parameters())
|
||||||
.orElse(null);
|
.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()) {
|
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()) {
|
if (resolvedUserId == null || resolvedUserId.trim().isEmpty()) {
|
||||||
resolvedUserId = "user_by_phone_" + primaryPhoneNumber.replaceAll("[^0-9]", ""); // Derive from phone number
|
resolvedUserId = "user_by_phone_" + primaryPhoneNumber.replaceAll("[^0-9]", "");
|
||||||
logger.warn("User ID not provided in request. Using derived ID from phone number: {}", resolvedUserId);
|
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.");
|
throw new IllegalArgumentException("Dialogflow query input text is required.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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;
|
package com.example.service.conversation;
|
||||||
import com.example.dto.dialogflow.ConversationSessionDTO;
|
|
||||||
|
import com.example.dto.dialogflow.conversation.ConversationEntryDTO;
|
||||||
|
import com.example.dto.dialogflow.conversation.ConversationSessionDTO;
|
||||||
import com.example.exception.FirestorePersistenceException;
|
import com.example.exception.FirestorePersistenceException;
|
||||||
import com.example.mapper.FirestoreConversationMapper;
|
import com.example.mapper.conversation.FirestoreConversationMapper;
|
||||||
import com.example.repository.FirestoreBaseRepository;
|
import com.example.repository.FirestoreBaseRepository;
|
||||||
import com.google.cloud.firestore.DocumentReference;
|
import com.google.cloud.firestore.DocumentReference;
|
||||||
import com.google.cloud.firestore.DocumentSnapshot;
|
import com.google.cloud.firestore.DocumentSnapshot;
|
||||||
@@ -17,6 +22,13 @@ import reactor.core.scheduler.Schedulers;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.ExecutionException;
|
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
|
@Service
|
||||||
public class FirestoreConversationService {
|
public class FirestoreConversationService {
|
||||||
|
|
||||||
@@ -31,7 +43,7 @@ public class FirestoreConversationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public Mono<Void> saveEntry(String userId, String sessionId, ConversationEntryDTO newEntry, String userPhoneNumber) {
|
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(() -> {
|
return Mono.fromRunnable(() -> {
|
||||||
DocumentReference sessionDocRef = getSessionDocumentReference(sessionId);
|
DocumentReference sessionDocRef = getSessionDocumentReference(sessionId);
|
||||||
WriteBatch batch = firestoreBaseRepository.createBatch();
|
WriteBatch batch = firestoreBaseRepository.createBatch();
|
||||||
@@ -41,13 +53,13 @@ public class FirestoreConversationService {
|
|||||||
// Update: Append the new entry using arrayUnion and update lastModified
|
// Update: Append the new entry using arrayUnion and update lastModified
|
||||||
Map<String, Object> updates = firestoreConversationMapper.createUpdateMapForSingleEntry(newEntry);
|
Map<String, Object> updates = firestoreConversationMapper.createUpdateMapForSingleEntry(newEntry);
|
||||||
batch.update(sessionDocRef, updates);
|
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 {
|
} else {
|
||||||
// Create: Start a new session with the first entry.
|
// Create: Start a new session with the first entry.
|
||||||
// Pass userId and userPhoneNumber to the mapper to be stored as fields in the document.
|
// 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);
|
Map<String, Object> newSessionMap = firestoreConversationMapper.createNewSessionMapForSingleEntry(sessionId, userId, userPhoneNumber, newEntry);
|
||||||
batch.set(sessionDocRef, newSessionMap);
|
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);
|
firestoreBaseRepository.commitBatch(batch);
|
||||||
logger.info("Successfully committed batch for session {} to Firestore.", sessionId);
|
logger.info("Successfully committed batch for session {} to Firestore.", sessionId);
|
||||||
@@ -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;
|
package com.example.service.conversation;
|
||||||
import com.example.dto.dialogflow.ConversationSessionDTO;
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.data.redis.core.ReactiveRedisTemplate;
|
import org.springframework.data.redis.core.ReactiveRedisTemplate;
|
||||||
import org.springframework.stereotype.Service;
|
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 reactor.core.publisher.Mono;
|
||||||
import java.time.Duration;
|
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
|
@Service
|
||||||
public class MemoryStoreConversationService {
|
public class MemoryStoreConversationService {
|
||||||
|
|
||||||
private static final Logger logger = LoggerFactory.getLogger(MemoryStoreConversationService.class);
|
private static final Logger logger = LoggerFactory.getLogger(MemoryStoreConversationService.class);
|
||||||
private static final String SESSION_KEY_PREFIX = "conversation:session:";
|
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 String PHONE_TO_SESSION_KEY_PREFIX = "conversation:phone_to_session:";
|
||||||
private static final Duration SESSION_TTL = Duration.ofHours(24);
|
private static final Duration SESSION_TTL = Duration.ofHours(24);
|
||||||
|
|
||||||
private final ReactiveRedisTemplate<String, ConversationSessionDTO> redisTemplate;
|
private final ReactiveRedisTemplate<String, ConversationSessionDTO> redisTemplate;
|
||||||
private final ReactiveRedisTemplate<String, String> stringRedisTemplate;
|
private final ReactiveRedisTemplate<String, String> stringRedisTemplate;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
public MemoryStoreConversationService(
|
public MemoryStoreConversationService(
|
||||||
ReactiveRedisTemplate<String, ConversationSessionDTO> redisTemplate,
|
ReactiveRedisTemplate<String, ConversationSessionDTO> redisTemplate,
|
||||||
@@ -28,34 +36,29 @@ public class MemoryStoreConversationService {
|
|||||||
this.redisTemplate = redisTemplate;
|
this.redisTemplate = redisTemplate;
|
||||||
this.stringRedisTemplate = stringRedisTemplate;
|
this.stringRedisTemplate = stringRedisTemplate;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Mono<Void> saveEntry(String userId, String sessionId, ConversationEntryDTO newEntry, String userPhoneNumber) {
|
public Mono<Void> saveEntry(String userId, String sessionId, ConversationEntryDTO newEntry, String userPhoneNumber) {
|
||||||
String sessionKey = SESSION_KEY_PREFIX + sessionId;
|
String sessionKey = SESSION_KEY_PREFIX + sessionId;
|
||||||
String phoneToSessionKey = PHONE_TO_SESSION_KEY_PREFIX + userPhoneNumber;
|
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)
|
return redisTemplate.opsForValue().get(sessionKey)
|
||||||
.defaultIfEmpty(ConversationSessionDTO.create(sessionId, userId, userPhoneNumber))
|
.defaultIfEmpty(ConversationSessionDTO.create(sessionId, userId, userPhoneNumber))
|
||||||
.flatMap(session -> {
|
.flatMap(session -> {
|
||||||
ConversationSessionDTO sessionWithUpdatedTelefono = session.withTelefono(userPhoneNumber);
|
ConversationSessionDTO sessionWithUpdatedTelefono = session.withTelefono(userPhoneNumber);
|
||||||
ConversationSessionDTO updatedSession = sessionWithUpdatedTelefono.withAddedEntry(newEntry);
|
ConversationSessionDTO updatedSession = sessionWithUpdatedTelefono.withAddedEntry(newEntry);
|
||||||
|
logger.info("Attempting to set updated session {} with new entry entity {} in Redis.", sessionId, newEntry.entity().name());
|
||||||
logger.info("Attempting to set updated session {} with new entry type {} in Redis.", sessionId, newEntry.type().name());
|
|
||||||
return redisTemplate.opsForValue().set(sessionKey, updatedSession, SESSION_TTL)
|
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()))
|
.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))
|
.doOnError(e -> logger.error("Error appending entry to Redis for session {}: {}", sessionId, e.getMessage(), e));
|
||||||
.then();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Mono<ConversationSessionDTO> getSessionByTelefono(String telefono) {
|
public Mono<ConversationSessionDTO> getSessionByTelefono(String telefono) {
|
||||||
if (telefono == null || telefono.trim().isEmpty()) {
|
if (telefono == null || telefono.isBlank()) {
|
||||||
return Mono.empty();
|
return Mono.empty();
|
||||||
}
|
}
|
||||||
String phoneToSessionKey = PHONE_TO_SESSION_KEY_PREFIX + telefono;
|
String phoneToSessionKey = PHONE_TO_SESSION_KEY_PREFIX + telefono;
|
||||||
logger.debug("Attempting to retrieve session ID for phone number {} from Redis.", telefono);
|
logger.debug("Attempting to retrieve session ID for phone number {} from Redis.", telefono);
|
||||||
|
|
||||||
return stringRedisTemplate.opsForValue().get(phoneToSessionKey)
|
return stringRedisTemplate.opsForValue().get(phoneToSessionKey)
|
||||||
.flatMap(sessionId -> {
|
.flatMap(sessionId -> {
|
||||||
logger.debug("Found session ID {} for phone number {}. Retrieving session data.", sessionId, telefono);
|
logger.debug("Found session ID {} for phone number {}. Retrieving session data.", sessionId, telefono);
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.ConversationSummaryRequest;
|
||||||
import com.example.dto.gemini.ConversationSummaryResponse;
|
import com.example.dto.gemini.ConversationSummaryResponse;
|
||||||
import com.example.dto.gemini.ConversationSessionSummaryDTO;
|
import com.example.dto.gemini.ConversationSessionSummaryDTO;
|
||||||
import com.example.dto.gemini.ConversationEntrySummaryDTO;
|
import com.example.dto.gemini.ConversationEntrySummaryDTO;
|
||||||
import com.example.repository.FirestoreBaseRepository;
|
import com.example.repository.FirestoreBaseRepository;
|
||||||
|
import com.example.service.base.GeminiClientService;
|
||||||
import com.google.cloud.firestore.DocumentReference;
|
import com.google.cloud.firestore.DocumentReference;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
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 String DEFAULT_GEMINI_MODEL_NAME = "gemini-2.0-flash-001";
|
||||||
private static final Float DEFAULT_TEMPERATURE = 0.7f;
|
private static final Float DEFAULT_TEMPERATURE = 0.7f;
|
||||||
private static final Integer DEFAULT_MAX_OUTPUT_TOKENS = 800;
|
private static final Integer DEFAULT_MAX_OUTPUT_TOKENS = 800;
|
||||||
|
private static final Float DEFAULT_tOPP = 0.1f;
|
||||||
|
|
||||||
|
|
||||||
public ConversationSummaryService(GeminiClientService geminiService, FirestoreBaseRepository firestoreBaseRepository) {
|
public ConversationSummaryService(GeminiClientService geminiService, FirestoreBaseRepository firestoreBaseRepository) {
|
||||||
this.geminiService = geminiService;
|
this.geminiService = geminiService;
|
||||||
@@ -55,6 +63,8 @@ public class ConversationSummaryService {
|
|||||||
? request.temperature() : DEFAULT_TEMPERATURE;
|
? request.temperature() : DEFAULT_TEMPERATURE;
|
||||||
Integer actualMaxOutputTokens = (request.maxOutputTokens() != null)
|
Integer actualMaxOutputTokens = (request.maxOutputTokens() != null)
|
||||||
? request.maxOutputTokens() : DEFAULT_MAX_OUTPUT_TOKENS;
|
? 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 collectionPath = String.format(CONVERSATION_COLLECTION_PATH_FORMAT, firestoreBaseRepository.getAppId());
|
||||||
String documentId = sessionId;
|
String documentId = sessionId;
|
||||||
@@ -99,7 +109,8 @@ public class ConversationSummaryService {
|
|||||||
fullPromptForGemini,
|
fullPromptForGemini,
|
||||||
actualTemperature,
|
actualTemperature,
|
||||||
actualMaxOutputTokens,
|
actualMaxOutputTokens,
|
||||||
actualModelName
|
actualModelName,
|
||||||
|
actualTopP
|
||||||
);
|
);
|
||||||
|
|
||||||
if (summaryText == null || summaryText.trim().isEmpty()) {
|
if (summaryText == null || summaryText.trim().isEmpty()) {
|
||||||
37
src/main/java/com/example/util/PerformanceTimer.java
Normal file
37
src/main/java/com/example/util/PerformanceTimer.java
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ public class ProtobufUtil {
|
|||||||
* Maps will be converted to Protobuf Structs.
|
* Maps will be converted to Protobuf Structs.
|
||||||
* Lists will be converted to Protobuf ListValues.
|
* Lists will be converted to Protobuf ListValues.
|
||||||
*/
|
*/
|
||||||
|
@SuppressWarnings("rawtypes")
|
||||||
public static Value convertJavaObjectToProtobufValue(Object obj) {
|
public static Value convertJavaObjectToProtobufValue(Object obj) {
|
||||||
if (obj == null) {
|
if (obj == null) {
|
||||||
return Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build();
|
return Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build();
|
||||||
@@ -35,6 +36,8 @@ public class ProtobufUtil {
|
|||||||
return Value.newBuilder().setNumberValue((Double) obj).build();
|
return Value.newBuilder().setNumberValue((Double) obj).build();
|
||||||
} else if (obj instanceof String) {
|
} else if (obj instanceof String) {
|
||||||
return Value.newBuilder().setStringValue((String) obj).build();
|
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) {
|
} else if (obj instanceof Map) {
|
||||||
Struct.Builder structBuilder = Struct.newBuilder();
|
Struct.Builder structBuilder = Struct.newBuilder();
|
||||||
((Map<?, ?>) obj).forEach((key, val) ->
|
((Map<?, ?>) obj).forEach((key, val) ->
|
||||||
|
|||||||
63
src/main/java/com/example/util/SessionIdGenerator.java
Normal file
63
src/main/java/com/example/util/SessionIdGenerator.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
# Spring Boot Configuration Template
|
||||||
# from your machine's environment.
|
# =========================================
|
||||||
spring.cloud.gcp.firestore.project-id=app-jovenes
|
# This file serves as a reference template for all application configuration properties.
|
||||||
|
|
||||||
# Credentials Configuration
|
# Best Practices:
|
||||||
# Use this setting if you want to manually specify service account credentials instead of inferring
|
# - Use Spring Profiles (e.g., application-dev.properties, application-prod.properties)
|
||||||
# from the machine's environment for firestore.
|
# to manage environment-specific settings.
|
||||||
#spring.cloud.gcp.firestore.credentials.location=file:{PATH_TO_YOUR_CREDENTIALS_FILE}
|
# - 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)
|
# Firestore Emulator Configuration (for local development)
|
||||||
#spring.cloud.gcp.firestore.emulator-host=localhost:8080
|
#spring.cloud.gcp.firestore.emulator-host=localhost:8080
|
||||||
spring.cloud.gcp.firestore.emulator.enabled=false
|
spring.cloud.gcp.firestore.emulator.enabled=false
|
||||||
|
# =========================================================
|
||||||
# Firestore Database Configuration
|
# Google Firestore Configuration
|
||||||
# ------------------------------------------
|
# =========================================================
|
||||||
spring.cloud.gcp.firestore.database-id=app-jovenes-cache-database
|
spring.cloud.gcp.firestore.project-id=app-jovenes
|
||||||
spring.cloud.gcp.firestore.host=firestore.googleapis.com
|
spring.cloud.gcp.firestore.database-id=app-jovenes-cache-database
|
||||||
spring.cloud.gcp.firestore.port=443
|
spring.cloud.gcp.firestore.host=firestore.googleapis.com
|
||||||
|
spring.cloud.gcp.firestore.port=443
|
||||||
# Memorystore (Redis) Configuration Properties
|
# =========================================================
|
||||||
# ------------------------------------------
|
# Google Memorystore(Redis) Configuration
|
||||||
|
# =========================================================
|
||||||
# Basic Connection Settings
|
spring.data.redis.host=10.241.0.11
|
||||||
#Secret Manager recomendation for credentials
|
spring.data.redis.port=6379
|
||||||
spring.data.redis.host=10.241.0.11
|
#spring.data.redis.password=23cb4c76-9d96-4c74-b8c0-778fb364877a
|
||||||
spring.data.redis.port=6379
|
#spring.data.redis.username=default
|
||||||
#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
|
|
||||||
|
|
||||||
# SSL Configuration (if using SSL)
|
# SSL Configuration (if using SSL)
|
||||||
# spring.data.redis.ssl=true
|
# spring.data.redis.ssl=true
|
||||||
# spring.data.redis.ssl.key-store=classpath:keystore.p12
|
# spring.data.redis.ssl.key-store=classpath:keystore.p12
|
||||||
# spring.data.redis.ssl.key-store-password=your-keystore-password
|
# spring.data.redis.ssl.key-store-password=your-keystore-password
|
||||||
|
# =========================================================
|
||||||
# Timeout Settings
|
# Google Conversational Agents Configuration
|
||||||
# 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
|
|
||||||
# ------------------------------------------
|
|
||||||
dialogflow.cx.project-id=app-jovenes
|
dialogflow.cx.project-id=app-jovenes
|
||||||
dialogflow.cx.location=us-central1
|
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
|
dialogflow.default-language-code=es
|
||||||
|
|
||||||
# =========================================================
|
# =========================================================
|
||||||
# Google Generative AI (Gemini) Configuration
|
# Google Generative AI (Gemini) Configuration
|
||||||
# =========================================================
|
# =========================================================
|
||||||
# Your Google Cloud Project ID where the Vertex AI service is enabled.
|
|
||||||
google.cloud.project=app-jovenes
|
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
|
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
|
||||||
|
|||||||
54
src/main/resources/prompts/message_filter_prompt.txt
Normal file
54
src/main/resources/prompts/message_filter_prompt.txt
Normal 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:
|
||||||
@@ -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>
|
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user