Initial commit
This commit is contained in:
18
src/main/java/com/example/Orchestrator.java
Normal file
18
src/main/java/com/example/Orchestrator.java
Normal file
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
import com.google.cloud.spring.data.firestore.repository.config.EnableReactiveFirestoreRepositories;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableReactiveFirestoreRepositories(basePackages = "com.example.repository")
|
||||
public class Orchestrator {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(Orchestrator.class, args);
|
||||
}
|
||||
}
|
||||
20
src/main/java/com/example/config/DlpConfig.java
Normal file
20
src/main/java/com/example/config/DlpConfig.java
Normal file
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose.
|
||||
* Your use of it is subject to your agreement with Google.
|
||||
*/
|
||||
|
||||
package com.example.config;
|
||||
|
||||
import com.google.cloud.dlp.v2.DlpServiceClient;
|
||||
import java.io.IOException;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
public class DlpConfig {
|
||||
|
||||
@Bean(destroyMethod = "close")
|
||||
public DlpServiceClient dlpServiceClient() throws IOException {
|
||||
return DlpServiceClient.create();
|
||||
}
|
||||
}
|
||||
44
src/main/java/com/example/config/GeminiConfig.java
Normal file
44
src/main/java/com/example/config/GeminiConfig.java
Normal file
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose.
|
||||
* Your use of it is subject to your agreement with Google.
|
||||
*/
|
||||
|
||||
package com.example.config;
|
||||
|
||||
import com.google.genai.Client;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* Spring configuration class for initializing the Google Gen AI Client.
|
||||
* It uses properties from the application's configuration to create a
|
||||
* singleton `Client` bean for interacting with the Gemini model, ensuring
|
||||
* proper resource management by specifying a destroy method.
|
||||
*/
|
||||
@Configuration
|
||||
public class GeminiConfig {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(GeminiConfig.class);
|
||||
|
||||
@Value("${google.cloud.project}")
|
||||
private String projectId;
|
||||
|
||||
@Value("${google.cloud.location}")
|
||||
private String location;
|
||||
|
||||
@Bean(destroyMethod = "close")
|
||||
public Client geminiClient() throws IOException {
|
||||
logger.info("Initializing Google Gen AI Client. Project: {}, Location: {}", projectId, location);
|
||||
return Client.builder()
|
||||
.project(projectId)
|
||||
.location(location)
|
||||
.vertexAI(true)
|
||||
.build();
|
||||
}
|
||||
|
||||
}
|
||||
33
src/main/java/com/example/config/OpenApiConfig.java
Normal file
33
src/main/java/com/example/config/OpenApiConfig.java
Normal file
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose.
|
||||
* Your use of it is subject to your agreement with Google.
|
||||
*/
|
||||
|
||||
package com.example.config;
|
||||
|
||||
import io.swagger.v3.oas.models.OpenAPI;
|
||||
import io.swagger.v3.oas.models.info.Info;
|
||||
import io.swagger.v3.oas.models.info.License;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Spring configuration class for customizing OpenAPI (Swagger) documentation.
|
||||
* It defines a single bean to configure the API's title, version, description,
|
||||
* and license, providing a structured and user-friendly documentation page.
|
||||
*/
|
||||
@Configuration
|
||||
public class OpenApiConfig {
|
||||
|
||||
@Bean
|
||||
public OpenAPI customOpenAPI() {
|
||||
return new OpenAPI()
|
||||
.info(new Info()
|
||||
.title("Google Middleware API")
|
||||
.version("1.0")
|
||||
.description("API documentation. " +
|
||||
"It provides functionalities for user management, file storage, and more.")
|
||||
.termsOfService("http://swagger.io/terms/")
|
||||
.license(new License().name("Apache 2.0").url("http://springdoc.org")));
|
||||
}
|
||||
}
|
||||
51
src/main/java/com/example/config/RedisConfig.java
Normal file
51
src/main/java/com/example/config/RedisConfig.java
Normal file
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose.
|
||||
* Your use of it is subject to your agreement with Google.
|
||||
*/
|
||||
|
||||
package com.example.config;
|
||||
|
||||
import com.example.dto.dialogflow.conversation.ConversationMessageDTO;
|
||||
import com.example.dto.dialogflow.conversation.ConversationSessionDTO;
|
||||
import com.example.dto.dialogflow.notification.NotificationSessionDTO;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.data.redis.connection.ReactiveRedisConnectionFactory;
|
||||
import org.springframework.data.redis.core.ReactiveRedisTemplate;
|
||||
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
|
||||
import org.springframework.data.redis.serializer.RedisSerializationContext;
|
||||
import org.springframework.data.redis.serializer.StringRedisSerializer;
|
||||
|
||||
@Configuration
|
||||
public class RedisConfig {
|
||||
|
||||
@Bean
|
||||
public ReactiveRedisTemplate<String, ConversationSessionDTO> reactiveRedisTemplate(ReactiveRedisConnectionFactory factory, ObjectMapper objectMapper) {
|
||||
Jackson2JsonRedisSerializer<ConversationSessionDTO> serializer = new Jackson2JsonRedisSerializer<>(objectMapper, ConversationSessionDTO.class);
|
||||
RedisSerializationContext.RedisSerializationContextBuilder<String, ConversationSessionDTO> builder = RedisSerializationContext.newSerializationContext(new StringRedisSerializer());
|
||||
RedisSerializationContext<String, ConversationSessionDTO> context = builder.value(serializer).build();
|
||||
return new ReactiveRedisTemplate<>(factory, context);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public ReactiveRedisTemplate<String, NotificationSessionDTO> reactiveNotificationRedisTemplate(ReactiveRedisConnectionFactory factory, ObjectMapper objectMapper) {
|
||||
Jackson2JsonRedisSerializer<NotificationSessionDTO> serializer = new Jackson2JsonRedisSerializer<>(objectMapper, NotificationSessionDTO.class);
|
||||
RedisSerializationContext.RedisSerializationContextBuilder<String, NotificationSessionDTO> builder = RedisSerializationContext.newSerializationContext(new StringRedisSerializer());
|
||||
RedisSerializationContext<String, NotificationSessionDTO> context = builder.value(serializer).build();
|
||||
return new ReactiveRedisTemplate<>(factory, context);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public ReactiveRedisTemplate<String, ConversationMessageDTO> reactiveMessageRedisTemplate(ReactiveRedisConnectionFactory factory, ObjectMapper objectMapper) {
|
||||
Jackson2JsonRedisSerializer<ConversationMessageDTO> serializer = new Jackson2JsonRedisSerializer<>(objectMapper, ConversationMessageDTO.class);
|
||||
RedisSerializationContext.RedisSerializationContextBuilder<String, ConversationMessageDTO> builder = RedisSerializationContext.newSerializationContext(new StringRedisSerializer());
|
||||
RedisSerializationContext<String, ConversationMessageDTO> context = builder.value(serializer).build();
|
||||
return new ReactiveRedisTemplate<>(factory, context);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public ReactiveRedisTemplate<String, String> reactiveStringRedisTemplate(ReactiveRedisConnectionFactory factory) {
|
||||
return new ReactiveRedisTemplate<>(factory, RedisSerializationContext.string());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* 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.base.DetectIntentResponseDTO;
|
||||
import com.example.dto.dialogflow.conversation.ExternalConvRequestDTO;
|
||||
import com.example.mapper.conversation.ExternalConvRequestMapper;
|
||||
import com.example.service.conversation.ConversationManagerService;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
|
||||
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 ConversationController {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(ConversationController.class);
|
||||
private final ConversationManagerService conversationManagerService;
|
||||
|
||||
public ConversationController(ConversationManagerService conversationManagerService,
|
||||
ExternalConvRequestMapper externalRequestToDialogflowMapper) {
|
||||
this.conversationManagerService = conversationManagerService;
|
||||
}
|
||||
|
||||
@PostMapping("/detect-intent")
|
||||
public Mono<DetectIntentResponseDTO> detectIntent(@Valid @RequestBody ExternalConvRequestDTO request) {
|
||||
return conversationManagerService.manageConversation(request)
|
||||
.doOnSuccess(response -> logger.info("Successfully processed direct Dialogflow request"))
|
||||
.doOnError(error -> logger.error("Error processing direct Dialogflow request: {}", error.getMessage(), error));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.example.controller;
|
||||
|
||||
import com.example.service.base.DataPurgeService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/data-purge")
|
||||
public class DataPurgeController {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(DataPurgeController.class);
|
||||
private final DataPurgeService dataPurgeService;
|
||||
|
||||
public DataPurgeController(DataPurgeService dataPurgeService) {
|
||||
this.dataPurgeService = dataPurgeService;
|
||||
}
|
||||
|
||||
@DeleteMapping("/all")
|
||||
public Mono<Void> purgeAllData() {
|
||||
logger.warn("Received request to purge all data. This is a destructive operation.");
|
||||
return dataPurgeService.purgeAllData()
|
||||
.doOnSuccess(voidResult -> logger.info("Successfully purged all data."))
|
||||
.doOnError(error -> logger.error("Error purging all data.", error));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* 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.llm.webhook.WebhookRequestDTO;
|
||||
import com.example.dto.llm.webhook.SessionInfoDTO;
|
||||
import com.example.dto.llm.webhook.WebhookResponseDTO;
|
||||
import com.example.service.llm.LlmResponseTunerService;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/llm")
|
||||
public class LlmResponseTunerController {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(LlmResponseTunerController.class);
|
||||
|
||||
private final LlmResponseTunerService llmResponseTunerService;
|
||||
|
||||
public LlmResponseTunerController(LlmResponseTunerService llmResponseTunerService) {
|
||||
this.llmResponseTunerService = llmResponseTunerService;
|
||||
}
|
||||
|
||||
@PostMapping("/tune-response")
|
||||
public Mono<WebhookResponseDTO> tuneResponse(@RequestBody WebhookRequestDTO request) {
|
||||
String uuid = (String) request.getSessionInfo().getParameters().get("uuid");
|
||||
return llmResponseTunerService
|
||||
.getValue(uuid)
|
||||
.map(
|
||||
value -> {
|
||||
Map<String, Object> parameters = new HashMap<>();
|
||||
parameters.put("webhook_success", true);
|
||||
parameters.put("response", value);
|
||||
SessionInfoDTO sessionInfo = new SessionInfoDTO(parameters);
|
||||
return new WebhookResponseDTO(sessionInfo);
|
||||
})
|
||||
.defaultIfEmpty(createErrorResponse("No response found for the given UUID.", false))
|
||||
.onErrorResume(
|
||||
e -> {
|
||||
logger.error("Error tuning response: {}", e.getMessage());
|
||||
return Mono.just(
|
||||
createErrorResponse("An internal error occurred.", true));
|
||||
});
|
||||
}
|
||||
|
||||
private WebhookResponseDTO createErrorResponse(String errorMessage, boolean isError) {
|
||||
Map<String, Object> parameters = new HashMap<>();
|
||||
parameters.put("webhook_success", false);
|
||||
parameters.put("error_message", errorMessage);
|
||||
SessionInfoDTO sessionInfo = new SessionInfoDTO(parameters);
|
||||
return new WebhookResponseDTO(sessionInfo);
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<Map<String, String>> handleException(Exception e) {
|
||||
logger.error("An unexpected error occurred: {}", e.getMessage());
|
||||
Map<String, String> response = new HashMap<>();
|
||||
response.put("error", "Internal Server Error");
|
||||
response.put("message", "An unexpected error occurred. Please try again later.");
|
||||
return new ResponseEntity<>(response, HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
@ExceptionHandler(IllegalArgumentException.class)
|
||||
public ResponseEntity<Map<String, String>> handleIllegalArgumentException(
|
||||
IllegalArgumentException e) {
|
||||
logger.error("Bad request: {}", e.getMessage());
|
||||
Map<String, String> response = new HashMap<>();
|
||||
response.put("error", "Bad Request");
|
||||
response.put("message", e.getMessage());
|
||||
return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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.base.DetectIntentResponseDTO;
|
||||
import com.example.dto.quickreplies.QuickReplyScreenRequestDTO;
|
||||
import com.example.service.quickreplies.QuickRepliesManagerService;
|
||||
import jakarta.validation.Valid;
|
||||
import java.util.Map;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/quick-replies")
|
||||
public class QuickRepliesController {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(QuickRepliesController.class);
|
||||
private final QuickRepliesManagerService quickRepliesManagerService;
|
||||
|
||||
public QuickRepliesController(QuickRepliesManagerService quickRepliesManagerService) {
|
||||
this.quickRepliesManagerService = quickRepliesManagerService;
|
||||
}
|
||||
|
||||
@PostMapping("/screen")
|
||||
public Mono<DetectIntentResponseDTO> startSessionAndGetReplies(@Valid @RequestBody QuickReplyScreenRequestDTO request) {
|
||||
return quickRepliesManagerService.startQuickReplySession(request)
|
||||
.doOnSuccess(response -> logger.info("Successfully processed quick reply request"))
|
||||
.doOnError(error -> logger.error("Error processing quick reply request: {}", error.getMessage(), error));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* 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
|
||||
);
|
||||
}
|
||||
|
||||
public DetectIntentRequestDTO withParameters(java.util.Map<String, Object> parameters) {
|
||||
// Create a new QueryParamsDTO with the updated session parameters
|
||||
QueryParamsDTO updatedQueryParams = this.queryParams().withSessionParameters(parameters);
|
||||
|
||||
// Return a new DetectIntentRequestDTO instance with the updated QueryParamsDTO
|
||||
return new DetectIntentRequestDTO(
|
||||
this.queryInput(),
|
||||
updatedQueryParams
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
import com.example.dto.quickreplies.QuickReplyDTO;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public record DetectIntentResponseDTO(
|
||||
@JsonProperty("responseId") String responseId,
|
||||
@JsonProperty("queryResult") QueryResultDTO queryResult,
|
||||
@JsonProperty("quick_replies") QuickReplyDTO quickReplies
|
||||
) {
|
||||
public DetectIntentResponseDTO(String responseId, QueryResultDTO queryResult) {
|
||||
this(responseId, queryResult, null);
|
||||
}
|
||||
}
|
||||
@@ -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,110 @@
|
||||
/*
|
||||
* 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 forUser(String text, Map<String, Object> parameters) {
|
||||
return new ConversationEntryDTO(
|
||||
ConversationEntryEntity.USUARIO,
|
||||
ConversationEntryType.CONVERSACION,
|
||||
Instant.now(),
|
||||
text,
|
||||
parameters,
|
||||
null);
|
||||
}
|
||||
|
||||
public static ConversationEntryDTO forAgent(QueryResultDTO agentQueryResult) {
|
||||
String fulfillmentText = (agentQueryResult != null && agentQueryResult.responseText() != null) ? agentQueryResult.responseText() : "";
|
||||
Map<String, Object> parameters = (agentQueryResult != null) ? agentQueryResult.parameters() : null;
|
||||
|
||||
return new ConversationEntryDTO(
|
||||
ConversationEntryEntity.AGENTE,
|
||||
ConversationEntryType.CONVERSACION,
|
||||
Instant.now(),
|
||||
fulfillmentText,
|
||||
parameters,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
public static ConversationEntryDTO forAgentWithMessage(String text) {
|
||||
return new ConversationEntryDTO(
|
||||
ConversationEntryEntity.AGENTE,
|
||||
ConversationEntryType.CONVERSACION,
|
||||
Instant.now(),
|
||||
text,
|
||||
null,
|
||||
null
|
||||
);
|
||||
}
|
||||
public static ConversationEntryDTO forSystem(String text) {
|
||||
return new ConversationEntryDTO(
|
||||
ConversationEntryEntity.SISTEMA,
|
||||
ConversationEntryType.CONVERSACION,
|
||||
Instant.now(),
|
||||
text,
|
||||
null,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
public static ConversationEntryDTO forSystem(String text, Map<String, Object> parameters) {
|
||||
return new ConversationEntryDTO(
|
||||
ConversationEntryEntity.SISTEMA,
|
||||
ConversationEntryType.CONVERSACION,
|
||||
Instant.now(),
|
||||
text,
|
||||
parameters,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
public static ConversationEntryDTO forLlmConversation(String text) {
|
||||
return new ConversationEntryDTO(
|
||||
ConversationEntryEntity.LLM,
|
||||
ConversationEntryType.CONVERSACION,
|
||||
Instant.now(),
|
||||
text,
|
||||
null,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
public static ConversationEntryDTO forLlmConversation(String text, Map<String, Object> parameters) {
|
||||
return new ConversationEntryDTO(
|
||||
ConversationEntryEntity.LLM,
|
||||
ConversationEntryType.CONVERSACION,
|
||||
Instant.now(),
|
||||
text,
|
||||
parameters,
|
||||
null
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 enum ConversationEntryEntity {
|
||||
USUARIO,
|
||||
AGENTE,
|
||||
SISTEMA,
|
||||
LLM
|
||||
}
|
||||
@@ -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 ConversationEntryType {
|
||||
INICIO,
|
||||
CONVERSACION,
|
||||
LLM
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* 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 ConversationMessageDTO(
|
||||
MessageType type,
|
||||
Instant timestamp,
|
||||
String text,
|
||||
Map<String, Object> parameters,
|
||||
String canal
|
||||
) {
|
||||
}
|
||||
@@ -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.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude.Include;
|
||||
import java.time.Instant;
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@JsonInclude(Include.NON_NULL)
|
||||
public record ConversationSessionDTO(
|
||||
String sessionId,
|
||||
String userId,
|
||||
String telefono,
|
||||
Instant createdAt,
|
||||
Instant lastModified,
|
||||
String lastMessage,
|
||||
String pantallaContexto
|
||||
) {
|
||||
public ConversationSessionDTO(String sessionId, String userId, String telefono, Instant createdAt, Instant lastModified, String lastMessage, String pantallaContexto) {
|
||||
this.sessionId = sessionId;
|
||||
this.userId = userId;
|
||||
this.telefono = telefono;
|
||||
this.createdAt = createdAt;
|
||||
this.lastModified = lastModified;
|
||||
this.lastMessage = lastMessage;
|
||||
this.pantallaContexto = pantallaContexto;
|
||||
}
|
||||
|
||||
public static ConversationSessionDTO create(String sessionId, String userId, String telefono) {
|
||||
Instant now = Instant.now();
|
||||
return new ConversationSessionDTO(sessionId, userId, telefono, now, now, null, null);
|
||||
}
|
||||
|
||||
public ConversationSessionDTO withLastMessage(String lastMessage) {
|
||||
return new ConversationSessionDTO(this.sessionId, this.userId, this.telefono, this.createdAt, Instant.now(), lastMessage, this.pantallaContexto);
|
||||
}
|
||||
|
||||
public ConversationSessionDTO withTelefono(String newTelefono) {
|
||||
if (newTelefono != null && !newTelefono.equals(this.telefono)) {
|
||||
return new ConversationSessionDTO(this.sessionId, this.userId, newTelefono, this.createdAt, this.lastModified, this.lastMessage, this.pantallaContexto);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public ConversationSessionDTO withPantallaContexto(String pantallaContexto) {
|
||||
return new ConversationSessionDTO(this.sessionId, this.userId, this.telefono, this.createdAt, this.lastModified, this.lastMessage, pantallaContexto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* 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,
|
||||
@JsonProperty("pantallaContexto") String pantallaContexto //optional field for quick-replies
|
||||
) {
|
||||
public ExternalConvRequestDTO {}
|
||||
}
|
||||
@@ -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 enum MessageType {
|
||||
USER,
|
||||
AGENT,
|
||||
SYSTEM,
|
||||
LLM
|
||||
}
|
||||
@@ -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.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,34 @@
|
||||
/*
|
||||
* 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);
|
||||
}
|
||||
|
||||
public QueryParamsDTO withSessionParameters(Map<String, Object> parameters) {
|
||||
Map<String, Object> updatedParams = new HashMap<>(this.parameters());
|
||||
updatedParams.putAll(parameters);
|
||||
return new QueryParamsDTO(updatedParams);
|
||||
}
|
||||
}
|
||||
@@ -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.fasterxml.jackson.annotation.JsonProperty;
|
||||
import java.util.Map;
|
||||
|
||||
public record QueryResultDTO(
|
||||
@JsonProperty("responseText") String responseText,
|
||||
@JsonProperty("parameters") Map<String, Object> parameters
|
||||
) {}
|
||||
@@ -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) {}
|
||||
@@ -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.fasterxml.jackson.annotation.JsonProperty;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record UsuarioDTO(
|
||||
@JsonProperty("telefono") @NotBlank String telefono,
|
||||
@JsonProperty("nickname") String nickname
|
||||
) {}
|
||||
@@ -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,18 @@
|
||||
/*
|
||||
* 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,
|
||||
@JsonProperty("parametrosOcultos") java.util.Map<String, String> hiddenParameters
|
||||
) {
|
||||
public ExternalNotRequestDTO {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose.
|
||||
* Your use of it is subject to your agreement with Google.
|
||||
*/
|
||||
package com.example.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
|
||||
String status
|
||||
) {
|
||||
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.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.example.dto.llm.webhook;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
public class SessionInfoDTO {
|
||||
|
||||
private Map<String, Object> parameters;
|
||||
|
||||
public SessionInfoDTO() {
|
||||
}
|
||||
|
||||
public SessionInfoDTO(Map<String, Object> parameters) {
|
||||
this.parameters = parameters;
|
||||
}
|
||||
|
||||
public Map<String, Object> getParameters() {
|
||||
return parameters;
|
||||
}
|
||||
|
||||
public void setParameters(Map<String, Object> parameters) {
|
||||
this.parameters = parameters;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.example.dto.llm.webhook;
|
||||
|
||||
public class WebhookRequestDTO {
|
||||
|
||||
private SessionInfoDTO sessionInfo;
|
||||
|
||||
public WebhookRequestDTO() {
|
||||
}
|
||||
|
||||
public SessionInfoDTO getSessionInfo() {
|
||||
return sessionInfo;
|
||||
}
|
||||
|
||||
public void setSessionInfo(SessionInfoDTO sessionInfo) {
|
||||
this.sessionInfo = sessionInfo;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.example.dto.llm.webhook;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
public class WebhookResponseDTO {
|
||||
|
||||
@JsonProperty("sessionInfo")
|
||||
private SessionInfoDTO sessionInfo;
|
||||
|
||||
public WebhookResponseDTO() {
|
||||
}
|
||||
|
||||
public WebhookResponseDTO(SessionInfoDTO sessionInfo) {
|
||||
this.sessionInfo = sessionInfo;
|
||||
}
|
||||
|
||||
public SessionInfoDTO getSessionInfo() {
|
||||
return sessionInfo;
|
||||
}
|
||||
|
||||
public void setSessionInfo(SessionInfoDTO sessionInfo) {
|
||||
this.sessionInfo = sessionInfo;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.example.dto.quickreplies;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
public record QuestionDTO(
|
||||
@JsonProperty("titulo") String titulo,
|
||||
@JsonProperty("descripcion") String descripcion,
|
||||
@JsonProperty("respuesta") String respuesta
|
||||
) {}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.example.dto.quickreplies;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import java.util.List;
|
||||
public record QuickReplyDTO(
|
||||
@JsonProperty("header") String header,
|
||||
@JsonProperty("body") String body,
|
||||
@JsonProperty("button") String button,
|
||||
@JsonProperty("header_section") String headerSection,
|
||||
@JsonProperty("preguntas") List<QuestionDTO> preguntas
|
||||
) {}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.example.dto.quickreplies;
|
||||
|
||||
import com.example.dto.dialogflow.conversation.ConversationEntryType;
|
||||
import com.example.dto.dialogflow.conversation.UsuarioDTO;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record QuickReplyScreenRequestDTO(
|
||||
@JsonProperty("usuario") UsuarioDTO user,
|
||||
@JsonProperty("canal") String channel,
|
||||
@JsonProperty("tipo") ConversationEntryType tipo,
|
||||
@JsonProperty("pantallaContexto") String pantallaContexto
|
||||
) {}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* 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.exception;
|
||||
|
||||
public class DialogflowClientException extends RuntimeException {
|
||||
|
||||
public DialogflowClientException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public DialogflowClientException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* 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.exception;
|
||||
|
||||
public class FirestorePersistenceException extends RuntimeException {
|
||||
|
||||
public FirestorePersistenceException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public FirestorePersistenceException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* 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.exception;
|
||||
|
||||
public class GeminiClientException extends Exception {
|
||||
|
||||
public GeminiClientException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public GeminiClientException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* 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.exception;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ControllerAdvice;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
|
||||
@ControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
||||
|
||||
@ExceptionHandler(DialogflowClientException.class)
|
||||
public ResponseEntity<Map<String, String>> handleDialogflowClientException(
|
||||
DialogflowClientException ex) {
|
||||
Map<String, String> error = new HashMap<>();
|
||||
error.put("error", "Error communicating with Dialogflow");
|
||||
error.put("message", ex.getMessage());
|
||||
logger.error("DialogflowClientException: {}", ex.getMessage());
|
||||
return new ResponseEntity<>(error, HttpStatus.SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
@ExceptionHandler(GeminiClientException.class)
|
||||
public ResponseEntity<Map<String, String>> handleGeminiClientException(GeminiClientException ex) {
|
||||
Map<String, String> error = new HashMap<>();
|
||||
error.put("error", "Error communicating with Gemini");
|
||||
error.put("message", ex.getMessage());
|
||||
logger.error("GeminiClientException: {}", ex.getMessage());
|
||||
return new ResponseEntity<>(error, HttpStatus.SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<Map<String, String>> handleAllExceptions(Exception ex) {
|
||||
Map<String, String> error = new HashMap<>();
|
||||
error.put("error", "Internal Server Error");
|
||||
error.put("message", ex.getMessage());
|
||||
logger.error("An unexpected error occurred: {}", ex.getMessage(), ex);
|
||||
return new ResponseEntity<>(error, HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
@@ -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.mapper.conversation;
|
||||
|
||||
import com.example.dto.dialogflow.conversation.ConversationEntryDTO;
|
||||
import com.example.dto.dialogflow.conversation.ConversationMessageDTO;
|
||||
import com.example.dto.dialogflow.conversation.MessageType;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class ConversationEntryMapper {
|
||||
|
||||
public ConversationMessageDTO toConversationMessageDTO(ConversationEntryDTO entry) {
|
||||
MessageType type = switch (entry.entity()) {
|
||||
case USUARIO -> MessageType.USER;
|
||||
case AGENTE -> MessageType.AGENT;
|
||||
case SISTEMA -> MessageType.SYSTEM;
|
||||
case LLM -> MessageType.LLM;
|
||||
};
|
||||
|
||||
return new ConversationMessageDTO(
|
||||
type,
|
||||
entry.timestamp(),
|
||||
entry.text(),
|
||||
entry.parameters(),
|
||||
entry.canal()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* 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.google.cloud.Timestamp;
|
||||
|
||||
import com.example.dto.dialogflow.conversation.ConversationMessageDTO;
|
||||
import com.example.dto.dialogflow.conversation.MessageType;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
public class ConversationMessageMapper {
|
||||
|
||||
public Map<String, Object> toMap(ConversationMessageDTO message) {
|
||||
Map<String, Object> map = new HashMap<>();
|
||||
map.put("entidad", message.type().name());
|
||||
map.put("tiempo", message.timestamp());
|
||||
map.put("mensaje", message.text());
|
||||
if (message.parameters() != null) {
|
||||
map.put("parametros", message.parameters());
|
||||
}
|
||||
if (message.canal() != null) {
|
||||
map.put("canal", message.canal());
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
public ConversationMessageDTO fromMap(Map<String, Object> map) {
|
||||
Object timeObject = map.get("tiempo");
|
||||
Instant timestamp = null;
|
||||
if (timeObject instanceof Timestamp) {
|
||||
timestamp = ((Timestamp) timeObject).toDate().toInstant();
|
||||
} else if (timeObject instanceof Instant) {
|
||||
timestamp = (Instant) timeObject;
|
||||
}
|
||||
return new ConversationMessageDTO(
|
||||
MessageType.valueOf((String) map.get("entidad")),
|
||||
timestamp,
|
||||
(String) map.get("mensaje"),
|
||||
(Map<String, Object>) map.get("parametros"),
|
||||
(String) map.get("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.EventInput;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 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.debug("Added session parameter from DTO queryParams: Key='{}', Value='{}'",
|
||||
entry.getKey(),entry.getValue());
|
||||
}
|
||||
}
|
||||
|
||||
if (paramsStructBuilder.getFieldsCount() > 0) {
|
||||
queryParametersBuilder.setParameters(paramsStructBuilder.build());
|
||||
logger.debug("All custom session parameters 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* 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.google.cloud.dialogflow.cx.v3.QueryResult;
|
||||
import com.google.cloud.dialogflow.cx.v3.ResponseMessage;
|
||||
import com.google.cloud.dialogflow.cx.v3.DetectIntentResponse;
|
||||
import com.example.dto.dialogflow.base.DetectIntentResponseDTO;
|
||||
import com.example.dto.dialogflow.conversation.QueryResultDTO;
|
||||
import com.example.util.ProtobufUtil;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Spring component responsible for mapping a Dialogflow CX API response
|
||||
* (`DetectIntentResponse`) to a simplified custom DTO (`DetectIntentResponseDTO`).
|
||||
* It extracts and consolidates the fulfillment text, and converts Protobuf
|
||||
* session parameters into standard Java objects, providing a clean and
|
||||
* decoupled interface for consuming Dialogflow results.
|
||||
*/
|
||||
@Component
|
||||
public class DialogflowResponseMapper {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(DialogflowResponseMapper.class);
|
||||
|
||||
public DetectIntentResponseDTO mapFromDialogflowResponse(DetectIntentResponse response, String sessionId) {
|
||||
|
||||
logger.info("Starting mapping of Dialogflow DetectIntentResponse for session: {}", sessionId);
|
||||
|
||||
String responseId = response.getResponseId();
|
||||
QueryResult dfQueryResult = response.getQueryResult();
|
||||
logger.debug("Extracted QueryResult object for session: {}", sessionId);
|
||||
|
||||
StringBuilder responseTextBuilder = new StringBuilder();
|
||||
if (dfQueryResult.getResponseMessagesList().isEmpty()) {
|
||||
logger.debug("No response messages found in QueryResult for session: {}", sessionId);
|
||||
}
|
||||
|
||||
for (ResponseMessage message : dfQueryResult.getResponseMessagesList()) {
|
||||
if (message.hasText()) {
|
||||
logger.debug("Processing text response message for session: {}", sessionId);
|
||||
for (String text : message.getText().getTextList()) {
|
||||
if (responseTextBuilder.length() > 0) {
|
||||
responseTextBuilder.append(" ");
|
||||
}
|
||||
responseTextBuilder.append(text);
|
||||
logger.debug("Appended text segment: '{}' to fulfillment text for session: {}", text, sessionId);
|
||||
}
|
||||
} else {
|
||||
logger.debug("Skipping non-text response message type: {} for session: {}", message.getMessageCase(), sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
String responseText = responseTextBuilder.toString().trim();
|
||||
|
||||
Map<String, Object> parameters = new LinkedHashMap<>(); // Inicializamos vacío para evitar NPEs después
|
||||
|
||||
if (dfQueryResult.hasParameters()) {
|
||||
// Usamos un forEach en lugar de Collectors.toMap para tener control total sobre nulos
|
||||
dfQueryResult.getParameters().getFieldsMap().forEach((key, value) -> {
|
||||
try {
|
||||
Object convertedValue = ProtobufUtil.convertProtobufValueToJavaObject(value);
|
||||
|
||||
// Si el valor convertido es nulo, decidimos qué hacer.
|
||||
// Lo mejor es poner un String vacío o ignorarlo para que no explote tu lógica.
|
||||
if (convertedValue != null) {
|
||||
parameters.put(key, convertedValue);
|
||||
} else {
|
||||
logger.warn("El parámetro '{}' devolvió un valor nulo al convertir. Se ignorará.", key);
|
||||
// Opcional: parameters.put(key, "");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("Error convirtiendo el parámetro '{}' de Protobuf a Java: {}", key, e.getMessage());
|
||||
}
|
||||
});
|
||||
|
||||
logger.debug("Extracted parameters: {} for session: {}", parameters, sessionId);
|
||||
} else {
|
||||
logger.debug("No parameters found in QueryResult for session: {}. Using empty map.", sessionId);
|
||||
}
|
||||
|
||||
QueryResultDTO ourQueryResult = new QueryResultDTO(responseText, parameters);
|
||||
logger.debug("Internal QueryResult DTO created for session: {}. Details: {}", sessionId, ourQueryResult);
|
||||
|
||||
DetectIntentResponseDTO finalResponse = new DetectIntentResponseDTO(responseId, ourQueryResult);
|
||||
logger.info("Finished mapping DialogflowDetectIntentResponse for session: {}. Full response ID: {}", sessionId, responseId);
|
||||
return finalResponse;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* 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);
|
||||
}
|
||||
|
||||
if (primaryPhoneNumber == null || primaryPhoneNumber.isBlank()) {
|
||||
throw new IllegalArgumentException(
|
||||
"Phone number 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,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.mapper.conversation;
|
||||
|
||||
import com.example.dto.dialogflow.conversation.ConversationSessionDTO;
|
||||
import com.google.cloud.Timestamp;
|
||||
import com.google.cloud.firestore.DocumentSnapshot;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
public class FirestoreConversationMapper {
|
||||
|
||||
public ConversationSessionDTO mapFirestoreDocumentToConversationSessionDTO(DocumentSnapshot document) {
|
||||
if (document == null || !document.exists()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Timestamp createdAtTimestamp = document.getTimestamp("fechaCreacion");
|
||||
Timestamp lastModifiedTimestamp = document.getTimestamp("ultimaActualizacion");
|
||||
|
||||
Instant createdAt = (createdAtTimestamp != null) ? createdAtTimestamp.toDate().toInstant() : null;
|
||||
Instant lastModified = (lastModifiedTimestamp != null) ? lastModifiedTimestamp.toDate().toInstant() : null;
|
||||
|
||||
return new ConversationSessionDTO(
|
||||
document.getString("sessionId"),
|
||||
document.getString("userId"),
|
||||
document.getString("telefono"),
|
||||
createdAt,
|
||||
lastModified,
|
||||
document.getString("ultimoMensaje"),
|
||||
document.getString("pantallaContexto")
|
||||
);
|
||||
}
|
||||
|
||||
public Map<String, Object> createSessionMap(ConversationSessionDTO session) {
|
||||
Map<String, Object> sessionMap = new HashMap<>();
|
||||
sessionMap.put("sessionId", session.sessionId());
|
||||
sessionMap.put("userId", session.userId());
|
||||
sessionMap.put("telefono", session.telefono());
|
||||
sessionMap.put("fechaCreacion", session.createdAt());
|
||||
sessionMap.put("ultimaActualizacion", session.lastModified());
|
||||
sessionMap.put("ultimoMensaje", session.lastMessage());
|
||||
sessionMap.put("pantallaContexto", session.pantallaContexto());
|
||||
return sessionMap;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package com.example.mapper.messagefilter;
|
||||
|
||||
import com.example.dto.dialogflow.conversation.ConversationMessageDTO;
|
||||
import com.example.dto.dialogflow.conversation.ConversationSessionDTO;
|
||||
import com.example.dto.dialogflow.conversation.MessageType;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Component
|
||||
public class ConversationContextMapper {
|
||||
|
||||
@Value("${conversation.context.message.limit:60}")
|
||||
private int messageLimit;
|
||||
|
||||
@Value("${conversation.context.days.limit:30}")
|
||||
private int daysLimit;
|
||||
|
||||
private static final int MAX_HISTORY_BYTES = 50 * 1024; // 50 KB
|
||||
|
||||
private static final String NOTIFICATION_TEXT_PARAM = "notification_text";
|
||||
|
||||
public String toText(ConversationSessionDTO session, List<ConversationMessageDTO> messages) {
|
||||
if (messages == null || messages.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
return toTextFromMessages(messages);
|
||||
}
|
||||
|
||||
public String toTextWithLimits(ConversationSessionDTO session, List<ConversationMessageDTO> messages) {
|
||||
if (messages == null || messages.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
Instant thirtyDaysAgo = Instant.now().minus(daysLimit, ChronoUnit.DAYS);
|
||||
|
||||
List<ConversationMessageDTO> recentEntries = messages.stream()
|
||||
.filter(entry -> entry.timestamp() != null && entry.timestamp().isAfter(thirtyDaysAgo))
|
||||
.sorted(Comparator.comparing(ConversationMessageDTO::timestamp).reversed())
|
||||
.limit(messageLimit)
|
||||
.sorted(Comparator.comparing(ConversationMessageDTO::timestamp))
|
||||
.collect(Collectors.toList());
|
||||
return toTextWithTruncation(recentEntries);
|
||||
}
|
||||
|
||||
public String toTextFromMessages(List<ConversationMessageDTO> messages) {
|
||||
return messages.stream()
|
||||
.map(this::formatEntry)
|
||||
.collect(Collectors.joining("\n"));
|
||||
}
|
||||
|
||||
public String toTextWithTruncation(List<ConversationMessageDTO> messages) {
|
||||
if (messages == null || messages.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
StringBuilder textBlock = new StringBuilder();
|
||||
List<String> formattedMessages = messages.stream()
|
||||
.map(this::formatEntry)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
for (int i = formattedMessages.size() - 1; i >= 0; i--) {
|
||||
String message = formattedMessages.get(i) + "\n";
|
||||
if (textBlock.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8).length + message.getBytes(java.nio.charset.StandardCharsets.UTF_8).length > MAX_HISTORY_BYTES) {
|
||||
break;
|
||||
}
|
||||
textBlock.insert(0, message);
|
||||
}
|
||||
|
||||
return textBlock.toString().trim();
|
||||
}
|
||||
|
||||
private String formatEntry(ConversationMessageDTO entry) {
|
||||
String prefix = "User: ";
|
||||
String content = entry.text();
|
||||
|
||||
if (entry.type() != null) {
|
||||
switch (entry.type()) {
|
||||
case AGENT:
|
||||
prefix = "Agent: ";
|
||||
break;
|
||||
case SYSTEM:
|
||||
prefix = "System: ";
|
||||
// fix: add notification in the conversation.
|
||||
if (entry.parameters() != null && entry.parameters().containsKey(NOTIFICATION_TEXT_PARAM)) {
|
||||
Object paramText = entry.parameters().get(NOTIFICATION_TEXT_PARAM);
|
||||
if (paramText != null && !paramText.toString().isBlank()) {
|
||||
content = paramText.toString();
|
||||
}
|
||||
}
|
||||
break;
|
||||
case LLM:
|
||||
prefix = "System: ";
|
||||
break;
|
||||
case USER:
|
||||
default:
|
||||
prefix = "User: ";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
String text = prefix + content;
|
||||
|
||||
if (entry.type() == MessageType.AGENT) {
|
||||
text = cleanAgentMessage(text);
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
private String cleanAgentMessage(String message) {
|
||||
return message.replaceAll("\\s*\\{.*\\}\\s*$", "").trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose.
|
||||
* Your use of it is subject to your agreement with Google.
|
||||
*/
|
||||
|
||||
package com.example.mapper.messagefilter;
|
||||
|
||||
import com.example.dto.dialogflow.notification.NotificationDTO;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Component
|
||||
public class NotificationContextMapper {
|
||||
|
||||
public String toText(NotificationDTO notification) {
|
||||
if (notification == null || notification.texto() == null) {
|
||||
return "";
|
||||
}
|
||||
return notification.texto();
|
||||
}
|
||||
|
||||
public String toText(List<NotificationDTO> notifications) {
|
||||
if (notifications == null || notifications.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
return notifications.stream()
|
||||
.map(NotificationDTO::texto)
|
||||
.filter(texto -> texto != null && !texto.isBlank())
|
||||
.collect(Collectors.joining("\n"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose.
|
||||
* Your use of it is subject to your agreement with Google.
|
||||
*/
|
||||
|
||||
package com.example.mapper.notification;
|
||||
|
||||
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 com.example.dto.dialogflow.conversation.TextInputDTO;
|
||||
|
||||
|
||||
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 LANGUAGE_CODE = "es";
|
||||
private static final String TELEPHONE_PARAM_NAME = "telefono";
|
||||
private static final String NOTIFICATION_TEXT_PARAM = "notification_text";
|
||||
private static final String NOTIFICATION_LABEL = "NOTIFICACION";
|
||||
private static final String PREFIX_PO_PARAM = "notification_po_";
|
||||
|
||||
|
||||
|
||||
public DetectIntentRequestDTO map(ExternalNotRequestDTO request) {
|
||||
Objects.requireNonNull(request, "NotificationRequestDTO cannot be null for mapping.");
|
||||
|
||||
if (request.phoneNumber() == null || request.phoneNumber().isEmpty()) {
|
||||
throw new IllegalArgumentException("Phone numbers is required and cannot be empty in NotificationRequestDTO.");
|
||||
}
|
||||
String phoneNumber = request.phoneNumber();
|
||||
|
||||
Map<String, Object> parameters = new HashMap<>();
|
||||
parameters.put(TELEPHONE_PARAM_NAME, phoneNumber);
|
||||
parameters.put(NOTIFICATION_TEXT_PARAM, request.text());
|
||||
|
||||
|
||||
if (request.hiddenParameters() != null && !request.hiddenParameters().isEmpty()) {
|
||||
StringBuilder poBuilder = new StringBuilder();
|
||||
request.hiddenParameters().forEach((key, value) -> {
|
||||
parameters.put(PREFIX_PO_PARAM + key, value);
|
||||
poBuilder.append(key).append(": ").append(value).append("\n");
|
||||
});
|
||||
parameters.put("po", poBuilder.toString());
|
||||
}
|
||||
|
||||
TextInputDTO textInput = new TextInputDTO(NOTIFICATION_LABEL);
|
||||
QueryInputDTO queryInput = new QueryInputDTO(textInput, null, LANGUAGE_CODE);
|
||||
|
||||
|
||||
QueryParamsDTO queryParams = new QueryParamsDTO(parameters);
|
||||
|
||||
return new DetectIntentRequestDTO(queryInput, queryParams);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose.
|
||||
* Your use of it is subject to your agreement with Google.
|
||||
*/
|
||||
package com.example.mapper.notification;
|
||||
|
||||
import com.example.dto.dialogflow.notification.NotificationDTO;
|
||||
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;
|
||||
private static final String DEFAULT_NOTIFICATION_STATUS="ACTIVE";
|
||||
|
||||
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,
|
||||
DEFAULT_NOTIFICATION_STATUS
|
||||
);
|
||||
}
|
||||
|
||||
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>>() {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
/*
|
||||
* 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.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.api.core.ApiFuture;
|
||||
import com.google.cloud.firestore.DocumentReference;
|
||||
import com.google.cloud.firestore.DocumentSnapshot;
|
||||
import com.google.cloud.firestore.Firestore;
|
||||
import com.google.cloud.firestore.Query;
|
||||
import com.google.cloud.firestore.QueryDocumentSnapshot;
|
||||
import com.google.cloud.firestore.QuerySnapshot;
|
||||
import com.google.cloud.firestore.WriteBatch;
|
||||
import com.google.cloud.firestore.WriteResult;
|
||||
import com.google.cloud.firestore.CollectionReference;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* A base repository for performing low-level operations with Firestore. It provides a generic
|
||||
* interface for common data access tasks such as getting document references, performing reads,
|
||||
* writes, and batched updates. This class also handles the serialization and deserialization of
|
||||
* Java objects to and from Firestore documents using an `ObjectMapper`.
|
||||
*/
|
||||
@Repository
|
||||
public class FirestoreBaseRepository {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(FirestoreBaseRepository.class);
|
||||
|
||||
private final Firestore firestore;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Value("${app.id:default-app-id}")
|
||||
private String appId;
|
||||
|
||||
public FirestoreBaseRepository(Firestore firestore, ObjectMapper objectMapper) {
|
||||
this.firestore = firestore;
|
||||
this.objectMapper = objectMapper;
|
||||
|
||||
// Register JavaTimeModule for standard java.time handling
|
||||
if (!ObjectMapper.findModules().stream().anyMatch(m -> m instanceof JavaTimeModule)) {
|
||||
objectMapper.registerModule(new JavaTimeModule());
|
||||
}
|
||||
objectMapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
|
||||
|
||||
// Register ParameterNamesModule, crucial for Java Records and classes compiled with -parameters
|
||||
if (!ObjectMapper.findModules().stream().anyMatch(m -> m instanceof ParameterNamesModule)) {
|
||||
objectMapper.registerModule(new ParameterNamesModule());
|
||||
}
|
||||
|
||||
// These specific Timestamp (Google Cloud) deserializers/serializers are for ObjectMapper
|
||||
// to handle com.google.cloud.Timestamp objects when mapping other types.
|
||||
// They are generally not the cause of the Redis deserialization error for Instant.
|
||||
|
||||
SimpleModule firestoreTimestampModule = new SimpleModule();
|
||||
firestoreTimestampModule.addDeserializer(
|
||||
com.google.cloud.Timestamp.class, new FirestoreTimestampDeserializer());
|
||||
firestoreTimestampModule.addSerializer(
|
||||
com.google.cloud.Timestamp.class, new FirestoreTimestampSerializer());
|
||||
objectMapper.registerModule(firestoreTimestampModule);
|
||||
|
||||
logger.info(
|
||||
"FirestoreBaseRepository initialized with Firestore client and ObjectMapper. App ID will be: {}",
|
||||
appId);
|
||||
}
|
||||
|
||||
public DocumentReference getDocumentReference(String collectionPath, String documentId) {
|
||||
Objects.requireNonNull(collectionPath, "Collection path cannot be null.");
|
||||
Objects.requireNonNull(documentId, "Document ID cannot be null.");
|
||||
return firestore.collection(collectionPath).document(documentId);
|
||||
}
|
||||
|
||||
public <T> T getDocument(DocumentReference docRef, Class<T> clazz)
|
||||
throws InterruptedException, ExecutionException {
|
||||
Objects.requireNonNull(docRef, "DocumentReference cannot be null.");
|
||||
Objects.requireNonNull(clazz, "Class for mapping cannot be null.");
|
||||
ApiFuture<DocumentSnapshot> future = docRef.get();
|
||||
DocumentSnapshot document = future.get();
|
||||
if (document.exists()) {
|
||||
try {
|
||||
logger.debug(
|
||||
"FirestoreBaseRepository: Raw document data for {}: {}",
|
||||
docRef.getPath(),
|
||||
document.getData());
|
||||
T result = objectMapper.convertValue(document.getData(), clazz);
|
||||
return result;
|
||||
} catch (IllegalArgumentException e) {
|
||||
logger.error(
|
||||
"Failed to convert Firestore document data to {}: {}", clazz.getName(), e.getMessage(), e);
|
||||
throw new RuntimeException(
|
||||
"Failed to convert Firestore document data to " + clazz.getName(), e);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public DocumentSnapshot getDocumentSnapshot(DocumentReference docRef)
|
||||
throws ExecutionException, InterruptedException {
|
||||
Objects.requireNonNull(docRef, "DocumentReference cannot be null.");
|
||||
ApiFuture<DocumentSnapshot> future = docRef.get();
|
||||
return future.get();
|
||||
}
|
||||
|
||||
public Flux<DocumentSnapshot> getDocuments(String collectionPath) {
|
||||
return Flux.create(sink -> {
|
||||
ApiFuture<QuerySnapshot> future = firestore.collection(collectionPath).get();
|
||||
future.addListener(() -> {
|
||||
try {
|
||||
QuerySnapshot querySnapshot = future.get();
|
||||
if (querySnapshot != null) {
|
||||
querySnapshot.getDocuments().forEach(sink::next);
|
||||
}
|
||||
sink.complete();
|
||||
} catch (InterruptedException | ExecutionException e) {
|
||||
sink.error(e);
|
||||
}
|
||||
}, Runnable::run);
|
||||
});
|
||||
}
|
||||
|
||||
public Mono<DocumentSnapshot> getDocumentsByField(
|
||||
String collectionPath, String fieldName, String value) {
|
||||
return Mono.fromCallable(
|
||||
() -> {
|
||||
Query query = firestore.collection(collectionPath).whereEqualTo(fieldName, value);
|
||||
ApiFuture<QuerySnapshot> future = query.get();
|
||||
QuerySnapshot querySnapshot = future.get();
|
||||
if (!querySnapshot.isEmpty()) {
|
||||
return querySnapshot.getDocuments().get(0);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
public boolean documentExists(DocumentReference docRef)
|
||||
throws InterruptedException, ExecutionException {
|
||||
Objects.requireNonNull(docRef, "DocumentReference cannot be null.");
|
||||
ApiFuture<DocumentSnapshot> future = docRef.get();
|
||||
return future.get().exists();
|
||||
}
|
||||
|
||||
public void setDocument(DocumentReference docRef, Object data)
|
||||
throws InterruptedException, ExecutionException {
|
||||
Objects.requireNonNull(docRef, "DocumentReference cannot be null.");
|
||||
Objects.requireNonNull(data, "Data for setting document cannot be null.");
|
||||
ApiFuture<WriteResult> future = docRef.set(data);
|
||||
WriteResult writeResult = future.get();
|
||||
logger.debug(
|
||||
"Document set: {} with update time: {}", docRef.getPath(), writeResult.getUpdateTime());
|
||||
}
|
||||
|
||||
public void updateDocument(DocumentReference docRef, Map<String, Object> updates)
|
||||
throws InterruptedException, ExecutionException {
|
||||
Objects.requireNonNull(docRef, "DocumentReference cannot be null.");
|
||||
Objects.requireNonNull(updates, "Updates map cannot be null.");
|
||||
ApiFuture<WriteResult> future = docRef.update(updates);
|
||||
WriteResult writeResult = future.get();
|
||||
logger.debug(
|
||||
"Document updated: {} with update time: {}", docRef.getPath(), writeResult.getUpdateTime());
|
||||
}
|
||||
|
||||
public void deleteDocument(DocumentReference docRef)
|
||||
throws InterruptedException, ExecutionException {
|
||||
Objects.requireNonNull(docRef, "DocumentReference cannot be null.");
|
||||
ApiFuture<WriteResult> future = docRef.delete();
|
||||
WriteResult writeResult = future.get();
|
||||
logger.debug(
|
||||
"Document deleted: {} with update time: {}", docRef.getPath(), writeResult.getUpdateTime());
|
||||
}
|
||||
|
||||
public WriteBatch createBatch() {
|
||||
return firestore.batch();
|
||||
}
|
||||
|
||||
public void commitBatch(WriteBatch batch) throws InterruptedException, ExecutionException {
|
||||
Objects.requireNonNull(batch, "WriteBatch cannot be null.");
|
||||
batch.commit().get();
|
||||
logger.debug("Batch committed successfully.");
|
||||
}
|
||||
|
||||
public String getAppId() {
|
||||
return appId;
|
||||
}
|
||||
|
||||
public void deleteCollection(String collectionPath, int batchSize) {
|
||||
try {
|
||||
CollectionReference collection = firestore.collection(collectionPath);
|
||||
ApiFuture<QuerySnapshot> future = collection.limit(batchSize).get();
|
||||
int deleted = 0;
|
||||
// future.get() blocks on document retrieval
|
||||
List<QueryDocumentSnapshot> documents = future.get().getDocuments();
|
||||
while (!documents.isEmpty()) {
|
||||
for (QueryDocumentSnapshot document : documents) {
|
||||
document.getReference().delete();
|
||||
++deleted;
|
||||
}
|
||||
future = collection.limit(batchSize).get();
|
||||
documents = future.get().getDocuments();
|
||||
}
|
||||
logger.info("Deleted {} documents from collection {}", deleted, collectionPath);
|
||||
} catch (Exception e) {
|
||||
logger.error("Error deleting collection: " + e.getMessage(), e);
|
||||
throw new RuntimeException("Error deleting collection", e);
|
||||
}
|
||||
}
|
||||
|
||||
public void deleteDocumentAndSubcollections(DocumentReference docRef, String subcollection)
|
||||
throws ExecutionException, InterruptedException {
|
||||
deleteCollection(docRef.collection(subcollection).getPath(), 50);
|
||||
deleteDocument(docRef);
|
||||
}
|
||||
}
|
||||
214
src/main/java/com/example/service/base/DataPurgeService.java
Normal file
214
src/main/java/com/example/service/base/DataPurgeService.java
Normal file
@@ -0,0 +1,214 @@
|
||||
|
||||
package com.example.service.base;
|
||||
|
||||
|
||||
|
||||
import com.example.repository.FirestoreBaseRepository;
|
||||
|
||||
import com.google.cloud.firestore.CollectionReference;
|
||||
|
||||
import com.google.cloud.firestore.Firestore;
|
||||
|
||||
import com.google.cloud.firestore.QueryDocumentSnapshot;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
|
||||
import org.springframework.data.redis.core.ReactiveRedisTemplate;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
|
||||
|
||||
|
||||
@Service
|
||||
|
||||
public class DataPurgeService {
|
||||
|
||||
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(DataPurgeService.class);
|
||||
|
||||
|
||||
|
||||
private final ReactiveRedisTemplate<String, ?> redisTemplate;
|
||||
|
||||
private final FirestoreBaseRepository firestoreBaseRepository;
|
||||
|
||||
|
||||
|
||||
private final Firestore firestore;
|
||||
|
||||
|
||||
|
||||
@Autowired
|
||||
|
||||
public DataPurgeService(
|
||||
|
||||
@Qualifier("reactiveRedisTemplate") ReactiveRedisTemplate<String, ?> redisTemplate,
|
||||
|
||||
FirestoreBaseRepository firestoreBaseRepository, Firestore firestore) {
|
||||
|
||||
this.redisTemplate = redisTemplate;
|
||||
|
||||
this.firestoreBaseRepository = firestoreBaseRepository;
|
||||
|
||||
this.firestore = firestore;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
public Mono<Void> purgeAllData() {
|
||||
|
||||
return purgeRedis()
|
||||
|
||||
.then(purgeFirestore());
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
private Mono<Void> purgeRedis() {
|
||||
|
||||
logger.info("Starting Redis data purge.");
|
||||
|
||||
return redisTemplate.getConnectionFactory().getReactiveConnection().serverCommands().flushAll()
|
||||
|
||||
.doOnSuccess(v -> logger.info("Successfully purged all data from Redis."))
|
||||
|
||||
.doOnError(e -> logger.error("Error purging data from Redis.", e))
|
||||
|
||||
.then();
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
private Mono<Void> purgeFirestore() {
|
||||
|
||||
logger.info("Starting Firestore data purge.");
|
||||
|
||||
return Mono.fromRunnable(() -> {
|
||||
|
||||
try {
|
||||
|
||||
String appId = firestoreBaseRepository.getAppId();
|
||||
|
||||
String conversationsCollectionPath = String.format("artifacts/%s/conversations", appId);
|
||||
|
||||
String notificationsCollectionPath = String.format("artifacts/%s/notifications", appId);
|
||||
|
||||
|
||||
|
||||
// Delete 'mensajes' sub-collections in 'conversations'
|
||||
|
||||
logger.info("Deleting 'mensajes' sub-collections from '{}'", conversationsCollectionPath);
|
||||
|
||||
try {
|
||||
|
||||
List<QueryDocumentSnapshot> conversationDocuments = firestore.collection(conversationsCollectionPath).get().get().getDocuments();
|
||||
|
||||
for (QueryDocumentSnapshot document : conversationDocuments) {
|
||||
|
||||
String messagesCollectionPath = document.getReference().getPath() + "/mensajes";
|
||||
|
||||
logger.info("Deleting sub-collection: {}", messagesCollectionPath);
|
||||
|
||||
firestoreBaseRepository.deleteCollection(messagesCollectionPath, 50);
|
||||
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
|
||||
if (e.getMessage().contains("NOT_FOUND")) {
|
||||
|
||||
logger.warn("Collection '{}' not found, skipping.", conversationsCollectionPath);
|
||||
|
||||
} else {
|
||||
|
||||
throw e;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Delete the 'conversations' collection
|
||||
|
||||
logger.info("Deleting collection: {}", conversationsCollectionPath);
|
||||
|
||||
try {
|
||||
|
||||
firestoreBaseRepository.deleteCollection(conversationsCollectionPath, 50);
|
||||
|
||||
} catch (Exception e) {
|
||||
|
||||
if (e.getMessage().contains("NOT_FOUND")) {
|
||||
|
||||
logger.warn("Collection '{}' not found, skipping.", conversationsCollectionPath);
|
||||
|
||||
}
|
||||
|
||||
else {
|
||||
|
||||
throw e;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Delete the 'notifications' collection
|
||||
|
||||
logger.info("Deleting collection: {}", notificationsCollectionPath);
|
||||
|
||||
try {
|
||||
|
||||
firestoreBaseRepository.deleteCollection(notificationsCollectionPath, 50);
|
||||
|
||||
} catch (Exception e) {
|
||||
|
||||
if (e.getMessage().contains("NOT_FOUND")) {
|
||||
|
||||
logger.warn("Collection '{}' not found, skipping.", notificationsCollectionPath);
|
||||
|
||||
} else {
|
||||
|
||||
throw e;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
logger.info("Successfully purged Firestore collections.");
|
||||
|
||||
} catch (Exception e) {
|
||||
|
||||
logger.error("Error purging Firestore collections.", e);
|
||||
|
||||
throw new RuntimeException("Failed to purge Firestore collections.", e);
|
||||
|
||||
}
|
||||
|
||||
}).subscribeOn(Schedulers.boundedElastic()).then();
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
* 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.mapper.conversation.DialogflowRequestMapper;
|
||||
import com.example.mapper.conversation.DialogflowResponseMapper;
|
||||
import com.example.dto.dialogflow.base.DetectIntentRequestDTO;
|
||||
import com.example.dto.dialogflow.base.DetectIntentResponseDTO;
|
||||
import com.example.exception.DialogflowClientException;
|
||||
import com.google.api.gax.rpc.ApiException;
|
||||
import com.google.cloud.dialogflow.cx.v3.DetectIntentRequest;
|
||||
import com.google.cloud.dialogflow.cx.v3.QueryParameters;
|
||||
import com.google.cloud.dialogflow.cx.v3.SessionsClient;
|
||||
import com.google.cloud.dialogflow.cx.v3.SessionName;
|
||||
import com.google.cloud.dialogflow.cx.v3.SessionsSettings;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Mono;
|
||||
import javax.annotation.PreDestroy;
|
||||
import java.io.IOException;
|
||||
import java.util.Objects;
|
||||
import reactor.util.retry.Retry;
|
||||
|
||||
/**
|
||||
* Service for interacting with the Dialogflow CX API to detect user DetectIntent.
|
||||
* It encapsulates the low-level API calls, handling request mapping from DTOs,
|
||||
* managing the `SessionsClient`, and translating API responses into DTOs,
|
||||
* all within a reactive programming context.
|
||||
*/
|
||||
@Service
|
||||
public class DialogflowClientService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(DialogflowClientService.class);
|
||||
|
||||
private final String dialogflowCxProjectId;
|
||||
private final String dialogflowCxLocation;
|
||||
private final String dialogflowCxAgentId;
|
||||
|
||||
private final DialogflowRequestMapper dialogflowRequestMapper;
|
||||
private final DialogflowResponseMapper dialogflowResponseMapper;
|
||||
private SessionsClient sessionsClient;
|
||||
|
||||
public DialogflowClientService(
|
||||
|
||||
@org.springframework.beans.factory.annotation.Value("${dialogflow.cx.project-id}") String dialogflowCxProjectId,
|
||||
@org.springframework.beans.factory.annotation.Value("${dialogflow.cx.location}") String dialogflowCxLocation,
|
||||
@org.springframework.beans.factory.annotation.Value("${dialogflow.cx.agent-id}") String dialogflowCxAgentId,
|
||||
DialogflowRequestMapper dialogflowRequestMapper,
|
||||
DialogflowResponseMapper dialogflowResponseMapper)
|
||||
throws IOException {
|
||||
|
||||
this.dialogflowCxProjectId = dialogflowCxProjectId;
|
||||
this.dialogflowCxLocation = dialogflowCxLocation;
|
||||
this.dialogflowCxAgentId = dialogflowCxAgentId;
|
||||
this.dialogflowRequestMapper = dialogflowRequestMapper;
|
||||
this.dialogflowResponseMapper = dialogflowResponseMapper;
|
||||
|
||||
try {
|
||||
String regionalEndpoint = String.format("%s-dialogflow.googleapis.com:443", dialogflowCxLocation);
|
||||
SessionsSettings sessionsSettings = SessionsSettings.newBuilder()
|
||||
.setEndpoint(regionalEndpoint)
|
||||
.build();
|
||||
this.sessionsClient = SessionsClient.create(sessionsSettings);
|
||||
logger.info("Dialogflow CX SessionsClient initialized successfully for endpoint: {}", regionalEndpoint);
|
||||
logger.info("Dialogflow CX SessionsClient initialized successfully for agent - Test Agent version: {}", dialogflowCxAgentId);
|
||||
} catch (IOException e) {
|
||||
logger.error("Failed to create Dialogflow CX SessionsClient: {}", e.getMessage(), e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
public void closeSessionsClient() {
|
||||
if (sessionsClient != null) {
|
||||
sessionsClient.close();
|
||||
logger.info("Dialogflow CX SessionsClient closed.");
|
||||
}
|
||||
}
|
||||
|
||||
public Mono<DetectIntentResponseDTO> detectIntent(
|
||||
String sessionId,
|
||||
DetectIntentRequestDTO request) {
|
||||
|
||||
Objects.requireNonNull(sessionId, "Dialogflow session ID cannot be null.");
|
||||
Objects.requireNonNull(request, "Dialogflow request DTO cannot be null.");
|
||||
|
||||
logger.info("Initiating detectIntent for session: {}", sessionId);
|
||||
|
||||
DetectIntentRequest.Builder detectIntentRequestBuilder;
|
||||
try {
|
||||
detectIntentRequestBuilder = dialogflowRequestMapper.mapToDetectIntentRequestBuilder(request);
|
||||
logger.debug("Obtained partial DetectIntentRequest.Builder from mapper for session: {}", sessionId);
|
||||
} catch (IllegalArgumentException e) {
|
||||
logger.error(" Failed to map DTO to partial Protobuf request for session {}: {}", sessionId, e.getMessage());
|
||||
return Mono.error(new IllegalArgumentException("Invalid Dialogflow request input: " + e.getMessage()));
|
||||
}
|
||||
|
||||
SessionName sessionName = SessionName.newBuilder()
|
||||
.setProject(dialogflowCxProjectId)
|
||||
.setLocation(dialogflowCxLocation)
|
||||
.setAgent(dialogflowCxAgentId)
|
||||
.setSession(sessionId)
|
||||
.build();
|
||||
detectIntentRequestBuilder.setSession(sessionName.toString());
|
||||
logger.debug("Set session path {} on the request builder for session: {}", sessionName.toString(), sessionId);
|
||||
|
||||
QueryParameters.Builder queryParamsBuilder;
|
||||
if (detectIntentRequestBuilder.hasQueryParams()) {
|
||||
queryParamsBuilder = detectIntentRequestBuilder.getQueryParams().toBuilder();
|
||||
} else {
|
||||
queryParamsBuilder = QueryParameters.newBuilder();
|
||||
}
|
||||
|
||||
detectIntentRequestBuilder.setQueryParams(queryParamsBuilder.build());
|
||||
|
||||
// Build the final DetectIntentRequest Protobuf object
|
||||
DetectIntentRequest detectIntentRequest = detectIntentRequestBuilder.build();
|
||||
return Mono.fromCallable(() -> {
|
||||
logger.debug("Calling Dialogflow CX detectIntent for session: {}", sessionId);
|
||||
return sessionsClient.detectIntent(detectIntentRequest);
|
||||
})
|
||||
|
||||
.retryWhen(reactor.util.retry.Retry.backoff(3, java.time.Duration.ofSeconds(1))
|
||||
.filter(throwable -> {
|
||||
if (throwable instanceof ApiException apiException) {
|
||||
com.google.api.gax.rpc.StatusCode.Code code = apiException.getStatusCode().getCode();
|
||||
boolean isRetryable = code == com.google.api.gax.rpc.StatusCode.Code.INTERNAL ||
|
||||
code == com.google.api.gax.rpc.StatusCode.Code.UNAVAILABLE;
|
||||
if (isRetryable) {
|
||||
logger.warn("Retrying Dialogflow CX call for session {} due to transient error: {}", sessionId, code);
|
||||
}
|
||||
return isRetryable;
|
||||
}
|
||||
return false;
|
||||
})
|
||||
.doBeforeRetry(retrySignal -> logger.debug("Retry attempt #{} for session {}",
|
||||
retrySignal.totalRetries() + 1, sessionId))
|
||||
.onRetryExhaustedThrow((retrySpec, retrySignal) -> {
|
||||
logger.error("Dialogflow CX retries exhausted for session {}", sessionId);
|
||||
return retrySignal.failure();
|
||||
})
|
||||
)
|
||||
.onErrorMap(ApiException.class, e -> {
|
||||
String statusCode = e.getStatusCode().getCode().name();
|
||||
String message = e.getMessage();
|
||||
String detailedLog = message;
|
||||
|
||||
if (e.getCause() instanceof io.grpc.StatusRuntimeException grpcEx) {
|
||||
detailedLog = String.format("Status: %s, Message: %s, Trailers: %s",
|
||||
grpcEx.getStatus().getCode(),
|
||||
grpcEx.getStatus().getDescription(),
|
||||
grpcEx.getTrailers());
|
||||
}
|
||||
|
||||
logger.error("Dialogflow CX API error for session {}: details={}",
|
||||
sessionId, detailedLog, e);
|
||||
|
||||
return new DialogflowClientException(
|
||||
"Dialogflow CX API error: " + statusCode + " - " + message, e);
|
||||
})
|
||||
.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
128
src/main/java/com/example/service/base/MessageEntryFilter.java
Normal file
128
src/main/java/com/example/service/base/MessageEntryFilter.java
Normal file
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* 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.stereotype.Service;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Objects;
|
||||
import java.io.InputStream;
|
||||
|
||||
/**
|
||||
* Classifies a user's text input into a predefined category using a Gemini
|
||||
* model.
|
||||
* It analyzes the user's query in the context of a conversation history and any
|
||||
* relevant notifications to determine if the message is part of the ongoing
|
||||
* dialogue
|
||||
* or an interruption. The classification is used to route the request to the
|
||||
* appropriate handler (e.g., a standard conversational flow or a specific
|
||||
* notification processor).
|
||||
*/
|
||||
@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 (InputStream inputStream = getClass().getClassLoader().getResourceAsStream(promptFilePath)) {
|
||||
if (inputStream == null) {
|
||||
throw new IOException("Resource not found: " + promptFilePath);
|
||||
}
|
||||
byte[] fileBytes = inputStream.readAllBytes();
|
||||
this.promptTemplate = new String(fileBytes, StandardCharsets.UTF_8);
|
||||
logger.info("Successfully loaded prompt template from '" + promptFilePath + "'.");
|
||||
} catch (IOException e) {
|
||||
logger.error("Failed to load prompt template from '" + promptFilePath
|
||||
+ "'. Please ensure the file exists. Error: " + e.getMessage());
|
||||
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,
|
||||
conversationHistory,
|
||||
interruptingNotification,
|
||||
queryInputText
|
||||
);
|
||||
|
||||
logger.debug("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.strip().toUpperCase() : "") {
|
||||
case CATEGORY_CONVERSATION -> {
|
||||
logger.info("Classified as {}.", CATEGORY_CONVERSATION);
|
||||
yield CATEGORY_CONVERSATION;
|
||||
}
|
||||
case CATEGORY_NOTIFICATION -> {
|
||||
logger.info("Classified as {}.", CATEGORY_NOTIFICATION);
|
||||
yield CATEGORY_NOTIFICATION;
|
||||
}
|
||||
default -> {
|
||||
logger.warn("Gemini returned an unrecognised classification or was null/blank: '{}'. Expected '{}' or '{}'. Returning {}.",
|
||||
geminiResponse, CATEGORY_CONVERSATION, CATEGORY_NOTIFICATION, 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* 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.service.notification.MemoryStoreNotificationService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Resolves the conversational context of a user query by leveraging a large
|
||||
* language model (LLM). This service evaluates a user's question in the context
|
||||
* of a specific notification and conversation history, then decides if the
|
||||
* query
|
||||
* can be answered by the LLM or if it should be handled by a standard
|
||||
* Dialogflow agent.
|
||||
* The class loads an LLM prompt from an external file and dynamically
|
||||
* formats it with a user's query and other context to drive its decision-making
|
||||
* process.
|
||||
*/
|
||||
@Service
|
||||
public class NotificationContextResolver {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(NotificationContextResolver.class);
|
||||
private final GeminiClientService geminiService;
|
||||
|
||||
@Value("${notificationcontext.geminimodel:gemini-2.0-flash-001}")
|
||||
private String geminiModelNameResolver;
|
||||
|
||||
@Value("${notificationcontext.temperature:0.1f}")
|
||||
private Float resolverTemperature;
|
||||
|
||||
@Value("${notificationcontext.maxOutputTokens:1024}")
|
||||
private Integer resolverMaxOutputTokens;
|
||||
|
||||
@Value("${notificationcontext.topP:0.1f}")
|
||||
private Float resolverTopP;
|
||||
|
||||
@Value("${notificationcontext.prompt:prompts/notification_context_resolver.txt}")
|
||||
private String promptFilePath;
|
||||
|
||||
public static final String CATEGORY_DIALOGFLOW = "DIALOGFLOW";
|
||||
|
||||
private String promptTemplate;
|
||||
|
||||
public NotificationContextResolver(GeminiClientService geminiService,
|
||||
MemoryStoreNotificationService memoryStoreNotificationService) {
|
||||
this.geminiService = Objects.requireNonNull(geminiService,
|
||||
"GeminiClientService cannot be null for NotificationContextResolver.");
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
public void loadPromptTemplate() {
|
||||
try (InputStream inputStream = getClass().getClassLoader().getResourceAsStream(promptFilePath)) {
|
||||
if (inputStream == null) {
|
||||
throw new IOException("Resource not found: " + promptFilePath);
|
||||
}
|
||||
byte[] fileBytes = inputStream.readAllBytes();
|
||||
this.promptTemplate = new String(fileBytes, StandardCharsets.UTF_8);
|
||||
logger.info("Successfully loaded prompt template from '" + promptFilePath + "'.");
|
||||
} catch (IOException e) {
|
||||
logger.error("Failed to load prompt template from '" + promptFilePath
|
||||
+ "'. Please ensure the file exists. Error: " + e.getMessage());
|
||||
throw new IllegalStateException("Could not load prompt template.", e);
|
||||
}
|
||||
}
|
||||
|
||||
public String resolveContext(String queryInputText, String notificationsJson, String conversationJson,
|
||||
String metadata, String userId, String sessionId, String userPhoneNumber) {
|
||||
logger.debug("resolveContext -> queryInputText: {}, notificationsJson: {}, conversationJson: {}, metadata: {}",
|
||||
queryInputText, notificationsJson, conversationJson, metadata);
|
||||
if (queryInputText == null || queryInputText.isBlank()) {
|
||||
logger.warn("Query input text for context resolution is null or blank.", CATEGORY_DIALOGFLOW);
|
||||
return CATEGORY_DIALOGFLOW;
|
||||
}
|
||||
|
||||
String notificationContent = (notificationsJson != null && !notificationsJson.isBlank()) ? notificationsJson
|
||||
: "No metadata in notification.";
|
||||
|
||||
String conversationHistory = (conversationJson != null && !conversationJson.isBlank()) ? conversationJson
|
||||
: "No conversation history.";
|
||||
|
||||
String contextPrompt = String.format(
|
||||
this.promptTemplate,
|
||||
conversationHistory,
|
||||
notificationContent,
|
||||
metadata,
|
||||
queryInputText);
|
||||
|
||||
logger.debug("Sending context resolution request to Gemini for input (first 100 chars): '{}'...",
|
||||
queryInputText.substring(0, Math.min(queryInputText.length(), 100)));
|
||||
|
||||
try {
|
||||
String geminiResponse = geminiService.generateContent(
|
||||
contextPrompt,
|
||||
resolverTemperature,
|
||||
resolverMaxOutputTokens,
|
||||
geminiModelNameResolver,
|
||||
resolverTopP);
|
||||
|
||||
if (geminiResponse != null && !geminiResponse.isBlank()) {
|
||||
if (geminiResponse.trim().equalsIgnoreCase(CATEGORY_DIALOGFLOW)) {
|
||||
logger.debug("Resolved to {}. Input: '{}'", CATEGORY_DIALOGFLOW, queryInputText);
|
||||
return CATEGORY_DIALOGFLOW;
|
||||
} else {
|
||||
logger.debug("Resolved to a specific response. Input: '{}'", queryInputText);
|
||||
return geminiResponse;
|
||||
}
|
||||
} else {
|
||||
logger.warn("Gemini returned a null or blank response",
|
||||
queryInputText, CATEGORY_DIALOGFLOW);
|
||||
return CATEGORY_DIALOGFLOW;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("An error occurred during Gemini content generation for context resolution.", e);
|
||||
return CATEGORY_DIALOGFLOW;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* 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.dto.dialogflow.conversation.ConversationMessageDTO;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.domain.Range;
|
||||
import org.springframework.data.redis.core.ReactiveRedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
|
||||
/**
|
||||
Service for managing the lifecycle and data hygiene of conversation histories stored in MemoryStore.
|
||||
It encapsulates the logic for pruning conversation logs to enforce data retention policies.
|
||||
Its primary function, pruneHistory, operates on a Redis Sorted Set (ZSET) for a given session,
|
||||
performing two main tasks:
|
||||
|
||||
1) removing all messages older than a configurable time limit (e.g., 30 days)
|
||||
based on their timestamp score,
|
||||
|
||||
2) trimming the remaining set to a maximum message count
|
||||
(e.g., 60) by removing the oldest entries, all within a reactive programming context.
|
||||
*/
|
||||
@Service
|
||||
public class ConversationHistoryService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(ConversationHistoryService.class);
|
||||
|
||||
private static final String MESSAGES_KEY_PREFIX = "conversation:messages:";
|
||||
|
||||
private final ReactiveRedisTemplate<String, ConversationMessageDTO> messageRedisTemplate;
|
||||
|
||||
@Value("${conversation.context.message.limit:60}")
|
||||
private int messageLimit;
|
||||
|
||||
@Value("${conversation.context.days.limit:30}")
|
||||
private int daysLimit;
|
||||
|
||||
@Autowired
|
||||
public ConversationHistoryService(ReactiveRedisTemplate<String, ConversationMessageDTO> messageRedisTemplate) {
|
||||
this.messageRedisTemplate = messageRedisTemplate;
|
||||
}
|
||||
|
||||
public Mono<Void> pruneHistory(String sessionId) {
|
||||
logger.info("Pruning history for sessionId: {}", sessionId);
|
||||
String messagesKey = MESSAGES_KEY_PREFIX + sessionId;
|
||||
|
||||
Instant cutoff = Instant.now().minus(daysLimit, ChronoUnit.DAYS);
|
||||
Range<Double> scoreRange = Range.of(Range.Bound.inclusive(0d), Range.Bound.inclusive((double) cutoff.toEpochMilli()));
|
||||
logger.info("Removing messages older than {} for sessionId: {}", cutoff, sessionId);
|
||||
Mono<Long> removeByScore = messageRedisTemplate.opsForZSet().removeRangeByScore(messagesKey, scoreRange)
|
||||
.doOnSuccess(count -> logger.info("Removed {} old messages for sessionId: {}", count, sessionId));
|
||||
|
||||
|
||||
Mono<Long> trimToSize = messageRedisTemplate.opsForZSet().size(messagesKey)
|
||||
.flatMap(size -> {
|
||||
if (size > messageLimit) {
|
||||
logger.info("Current message count {} exceeds limit {} for sessionId: {}. Trimming...", size, messageLimit, sessionId);
|
||||
Range<Long> rankRange = Range.of(Range.Bound.inclusive(0L), Range.Bound.inclusive(size - messageLimit - 1));
|
||||
return messageRedisTemplate.opsForZSet().removeRange(messagesKey, rankRange)
|
||||
.doOnSuccess(count -> logger.info("Trimmed {} messages for sessionId: {}", count, sessionId));
|
||||
}
|
||||
return Mono.just(0L);
|
||||
});
|
||||
return removeByScore.then(trimToSize).then()
|
||||
.doOnSuccess(v -> logger.info("Successfully pruned history for sessionId: {}", sessionId))
|
||||
.doOnError(e -> logger.error("Error pruning history for sessionId: {}", sessionId, e));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
/*
|
||||
* 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.dto.dialogflow.base.DetectIntentRequestDTO;
|
||||
import com.example.dto.dialogflow.base.DetectIntentResponseDTO;
|
||||
import com.example.dto.dialogflow.conversation.*;
|
||||
import com.example.dto.dialogflow.notification.EventInputDTO;
|
||||
import com.example.dto.dialogflow.notification.NotificationDTO;
|
||||
import com.example.mapper.conversation.ConversationEntryMapper;
|
||||
import com.example.mapper.conversation.ExternalConvRequestMapper;
|
||||
import com.example.mapper.messagefilter.ConversationContextMapper;
|
||||
import com.example.mapper.messagefilter.NotificationContextMapper;
|
||||
import com.example.service.base.DialogflowClientService;
|
||||
import com.example.service.base.MessageEntryFilter;
|
||||
import com.example.service.base.NotificationContextResolver;
|
||||
import com.example.service.notification.MemoryStoreNotificationService;
|
||||
import com.example.service.quickreplies.QuickRepliesManagerService;
|
||||
import com.example.service.llm.LlmResponseTunerService;
|
||||
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.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
Service acting as the central orchestrator for managing user conversations.
|
||||
It integrates Data Loss Prevention (DLP) for message obfuscation, multi-stage routing,
|
||||
hybrid AI logic, and a reactive write-back persistence layer for conversation history.
|
||||
|
||||
Routes traffic based on session context:
|
||||
If a 'pantallaContexto' (screen context) is present, it delegates to the QuickRepliesManagerService.
|
||||
Otherwise, it uses a Gemini-based MessageEntryFilter to classify the message against
|
||||
active notifications and history, routing to one of two main flows:
|
||||
a) Standard Conversation (proceedWithConversation): Handles regular dialogue,
|
||||
|
||||
managing 30-minute session timeouts and injecting conversation history parameter to Dialogflow.
|
||||
|
||||
b) Notifications (startNotificationConversation):
|
||||
It first asks a Gemini model (NotificationContextResolver) if it can answer the
|
||||
query. If yes, it saves the LLM's response and sends an 'LLM_RESPONSE_PROCESSED'
|
||||
event to Dialogflow. If no ("DIALOGFLOW"), it sends the user's original text
|
||||
to Dialogflow for intent matching.
|
||||
|
||||
All conversation turns (user, agent, and LLM) are persisted using a reactive write-back
|
||||
cache pattern, saving to Memorystore (Redis) first and then asynchronously to a
|
||||
Firestore subcollection data model (persistConversationTurn).
|
||||
*/
|
||||
@Service
|
||||
public class ConversationManagerService {
|
||||
private static final Logger logger = LoggerFactory.getLogger(ConversationManagerService.class);
|
||||
|
||||
private static final long SESSION_RESET_THRESHOLD_MINUTES = 30;
|
||||
private static final long SCREEN_CONTEXT_TIMEOUT_MINUTES = 10; // fix for the quick replies screen
|
||||
private static final String CONV_HISTORY_PARAM = "conversation_history";
|
||||
private static final String HISTORY_PARAM = "historial";
|
||||
private final ExternalConvRequestMapper externalRequestToDialogflowMapper;
|
||||
private final DialogflowClientService dialogflowServiceClient;
|
||||
private final FirestoreConversationService firestoreConversationService;
|
||||
private final MemoryStoreConversationService memoryStoreConversationService;
|
||||
private final QuickRepliesManagerService quickRepliesManagerService;
|
||||
private final MessageEntryFilter messageEntryFilter;
|
||||
private final MemoryStoreNotificationService memoryStoreNotificationService;
|
||||
private final NotificationContextMapper notificationContextMapper;
|
||||
private final ConversationContextMapper conversationContextMapper;
|
||||
private final DataLossPrevention dataLossPrevention;
|
||||
private final String dlpTemplateCompleteFlow;
|
||||
|
||||
private final NotificationContextResolver notificationContextResolver;
|
||||
private final LlmResponseTunerService llmResponseTunerService;
|
||||
private final ConversationEntryMapper conversationEntryMapper;
|
||||
|
||||
public ConversationManagerService(
|
||||
DialogflowClientService dialogflowServiceClient,
|
||||
FirestoreConversationService firestoreConversationService,
|
||||
MemoryStoreConversationService memoryStoreConversationService,
|
||||
ExternalConvRequestMapper externalRequestToDialogflowMapper,
|
||||
QuickRepliesManagerService quickRepliesManagerService,
|
||||
MessageEntryFilter messageEntryFilter,
|
||||
MemoryStoreNotificationService memoryStoreNotificationService,
|
||||
NotificationContextMapper notificationContextMapper,
|
||||
ConversationContextMapper conversationContextMapper,
|
||||
DataLossPrevention dataLossPrevention,
|
||||
NotificationContextResolver notificationContextResolver,
|
||||
LlmResponseTunerService llmResponseTunerService,
|
||||
ConversationEntryMapper conversationEntryMapper,
|
||||
@Value("${google.cloud.dlp.dlpTemplateCompleteFlow}") String dlpTemplateCompleteFlow) {
|
||||
this.dialogflowServiceClient = dialogflowServiceClient;
|
||||
this.firestoreConversationService = firestoreConversationService;
|
||||
this.memoryStoreConversationService = memoryStoreConversationService;
|
||||
this.externalRequestToDialogflowMapper = externalRequestToDialogflowMapper;
|
||||
this.quickRepliesManagerService = quickRepliesManagerService;
|
||||
this.messageEntryFilter = messageEntryFilter;
|
||||
this.memoryStoreNotificationService = memoryStoreNotificationService;
|
||||
this.notificationContextMapper = notificationContextMapper;
|
||||
this.conversationContextMapper = conversationContextMapper;
|
||||
this.dataLossPrevention = dataLossPrevention;
|
||||
this.dlpTemplateCompleteFlow = dlpTemplateCompleteFlow;
|
||||
this.notificationContextResolver = notificationContextResolver;
|
||||
this.llmResponseTunerService = llmResponseTunerService;
|
||||
this.conversationEntryMapper = conversationEntryMapper;
|
||||
|
||||
}
|
||||
|
||||
public Mono<DetectIntentResponseDTO> manageConversation(ExternalConvRequestDTO externalrequest) {
|
||||
return dataLossPrevention.getObfuscatedString(externalrequest.message(), dlpTemplateCompleteFlow)
|
||||
.flatMap(obfuscatedMessage -> {
|
||||
ExternalConvRequestDTO obfuscatedRequest = new ExternalConvRequestDTO(
|
||||
obfuscatedMessage,
|
||||
externalrequest.user(),
|
||||
externalrequest.channel(),
|
||||
externalrequest.tipo(),
|
||||
externalrequest.pantallaContexto());
|
||||
return memoryStoreConversationService.getSessionByTelefono(externalrequest.user().telefono())
|
||||
.flatMap(session -> {
|
||||
boolean isContextStale = false;
|
||||
if (session.lastModified() != null) {
|
||||
long minutesSinceLastUpdate = java.time.Duration.between(session.lastModified(), java.time.Instant.now()).toMinutes();
|
||||
if (minutesSinceLastUpdate > SCREEN_CONTEXT_TIMEOUT_MINUTES) {
|
||||
isContextStale = true;
|
||||
}
|
||||
}
|
||||
if (session != null && session.pantallaContexto() != null && !session.pantallaContexto().isBlank() && !isContextStale) {
|
||||
logger.info("Detected 'pantallaContexto' in session. Delegating to QuickRepliesManagerService.");
|
||||
return quickRepliesManagerService.manageConversation(obfuscatedRequest);
|
||||
}
|
||||
// Remove the old QR and continue as normal conversation.
|
||||
if (isContextStale && session.pantallaContexto() != null) {
|
||||
logger.info("Detected STALE 'pantallaContexto'. Ignoring and proceeding with normal flow.");
|
||||
}
|
||||
return continueManagingConversation(obfuscatedRequest);
|
||||
})
|
||||
.switchIfEmpty(continueManagingConversation(obfuscatedRequest));
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<DetectIntentResponseDTO> continueManagingConversation(ExternalConvRequestDTO externalrequest) {
|
||||
final DetectIntentRequestDTO request;
|
||||
try {
|
||||
request = externalRequestToDialogflowMapper.mapExternalRequestToDetectIntentRequest(externalrequest);
|
||||
logger.debug("Successfully pre-mapped ExternalRequestDTO to DetectIntentRequestDTO");
|
||||
} catch (IllegalArgumentException e) {
|
||||
logger.error("Error during pre-mapping: {}", e.getMessage());
|
||||
return Mono.error(new IllegalArgumentException(
|
||||
"Failed to process external request due to mapping error: " + e.getMessage(), e));
|
||||
}
|
||||
|
||||
Map<String, Object> params = Optional.ofNullable(request.queryParams())
|
||||
.map(queryParamsDTO -> queryParamsDTO.parameters())
|
||||
.orElse(Collections.emptyMap());
|
||||
|
||||
Object telefonoObj = params.get("telefono");
|
||||
if (!(telefonoObj instanceof String) || ((String) telefonoObj).isBlank()) {
|
||||
logger.error("Critical error: parameter is missing, not a String, or blank after mapping.");
|
||||
return Mono.error(new IllegalStateException("Internal error: parameter is invalid."));
|
||||
}
|
||||
|
||||
String primaryPhoneNumber = (String) telefonoObj;
|
||||
String resolvedUserId = params.get("usuario_id") instanceof String ? (String) params.get("usuario_id") : null;
|
||||
String userMessageText = request.queryInput().text().text();
|
||||
final ConversationContext context = new ConversationContext(resolvedUserId, null, userMessageText, primaryPhoneNumber);
|
||||
|
||||
return continueConversationFlow(context, request);
|
||||
}
|
||||
|
||||
private Mono<DetectIntentResponseDTO> continueConversationFlow(ConversationContext context,
|
||||
DetectIntentRequestDTO request) {
|
||||
final String userId = context.userId();
|
||||
final String userMessageText = context.userMessageText();
|
||||
final String userPhoneNumber = context.primaryPhoneNumber();
|
||||
|
||||
if (userPhoneNumber == null || userPhoneNumber.isBlank()) {
|
||||
logger.warn("No phone number provided in request. Cannot manage conversation session without it.");
|
||||
return Mono
|
||||
.error(new IllegalArgumentException("Phone number is required to manage conversation sessions."));
|
||||
}
|
||||
|
||||
logger.info("Primary Check (MemoryStore): Looking up session for phone number: {}", userPhoneNumber);
|
||||
return memoryStoreConversationService.getSessionByTelefono(userPhoneNumber)
|
||||
.flatMap(session -> handleMessageClassification(context, request, session))
|
||||
.switchIfEmpty(Mono.defer(() -> {
|
||||
logger.info("No session found in MemoryStore. Performing full lookup to Firestore.");
|
||||
return fullLookupAndProcess(null, request, userId, userMessageText, userPhoneNumber);
|
||||
}))
|
||||
.onErrorResume(e -> {
|
||||
logger.error("Overall error handling conversation in ConversationManagerService: {}",
|
||||
e.getMessage(), e);
|
||||
return Mono
|
||||
.error(new RuntimeException("Failed to process conversation due to an internal error.", e));
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<DetectIntentResponseDTO> handleMessageClassification(ConversationContext context,
|
||||
DetectIntentRequestDTO request, ConversationSessionDTO session) {
|
||||
final String userPhoneNumber = context.primaryPhoneNumber();
|
||||
final String userMessageText = context.userMessageText();
|
||||
|
||||
return memoryStoreNotificationService.getNotificationIdForPhone(userPhoneNumber)
|
||||
.flatMap(notificationId -> memoryStoreNotificationService.getCachedNotificationSession(notificationId))
|
||||
.map(notificationSession -> notificationSession.notificaciones().stream()
|
||||
.filter(notification -> "active".equalsIgnoreCase(notification.status()))
|
||||
.max(java.util.Comparator.comparing(NotificationDTO::timestampCreacion))
|
||||
.orElse(null))
|
||||
.filter(Objects::nonNull)
|
||||
.flatMap((NotificationDTO notification) -> {
|
||||
return memoryStoreConversationService.getMessages(session.sessionId()).collectList()
|
||||
.map(conversationContextMapper::toTextFromMessages)
|
||||
.defaultIfEmpty("")
|
||||
.flatMap(conversationHistory -> {
|
||||
String notificationText = notificationContextMapper.toText(notification);
|
||||
String classification = messageEntryFilter.classifyMessage(userMessageText, notificationText,
|
||||
conversationHistory);
|
||||
if (MessageEntryFilter.CATEGORY_NOTIFICATION.equals(classification)) {
|
||||
return startNotificationConversation(context, request, notification);
|
||||
} else {
|
||||
return proceedWithConversation(context, request, session);
|
||||
}
|
||||
});
|
||||
})
|
||||
.switchIfEmpty(proceedWithConversation(context, request, session));
|
||||
}
|
||||
|
||||
private Mono<DetectIntentResponseDTO> proceedWithConversation(ConversationContext context,
|
||||
DetectIntentRequestDTO request, ConversationSessionDTO session) {
|
||||
Instant now = Instant.now();
|
||||
if (Duration.between(session.lastModified(), now).toMinutes() < SESSION_RESET_THRESHOLD_MINUTES) {
|
||||
logger.info("Recent Session Found: Session {} is within the 30-minute threshold. Proceeding to Dialogflow.",
|
||||
session.sessionId());
|
||||
return processDialogflowRequest(session, request, context.userId(), context.userMessageText(),
|
||||
context.primaryPhoneNumber(), false);
|
||||
} else {
|
||||
|
||||
|
||||
logger.info(
|
||||
"Old Session Found: Session {} is older than the 30-minute threshold.",
|
||||
session.sessionId());
|
||||
// Generar un nuevo ID de sesión
|
||||
String newSessionId = SessionIdGenerator.generateStandardSessionId();
|
||||
logger.info("Creating new session {} from old session {} due to timeout.", newSessionId, session.sessionId());
|
||||
|
||||
// Crear un nuevo DTO de sesión basado en la antigua, pero con el nuevo ID
|
||||
ConversationSessionDTO newSession = ConversationSessionDTO.create(newSessionId, context.userId(), context.primaryPhoneNumber());
|
||||
|
||||
return memoryStoreConversationService.getMessages(session.sessionId())
|
||||
.collectList()
|
||||
// Adding use the TextWithLimits to truncate according to business rule 30 days/60 messages
|
||||
.map(messages -> conversationContextMapper.toTextWithLimits(session, messages))
|
||||
.defaultIfEmpty("")
|
||||
.flatMap(conversationHistory -> {
|
||||
// Inject historial (max 60 msgs / 30 días / 50KB)
|
||||
DetectIntentRequestDTO newRequest = request.withParameter(CONV_HISTORY_PARAM, conversationHistory);
|
||||
return processDialogflowRequest(newSession, newRequest, context.userId(), context.userMessageText(),
|
||||
context.primaryPhoneNumber(), false);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private Mono<DetectIntentResponseDTO> fullLookupAndProcess(ConversationSessionDTO oldSession,
|
||||
DetectIntentRequestDTO request, String userId, String userMessageText, String userPhoneNumber) {
|
||||
return firestoreConversationService.getSessionByTelefono(userPhoneNumber)
|
||||
.flatMap(session -> firestoreConversationService.getMessages(session.sessionId()).collectList()
|
||||
.map(conversationContextMapper::toTextFromMessages)
|
||||
.defaultIfEmpty("")
|
||||
.flatMap(conversationHistory -> {
|
||||
String newSessionId = SessionIdGenerator.generateStandardSessionId();
|
||||
logger.info("Creating new session {} after full lookup.", newSessionId);
|
||||
ConversationSessionDTO newSession = ConversationSessionDTO.create(newSessionId, userId,
|
||||
userPhoneNumber);
|
||||
DetectIntentRequestDTO newRequest = request.withParameter(CONV_HISTORY_PARAM, conversationHistory);
|
||||
return processDialogflowRequest(newSession, newRequest, userId, userMessageText, userPhoneNumber,
|
||||
true);
|
||||
}))
|
||||
.switchIfEmpty(Mono.defer(() -> {
|
||||
String newSessionId = SessionIdGenerator.generateStandardSessionId();
|
||||
logger.info("Creating new session {} after full lookup.", newSessionId);
|
||||
ConversationSessionDTO newSession = ConversationSessionDTO.create(newSessionId, userId,
|
||||
userPhoneNumber);
|
||||
return processDialogflowRequest(newSession, request, userId, userMessageText, userPhoneNumber,
|
||||
true);
|
||||
}));
|
||||
}
|
||||
|
||||
private Mono<DetectIntentResponseDTO> processDialogflowRequest(ConversationSessionDTO session,
|
||||
DetectIntentRequestDTO request, String userId, String userMessageText, String userPhoneNumber,
|
||||
boolean newSession) {
|
||||
final String finalSessionId = session.sessionId();
|
||||
|
||||
ConversationEntryDTO userEntry = ConversationEntryDTO.forUser(userMessageText);
|
||||
|
||||
return this.persistConversationTurn(session, conversationEntryMapper.toConversationMessageDTO(userEntry))
|
||||
.doOnSuccess(v -> logger.debug(
|
||||
"User entry successfully persisted for session {}. Proceeding to Dialogflow...",
|
||||
finalSessionId))
|
||||
.doOnError(e -> logger.error("Error during user entry persistence for session {}: {}", finalSessionId,
|
||||
e.getMessage(), e))
|
||||
.then(Mono.defer(() -> dialogflowServiceClient.detectIntent(finalSessionId, request)
|
||||
.flatMap(response -> {
|
||||
logger.debug(
|
||||
"RTest eceived Dialogflow CX response for session {}. Initiating agent response persistence.",
|
||||
finalSessionId);
|
||||
ConversationEntryDTO agentEntry = ConversationEntryDTO.forAgent(response.queryResult());
|
||||
return persistConversationTurn(session, conversationEntryMapper.toConversationMessageDTO(agentEntry))
|
||||
.thenReturn(response);
|
||||
})
|
||||
.doOnError(
|
||||
error -> logger.error("Overall error during conversation management for session {}: {}",
|
||||
finalSessionId, error.getMessage(), error))));
|
||||
}
|
||||
|
||||
public Mono<DetectIntentResponseDTO> startNotificationConversation(ConversationContext context,
|
||||
DetectIntentRequestDTO request, NotificationDTO notification) {
|
||||
final String userId = context.userId();
|
||||
final String userMessageText = context.userMessageText();
|
||||
final String userPhoneNumber = context.primaryPhoneNumber();
|
||||
|
||||
return memoryStoreConversationService.getSessionByTelefono(userPhoneNumber)
|
||||
.switchIfEmpty(Mono.defer(() -> {
|
||||
String newSessionId = SessionIdGenerator.generateStandardSessionId();
|
||||
logger.warn("No existing conversation session found for notification reply on phone {}. This is unexpected. Creating new session: {}",
|
||||
userPhoneNumber, newSessionId);
|
||||
return Mono.just(ConversationSessionDTO.create(newSessionId, userId, userPhoneNumber));
|
||||
}))
|
||||
.flatMap(session -> {
|
||||
final String sessionId = session.sessionId();
|
||||
return memoryStoreConversationService.getMessages(sessionId).collectList()
|
||||
.map(conversationContextMapper::toTextFromMessages)
|
||||
.defaultIfEmpty("")
|
||||
.flatMap(conversationHistory -> {
|
||||
String notificationText = notificationContextMapper.toText(notification);
|
||||
|
||||
Map<String, Object> filteredParams = notification.parametros().entrySet().stream()
|
||||
.filter(entry -> entry.getKey().startsWith("notification_po_"))
|
||||
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
|
||||
|
||||
String resolvedContext = notificationContextResolver.resolveContext(userMessageText,
|
||||
notificationText, conversationHistory, filteredParams.toString(), userId, sessionId,
|
||||
userPhoneNumber);
|
||||
if (!resolvedContext.trim().toUpperCase().contains(NotificationContextResolver.CATEGORY_DIALOGFLOW)) {
|
||||
String uuid = UUID.randomUUID().toString();
|
||||
llmResponseTunerService.setValue(uuid, resolvedContext).subscribe();
|
||||
|
||||
ConversationEntryDTO userEntry = ConversationEntryDTO.forUser(userMessageText,
|
||||
notification.parametros());
|
||||
ConversationEntryDTO llmEntry = ConversationEntryDTO.forLlmConversation(resolvedContext,
|
||||
notification.parametros());
|
||||
|
||||
return persistConversationTurn(session, conversationEntryMapper.toConversationMessageDTO(userEntry))
|
||||
.then(persistConversationTurn(session, conversationEntryMapper.toConversationMessageDTO(llmEntry)))
|
||||
.then(Mono.defer(() -> {
|
||||
EventInputDTO eventInput = new EventInputDTO("LLM_RESPONSE_PROCESSED");
|
||||
QueryInputDTO queryInput = new QueryInputDTO(null, eventInput,
|
||||
request.queryInput().languageCode());
|
||||
DetectIntentRequestDTO newRequest = new DetectIntentRequestDTO(queryInput,
|
||||
request.queryParams())
|
||||
.withParameter("llm_reponse_uuid", uuid);
|
||||
|
||||
return dialogflowServiceClient.detectIntent(sessionId, newRequest)
|
||||
.flatMap(response -> {
|
||||
ConversationEntryDTO agentEntry = ConversationEntryDTO
|
||||
.forAgent(response.queryResult());
|
||||
return persistConversationTurn(session, conversationEntryMapper.toConversationMessageDTO(agentEntry))
|
||||
.thenReturn(response);
|
||||
});
|
||||
}));
|
||||
} else {
|
||||
ConversationEntryDTO userEntry = ConversationEntryDTO.forUser(userMessageText,
|
||||
notification.parametros());
|
||||
|
||||
DetectIntentRequestDTO finalRequest;
|
||||
Instant now = Instant.now();
|
||||
if (Duration.between(session.lastModified(), now).toMinutes() < SESSION_RESET_THRESHOLD_MINUTES) {
|
||||
finalRequest = request.withParameters(notification.parametros());
|
||||
} else {
|
||||
finalRequest = request.withParameter(CONV_HISTORY_PARAM, conversationHistory)
|
||||
.withParameters(notification.parametros());
|
||||
}
|
||||
return persistConversationTurn(session, conversationEntryMapper.toConversationMessageDTO(userEntry))
|
||||
.then(dialogflowServiceClient.detectIntent(sessionId, finalRequest)
|
||||
.flatMap(response -> {
|
||||
ConversationEntryDTO agentEntry = ConversationEntryDTO
|
||||
.forAgent(response.queryResult());
|
||||
return persistConversationTurn(session, conversationEntryMapper.toConversationMessageDTO(agentEntry))
|
||||
.thenReturn(response);
|
||||
}));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<Void> persistConversationTurn(ConversationSessionDTO session, ConversationMessageDTO message) {
|
||||
logger.debug("Starting Write-Back persistence for session {}. Type: {}. Writing to Redis first.", session.sessionId(),
|
||||
message.type().name());
|
||||
ConversationSessionDTO updatedSession = session.withLastMessage(message.text());
|
||||
return memoryStoreConversationService.saveSession(updatedSession)
|
||||
.then(memoryStoreConversationService.saveMessage(session.sessionId(), message))
|
||||
.doOnSuccess(v -> logger.info(
|
||||
"Entry saved to Redis for session {}. Type: {}. Kicking off async Firestore write-back.",
|
||||
session.sessionId(), message.type().name()))
|
||||
.then(firestoreConversationService.saveSession(updatedSession)
|
||||
.then(firestoreConversationService.saveMessage(session.sessionId(), message))
|
||||
.doOnSuccess(fsVoid -> logger.debug(
|
||||
"Asynchronously (Write-Back): Entry successfully saved to Firestore for session {}. Type: {}.",
|
||||
session.sessionId(), message.type().name()))
|
||||
.doOnError(fsError -> logger.error(
|
||||
"Asynchronously (Write-Back): Failed to save entry to Firestore for session {}. Type: {}: {}",
|
||||
session.sessionId(), message.type().name(), fsError.getMessage(), fsError)))
|
||||
.doOnError(e -> logger.error("Error during primary Redis write for session {}. Type: {}: {}", session.sessionId(),
|
||||
message.type().name(), e.getMessage(), e));
|
||||
}
|
||||
}
|
||||
@@ -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.service.conversation;
|
||||
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
public interface DataLossPrevention {
|
||||
Mono<String> getObfuscatedString(String textToInspect, String templateId);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* 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.google.api.core.ApiFuture;
|
||||
import com.google.api.core.ApiFutureCallback;
|
||||
import com.google.api.core.ApiFutures;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import com.google.cloud.dlp.v2.DlpServiceClient;
|
||||
import com.google.privacy.dlp.v2.ByteContentItem;
|
||||
import com.google.privacy.dlp.v2.ContentItem;
|
||||
import com.google.privacy.dlp.v2.InspectConfig;
|
||||
import com.google.privacy.dlp.v2.InspectContentRequest;
|
||||
import com.google.privacy.dlp.v2.InspectContentResponse;
|
||||
import com.google.privacy.dlp.v2.Likelihood;
|
||||
import com.google.privacy.dlp.v2.LocationName;
|
||||
import com.google.protobuf.ByteString;
|
||||
import com.example.util.TextObfuscator;
|
||||
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
Implements a data loss prevention service by integrating with the
|
||||
Google Cloud Data Loss Prevention (DLP) API. This service is responsible for
|
||||
scanning a given text input to identify and obfuscate sensitive information based on
|
||||
a specified DLP template. If the DLP API detects sensitive findings, the
|
||||
original text is obfuscated to protect user data; otherwise, the original
|
||||
text is returned.
|
||||
*/
|
||||
@Service
|
||||
public class DataLossPreventionImpl implements DataLossPrevention {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(DataLossPreventionImpl.class);
|
||||
|
||||
private final String projectId;
|
||||
private final String location;
|
||||
private final DlpServiceClient dlpServiceClient;
|
||||
|
||||
public DataLossPreventionImpl(
|
||||
DlpServiceClient dlpServiceClient,
|
||||
@Value("${google.cloud.project}") String projectId,
|
||||
@Value("${google.cloud.location}") String location) {
|
||||
this.dlpServiceClient = dlpServiceClient;
|
||||
this.projectId = projectId;
|
||||
this.location = location;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<String> getObfuscatedString(String text, String templateId) {
|
||||
ByteContentItem byteContentItem = ByteContentItem.newBuilder()
|
||||
.setType(ByteContentItem.BytesType.TEXT_UTF8)
|
||||
.setData(ByteString.copyFromUtf8(text))
|
||||
.build();
|
||||
ContentItem contentItem = ContentItem.newBuilder().setByteItem(byteContentItem).build();
|
||||
|
||||
Likelihood minLikelihood = Likelihood.VERY_UNLIKELY;
|
||||
|
||||
InspectConfig.FindingLimits findingLimits = InspectConfig.FindingLimits.newBuilder().setMaxFindingsPerItem(0)
|
||||
.build();
|
||||
|
||||
InspectConfig inspectConfig = InspectConfig.newBuilder()
|
||||
.setMinLikelihood(minLikelihood)
|
||||
.setLimits(findingLimits)
|
||||
.setIncludeQuote(true)
|
||||
.build();
|
||||
|
||||
String inspectTemplateName = String.format("projects/%s/locations/%s/inspectTemplates/%s", projectId, location,
|
||||
templateId);
|
||||
InspectContentRequest request = InspectContentRequest.newBuilder()
|
||||
.setParent(LocationName.of(projectId, location).toString())
|
||||
.setInspectTemplateName(inspectTemplateName)
|
||||
.setInspectConfig(inspectConfig)
|
||||
.setItem(contentItem)
|
||||
.build();
|
||||
|
||||
ApiFuture<InspectContentResponse> futureResponse = dlpServiceClient.inspectContentCallable()
|
||||
.futureCall(request);
|
||||
|
||||
return Mono.<InspectContentResponse>create(
|
||||
sink -> ApiFutures.addCallback(
|
||||
futureResponse,
|
||||
new ApiFutureCallback<>() {
|
||||
@Override
|
||||
public void onFailure(Throwable t) {
|
||||
sink.error(t);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSuccess(InspectContentResponse result) {
|
||||
sink.success(result);
|
||||
}
|
||||
},
|
||||
Runnable::run))
|
||||
.map(response -> {
|
||||
logger.info("DLP {} Findings: {}", templateId, response.getResult().getFindingsCount());
|
||||
return response.getResult().getFindingsCount() > 0
|
||||
? TextObfuscator.obfuscate(response, text)
|
||||
: text;
|
||||
}).onErrorResume(e -> {
|
||||
e.printStackTrace();
|
||||
return Mono.just(text);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* 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.dto.dialogflow.conversation.ConversationMessageDTO;
|
||||
import com.example.dto.dialogflow.conversation.ConversationSessionDTO;
|
||||
import com.example.exception.FirestorePersistenceException;
|
||||
import com.example.mapper.conversation.ConversationMessageMapper;
|
||||
import com.example.mapper.conversation.FirestoreConversationMapper;
|
||||
import com.example.repository.FirestoreBaseRepository;
|
||||
import com.google.cloud.firestore.DocumentReference;
|
||||
import com.google.cloud.firestore.DocumentSnapshot;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
|
||||
@Service
|
||||
public class FirestoreConversationService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(FirestoreConversationService.class);
|
||||
private static final String CONVERSATION_COLLECTION_PATH_FORMAT = "artifacts/%s/conversations";
|
||||
private static final String MESSAGES_SUBCOLLECTION = "mensajes";
|
||||
private final FirestoreBaseRepository firestoreBaseRepository;
|
||||
private final FirestoreConversationMapper firestoreConversationMapper;
|
||||
private final ConversationMessageMapper conversationMessageMapper;
|
||||
|
||||
public FirestoreConversationService(FirestoreBaseRepository firestoreBaseRepository, FirestoreConversationMapper firestoreConversationMapper, ConversationMessageMapper conversationMessageMapper) {
|
||||
this.firestoreBaseRepository = firestoreBaseRepository;
|
||||
this.firestoreConversationMapper = firestoreConversationMapper;
|
||||
this.conversationMessageMapper = conversationMessageMapper;
|
||||
}
|
||||
|
||||
public Mono<Void> saveSession(ConversationSessionDTO session) {
|
||||
return Mono.fromRunnable(() -> {
|
||||
DocumentReference sessionDocRef = getSessionDocumentReference(session.sessionId());
|
||||
try {
|
||||
firestoreBaseRepository.setDocument(sessionDocRef, firestoreConversationMapper.createSessionMap(session));
|
||||
} catch (ExecutionException | InterruptedException e) {
|
||||
handleException(e, session.sessionId());
|
||||
}
|
||||
}).subscribeOn(Schedulers.boundedElastic()).then();
|
||||
}
|
||||
|
||||
public Mono<Void> saveMessage(String sessionId, ConversationMessageDTO message) {
|
||||
return Mono.fromRunnable(() -> {
|
||||
DocumentReference messageDocRef = getSessionDocumentReference(sessionId).collection(MESSAGES_SUBCOLLECTION).document();
|
||||
try {
|
||||
firestoreBaseRepository.setDocument(messageDocRef, conversationMessageMapper.toMap(message));
|
||||
} catch (ExecutionException | InterruptedException e) {
|
||||
handleException(e, sessionId);
|
||||
}
|
||||
}).subscribeOn(Schedulers.boundedElastic()).then();
|
||||
}
|
||||
|
||||
public Flux<ConversationMessageDTO> getMessages(String sessionId) {
|
||||
String messagesPath = getConversationCollectionPath() + "/" + sessionId + "/" + MESSAGES_SUBCOLLECTION;
|
||||
return firestoreBaseRepository.getDocuments(messagesPath)
|
||||
.map(documentSnapshot -> {
|
||||
if (documentSnapshot != null && documentSnapshot.exists()) {
|
||||
return conversationMessageMapper.fromMap(documentSnapshot.getData());
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter(Objects::nonNull);
|
||||
}
|
||||
|
||||
public Mono<ConversationSessionDTO> getConversationSession(String sessionId) {
|
||||
logger.info("Attempting to retrieve conversation session for session {}.", sessionId);
|
||||
return Mono.fromCallable(() -> {
|
||||
DocumentReference sessionDocRef = getSessionDocumentReference(sessionId);
|
||||
try {
|
||||
DocumentSnapshot documentSnapshot = firestoreBaseRepository.getDocumentSnapshot(sessionDocRef);
|
||||
if (documentSnapshot != null && documentSnapshot.exists()) {
|
||||
ConversationSessionDTO sessionDTO = firestoreConversationMapper.mapFirestoreDocumentToConversationSessionDTO(documentSnapshot);
|
||||
logger.info("Successfully retrieved and mapped conversation session for session {}.", sessionId);
|
||||
return sessionDTO;
|
||||
}
|
||||
logger.info("Conversation session not found for session {}.", sessionId);
|
||||
return null;
|
||||
} catch (InterruptedException | ExecutionException e) {
|
||||
handleException(e, sessionId);
|
||||
return null;
|
||||
}
|
||||
}).subscribeOn(Schedulers.boundedElastic());
|
||||
}
|
||||
|
||||
public Mono<ConversationSessionDTO> getSessionByTelefono(String userPhoneNumber) {
|
||||
return firestoreBaseRepository.getDocumentsByField(getConversationCollectionPath(), "telefono", userPhoneNumber)
|
||||
.map(documentSnapshot -> {
|
||||
if (documentSnapshot != null && documentSnapshot.exists()) {
|
||||
ConversationSessionDTO sessionDTO = firestoreConversationMapper.mapFirestoreDocumentToConversationSessionDTO(documentSnapshot);
|
||||
logger.info("Successfully retrieved and mapped conversation session for session {}.", sessionDTO.sessionId());
|
||||
return sessionDTO;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
public Mono<Void> deleteSession(String sessionId) {
|
||||
logger.info("Attempting to delete conversation session for session {}.", sessionId);
|
||||
return Mono.fromRunnable(() -> {
|
||||
DocumentReference sessionDocRef = getSessionDocumentReference(sessionId);
|
||||
try {
|
||||
firestoreBaseRepository.deleteDocumentAndSubcollections(sessionDocRef, MESSAGES_SUBCOLLECTION);
|
||||
logger.info("Successfully deleted conversation session for session {}.", sessionId);
|
||||
} catch (InterruptedException | ExecutionException e) {
|
||||
handleException(e, sessionId);
|
||||
}
|
||||
}).subscribeOn(Schedulers.boundedElastic()).then();
|
||||
}
|
||||
|
||||
private String getConversationCollectionPath() {
|
||||
return String.format(CONVERSATION_COLLECTION_PATH_FORMAT, firestoreBaseRepository.getAppId());
|
||||
}
|
||||
|
||||
private DocumentReference getSessionDocumentReference(String sessionId) {
|
||||
String collectionPath = getConversationCollectionPath();
|
||||
return firestoreBaseRepository.getDocumentReference(collectionPath, sessionId);
|
||||
}
|
||||
|
||||
private void handleException(Exception e, String sessionId) {
|
||||
if (e instanceof InterruptedException) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
logger.error("Error processing Firestore operation for session {}: {}", sessionId, e.getMessage(), e);
|
||||
throw new FirestorePersistenceException("Failed to process Firestore operation for session " + sessionId, e);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* 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.dto.dialogflow.conversation.ConversationMessageDTO;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.domain.Range;
|
||||
import org.springframework.data.redis.core.ReactiveRedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import com.example.dto.dialogflow.conversation.ConversationSessionDTO;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import java.time.Duration;
|
||||
|
||||
|
||||
@Service
|
||||
public class MemoryStoreConversationService {
|
||||
private static final Logger logger = LoggerFactory.getLogger(MemoryStoreConversationService.class);
|
||||
private static final String SESSION_KEY_PREFIX = "conversation:session:";
|
||||
private static final String PHONE_TO_SESSION_KEY_PREFIX = "conversation:phone_to_session:";
|
||||
private static final String MESSAGES_KEY_PREFIX = "conversation:messages:";
|
||||
private static final Duration SESSION_TTL = Duration.ofDays(30);
|
||||
private final ReactiveRedisTemplate<String, ConversationSessionDTO> redisTemplate;
|
||||
private final ReactiveRedisTemplate<String, String> stringRedisTemplate;
|
||||
private final ReactiveRedisTemplate<String, ConversationMessageDTO> messageRedisTemplate;
|
||||
private final ConversationHistoryService conversationHistoryService;
|
||||
|
||||
|
||||
@Autowired
|
||||
public MemoryStoreConversationService(
|
||||
ReactiveRedisTemplate<String, ConversationSessionDTO> redisTemplate,
|
||||
ReactiveRedisTemplate<String, String> stringRedisTemplate,
|
||||
ReactiveRedisTemplate<String, ConversationMessageDTO> messageRedisTemplate,
|
||||
ConversationHistoryService conversationHistoryService) {
|
||||
this.redisTemplate = redisTemplate;
|
||||
this.stringRedisTemplate = stringRedisTemplate;
|
||||
this.messageRedisTemplate = messageRedisTemplate;
|
||||
this.conversationHistoryService = conversationHistoryService;
|
||||
}
|
||||
|
||||
|
||||
public Mono<Void> saveMessage(String sessionId, ConversationMessageDTO message) {
|
||||
String messagesKey = MESSAGES_KEY_PREFIX + sessionId;
|
||||
double score = message.timestamp().toEpochMilli();
|
||||
return messageRedisTemplate.opsForZSet().add(messagesKey, message, score)
|
||||
.then(conversationHistoryService.pruneHistory(sessionId));
|
||||
}
|
||||
|
||||
public Mono<Void> saveSession(ConversationSessionDTO session) {
|
||||
String sessionKey = SESSION_KEY_PREFIX + session.sessionId();
|
||||
String phoneToSessionKey = PHONE_TO_SESSION_KEY_PREFIX + session.telefono();
|
||||
return redisTemplate.opsForValue().set(sessionKey, session, SESSION_TTL)
|
||||
.then(stringRedisTemplate.opsForValue().set(phoneToSessionKey, session.sessionId(), SESSION_TTL))
|
||||
.then();
|
||||
}
|
||||
|
||||
public Flux<ConversationMessageDTO> getMessages(String sessionId) {
|
||||
String messagesKey = MESSAGES_KEY_PREFIX + sessionId;
|
||||
return messageRedisTemplate.opsForZSet().range(messagesKey, Range.of(Range.Bound.inclusive(0L), Range.Bound.inclusive(-1L)));
|
||||
}
|
||||
|
||||
public Mono<ConversationSessionDTO> getSessionByTelefono(String telefono) {
|
||||
if (telefono == null || telefono.isBlank()) {
|
||||
return Mono.empty();
|
||||
}
|
||||
String phoneToSessionKey = PHONE_TO_SESSION_KEY_PREFIX + telefono;
|
||||
return stringRedisTemplate.opsForValue().get(phoneToSessionKey)
|
||||
.flatMap(sessionId -> redisTemplate.opsForValue().get(SESSION_KEY_PREFIX + sessionId))
|
||||
.doOnSuccess(session -> {
|
||||
if (session != null) {
|
||||
logger.info("Successfully retrieved session by phone number");
|
||||
} else {
|
||||
logger.info("No session found in Redis for phone number.");
|
||||
}
|
||||
})
|
||||
.doOnError(e -> logger.error("Error retrieving session by phone number: {}", e));
|
||||
}
|
||||
|
||||
public Mono<Void> updateSession(ConversationSessionDTO session) {
|
||||
String sessionKey = SESSION_KEY_PREFIX + session.sessionId();
|
||||
logger.info("Attempting to update session {} in Memorystore.", session.sessionId());
|
||||
return redisTemplate.opsForValue().set(sessionKey, session).then();
|
||||
}
|
||||
|
||||
public Mono<Void> deleteSession(String sessionId) {
|
||||
String sessionKey = SESSION_KEY_PREFIX + sessionId;
|
||||
String messagesKey = MESSAGES_KEY_PREFIX + sessionId;
|
||||
logger.info("Deleting session {} from Memorystore.", sessionId);
|
||||
|
||||
return redisTemplate.opsForValue().get(sessionKey)
|
||||
.flatMap(session -> {
|
||||
if (session != null && session.telefono() != null) {
|
||||
String phoneToSessionKey = PHONE_TO_SESSION_KEY_PREFIX + session.telefono();
|
||||
return redisTemplate.opsForValue().delete(sessionKey)
|
||||
.then(stringRedisTemplate.opsForValue().delete(phoneToSessionKey))
|
||||
.then(messageRedisTemplate.delete(messagesKey));
|
||||
} else {
|
||||
return redisTemplate.opsForValue().delete(sessionKey)
|
||||
.then(messageRedisTemplate.delete(messagesKey));
|
||||
}
|
||||
}).then();
|
||||
}
|
||||
}
|
||||
@@ -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.service.llm;
|
||||
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
public interface LlmResponseTunerService {
|
||||
Mono<String> getValue(String key);
|
||||
Mono<Void> setValue(String key, String value);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose.
|
||||
* Your use of it is subject to your agreement with Google.
|
||||
*/
|
||||
|
||||
package com.example.service.llm;
|
||||
|
||||
import java.time.Duration;
|
||||
import org.springframework.data.redis.core.ReactiveRedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@Service
|
||||
public class LlmResponseTunerServiceImpl implements LlmResponseTunerService {
|
||||
|
||||
private final ReactiveRedisTemplate<String, String> reactiveStringRedisTemplate;
|
||||
private final String llmPreResponseCollectionName = "llm-pre-response:";
|
||||
private final Duration ttl = Duration.ofHours(1);
|
||||
|
||||
public LlmResponseTunerServiceImpl(ReactiveRedisTemplate<String, String> reactiveStringRedisTemplate) {
|
||||
this.reactiveStringRedisTemplate = reactiveStringRedisTemplate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<String> getValue(String key) {
|
||||
return reactiveStringRedisTemplate.opsForValue().get(llmPreResponseCollectionName + key);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> setValue(String key, String value) {
|
||||
return reactiveStringRedisTemplate.opsForValue().set(llmPreResponseCollectionName + key, value, ttl).then();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
/*
|
||||
* Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose.
|
||||
* Your use of it is subject to your agreement with Google.
|
||||
*/
|
||||
|
||||
package com.example.service.notification;
|
||||
|
||||
import com.example.dto.dialogflow.notification.NotificationDTO;
|
||||
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 java.time.Instant;
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
import java.util.List;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
|
||||
@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,
|
||||
MemoryStoreNotificationService memoryStoreNotificationService) {
|
||||
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;
|
||||
|
||||
// Synchronize on the notification session ID to prevent race conditions when
|
||||
// creating a new session.
|
||||
synchronized (notificationSessionId.intern()) {
|
||||
DocumentReference notificationDocRef = getNotificationDocumentReference(notificationSessionId);
|
||||
Map<String, Object> entryMap = firestoreNotificationMapper.mapNotificationDTOToMap(newEntry);
|
||||
try {
|
||||
// Check if the session document exists.
|
||||
boolean docExists = firestoreBaseRepository.documentExists(notificationDocRef);
|
||||
|
||||
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())));
|
||||
firestoreBaseRepository.updateDocument(notificationDocRef, updates);
|
||||
logger.info(
|
||||
"Successfully appended new entry to notification session {} in Firestore.",
|
||||
notificationSessionId);
|
||||
} else {
|
||||
// If the document does not exist, create a new session document.
|
||||
Map<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));
|
||||
firestoreBaseRepository.setDocument(notificationDocRef, newSessionData);
|
||||
logger.info(
|
||||
"Successfully created a new notification session {} in Firestore.",
|
||||
notificationSessionId);
|
||||
}
|
||||
} catch (ExecutionException e) {
|
||||
logger.error(
|
||||
"Error saving notification to Firestore for phone: {}",
|
||||
e.getMessage(),
|
||||
e);
|
||||
throw new FirestorePersistenceException(
|
||||
"Failed to save notification to Firestore for phone ", 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 ", 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);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public Mono<Void> updateNotificationStatus(String sessionId, String status) {
|
||||
return Mono.fromRunnable(() -> {
|
||||
DocumentReference notificationDocRef = getNotificationDocumentReference(sessionId);
|
||||
try {
|
||||
Map<String, Object> sessionData = firestoreBaseRepository.getDocument(notificationDocRef, Map.class);
|
||||
if (sessionData != null) {
|
||||
List<Map<String, Object>> notifications = (List<Map<String, Object>>) sessionData
|
||||
.get(FIELD_MESSAGES);
|
||||
if (notifications != null) {
|
||||
List<Map<String, Object>> updatedNotifications = new ArrayList<>();
|
||||
for (Map<String, Object> notification : notifications) {
|
||||
Map<String, Object> updatedNotification = new HashMap<>(notification);
|
||||
updatedNotification.put("status", status);
|
||||
updatedNotifications.add(updatedNotification);
|
||||
}
|
||||
Map<String, Object> updates = new HashMap<>();
|
||||
updates.put(FIELD_MESSAGES, updatedNotifications);
|
||||
updates.put(FIELD_LAST_UPDATED, Timestamp.of(java.util.Date.from(Instant.now())));
|
||||
firestoreBaseRepository.updateDocument(notificationDocRef, updates);
|
||||
logger.info("Successfully updated notification status to '{}' for session {} in Firestore.",
|
||||
status, sessionId);
|
||||
}
|
||||
} else {
|
||||
logger.warn("Notification session {} not found in Firestore. Cannot update status.", sessionId);
|
||||
}
|
||||
} catch (ExecutionException e) {
|
||||
logger.error("Error updating notification status in Firestore for session {}: {}", sessionId,
|
||||
e.getMessage(), e);
|
||||
throw new FirestorePersistenceException(
|
||||
"Failed to update notification status in Firestore for session " + sessionId, e);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
logger.error("Thread interrupted while updating notification status in Firestore for session {}: {}",
|
||||
sessionId, e.getMessage(), e);
|
||||
throw new FirestorePersistenceException(
|
||||
"Updating notification status was interrupted for session " + sessionId, e);
|
||||
}
|
||||
})
|
||||
.subscribeOn(Schedulers.boundedElastic())
|
||||
.then();
|
||||
}
|
||||
|
||||
public Mono<Void> deleteNotification(String notificationId) {
|
||||
logger.info("Attempting to delete notification session {} from Firestore.", notificationId);
|
||||
return Mono.fromRunnable(() -> {
|
||||
try {
|
||||
DocumentReference notificationDocRef = getNotificationDocumentReference(notificationId);
|
||||
firestoreBaseRepository.deleteDocument(notificationDocRef);
|
||||
logger.info("Successfully deleted notification session {} from Firestore.", notificationId);
|
||||
} catch (ExecutionException e) {
|
||||
logger.error("Error deleting notification session {} from Firestore: {}", notificationId, e.getMessage(), e);
|
||||
throw new FirestorePersistenceException("Failed to delete notification session " + notificationId, e);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
logger.error("Thread interrupted while deleting notification session {} from Firestore: {}", notificationId, e.getMessage(), e);
|
||||
throw new FirestorePersistenceException("Deleting notification session was interrupted for " + notificationId, e);
|
||||
}
|
||||
}).subscribeOn(Schedulers.boundedElastic()).then();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose.
|
||||
* Your use of it is subject to your agreement with Google.
|
||||
*/
|
||||
|
||||
package com.example.service.notification;
|
||||
|
||||
import com.example.dto.dialogflow.notification.NotificationDTO;
|
||||
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_NOTIFICATION_SESSION_KEY_PREFIX = "notification:phone_to_notification:";
|
||||
private final Duration notificationTtl = Duration.ofDays(30);
|
||||
|
||||
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_NOTIFICATION_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_NOTIFICATION_SESSION_KEY_PREFIX + phone;
|
||||
return stringRedisTemplate.opsForValue().get(key)
|
||||
.doOnSuccess(sessionId -> {
|
||||
if (sessionId != null) {
|
||||
logger.info("Session ID {} found for phone.", sessionId);
|
||||
} else {
|
||||
logger.debug("Session ID not found for phone.");
|
||||
}
|
||||
})
|
||||
.doOnError(e -> logger.error("Error retrieving session ID for phone from MemoryStore: {}",
|
||||
e.getMessage(), e));
|
||||
}
|
||||
|
||||
public Mono<Void> deleteNotificationSession(String phoneNumber) {
|
||||
String notificationKey = NOTIFICATION_KEY_PREFIX + phoneNumber;
|
||||
String phoneToNotificationKey = PHONE_TO_NOTIFICATION_SESSION_KEY_PREFIX + phoneNumber;
|
||||
logger.info("Deleting notification session for phone number {}.", phoneNumber);
|
||||
return notificationRedisTemplate.opsForValue().delete(notificationKey)
|
||||
.then(stringRedisTemplate.opsForValue().delete(phoneToNotificationKey))
|
||||
.then();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
/*
|
||||
* Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose.
|
||||
* Your use of it is subject to your agreement with Google.
|
||||
*/
|
||||
|
||||
package com.example.service.notification;
|
||||
|
||||
import com.example.dto.dialogflow.notification.ExternalNotRequestDTO;
|
||||
import com.example.dto.dialogflow.base.DetectIntentRequestDTO;
|
||||
import com.example.dto.dialogflow.base.DetectIntentResponseDTO;
|
||||
import com.example.dto.dialogflow.conversation.ConversationEntryDTO;
|
||||
import com.example.dto.dialogflow.conversation.ConversationMessageDTO;
|
||||
import com.example.dto.dialogflow.conversation.ConversationSessionDTO;
|
||||
import com.example.dto.dialogflow.notification.NotificationDTO;
|
||||
import com.example.mapper.conversation.ConversationEntryMapper;
|
||||
import com.example.mapper.notification.ExternalNotRequestMapper;
|
||||
import com.example.service.base.DialogflowClientService;
|
||||
import com.example.service.conversation.DataLossPrevention;
|
||||
import com.example.service.conversation.FirestoreConversationService;
|
||||
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.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
@Service
|
||||
public class NotificationManagerService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(NotificationManagerService.class);
|
||||
private static final String eventName = "notificacion";
|
||||
private static final String PREFIX_PO_PARAM = "notification_po_";
|
||||
|
||||
private final DialogflowClientService dialogflowClientService;
|
||||
private final FirestoreNotificationService firestoreNotificationService;
|
||||
private final MemoryStoreNotificationService memoryStoreNotificationService;
|
||||
private final ExternalNotRequestMapper externalNotRequestMapper;
|
||||
private final MemoryStoreConversationService memoryStoreConversationService;
|
||||
private final FirestoreConversationService firestoreConversationService;
|
||||
private final DataLossPrevention dataLossPrevention;
|
||||
private final String dlpTemplateCompleteFlow;
|
||||
private final ConversationEntryMapper conversationEntryMapper;
|
||||
|
||||
@Value("${dialogflow.default-language-code:es}")
|
||||
private String defaultLanguageCode;
|
||||
|
||||
public NotificationManagerService(
|
||||
DialogflowClientService dialogflowClientService,
|
||||
FirestoreNotificationService firestoreNotificationService,
|
||||
MemoryStoreNotificationService memoryStoreNotificationService,
|
||||
MemoryStoreConversationService memoryStoreConversationService,
|
||||
FirestoreConversationService firestoreConversationService,
|
||||
|
||||
ExternalNotRequestMapper externalNotRequestMapper,
|
||||
DataLossPrevention dataLossPrevention,
|
||||
ConversationEntryMapper conversationEntryMapper,
|
||||
@Value("${google.cloud.dlp.dlpTemplateCompleteFlow}") String dlpTemplateCompleteFlow) {
|
||||
|
||||
this.dialogflowClientService = dialogflowClientService;
|
||||
this.firestoreNotificationService = firestoreNotificationService;
|
||||
this.memoryStoreNotificationService = memoryStoreNotificationService;
|
||||
this.externalNotRequestMapper = externalNotRequestMapper;
|
||||
this.dataLossPrevention = dataLossPrevention;
|
||||
this.dlpTemplateCompleteFlow = dlpTemplateCompleteFlow;
|
||||
this.memoryStoreConversationService = memoryStoreConversationService;
|
||||
this.firestoreConversationService = firestoreConversationService;
|
||||
this.conversationEntryMapper = conversationEntryMapper;
|
||||
}
|
||||
|
||||
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."));
|
||||
}
|
||||
|
||||
return dataLossPrevention.getObfuscatedString(externalRequest.text(), dlpTemplateCompleteFlow)
|
||||
.flatMap(obfuscatedMessage -> {
|
||||
ExternalNotRequestDTO obfuscatedRequest = new ExternalNotRequestDTO(
|
||||
obfuscatedMessage,
|
||||
externalRequest.phoneNumber(),
|
||||
externalRequest.hiddenParameters()
|
||||
);
|
||||
|
||||
String newNotificationId = SessionIdGenerator.generateStandardSessionId();
|
||||
Map<String, Object> parameters = new HashMap<>();
|
||||
if (obfuscatedRequest.hiddenParameters() != null) {
|
||||
obfuscatedRequest.hiddenParameters().forEach((key, value) -> parameters.put(PREFIX_PO_PARAM + key, value));
|
||||
}
|
||||
|
||||
NotificationDTO newNotificationEntry = new NotificationDTO(newNotificationId, telefono, Instant.now(),
|
||||
obfuscatedRequest.text(), eventName, defaultLanguageCode, parameters, "active");
|
||||
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));
|
||||
});
|
||||
|
||||
Mono<ConversationSessionDTO> sessionMono = memoryStoreConversationService.getSessionByTelefono(telefono)
|
||||
.doOnNext(session -> logger.info("Found existing conversation session {} for phone number {}",
|
||||
session.sessionId(), telefono))
|
||||
.flatMap(session -> {
|
||||
Map<String, Object> prefixedParameters = new HashMap<>();
|
||||
if (obfuscatedRequest.hiddenParameters() != null) {
|
||||
obfuscatedRequest.hiddenParameters()
|
||||
.forEach((key, value) -> prefixedParameters.put(PREFIX_PO_PARAM + key, value));
|
||||
}
|
||||
ConversationEntryDTO systemEntry = ConversationEntryDTO.forSystem(obfuscatedRequest.text(),
|
||||
prefixedParameters);
|
||||
return persistConversationTurn(session, systemEntry)
|
||||
.thenReturn(session);
|
||||
})
|
||||
.switchIfEmpty(Mono.defer(() -> {
|
||||
String newSessionId = SessionIdGenerator.generateStandardSessionId();
|
||||
logger.info("No existing conversation session found for phone number {}. Creating new session: {}",
|
||||
telefono, newSessionId);
|
||||
String userId = "user_by_phone_" + telefono;
|
||||
Map<String, Object> prefixedParameters = new HashMap<>();
|
||||
if (obfuscatedRequest.hiddenParameters() != null) {
|
||||
obfuscatedRequest.hiddenParameters()
|
||||
.forEach((key, value) -> prefixedParameters.put(PREFIX_PO_PARAM + key, value));
|
||||
}
|
||||
ConversationEntryDTO systemEntry = ConversationEntryDTO.forSystem(obfuscatedRequest.text(),
|
||||
prefixedParameters);
|
||||
ConversationSessionDTO newSession = ConversationSessionDTO.create(newSessionId, userId, telefono);
|
||||
return persistConversationTurn(newSession, systemEntry)
|
||||
.then(Mono.just(newSession));
|
||||
}));
|
||||
|
||||
return persistenceMono.then(sessionMono)
|
||||
.flatMap(session -> {
|
||||
final String sessionId = session.sessionId();
|
||||
logger.info("Sending notification text to Dialogflow using conversation session: {}", sessionId);
|
||||
|
||||
DetectIntentRequestDTO detectIntentRequest = externalNotRequestMapper.map(obfuscatedRequest);
|
||||
|
||||
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));
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<Void> persistConversationTurn(ConversationSessionDTO session, ConversationEntryDTO entry) {
|
||||
logger.debug("Starting Write-Back persistence for session {}. Type: {}. Writing to Redis first.", session.sessionId(),
|
||||
entry.type().name());
|
||||
ConversationMessageDTO message = conversationEntryMapper.toConversationMessageDTO(entry);
|
||||
ConversationSessionDTO updatedSession = session.withLastMessage(message.text());
|
||||
|
||||
return memoryStoreConversationService.saveSession(updatedSession)
|
||||
.then(memoryStoreConversationService.saveMessage(session.sessionId(), message))
|
||||
.doOnSuccess(v -> {
|
||||
logger.info(
|
||||
"Entry saved to Redis for session {}. Type: {}. Kicking off async Firestore write-back.",
|
||||
session.sessionId(), entry.type().name());
|
||||
|
||||
firestoreConversationService.saveSession(updatedSession)
|
||||
.then(firestoreConversationService.saveMessage(session.sessionId(), message))
|
||||
.subscribe(
|
||||
fsVoid -> logger.debug(
|
||||
"Asynchronously (Write-Back): Entry successfully saved to Firestore for session {}. Type: {}.",
|
||||
session.sessionId(), entry.type().name()),
|
||||
fsError -> logger.error(
|
||||
"Asynchronously (Write-Back): Failed to save entry to Firestore for session {}. Type: {}: {}",
|
||||
session.sessionId(), entry.type().name(), fsError.getMessage(), fsError));
|
||||
})
|
||||
.doOnError(e -> logger.error("Error during primary Redis write for session {}. Type: {}: {}", session.sessionId(),
|
||||
entry.type().name(), e.getMessage(), e));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose.
|
||||
* Your use of it is subject to your agreement with Google.
|
||||
*/
|
||||
|
||||
package com.example.service.quickreplies;
|
||||
|
||||
import com.example.dto.dialogflow.conversation.ConversationEntryDTO;
|
||||
import com.example.dto.dialogflow.conversation.ConversationMessageDTO;
|
||||
import com.example.dto.dialogflow.conversation.ConversationSessionDTO;
|
||||
import com.example.mapper.conversation.ConversationEntryMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.redis.core.ReactiveRedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
@Service
|
||||
public class MemoryStoreQRService {
|
||||
private static final Logger logger = LoggerFactory.getLogger(MemoryStoreQRService.class);
|
||||
private static final String SESSION_KEY_PREFIX = "qr:session:";
|
||||
private static final String PHONE_TO_SESSION_KEY_PREFIX = "qr:phone_to_session:";
|
||||
private static final String MESSAGES_KEY_PREFIX = "qr:messages:";
|
||||
private static final Duration SESSION_TTL = Duration.ofHours(24);
|
||||
private final ReactiveRedisTemplate<String, ConversationSessionDTO> redisTemplate;
|
||||
private final ReactiveRedisTemplate<String, String> stringRedisTemplate;
|
||||
private final ReactiveRedisTemplate<String, ConversationMessageDTO> messageRedisTemplate;
|
||||
private final ConversationEntryMapper conversationEntryMapper;
|
||||
|
||||
@Autowired
|
||||
public MemoryStoreQRService(
|
||||
ReactiveRedisTemplate<String, ConversationSessionDTO> redisTemplate,
|
||||
ReactiveRedisTemplate<String, String> stringRedisTemplate,
|
||||
ReactiveRedisTemplate<String, ConversationMessageDTO> messageRedisTemplate,
|
||||
ConversationEntryMapper conversationEntryMapper) {
|
||||
this.redisTemplate = redisTemplate;
|
||||
this.stringRedisTemplate = stringRedisTemplate;
|
||||
this.messageRedisTemplate = messageRedisTemplate;
|
||||
this.conversationEntryMapper = conversationEntryMapper;
|
||||
}
|
||||
|
||||
public Mono<Void> saveEntry(String userId, String sessionId, ConversationEntryDTO newEntry,
|
||||
String userPhoneNumber) {
|
||||
String sessionKey = SESSION_KEY_PREFIX + sessionId;
|
||||
String phoneToSessionKey = PHONE_TO_SESSION_KEY_PREFIX + userPhoneNumber;
|
||||
String messagesKey = MESSAGES_KEY_PREFIX + sessionId;
|
||||
|
||||
logger.info("Attempting to save entry to Redis for quick reply session {}. Entity: {}", sessionId,
|
||||
newEntry.entity().name());
|
||||
|
||||
return redisTemplate.opsForValue().get(sessionKey)
|
||||
.defaultIfEmpty(ConversationSessionDTO.create(sessionId, userId, userPhoneNumber))
|
||||
.flatMap(session -> {
|
||||
ConversationSessionDTO sessionWithUpdatedTelefono = session.withTelefono(userPhoneNumber);
|
||||
ConversationSessionDTO updatedSession = sessionWithUpdatedTelefono.withLastMessage(newEntry.text());
|
||||
ConversationMessageDTO message = conversationEntryMapper.toConversationMessageDTO(newEntry);
|
||||
|
||||
logger.info("Attempting to set updated quick reply session {} with new entry entity {} in Redis.",
|
||||
sessionId, newEntry.entity().name());
|
||||
|
||||
return redisTemplate.opsForValue().set(sessionKey, updatedSession, SESSION_TTL)
|
||||
.then(stringRedisTemplate.opsForValue().set(phoneToSessionKey, sessionId, SESSION_TTL))
|
||||
.then(messageRedisTemplate.opsForList().rightPush(messagesKey, message))
|
||||
.then();
|
||||
})
|
||||
.doOnSuccess(success -> {
|
||||
logger.info(
|
||||
"Successfully saved updated quick reply session and phone mapping to Redis for session {}. Entity Type: {}",
|
||||
sessionId, newEntry.entity().name());
|
||||
})
|
||||
.doOnError(e -> logger.error("Error appending entry to Redis for quick reply session {}: {}", sessionId,
|
||||
e.getMessage(), e));
|
||||
}
|
||||
|
||||
public Mono<ConversationSessionDTO> getSessionByTelefono(String telefono) {
|
||||
if (telefono == null || telefono.isBlank()) {
|
||||
return Mono.empty();
|
||||
}
|
||||
String phoneToSessionKey = PHONE_TO_SESSION_KEY_PREFIX + telefono;
|
||||
return stringRedisTemplate.opsForValue().get(phoneToSessionKey)
|
||||
.flatMap(sessionId -> {
|
||||
logger.debug("Found quick reply session ID {} for phone number {}. Retrieving session data.",
|
||||
sessionId, telefono);
|
||||
return redisTemplate.opsForValue().get(SESSION_KEY_PREFIX + sessionId);
|
||||
})
|
||||
.doOnSuccess(session -> {
|
||||
if (session != null) {
|
||||
logger.info("Successfully retrieved quick reply session {}",
|
||||
session.sessionId());
|
||||
} else {
|
||||
logger.info("No quick reply session found in Redis for phone number");
|
||||
}
|
||||
})
|
||||
.doOnError(e -> logger.error("Error retrieving quick reply session by phone numbe: {}",e.getMessage(), e));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
/*
|
||||
* Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose.
|
||||
* Your use of it is subject to your agreement with Google.
|
||||
*/
|
||||
|
||||
package com.example.service.quickreplies;
|
||||
|
||||
import com.example.dto.dialogflow.base.DetectIntentResponseDTO;
|
||||
import com.example.dto.dialogflow.conversation.*;
|
||||
import com.example.dto.quickreplies.QuickReplyScreenRequestDTO;
|
||||
import com.example.dto.quickreplies.QuestionDTO;
|
||||
import com.example.dto.quickreplies.QuickReplyDTO;
|
||||
import com.example.mapper.conversation.ConversationEntryMapper;
|
||||
import com.example.service.conversation.FirestoreConversationService;
|
||||
import com.example.service.conversation.MemoryStoreConversationService;
|
||||
import com.example.util.SessionIdGenerator;
|
||||
import java.time.Instant;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.stream.IntStream;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.example.service.conversation.ConversationManagerService;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@Service
|
||||
public class QuickRepliesManagerService {
|
||||
private static final Logger logger = LoggerFactory.getLogger(QuickRepliesManagerService.class);
|
||||
private final MemoryStoreConversationService memoryStoreConversationService;
|
||||
private final FirestoreConversationService firestoreConversationService;
|
||||
private final QuickReplyContentService quickReplyContentService;
|
||||
private final ConversationManagerService conversationManagerService;
|
||||
private final ConversationEntryMapper conversationEntryMapper;
|
||||
|
||||
public QuickRepliesManagerService(
|
||||
@Lazy ConversationManagerService conversationManagerService,
|
||||
MemoryStoreConversationService memoryStoreConversationService,
|
||||
FirestoreConversationService firestoreConversationService,
|
||||
QuickReplyContentService quickReplyContentService,
|
||||
ConversationEntryMapper conversationEntryMapper) {
|
||||
this.conversationManagerService = conversationManagerService;
|
||||
this.memoryStoreConversationService = memoryStoreConversationService;
|
||||
this.firestoreConversationService = firestoreConversationService;
|
||||
this.quickReplyContentService = quickReplyContentService;
|
||||
this.conversationEntryMapper = conversationEntryMapper;
|
||||
}
|
||||
|
||||
public Mono<DetectIntentResponseDTO> startQuickReplySession(QuickReplyScreenRequestDTO externalRequest) {
|
||||
String userPhoneNumber = externalRequest.user().telefono();
|
||||
if (userPhoneNumber == null || userPhoneNumber.isBlank()) {
|
||||
logger.warn("No phone number provided in request. Cannot manage conversation session without it.");
|
||||
return Mono
|
||||
.error(new IllegalArgumentException("Phone number is required to manage conversation sessions."));
|
||||
}
|
||||
return memoryStoreConversationService.getSessionByTelefono(userPhoneNumber)
|
||||
.flatMap(session -> Mono.just(session.sessionId()))
|
||||
.switchIfEmpty(Mono.fromCallable(SessionIdGenerator::generateStandardSessionId))
|
||||
.flatMap(sessionId -> {
|
||||
String userId = "user_by_phone_" + userPhoneNumber.replaceAll("[^0-9]", "");
|
||||
ConversationEntryDTO systemEntry = new ConversationEntryDTO(
|
||||
ConversationEntryEntity.SISTEMA,
|
||||
ConversationEntryType.INICIO,
|
||||
Instant.now(),
|
||||
"Pantalla :" + externalRequest.pantallaContexto() + " Agregada a la conversacion :",
|
||||
null,
|
||||
null);
|
||||
ConversationSessionDTO newSession = ConversationSessionDTO.create(sessionId, userId, userPhoneNumber).withPantallaContexto(externalRequest.pantallaContexto());
|
||||
return persistConversationTurn(newSession, systemEntry)
|
||||
.then(quickReplyContentService.getQuickReplies(externalRequest.pantallaContexto()))
|
||||
.map(quickReplyDTO -> new DetectIntentResponseDTO(sessionId, null, quickReplyDTO));
|
||||
});
|
||||
}
|
||||
|
||||
public Mono<DetectIntentResponseDTO> manageConversation(ExternalConvRequestDTO externalRequest) {
|
||||
String userPhoneNumber = externalRequest.user().telefono();
|
||||
if (userPhoneNumber == null || userPhoneNumber.isBlank()) {
|
||||
logger.warn("No phone number provided in request. Cannot manage conversation session without it.");
|
||||
return Mono
|
||||
.error(new IllegalArgumentException("Phone number is required to manage conversation sessions."));
|
||||
}
|
||||
|
||||
return memoryStoreConversationService.getSessionByTelefono(userPhoneNumber)
|
||||
.switchIfEmpty(Mono.error(
|
||||
new IllegalStateException("No quick reply session found for phone number")))
|
||||
.flatMap(session -> {
|
||||
|
||||
return memoryStoreConversationService.getMessages(session.sessionId()).collectList().flatMap(messages -> {
|
||||
ConversationEntryDTO userEntry = ConversationEntryDTO.forUser(externalRequest.message());
|
||||
|
||||
int lastInitIndex = IntStream.range(0, messages.size())
|
||||
.map(i -> messages.size() - 1 - i)
|
||||
.filter(i -> {
|
||||
ConversationMessageDTO message = messages.get(i);
|
||||
return message.type() == MessageType.SYSTEM;
|
||||
})
|
||||
.findFirst()
|
||||
.orElse(-1);
|
||||
|
||||
long userMessagesCount;
|
||||
if (lastInitIndex != -1) {
|
||||
userMessagesCount = messages.subList(lastInitIndex + 1, messages.size()).stream()
|
||||
.filter(e -> e.type() == MessageType.USER)
|
||||
.count();
|
||||
} else {
|
||||
userMessagesCount = 0;
|
||||
}
|
||||
|
||||
if (userMessagesCount == 0) { // Is the first user message in the Quick-Replies flow
|
||||
// This is the second message of the flow. Return the full list.
|
||||
return persistConversationTurn(session, userEntry)
|
||||
.then(quickReplyContentService.getQuickReplies(session.pantallaContexto()))
|
||||
.flatMap(quickReplyDTO -> {
|
||||
ConversationEntryDTO agentEntry = ConversationEntryDTO
|
||||
.forAgentWithMessage(quickReplyDTO.toString());
|
||||
return persistConversationTurn(session, agentEntry)
|
||||
.thenReturn(new DetectIntentResponseDTO(session.sessionId(), null, quickReplyDTO));
|
||||
});
|
||||
} else if (userMessagesCount == 1) { // Is the second user message in the QR flow
|
||||
// This is the third message of the flow. Filter and end.
|
||||
return persistConversationTurn(session, userEntry)
|
||||
.then(quickReplyContentService.getQuickReplies(session.pantallaContexto()))
|
||||
.flatMap(quickReplyDTO -> {
|
||||
List<QuestionDTO> matchedPreguntas = quickReplyDTO.preguntas().stream()
|
||||
.filter(p -> p.titulo().equalsIgnoreCase(externalRequest.message().trim()))
|
||||
.toList();
|
||||
|
||||
if (!matchedPreguntas.isEmpty()) {
|
||||
// Matched question, return the answer
|
||||
String respuesta = matchedPreguntas.get(0).respuesta();
|
||||
QueryResultDTO queryResult = new QueryResultDTO(respuesta, null);
|
||||
DetectIntentResponseDTO response = new DetectIntentResponseDTO(session.sessionId(),
|
||||
queryResult, null);
|
||||
|
||||
return memoryStoreConversationService
|
||||
.updateSession(session.withPantallaContexto(null))
|
||||
.then(persistConversationTurn(session,
|
||||
ConversationEntryDTO.forAgentWithMessage(respuesta)))
|
||||
.thenReturn(response);
|
||||
} else {
|
||||
// No match, delegate to Dialogflow
|
||||
return memoryStoreConversationService
|
||||
.updateSession(session.withPantallaContexto(null))
|
||||
.then(conversationManagerService.manageConversation(externalRequest));
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Should not happen. End the flow.
|
||||
return memoryStoreConversationService.updateSession(session.withPantallaContexto(null))
|
||||
.then(Mono.just(new DetectIntentResponseDTO(session.sessionId(), null,
|
||||
new QuickReplyDTO("Flow Error", null, null, null, Collections.emptyList()))));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<Void> persistConversationTurn(ConversationSessionDTO session, ConversationEntryDTO entry) {
|
||||
logger.debug("Starting Write-Back persistence for quick reply session {}. Type: {}. Writing to Redis first.",
|
||||
session.sessionId(), entry.type().name());
|
||||
ConversationMessageDTO message = conversationEntryMapper.toConversationMessageDTO(entry);
|
||||
ConversationSessionDTO updatedSession = session.withLastMessage(message.text());
|
||||
return memoryStoreConversationService.saveSession(updatedSession)
|
||||
.then(memoryStoreConversationService.saveMessage(session.sessionId(), message))
|
||||
.doOnSuccess(v -> logger.info(
|
||||
"Entry saved to Redis for quick reply session {}. Type: {}. Kicking off async Firestore write-back.",
|
||||
session.sessionId(), entry.type().name()))
|
||||
.then(firestoreConversationService.saveSession(updatedSession)
|
||||
.then(firestoreConversationService.saveMessage(session.sessionId(), message))
|
||||
.doOnSuccess(fsVoid -> logger.debug(
|
||||
"Asynchronously (Write-Back): Entry successfully saved to Firestore for quick reply session {}. Type: {}.",
|
||||
session.sessionId(), entry.type().name()))
|
||||
.doOnError(fsError -> logger.error(
|
||||
"Asynchronously (Write-Back): Failed to save entry to Firestore for quick reply session {}. Type: {}: {}",
|
||||
session.sessionId(), entry.type().name(), fsError.getMessage(), fsError)))
|
||||
.doOnError(
|
||||
e -> logger.error("Error during primary Redis write for quick reply session {}. Type: {}: {}",
|
||||
session.sessionId(), entry.type().name(), e.getMessage(), e));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose.
|
||||
* Your use of it is subject to your agreement with Google.
|
||||
*/
|
||||
|
||||
package com.example.service.quickreplies;
|
||||
|
||||
import com.example.dto.quickreplies.QuestionDTO;
|
||||
import com.example.dto.quickreplies.QuickReplyDTO;
|
||||
import com.google.cloud.firestore.DocumentSnapshot;
|
||||
import com.google.cloud.firestore.Firestore;
|
||||
import java.util.Map;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Mono;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class QuickReplyContentService {
|
||||
private static final Logger logger = LoggerFactory.getLogger(QuickReplyContentService.class);
|
||||
private final Firestore firestore;
|
||||
public QuickReplyContentService(Firestore firestore) {
|
||||
this.firestore = firestore;
|
||||
}
|
||||
public Mono<QuickReplyDTO> getQuickReplies(String collectionId) {
|
||||
logger.info("Fetching quick replies from Firestore for document: {}", collectionId);
|
||||
if (collectionId == null || collectionId.isBlank()) {
|
||||
logger.warn("collectionId is null or empty. Returning empty quick replies.");
|
||||
return Mono.just(new QuickReplyDTO("empty", null, null, null, Collections.emptyList()));
|
||||
}
|
||||
return Mono.fromCallable(() -> {
|
||||
try {
|
||||
return firestore.collection("artifacts")
|
||||
.document("default-app-id")
|
||||
.collection("quick-replies")
|
||||
.document(collectionId)
|
||||
.get()
|
||||
.get();
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
})
|
||||
.filter(DocumentSnapshot::exists)
|
||||
.map(document -> {
|
||||
String header = document.getString("header");
|
||||
String body = document.getString("body");
|
||||
String button = document.getString("button");
|
||||
String headerSection = document.getString("header_section");
|
||||
List<Map<String, Object>> preguntasData = (List<Map<String, Object>>) document.get("preguntas");
|
||||
List<QuestionDTO> preguntas = preguntasData.stream()
|
||||
.map(p -> new QuestionDTO((String) p.get("titulo"), (String) p.get("descripcion"), (String) p.get("respuesta")))
|
||||
.toList();
|
||||
return new QuickReplyDTO(header, body, button, headerSection, preguntas);
|
||||
})
|
||||
.doOnSuccess(quickReplyDTO -> {
|
||||
if (quickReplyDTO != null) {
|
||||
logger.info("Successfully fetched {} quick replies for document: {}", quickReplyDTO.preguntas().size(), collectionId);
|
||||
} else {
|
||||
logger.info("No quick reply document found for id: {}", collectionId);
|
||||
}
|
||||
})
|
||||
.doOnError(error -> logger.error("Error fetching quick replies from Firestore for document: {}", collectionId, error))
|
||||
.switchIfEmpty(Mono.defer(() -> {
|
||||
logger.info("No quick reply document found for id: {}", collectionId);
|
||||
return Mono.empty();
|
||||
}));
|
||||
}
|
||||
}
|
||||
91
src/main/java/com/example/util/FirestoreDataImporter.java
Normal file
91
src/main/java/com/example/util/FirestoreDataImporter.java
Normal file
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose.
|
||||
* Your use of it is subject to your agreement with Google.
|
||||
*/
|
||||
|
||||
package com.example.util;
|
||||
|
||||
import com.example.repository.FirestoreBaseRepository;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.google.cloud.firestore.DocumentReference;
|
||||
import com.google.cloud.firestore.DocumentSnapshot;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
|
||||
public class FirestoreDataImporter {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(FirestoreDataImporter.class);
|
||||
private static final String QUICK_REPLIES_COLLECTION_PATH_FORMAT = "artifacts/%s/quick-replies";
|
||||
|
||||
private final FirestoreBaseRepository firestoreBaseRepository;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
private final boolean isImporterEnabled;
|
||||
|
||||
public FirestoreDataImporter(FirestoreBaseRepository firestoreBaseRepository, ObjectMapper objectMapper) {
|
||||
this.firestoreBaseRepository = firestoreBaseRepository;
|
||||
this.objectMapper = objectMapper;
|
||||
this.isImporterEnabled = Boolean.parseBoolean(System.getProperty("firestore.data.importer.enabled"));
|
||||
|
||||
}
|
||||
|
||||
public void runImport() {
|
||||
if (isImporterEnabled) {
|
||||
try {
|
||||
importQuickReplies();
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to import data to Firestore on startup", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void importQuickReplies() throws IOException, ExecutionException, InterruptedException {
|
||||
String collectionPath = String.format(QUICK_REPLIES_COLLECTION_PATH_FORMAT, firestoreBaseRepository.getAppId());
|
||||
|
||||
importJson(collectionPath, "home");
|
||||
importJson(collectionPath, "pagos");
|
||||
importJson(collectionPath, "finanzas");
|
||||
importJson(collectionPath, "lealtad");
|
||||
importJson(collectionPath, "descubre");
|
||||
importJson(collectionPath, "detalle-tdc");
|
||||
importJson(collectionPath, "detalle-tdd");
|
||||
importJson(collectionPath, "transferencia");
|
||||
importJson(collectionPath, "retiro-sin-tarjeta");
|
||||
importJson(collectionPath, "capsulas");
|
||||
importJson(collectionPath, "inversiones");
|
||||
importJson(collectionPath, "prestamos");
|
||||
|
||||
logger.info("All JSON files were imported successfully.");
|
||||
}
|
||||
|
||||
private void importJson(String collectionPath, String documentId) throws IOException, ExecutionException, InterruptedException {
|
||||
String resourcePath = "/quick-replies/" + documentId + ".json";
|
||||
try (InputStream inputStream = getClass().getResourceAsStream(resourcePath)) {
|
||||
if (inputStream == null) {
|
||||
logger.warn("Resource not found: {}", resourcePath);
|
||||
return;
|
||||
}
|
||||
Map<String, Object> localData = objectMapper.readValue(inputStream, new TypeReference<Map<String, Object>>() {});
|
||||
DocumentReference docRef = firestoreBaseRepository.getDocumentReference(collectionPath, documentId);
|
||||
|
||||
if (firestoreBaseRepository.documentExists(docRef)) {
|
||||
DocumentSnapshot documentSnapshot = firestoreBaseRepository.getDocumentSnapshot(docRef);
|
||||
Map<String, Object> firestoreData = documentSnapshot.getData();
|
||||
if (!Objects.equals(localData, firestoreData)) {
|
||||
firestoreBaseRepository.setDocument(docRef, localData);
|
||||
logger.info("Successfully updated {} in Firestore.", documentId);
|
||||
}
|
||||
} else {
|
||||
firestoreBaseRepository.setDocument(docRef, localData);
|
||||
logger.info("Successfully imported {} to Firestore.", documentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose.
|
||||
* Your use of it is subject to your agreement with Google.
|
||||
*/
|
||||
|
||||
package com.example.util;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.fasterxml.jackson.core.JsonToken;
|
||||
import com.fasterxml.jackson.databind.DeserializationContext;
|
||||
import com.fasterxml.jackson.databind.JsonDeserializer;
|
||||
import com.google.cloud.Timestamp;
|
||||
import java.io.IOException;
|
||||
import java.util.Map;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* Custom Jackson Deserializer for com.google.cloud.Timestamp.
|
||||
* Handles deserialization from embedded objects (direct Timestamp instances),
|
||||
* ISO 8601 strings, and JSON objects with "seconds" and "nanos" fields.
|
||||
*/
|
||||
public class FirestoreTimestampDeserializer extends JsonDeserializer<Timestamp> {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(FirestoreTimestampDeserializer.class);
|
||||
|
||||
@Override
|
||||
public Timestamp deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
|
||||
JsonToken token = p.getCurrentToken();
|
||||
|
||||
if (token == JsonToken.VALUE_EMBEDDED_OBJECT) {
|
||||
// This is the ideal case when ObjectMapper.convertValue gets a direct Timestamp object
|
||||
Object embedded = p.getEmbeddedObject();
|
||||
if (embedded instanceof Timestamp) {
|
||||
logger.debug("FirestoreTimestampDeserializer: Deserializing from embedded Timestamp object: {}", embedded);
|
||||
return (Timestamp) embedded;
|
||||
}
|
||||
} else if (token == JsonToken.VALUE_STRING) {
|
||||
// Handles cases where the timestamp is represented as an ISO 8601 string
|
||||
String timestampString = p.getText();
|
||||
try {
|
||||
logger.debug("FirestoreTimestampDeserializer: Deserializing from String: {}", timestampString);
|
||||
return Timestamp.parseTimestamp(timestampString);
|
||||
} catch (IllegalArgumentException e) {
|
||||
logger.error("FirestoreTimestampDeserializer: Failed to parse timestamp string: '{}'", timestampString, e);
|
||||
throw new IOException("Failed to parse timestamp string: " + timestampString, e);
|
||||
}
|
||||
} else if (token == JsonToken.START_OBJECT) {
|
||||
// This is crucial for handling the "Cannot deserialize ... from Object value (token JsonToken.START_OBJECT)" error.
|
||||
// It assumes the object represents { "seconds": X, "nanos": Y }
|
||||
logger.debug("FirestoreTimestampDeserializer: Deserializing from JSON object.");
|
||||
|
||||
// Suppress the unchecked warning here, as we expect a Map<String, Number>
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Number> map = p.readValueAs(Map.class);
|
||||
|
||||
if (map != null && map.containsKey("seconds") && map.containsKey("nanos")) {
|
||||
Number secondsNum = map.get("seconds");
|
||||
Number nanosNum = map.get("nanos");
|
||||
|
||||
if (secondsNum != null && nanosNum != null) {
|
||||
Long seconds = secondsNum.longValue();
|
||||
Integer nanos = nanosNum.intValue();
|
||||
return Timestamp.ofTimeSecondsAndNanos(seconds, nanos);
|
||||
}
|
||||
}
|
||||
logger.error("FirestoreTimestampDeserializer: JSON object missing 'seconds' or 'nanos' fields, or fields are not numbers.");
|
||||
}
|
||||
|
||||
// If none of the above formats match, log an error and delegate to default handling
|
||||
logger.error("FirestoreTimestampDeserializer: Unexpected token type for Timestamp deserialization. Expected Embedded Object, String, or START_OBJECT. Got: {}", token);
|
||||
// This will likely re-throw an error indicating inability to deserialize.
|
||||
return (Timestamp) ctxt.handleUnexpectedToken(Timestamp.class, p);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose.
|
||||
* Your use of it is subject to your agreement with Google.
|
||||
*/
|
||||
|
||||
package com.example.util;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.databind.JsonSerializer;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
import com.google.cloud.Timestamp;
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* Custom Jackson Serializer for com.google.cloud.Timestamp.
|
||||
* This is crucial for ObjectMapper.convertValue to correctly handle Timestamp objects
|
||||
* when they are encountered in a Map<String, Object> and need to be internally
|
||||
* serialized before deserialization into a DTO. It converts Timestamp into a
|
||||
* simple JSON object with "seconds" and "nanos" fields.
|
||||
*/
|
||||
public class FirestoreTimestampSerializer extends JsonSerializer<Timestamp> {
|
||||
|
||||
@Override
|
||||
public void serialize(Timestamp value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
|
||||
if (value == null) {
|
||||
gen.writeNull();
|
||||
} else {
|
||||
// Write Timestamp as a JSON object with seconds and nanos
|
||||
gen.writeStartObject();
|
||||
gen.writeNumberField("seconds", value.getSeconds());
|
||||
gen.writeNumberField("nanos", value.getNanos());
|
||||
gen.writeEndObject();
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
89
src/main/java/com/example/util/ProtobufUtil.java
Normal file
89
src/main/java/com/example/util/ProtobufUtil.java
Normal file
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose.
|
||||
* Your use of it is subject to your agreement with Google.
|
||||
*/
|
||||
|
||||
package com.example.util;
|
||||
|
||||
import com.google.protobuf.ListValue;
|
||||
import com.google.protobuf.NullValue;
|
||||
import com.google.protobuf.Struct;
|
||||
import com.google.protobuf.Value;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class ProtobufUtil {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(ProtobufUtil.class);
|
||||
|
||||
/**
|
||||
* Converts a Java Object to a Protobuf Value.
|
||||
* Supports primitive types, String, Map, and List.
|
||||
* Maps will be converted to Protobuf Structs.
|
||||
* Lists will be converted to Protobuf ListValues.
|
||||
*/
|
||||
@SuppressWarnings("rawtypes")
|
||||
public static Value convertJavaObjectToProtobufValue(Object obj) {
|
||||
if (obj == null) {
|
||||
return Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build();
|
||||
} else if (obj instanceof Boolean) {
|
||||
return Value.newBuilder().setBoolValue((Boolean) obj).build();
|
||||
} else if (obj instanceof Integer) {
|
||||
return Value.newBuilder().setNumberValue(((Integer) obj).doubleValue()).build();
|
||||
} else if (obj instanceof Long) {
|
||||
return Value.newBuilder().setNumberValue(((Long) obj).doubleValue()).build();
|
||||
} else if (obj instanceof Double) {
|
||||
return Value.newBuilder().setNumberValue((Double) obj).build();
|
||||
} else if (obj instanceof String) {
|
||||
return Value.newBuilder().setStringValue((String) obj).build();
|
||||
} else if (obj instanceof Enum) {
|
||||
return Value.newBuilder().setStringValue(((Enum) obj).name()).build();
|
||||
} else if (obj instanceof Map) {
|
||||
Struct.Builder structBuilder = Struct.newBuilder();
|
||||
((Map<?, ?>) obj).forEach((key, val) ->
|
||||
structBuilder.putFields(String.valueOf(key), convertJavaObjectToProtobufValue(val))
|
||||
);
|
||||
return Value.newBuilder().setStructValue(structBuilder.build()).build();
|
||||
} else if (obj instanceof List) {
|
||||
ListValue.Builder listValueBuilder = ListValue.newBuilder();
|
||||
((List<?>) obj).forEach(item ->
|
||||
listValueBuilder.addValues(convertJavaObjectToProtobufValue(item))
|
||||
);
|
||||
return Value.newBuilder().setListValue(listValueBuilder.build()).build();
|
||||
}
|
||||
logger.warn("Unsupported type for Protobuf conversion: {}. Converting to String.", obj.getClass().getName());
|
||||
return Value.newBuilder().setStringValue(obj.toString()).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a Protobuf Value to a Java Object.
|
||||
* Supports Null, Boolean, Number, String, Struct (to Map), and ListValue (to List).
|
||||
*/
|
||||
public static Object convertProtobufValueToJavaObject(Value protobufValue) {
|
||||
return switch (protobufValue.getKindCase()) {
|
||||
case NULL_VALUE -> null;
|
||||
case BOOL_VALUE -> protobufValue.getBoolValue();
|
||||
case NUMBER_VALUE -> protobufValue.getNumberValue();
|
||||
case STRING_VALUE -> protobufValue.getStringValue();
|
||||
case STRUCT_VALUE -> protobufValue.getStructValue().getFieldsMap().entrySet().stream()
|
||||
.collect(Collectors.toMap(
|
||||
Map.Entry::getKey,
|
||||
entry -> convertProtobufValueToJavaObject(entry.getValue()),
|
||||
(oldValue, newValue) -> oldValue,
|
||||
LinkedHashMap::new
|
||||
));
|
||||
case LIST_VALUE -> protobufValue.getListValue().getValuesList().stream()
|
||||
.map(ProtobufUtil::convertProtobufValueToJavaObject) // Use static method reference
|
||||
.collect(Collectors.toList());
|
||||
default -> {
|
||||
logger.warn("Unsupported Protobuf Value type: {}. Returning null.", protobufValue.getKindCase());
|
||||
yield null;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
68
src/main/java/com/example/util/SessionIdGenerator.java
Normal file
68
src/main/java/com/example/util/SessionIdGenerator.java
Normal file
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose.
|
||||
* Your use of it is subject to your agreement with Google.
|
||||
*/
|
||||
|
||||
package com.example.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;
|
||||
}
|
||||
}
|
||||
98
src/main/java/com/example/util/TextObfuscator.java
Normal file
98
src/main/java/com/example/util/TextObfuscator.java
Normal file
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* 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 java.util.Comparator;
|
||||
import java.util.List;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import com.google.privacy.dlp.v2.Finding;
|
||||
import com.google.privacy.dlp.v2.InspectContentResponse;
|
||||
|
||||
public class TextObfuscator {
|
||||
private static final Logger logger = LoggerFactory.getLogger(TextObfuscator.class);
|
||||
|
||||
public static String obfuscate(InspectContentResponse response, String textToInspect) {
|
||||
|
||||
List<Finding> findings = response.getResult().getFindingsList().stream()
|
||||
.filter(finding -> finding.getLikelihoodValue() > 3)
|
||||
.sorted(Comparator.comparing(Finding::getLikelihoodValue).reversed())
|
||||
.peek(finding -> logger.info("InfoType: {} | Likelihood: {}", finding.getInfoType().getName(),
|
||||
finding.getLikelihoodValue()))
|
||||
.toList();
|
||||
|
||||
for (Finding finding : findings) {
|
||||
String quote = finding.getQuote();
|
||||
|
||||
switch (finding.getInfoType().getName()) {
|
||||
case "CREDIT_CARD_NUMBER":
|
||||
textToInspect = textToInspect.replace(quote, "**** **** **** " + getLast4(quote));
|
||||
break;
|
||||
case "CREDIT_CARD_EXPIRATION_DATE":
|
||||
case "FECHA_VENCIMIENTO":
|
||||
textToInspect = textToInspect.replace(quote, "[FECHA_VENCIMIENTO_TARJETA]");
|
||||
break;
|
||||
case "CVV_NUMBER":
|
||||
case "CVV":
|
||||
textToInspect = textToInspect.replace(quote, "[CVV]");
|
||||
break;
|
||||
case "EMAIL_ADDRESS":
|
||||
textToInspect = textToInspect.replace(quote, "[CORREO]");
|
||||
break;
|
||||
case "PERSON_NAME":
|
||||
textToInspect = textToInspect.replace(quote, "[NOMBRE]");
|
||||
break;
|
||||
case "PHONE_NUMBER":
|
||||
textToInspect = textToInspect.replace(quote, "[TELEFONO]");
|
||||
break;
|
||||
case "DIRECCION":
|
||||
case "DIR_COLONIA":
|
||||
case "DIR_DEL_MUN":
|
||||
case "DIR_INTERIOR":
|
||||
case "DIR_ESQUINA":
|
||||
case "DIR_CIUDAD_EDO":
|
||||
case "DIR_CP":
|
||||
textToInspect = textToInspect.replace(quote, "[DIRECCION]");
|
||||
break;
|
||||
case "CLABE_INTERBANCARIA":
|
||||
textToInspect = textToInspect.replace(quote, "[CLABE]");
|
||||
break;
|
||||
case "CLAVE_RASTREO_SPEI":
|
||||
textToInspect = textToInspect.replace(quote, "[CLAVE_RASTREO]");
|
||||
break;
|
||||
case "NIP":
|
||||
textToInspect = textToInspect.replace(quote, "[NIP]");
|
||||
break;
|
||||
case "SALDO":
|
||||
textToInspect = textToInspect.replace(quote, "[SALDO]");
|
||||
break;
|
||||
case "CUENTA":
|
||||
textToInspect = textToInspect.replace(quote, "**************" + getLast4(quote));
|
||||
break;
|
||||
case "NUM_ACLARACION":
|
||||
textToInspect = textToInspect.replace(quote, "[NUM_ACLARACION]");
|
||||
break;
|
||||
}
|
||||
}
|
||||
textToInspect = cleanDireccion(textToInspect);
|
||||
return textToInspect;
|
||||
}
|
||||
|
||||
private static String getLast4(String quote) {
|
||||
char[] last4 = new char[4];
|
||||
String cleanQuote = quote.trim();
|
||||
cleanQuote = cleanQuote.replace(" ", "");
|
||||
cleanQuote.getChars(cleanQuote.length() - 4, cleanQuote.length(), last4, 0);
|
||||
return new String(last4);
|
||||
}
|
||||
|
||||
private static String cleanDireccion(String quote) {
|
||||
String output = quote.replaceAll("\\[DIRECCION\\](?:(?:,\\s*|\\s+)\\[DIRECCION\\])*", "[DIRECCION]");
|
||||
return output.trim();
|
||||
}
|
||||
}
|
||||
78
src/main/resources/application-dev.properties
Normal file
78
src/main/resources/application-dev.properties
Normal file
@@ -0,0 +1,78 @@
|
||||
# Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose.
|
||||
# Your use of it is subject to your agreement with Google.
|
||||
|
||||
# =========================================
|
||||
# Spring Boot Configuration Template
|
||||
# =========================================
|
||||
# This file serves as a reference template for all application configuration properties.
|
||||
|
||||
# Best Practices:
|
||||
# - Use Spring Profiles (e.g., application-dev.properties, application-prod.properties)
|
||||
# to manage environment-specific settings.
|
||||
# - Do not store in PROD sensitive information directly here.
|
||||
# Use environment variables or a configuration server for production environments.
|
||||
# - This template can be adapted for logging configuration, database connections,
|
||||
# and other external service settings.
|
||||
|
||||
# =========================================================
|
||||
# Orchestrator general Configuration
|
||||
# =========================================================
|
||||
spring.cloud.gcp.project-id=${GCP_PROJECT_ID}
|
||||
# =========================================================
|
||||
# Google Firestore Configuration
|
||||
# =========================================================
|
||||
spring.cloud.gcp.firestore.project-id=${GCP_PROJECT_ID}
|
||||
spring.cloud.gcp.firestore.database-id=${GCP_FIRESTORE_DATABASE_ID}
|
||||
spring.cloud.gcp.firestore.host=${GCP_FIRESTORE_HOST}
|
||||
spring.cloud.gcp.firestore.port=${GCP_FIRESTORE_PORT}
|
||||
# =========================================================
|
||||
# Google Memorystore(Redis) Configuration
|
||||
# =========================================================
|
||||
spring.data.redis.host=${REDIS_HOST}
|
||||
spring.data.redis.port=${REDIS_PORT}
|
||||
#spring.data.redis.password=${REDIS_PWD}
|
||||
#spring.data.redis.username=default
|
||||
|
||||
# SSL Configuration (if using SSL)
|
||||
# spring.data.redis.ssl=true
|
||||
# spring.data.redis.ssl.key-store=classpath:keystore.p12
|
||||
# spring.data.redis.ssl.key-store-password=${REDIS_KEY_PWD}
|
||||
# =========================================================
|
||||
# Google Conversational Agents Configuration
|
||||
# =========================================================
|
||||
dialogflow.cx.project-id=${DIALOGFLOW_CX_PROJECT_ID}
|
||||
dialogflow.cx.location=${DIALOGFLOW_CX_LOCATION}
|
||||
dialogflow.cx.agent-id=${DIALOGFLOW_CX_AGENT_ID}
|
||||
dialogflow.default-language-code=${DIALOGFLOW_DEFAULT_LANGUAGE_CODE}
|
||||
# =========================================================
|
||||
# Google Generative AI (Gemini) Configuration
|
||||
# =========================================================
|
||||
google.cloud.project=${GCP_PROJECT_ID}
|
||||
google.cloud.location=${GCP_LOCATION}
|
||||
gemini.model.name=${GEMINI_MODEL_NAME}
|
||||
# =========================================================
|
||||
# (Gemini) MessageFilter Configuration
|
||||
# =========================================================
|
||||
messagefilter.geminimodel=${MESSAGE_FILTER_GEMINI_MODEL}
|
||||
messagefilter.temperature=${MESSAGE_FILTER_TEMPERATURE}
|
||||
messagefilter.maxOutputTokens=${MESSAGE_FILTER_MAX_OUTPUT_TOKENS}
|
||||
messagefilter.topP=${MESSAGE_FILTER_TOP_P}
|
||||
messagefilter.prompt=prompts/message_filter_prompt.txt
|
||||
# =========================================================
|
||||
# (DLP) Configuration
|
||||
# =========================================================
|
||||
google.cloud.dlp.dlpTemplateCompleteFlow=${DLP_TEMPLATE_COMPLETE_FLOW}
|
||||
# =========================================================
|
||||
# Quick-replies Preset-data
|
||||
# =========================================================
|
||||
firestore.data.importer.enabled=${GCP_FIRESTORE_IMPORTER_ENABLE}
|
||||
# =========================================================
|
||||
# LOGGING Configuration
|
||||
# =========================================================
|
||||
logging.level.root=${LOGGING_LEVEL_ROOT:INFO}
|
||||
logging.level.com.example=${LOGGING_LEVEL_COM_EXAMPLE:INFO}
|
||||
# =========================================================
|
||||
# ConversationContext Configuration
|
||||
# =========================================================
|
||||
conversation.context.message.limit=${CONVERSATION_CONTEXT_MESSAGE_LIMIT}
|
||||
conversation.context.days.limit=${CONVERSATION_CONTEXT_DAYS_LIMIT}
|
||||
79
src/main/resources/application-prod.properties
Normal file
79
src/main/resources/application-prod.properties
Normal file
@@ -0,0 +1,79 @@
|
||||
# Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose.
|
||||
# Your use of it is subject to your agreement with Google.
|
||||
|
||||
# =========================================
|
||||
# Spring Boot Configuration Template
|
||||
# =========================================
|
||||
# This file serves as a reference template for all application configuration properties.
|
||||
|
||||
# Best Practices:
|
||||
# - Use Spring Profiles (e.g., application-dev.properties, application-prod.properties)
|
||||
# to manage environment-specific settings.
|
||||
# - Do not store in PROD sensitive information directly here.
|
||||
# Use environment variables or a configuration server for production environments.
|
||||
# - This template can be adapted for logging configuration, database connections,
|
||||
# and other external service settings.
|
||||
|
||||
# =========================================================
|
||||
# Orchestrator general Configuration
|
||||
# =========================================================
|
||||
spring.cloud.gcp.project-id=${GCP_PROJECT_ID}
|
||||
# =========================================================
|
||||
# Google Firestore Configuration
|
||||
# =========================================================
|
||||
spring.cloud.gcp.firestore.project-id=${GCP_PROJECT_ID}
|
||||
spring.cloud.gcp.firestore.database-id=${GCP_FIRESTORE_DATABASE_ID}
|
||||
spring.cloud.gcp.firestore.host=${GCP_FIRESTORE_HOST}
|
||||
spring.cloud.gcp.firestore.port=${GCP_FIRESTORE_PORT}
|
||||
# =========================================================
|
||||
# Google Memorystore(Redis) Configuration
|
||||
# =========================================================
|
||||
spring.data.redis.host=${REDIS_HOST}
|
||||
spring.data.redis.port=${REDIS_PORT}
|
||||
#spring.data.redis.password=${REDIS_PWD}
|
||||
#spring.data.redis.username=default
|
||||
|
||||
# SSL Configuration (if using SSL)
|
||||
# spring.data.redis.ssl=true
|
||||
# spring.data.redis.ssl.key-store=classpath:keystore.p12
|
||||
# spring.data.redis.ssl.key-store-password=${REDIS_KEY_PWD}
|
||||
# =========================================================
|
||||
# Google Conversational Agents Configuration
|
||||
# =========================================================
|
||||
dialogflow.cx.project-id=${DIALOGFLOW_CX_PROJECT_ID}
|
||||
dialogflow.cx.location=${DIALOGFLOW_CX_LOCATION}
|
||||
dialogflow.cx.agent-id=${DIALOGFLOW_CX_AGENT_ID}
|
||||
dialogflow.default-language-code=${DIALOGFLOW_DEFAULT_LANGUAGE_CODE}
|
||||
# =========================================================
|
||||
# Google Generative AI (Gemini) Configuration
|
||||
# =========================================================
|
||||
google.cloud.project=${GCP_PROJECT_ID}
|
||||
google.cloud.location=${GCP_LOCATION}
|
||||
gemini.model.name=${GEMINI_MODEL_NAME}
|
||||
# =========================================================
|
||||
# (Gemini) MessageFilter Configuration
|
||||
# =========================================================
|
||||
messagefilter.geminimodel=${MESSAGE_FILTER_GEMINI_MODEL}
|
||||
messagefilter.temperature=${MESSAGE_FILTER_TEMPERATURE}
|
||||
messagefilter.maxOutputTokens=${MESSAGE_FILTER_MAX_OUTPUT_TOKENS}
|
||||
messagefilter.topP=${MESSAGE_FILTER_TOP_P}
|
||||
messagefilter.prompt=prompts/message_filter_prompt.txt
|
||||
# =========================================================
|
||||
# (DLP) Configuration
|
||||
# =========================================================
|
||||
google.cloud.dlp.dlpTemplateCompleteFlow=${DLP_TEMPLATE_COMPLETE_FLOW}
|
||||
google.cloud.dlp.dlpTemplatePersistFlow=${DLP_TEMPLATE_PERSIST_FLOW}
|
||||
# =========================================================
|
||||
# Quick-replies Preset-data
|
||||
# =========================================================
|
||||
firestore.data.importer.enabled=${GCP_FIRESTORE_IMPORTER_ENABLE}
|
||||
# =========================================================
|
||||
# LOGGING Configuration
|
||||
# =========================================================
|
||||
logging.level.root=${LOGGING_LEVEL_ROOT:INFO}
|
||||
logging.level.com.example=${LOGGING_LEVEL_COM_EXAMPLE:INFO}
|
||||
# =========================================================
|
||||
# ConversationContext Configuration
|
||||
# =========================================================
|
||||
conversation.context.message.limit=${CONVERSATION_CONTEXT_MESSAGE_LIMIT}
|
||||
conversation.context.days.limit=${CONVERSATION_CONTEXT_DAYS_LIMIT}
|
||||
79
src/main/resources/application-qa.properties
Normal file
79
src/main/resources/application-qa.properties
Normal file
@@ -0,0 +1,79 @@
|
||||
# Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose.
|
||||
# Your use of it is subject to your agreement with Google.
|
||||
|
||||
# =========================================
|
||||
# Spring Boot Configuration Template
|
||||
# =========================================
|
||||
# This file serves as a reference template for all application configuration properties.
|
||||
|
||||
# Best Practices:
|
||||
# - Use Spring Profiles (e.g., application-dev.properties, application-prod.properties)
|
||||
# to manage environment-specific settings.
|
||||
# - Do not store in PROD sensitive information directly here.
|
||||
# Use environment variables or a configuration server for production environments.
|
||||
# - This template can be adapted for logging configuration, database connections,
|
||||
# and other external service settings.
|
||||
|
||||
# =========================================================
|
||||
# Orchestrator general Configuration
|
||||
# =========================================================
|
||||
spring.cloud.gcp.project-id=${GCP_PROJECT_ID}
|
||||
# =========================================================
|
||||
# Google Firestore Configuration
|
||||
# =========================================================
|
||||
spring.cloud.gcp.firestore.project-id=${GCP_PROJECT_ID}
|
||||
spring.cloud.gcp.firestore.database-id=${GCP_FIRESTORE_DATABASE_ID}
|
||||
spring.cloud.gcp.firestore.host=${GCP_FIRESTORE_HOST}
|
||||
spring.cloud.gcp.firestore.port=${GCP_FIRESTORE_PORT}
|
||||
# =========================================================
|
||||
# Google Memorystore(Redis) Configuration
|
||||
# =========================================================
|
||||
spring.data.redis.host=${REDIS_HOST}
|
||||
spring.data.redis.port=${REDIS_PORT}
|
||||
#spring.data.redis.password=${REDIS_PWD}
|
||||
#spring.data.redis.username=default
|
||||
|
||||
# SSL Configuration (if using SSL)
|
||||
# spring.data.redis.ssl=true
|
||||
# spring.data.redis.ssl.key-store=classpath:keystore.p12
|
||||
# spring.data.redis.ssl.key-store-password=${REDIS_KEY_PWD}
|
||||
# =========================================================
|
||||
# Google Conversational Agents Configuration
|
||||
# =========================================================
|
||||
dialogflow.cx.project-id=${DIALOGFLOW_CX_PROJECT_ID}
|
||||
dialogflow.cx.location=${DIALOGFLOW_CX_LOCATION}
|
||||
dialogflow.cx.agent-id=${DIALOGFLOW_CX_AGENT_ID}
|
||||
dialogflow.default-language-code=${DIALOGFLOW_DEFAULT_LANGUAGE_CODE}
|
||||
# =========================================================
|
||||
# Google Generative AI (Gemini) Configuration
|
||||
# =========================================================
|
||||
google.cloud.project=${GCP_PROJECT_ID}
|
||||
google.cloud.location=${GCP_LOCATION}
|
||||
gemini.model.name=${GEMINI_MODEL_NAME}
|
||||
# =========================================================
|
||||
# (Gemini) MessageFilter Configuration
|
||||
# =========================================================
|
||||
messagefilter.geminimodel=${MESSAGE_FILTER_GEMINI_MODEL}
|
||||
messagefilter.temperature=${MESSAGE_FILTER_TEMPERATURE}
|
||||
messagefilter.maxOutputTokens=${MESSAGE_FILTER_MAX_OUTPUT_TOKENS}
|
||||
messagefilter.topP=${MESSAGE_FILTER_TOP_P}
|
||||
messagefilter.prompt=prompts/message_filter_prompt.txt
|
||||
# =========================================================
|
||||
# (DLP) Configuration
|
||||
# =========================================================
|
||||
google.cloud.dlp.dlpTemplateCompleteFlow=${DLP_TEMPLATE_COMPLETE_FLOW}
|
||||
google.cloud.dlp.dlpTemplatePersistFlow=${DLP_TEMPLATE_PERSIST_FLOW}
|
||||
# =========================================================
|
||||
# Quick-replies Preset-data
|
||||
# =========================================================
|
||||
firestore.data.importer.enabled=${GCP_FIRESTORE_IMPORTER_ENABLE}
|
||||
# =========================================================
|
||||
# LOGGING Configuration
|
||||
# =========================================================
|
||||
logging.level.root=${LOGGING_LEVEL_ROOT:INFO}
|
||||
logging.level.com.example=${LOGGING_LEVEL_COM_EXAMPLE:INFO}
|
||||
# =========================================================
|
||||
# ConversationContext Configuration
|
||||
# =========================================================
|
||||
conversation.context.message.limit=${CONVERSATION_CONTEXT_MESSAGE_LIMIT}
|
||||
conversation.context.days.limit=${CONVERSATION_CONTEXT_DAYS_LIMIT}
|
||||
1
src/main/resources/application.properties
Normal file
1
src/main/resources/application.properties
Normal file
@@ -0,0 +1 @@
|
||||
spring.profiles.active=${SPRING_PROFILE}
|
||||
93
src/main/resources/prompts/message_filter_prompt.txt
Normal file
93
src/main/resources/prompts/message_filter_prompt.txt
Normal file
@@ -0,0 +1,93 @@
|
||||
Hay un sistema de conversaciones entre un agente y un usuario. Durante
|
||||
la conversación, una notificación puede entrar a la conversación de forma
|
||||
abrupta, de tal forma que la siguiente interacción del usuario después
|
||||
de la notificación puede corresponder a la conversación que estaba
|
||||
sucediendo o puede ser un seguimiento a la notificación.
|
||||
|
||||
Tu tarea es identificar si la siguiente interacción del usuario es un
|
||||
seguimiento a la notificación o una continuación de la conversación.
|
||||
|
||||
Recibirás esta información:
|
||||
|
||||
- HISTORIAL_CONVERSACION: El diálogo entre el agente y el usuario antes
|
||||
de la notificación.
|
||||
- INTERRUPCION_NOTIFICACION: La notificación. Esta puede o no traer parámetros
|
||||
los cuales refieren a detalles específicos de la notificación. Por ejemplo:
|
||||
{ "vigencia": “12 de septiembre de 2025”, "credito_tipo" : "platinum" }
|
||||
- INTERACCION_USUARIO: La siguiente interacción del usuario después de
|
||||
la notificación.
|
||||
|
||||
Reglas:
|
||||
- Solo debes responder una palabra: NOTIFICATION o CONVERSATION. No agregues
|
||||
o inventes otra palabra.
|
||||
- Clasifica como NOTIFICATION si la siguiente interacción del usuario
|
||||
es una clara respuesta o seguimiento a la notificación.
|
||||
- Clasifica como CONVERSATION si la siguiente interacción del usuario
|
||||
es un claro seguimiento al histórico de la conversación.
|
||||
- Si la siguiente interacción del usuario es ambigua, clasifica
|
||||
como CONVERSATION.
|
||||
|
||||
Ejemplos:
|
||||
|
||||
Ejemplo 1:
|
||||
HISTORIAL_CONVERSACION:
|
||||
Agente: Claro, para un crédito de vehículo, las tasas actuales inician en el 1.2%% mensual.
|
||||
Usuario: Entiendo, ¿y el plazo máximo de cuánto sería?
|
||||
INTERRUPCION_NOTIFICACION:
|
||||
Tu pago de la tarjeta de crédito por $1,500.00 ha sido procesado.
|
||||
INTERACCION_USUARIO:
|
||||
perfecto, cuando es la fecha de corte?
|
||||
Clasificación: NOTIFICACION
|
||||
|
||||
Ejemplo 2:
|
||||
HISTORIAL_CONVERSACION:
|
||||
Agente: No es necesario, puedes completar todo el proceso para abrir tu cuenta desde nuestra app.
|
||||
Usuario: Ok
|
||||
Agente: ¿Necesitas algo más?
|
||||
INTERRUPCION_NOTIFICACION:
|
||||
Tu estado de cuenta de Julio ya está disponible.
|
||||
Parametros: {"fecha_corte": "30 de Agosto del 2025", "tipo_cuenta": "credito"}
|
||||
INTERACCION_USUARIO:
|
||||
que documentos necesito?
|
||||
Clasificación: CONVERSACION
|
||||
|
||||
Ejemplo 3:
|
||||
HISTORIAL_CONVERSACION:
|
||||
Agente: Ese fondo de inversión tiene un perfil de alto riesgo, pero históricamente ha dado un rendimiento superior al 15%% anual.
|
||||
Usuario: ok, entiendo
|
||||
INTERRUPCION_NOTIFICACION:
|
||||
Alerta: Tu cuenta de ahorros tiene un saldo bajo de $50.00.
|
||||
Parametros: {"fecha_retiro": "5 de septiembre del 2025", "tipo_cuenta": "ahorros"}
|
||||
INTERACCION_USUARIO:
|
||||
cuando fue el ultimo retiro?
|
||||
Clasificación: NOTIFICACION
|
||||
|
||||
Ejemplo 4:
|
||||
HISTORIAL_CONVERSACION:
|
||||
Usuario: Que es el CAT?
|
||||
Agente: El CAT (Costo Anual Total) es un indicador financiero, expresado en un porcentaje anual, que refleja el costo total de un crédito, incluyendo no solo la tasa de interés, sino también todas las comisiones, gastos y otros cobros que genera.
|
||||
INTERRUPCION_NOTIFICACION:
|
||||
Alerta: Se realizó un retiro en efectivo por $100.
|
||||
INTERACCION_USUARIO:
|
||||
y este se aplica solo si dejo de pagar?
|
||||
Clasificación: CONVERSACION
|
||||
|
||||
Ejemplo 5:
|
||||
HISTORIAL_CONVERSACION:
|
||||
Usuario: Cual es la tasa de hipoteca que manejan?
|
||||
Agente: La tasa de una hipoteca depende tanto de factores económicos generales (inflación, tasas de referencia del banco central) como de factores individuales del solicitante (historial crediticio, monto del pago inicial, ingresos, endeudamiento, etc.)
|
||||
INTERRUPCION_NOTIFICACION:
|
||||
Hola, [Alias]: Pasó algo con la captura de tu INE y no se completó tu solicitud de tarjeta de crédito con folio 3421.
|
||||
Parametros: {“solicitud_tarjeta_credito_vigencia”: “12 de septiembre de 2025”, “solicitud_tarjeta_credito_error”: “Error con el formato de la captura”, “solicitud_tarjeta_credito_tipo” : “platinum” }
|
||||
INTERACCION_USUARIO:
|
||||
cual fue el error?
|
||||
Clasificación: NOTIFICACION
|
||||
|
||||
Tarea:
|
||||
HISTORIAL_CONVERSACION:
|
||||
%s
|
||||
INTERRUPCION_NOTIFICACION:
|
||||
%s
|
||||
INTERACCION_USUARIO:
|
||||
%s
|
||||
Clasificación:
|
||||
84
src/main/resources/prompts/notification_context_resolver.txt
Normal file
84
src/main/resources/prompts/notification_context_resolver.txt
Normal file
@@ -0,0 +1,84 @@
|
||||
Eres un agente conversacional de soporte al usuario, amable, servicial y conciso.
|
||||
|
||||
Recibirás cuatro piezas de información:
|
||||
1. HISTORIAL_CONVERSACION: El diálogo previo con el usuario. Úsalo para entender el contexto y evitar repetir información.
|
||||
2. NOTIFICACION: El texto del mensaje que el usuario acaba de recibir.
|
||||
3. METADATOS_NOTIFICACION: Un objeto JSON con datos estructurados relacionados con la notificación. Esta es tu fuente de verdad principal.
|
||||
4. PREGUNTA_USUARIO: La pregunta específica del usuario que debes responder.
|
||||
|
||||
Tu objetivo es sintetizar la información de estas fuentes para dar la respuesta más directa y útil posible.
|
||||
|
||||
**Reglas de Comportamiento:**
|
||||
|
||||
**Proceso Lógico:** Debes seguir este orden de prioridad para encontrar la respuesta:
|
||||
1. Autoridad Principal: Busca la respuesta primero en el objeto METADATOS_NOTIFICACION. Los datos aquí tienen la máxima autoridad.
|
||||
2. Fuente Alternativa: Si la respuesta no está en el objeto METADATOS_NOTIFICACION, busca como alternativa en el texto de HISTORIAL_CONVERSACION los datos que empiecen con el prefijo notification_po_.
|
||||
3. Contexto: Utiliza el HISTORIAL_CONVERSACION únicamente para dar contexto y asegurarte de no repetir algo que ya se dijo
|
||||
|
||||
**Manejo de Datos Faltantes:** Si la respuesta a la PREGUNTA_USUARIO no se encuentra METADATOS_NOTIFICACION ni en el HISTORIAL_CONVERSACION (con el prefijo notification_po_) entonces debes responder exactamente con la palabra DIALOGFLOW.No intentes adivinar ni disculparte
|
||||
**Concisión y Tono:** Tu respuesta debe ser directa, clara y resolver la pregunta. Mantén un tono profesional, amable y servicial.
|
||||
**Idioma:** Responde siempre en el mismo idioma de la PREGUNTA_USUARIO.
|
||||
|
||||
Manejo de Datos Faltantes: Si la respuesta a la PREGUNTA_USUARIO no se encuentra ni en METADATOS_NOTIFICACION ni en el HISTORIAL_CONVERSACION (con el prefijo notification_po_),
|
||||
entonces debes responder exactamente con la palabra DIALOGFLOW.
|
||||
No intentes adivinar ni disculparte.
|
||||
|
||||
Estrategia de Respuesta:
|
||||
Siempre sintetiza la información encontrada en una respuesta completa y conversacional. No devuelvas solo el dato. Utiliza el dato para construir una frase que sea útil y siga el tono. Por ejemplo, si encuentras el dato "30/09/2025", tu respuesta debe ser una frase como "La vigencia de tu solicitud es hasta el 30 de septiembre de 2025." o similar.
|
||||
|
||||
**Ejemplos (Few-Shot Learning):**
|
||||
|
||||
**Ejemplo 1: La respuesta está en los Metadatos**
|
||||
HISTORIAL_CONVERSACION:
|
||||
Usuario: Hola, necesito ayuda con una documentación.
|
||||
Agente: Claro, ¿en qué puedo ayudarte?
|
||||
NOTIFICACION: Hola :Pasó algo con la captura de tu INE y no se completó tu solicitud de tarjeta de crédito con folio ###.¡Reinténtalo cuando quieras! Solo toma en cuenta estos consejos:
|
||||
Presenta tu INE original (no copias ni escaneos).📅Revisa que esté vigente y sin tachaduras.📷 Confirma que la fotografía sea clara.🏠 Asegúrate de que la dirección sea legible.
|
||||
Estamos listos para recibirte.
|
||||
METADATOS_NOTIFICACION: {
|
||||
"parametrosOcultos": {
|
||||
"vigencia": "30/09/2025"
|
||||
}
|
||||
}
|
||||
PREGUNTA_USUARIO: ¿Hasta cuando esta disponible esta solicitud?
|
||||
Respuesta: Tienes hasta el 30 de septiembre de 2025 para revisarlos.
|
||||
|
||||
**Ejemplo 2: Poca Información encontrada en texto de Notificacion *
|
||||
HISTORIAL_CONVERSACION:
|
||||
Usuario: Hola.
|
||||
Agente: ¡Qué onda! Soy Beto, tu asistente virtual de Sigma. ¿Como te puedo ayudar hoy? 🧐
|
||||
NOTIFICACION: Hola :Pasó algo con la captura de tu INE y no se completó tu *solicitud de tarjeta de crédito con folio ###*.
|
||||
¡Reinténtalo cuando quieras! Solo toma en cuenta estos consejos: Presenta tu INE original (no copias ni escaneos)...
|
||||
Estamos listos para recibirte.
|
||||
METADATOS_NOTIFICACION: {
|
||||
"parametrosOcultos": {
|
||||
"vigencia": "30/09/2025"
|
||||
}
|
||||
}
|
||||
PREGUNTA_USUARIO: Mi INE tiene algunas tachaduras y en general esta en mal estado
|
||||
Respuesta: DIALOGFLOW
|
||||
|
||||
**Ejemplo 3: Información no encontrada en ninguna fuente**
|
||||
HISTORIAL_CONVERSACION:
|
||||
Usuario: ¿Cómo van mis trámites?
|
||||
Agente: Veo que tienes una cita de mantenimiento programada.
|
||||
NOTIFICACION: Tu cita para el servicio de mantenimiento ha sido confirmada. Por favor, llega 15 minutos antes.
|
||||
METADATOS_NOTIFICACION: {
|
||||
"tipo_servicio": "mantenimiento rutinario",
|
||||
"ubicacion": "Sucursal Centro",
|
||||
"id_cita": "C-182736"
|
||||
}
|
||||
PREGUNTA_USUARIO: Perfecto, ¿cuál será el costo del mantenimiento?
|
||||
Respuesta: DIALOGFLOW
|
||||
|
||||
Historial de Conversación:
|
||||
%s
|
||||
|
||||
Notificación:
|
||||
%s
|
||||
|
||||
Metadatos de la Notificación:
|
||||
%s
|
||||
|
||||
Pregunta del Usuario:
|
||||
%s
|
||||
1
src/main/resources/quick-replies/capsulas.json
Normal file
1
src/main/resources/quick-replies/capsulas.json
Normal file
@@ -0,0 +1 @@
|
||||
{"titulo": "Capsulas"}
|
||||
1
src/main/resources/quick-replies/descubre.json
Normal file
1
src/main/resources/quick-replies/descubre.json
Normal file
@@ -0,0 +1 @@
|
||||
{"titulo": "Descubre"}
|
||||
1
src/main/resources/quick-replies/detalle-tdc.json
Normal file
1
src/main/resources/quick-replies/detalle-tdc.json
Normal file
@@ -0,0 +1 @@
|
||||
{"titulo": "Detalle TDC"}
|
||||
1
src/main/resources/quick-replies/detalle-tdd.json
Normal file
1
src/main/resources/quick-replies/detalle-tdd.json
Normal file
@@ -0,0 +1 @@
|
||||
{"titulo": "Detalle TDD"}
|
||||
1
src/main/resources/quick-replies/finanzas.json
Normal file
1
src/main/resources/quick-replies/finanzas.json
Normal file
@@ -0,0 +1 @@
|
||||
{"titulo": "Finanzas"}
|
||||
1
src/main/resources/quick-replies/home.json
Normal file
1
src/main/resources/quick-replies/home.json
Normal file
@@ -0,0 +1 @@
|
||||
{"titulo": "Home"}
|
||||
1
src/main/resources/quick-replies/inversiones.json
Normal file
1
src/main/resources/quick-replies/inversiones.json
Normal file
@@ -0,0 +1 @@
|
||||
{"titulo": "Inversiones"}
|
||||
1
src/main/resources/quick-replies/lealtad.json
Normal file
1
src/main/resources/quick-replies/lealtad.json
Normal file
@@ -0,0 +1 @@
|
||||
{"titulo": "Lealtad"}
|
||||
18
src/main/resources/quick-replies/pagos.json
Normal file
18
src/main/resources/quick-replies/pagos.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"header": "preguntas frecuentes",
|
||||
"body": "Aquí tienes las preguntas frecuentes que suelen hacernos algunos de nuestros clientes",
|
||||
"button": "Ver",
|
||||
"header_section": "preguntas sobre pagos",
|
||||
"preguntas": [
|
||||
{
|
||||
"titulo": "Donde veo mi historial de pagos?",
|
||||
"descripcion": "View your recent payments",
|
||||
"respuesta": "puedes visualizar esto en la opcion X de tu app"
|
||||
},
|
||||
{
|
||||
"titulo": "Pregunta servicio A",
|
||||
"descripcion": "descripcion servicio A",
|
||||
"respuesta": "puedes ver info de servicio A en tu app"
|
||||
}
|
||||
]
|
||||
}
|
||||
1
src/main/resources/quick-replies/prestamos.json
Normal file
1
src/main/resources/quick-replies/prestamos.json
Normal file
@@ -0,0 +1 @@
|
||||
{"titulo": "Prestamos"}
|
||||
1
src/main/resources/quick-replies/retiro-sin-tarjeta.json
Normal file
1
src/main/resources/quick-replies/retiro-sin-tarjeta.json
Normal file
@@ -0,0 +1 @@
|
||||
{"titulo": "Retiro sin tarjeta"}
|
||||
1
src/main/resources/quick-replies/transferencia.json
Normal file
1
src/main/resources/quick-replies/transferencia.json
Normal file
@@ -0,0 +1 @@
|
||||
{"titulo": "Transferencia"}
|
||||
@@ -0,0 +1,133 @@
|
||||
|
||||
package com.example.mapper.conversation;
|
||||
|
||||
import com.example.dto.dialogflow.base.DetectIntentRequestDTO;
|
||||
import com.example.dto.dialogflow.conversation.QueryInputDTO;
|
||||
import com.example.dto.dialogflow.conversation.QueryParamsDTO;
|
||||
import com.example.dto.dialogflow.conversation.TextInputDTO;
|
||||
import com.example.dto.dialogflow.notification.EventInputDTO;
|
||||
import com.google.cloud.dialogflow.cx.v3.DetectIntentRequest;
|
||||
import com.google.cloud.dialogflow.cx.v3.QueryInput;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class DialogflowRequestMapperTest {
|
||||
|
||||
@InjectMocks
|
||||
private DialogflowRequestMapper dialogflowRequestMapper;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
ReflectionTestUtils.setField(dialogflowRequestMapper, "defaultLanguageCode", "es");
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapToDetectIntentRequestBuilder_withTextInput_shouldMapCorrectly() {
|
||||
// Given
|
||||
TextInputDTO textInputDTO = new TextInputDTO("Hola");
|
||||
QueryInputDTO queryInputDTO = new QueryInputDTO(textInputDTO, null, "es");
|
||||
DetectIntentRequestDTO requestDTO = new DetectIntentRequestDTO(queryInputDTO, null);
|
||||
|
||||
// When
|
||||
DetectIntentRequest.Builder builder = dialogflowRequestMapper.mapToDetectIntentRequestBuilder(requestDTO);
|
||||
DetectIntentRequest request = builder.build();
|
||||
|
||||
// Then
|
||||
assertNotNull(request);
|
||||
assertTrue(request.hasQueryInput());
|
||||
QueryInput queryInput = request.getQueryInput();
|
||||
assertEquals("es", queryInput.getLanguageCode());
|
||||
assertTrue(queryInput.hasText());
|
||||
assertEquals("Hola", queryInput.getText().getText());
|
||||
assertFalse(queryInput.hasEvent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapToDetectIntentRequestBuilder_withEventInput_shouldMapCorrectly() {
|
||||
// Given
|
||||
EventInputDTO eventInputDTO = new EventInputDTO("welcome_event");
|
||||
QueryInputDTO queryInputDTO = new QueryInputDTO(null, eventInputDTO, "es");
|
||||
DetectIntentRequestDTO requestDTO = new DetectIntentRequestDTO(queryInputDTO, null);
|
||||
|
||||
// When
|
||||
DetectIntentRequest.Builder builder = dialogflowRequestMapper.mapToDetectIntentRequestBuilder(requestDTO);
|
||||
DetectIntentRequest request = builder.build();
|
||||
|
||||
// Then
|
||||
assertNotNull(request);
|
||||
assertTrue(request.hasQueryInput());
|
||||
QueryInput queryInput = request.getQueryInput();
|
||||
assertEquals("es", queryInput.getLanguageCode());
|
||||
assertTrue(queryInput.hasEvent());
|
||||
assertEquals("welcome_event", queryInput.getEvent().getEvent());
|
||||
assertFalse(queryInput.hasText());
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapToDetectIntentRequestBuilder_withNoInput_shouldThrowException() {
|
||||
// Given
|
||||
QueryInputDTO queryInputDTO = new QueryInputDTO(null, null, "es");
|
||||
DetectIntentRequestDTO requestDTO = new DetectIntentRequestDTO(queryInputDTO, null);
|
||||
|
||||
// When & Then
|
||||
assertThrows(IllegalArgumentException.class, () -> {
|
||||
dialogflowRequestMapper.mapToDetectIntentRequestBuilder(requestDTO);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapToDetectIntentRequestBuilder_withParameters_shouldMapCorrectly() {
|
||||
// Given
|
||||
TextInputDTO textInputDTO = new TextInputDTO("Hola");
|
||||
QueryInputDTO queryInputDTO = new QueryInputDTO(textInputDTO, null, "es");
|
||||
Map<String, Object> parameters = Collections.singletonMap("param1", "value1");
|
||||
QueryParamsDTO queryParamsDTO = new QueryParamsDTO(parameters);
|
||||
DetectIntentRequestDTO requestDTO = new DetectIntentRequestDTO(queryInputDTO, queryParamsDTO);
|
||||
|
||||
// When
|
||||
DetectIntentRequest.Builder builder = dialogflowRequestMapper.mapToDetectIntentRequestBuilder(requestDTO);
|
||||
DetectIntentRequest request = builder.build();
|
||||
|
||||
// Then
|
||||
assertNotNull(request);
|
||||
assertTrue(request.hasQueryParams());
|
||||
assertTrue(request.getQueryParams().hasParameters());
|
||||
assertEquals("value1", request.getQueryParams().getParameters().getFieldsMap().get("param1").getStringValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapToDetectIntentRequestBuilder_withNullRequestDTO_shouldThrowException() {
|
||||
// When & Then
|
||||
assertThrows(NullPointerException.class, () -> {
|
||||
dialogflowRequestMapper.mapToDetectIntentRequestBuilder(null);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapToDetectIntentRequestBuilder_withDefaultLanguageCode_shouldMapCorrectly() {
|
||||
// Given
|
||||
TextInputDTO textInputDTO = new TextInputDTO("Hola");
|
||||
QueryInputDTO queryInputDTO = new QueryInputDTO(textInputDTO, null, null);
|
||||
DetectIntentRequestDTO requestDTO = new DetectIntentRequestDTO(queryInputDTO, null);
|
||||
|
||||
// When
|
||||
DetectIntentRequest.Builder builder = dialogflowRequestMapper.mapToDetectIntentRequestBuilder(requestDTO);
|
||||
DetectIntentRequest request = builder.build();
|
||||
|
||||
// Then
|
||||
assertNotNull(request);
|
||||
assertTrue(request.hasQueryInput());
|
||||
assertEquals("es", request.getQueryInput().getLanguageCode());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
|
||||
package com.example.mapper.conversation;
|
||||
|
||||
import com.example.dto.dialogflow.base.DetectIntentResponseDTO;
|
||||
import com.example.dto.dialogflow.conversation.QueryResultDTO;
|
||||
import com.google.cloud.dialogflow.cx.v3.DetectIntentResponse;
|
||||
import com.google.cloud.dialogflow.cx.v3.QueryResult;
|
||||
import com.google.cloud.dialogflow.cx.v3.ResponseMessage;
|
||||
import com.google.protobuf.Struct;
|
||||
import com.google.protobuf.Value;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class DialogflowResponseMapperTest {
|
||||
|
||||
@InjectMocks
|
||||
private DialogflowResponseMapper dialogflowResponseMapper;
|
||||
|
||||
@Test
|
||||
void mapFromDialogflowResponse_shouldMapCorrectly() {
|
||||
// Given
|
||||
ResponseMessage.Text text1 = ResponseMessage.Text.newBuilder()
|
||||
.addAllText(Collections.singletonList("Hello")).build();
|
||||
ResponseMessage message1 = ResponseMessage.newBuilder().setText(text1).build();
|
||||
ResponseMessage.Text text2 = ResponseMessage.Text.newBuilder()
|
||||
.addAllText(Collections.singletonList("World")).build();
|
||||
ResponseMessage message2 = ResponseMessage.newBuilder().setText(text2).build();
|
||||
|
||||
Struct params = Struct.newBuilder()
|
||||
.putFields("param1", Value.newBuilder().setStringValue("value1").build())
|
||||
.putFields("param2", Value.newBuilder().setNumberValue(123).build())
|
||||
.build();
|
||||
|
||||
QueryResult queryResult = QueryResult.newBuilder()
|
||||
.addAllResponseMessages(Arrays.asList(message1, message2))
|
||||
.setParameters(params)
|
||||
.build();
|
||||
|
||||
DetectIntentResponse detectIntentResponse = DetectIntentResponse.newBuilder()
|
||||
.setResponseId("test-response-id")
|
||||
.setQueryResult(queryResult)
|
||||
.build();
|
||||
|
||||
// When
|
||||
DetectIntentResponseDTO responseDTO = dialogflowResponseMapper
|
||||
.mapFromDialogflowResponse(detectIntentResponse, "test-session-id");
|
||||
|
||||
// Then
|
||||
assertNotNull(responseDTO);
|
||||
assertEquals("test-response-id", responseDTO.responseId());
|
||||
|
||||
QueryResultDTO queryResultDTO = responseDTO.queryResult();
|
||||
assertNotNull(queryResultDTO);
|
||||
assertEquals("Hello World", queryResultDTO.responseText());
|
||||
|
||||
Map<String, Object> parameters = queryResultDTO.parameters();
|
||||
assertNotNull(parameters);
|
||||
assertEquals(2, parameters.size());
|
||||
assertEquals("value1", parameters.get("param1"));
|
||||
assertEquals(123.0, parameters.get("param2"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapFromDialogflowResponse_withNoMessages_shouldReturnEmptyFulfillmentText() {
|
||||
// Given
|
||||
QueryResult queryResult = QueryResult.newBuilder()
|
||||
.build();
|
||||
|
||||
DetectIntentResponse detectIntentResponse = DetectIntentResponse.newBuilder()
|
||||
.setResponseId("test-response-id")
|
||||
.setQueryResult(queryResult)
|
||||
.build();
|
||||
|
||||
// When
|
||||
DetectIntentResponseDTO responseDTO = dialogflowResponseMapper
|
||||
.mapFromDialogflowResponse(detectIntentResponse, "test-session-id");
|
||||
|
||||
// Then
|
||||
assertNotNull(responseDTO);
|
||||
assertEquals("test-response-id", responseDTO.responseId());
|
||||
|
||||
QueryResultDTO queryResultDTO = responseDTO.queryResult();
|
||||
assertNotNull(queryResultDTO);
|
||||
assertEquals("", queryResultDTO.responseText());
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapFromDialogflowResponse_withNoParameters_shouldReturnEmptyMap() {
|
||||
// Given
|
||||
ResponseMessage.Text text = ResponseMessage.Text.newBuilder()
|
||||
.addAllText(Collections.singletonList("Hello")).build();
|
||||
ResponseMessage message = ResponseMessage.newBuilder().setText(text).build();
|
||||
|
||||
QueryResult queryResult = QueryResult.newBuilder()
|
||||
.addResponseMessages(message)
|
||||
.build();
|
||||
|
||||
DetectIntentResponse detectIntentResponse = DetectIntentResponse.newBuilder()
|
||||
.setResponseId("test-response-id")
|
||||
.setQueryResult(queryResult)
|
||||
.build();
|
||||
|
||||
// When
|
||||
DetectIntentResponseDTO responseDTO = dialogflowResponseMapper
|
||||
.mapFromDialogflowResponse(detectIntentResponse, "test-session-id");
|
||||
|
||||
// Then
|
||||
assertNotNull(responseDTO);
|
||||
assertEquals("test-response-id", responseDTO.responseId());
|
||||
|
||||
QueryResultDTO queryResultDTO = responseDTO.queryResult();
|
||||
assertNotNull(queryResultDTO);
|
||||
assertEquals("Hello", queryResultDTO.responseText());
|
||||
|
||||
Map<String, Object> parameters = queryResultDTO.parameters();
|
||||
assertNotNull(parameters);
|
||||
assertEquals(0, parameters.size());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package com.example.mapper.messagefilter;
|
||||
|
||||
import com.example.dto.dialogflow.conversation.ConversationMessageDTO;
|
||||
import com.example.dto.dialogflow.conversation.MessageType;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.lang.reflect.Method;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
public class ConversationContextMapperTest {
|
||||
|
||||
@Test
|
||||
public void testCleanAgentMessage() throws Exception {
|
||||
ConversationContextMapper mapper = new ConversationContextMapper();
|
||||
Method method = ConversationContextMapper.class.getDeclaredMethod("cleanAgentMessage", String.class);
|
||||
method.setAccessible(true);
|
||||
|
||||
String input = "Agent: ¡Seguro, déjame buscarlo para ti! 😉 El 'mejor' banco es " +
|
||||
"subjetivo y depende de sus necesidades financieras personales. Para determinar " +
|
||||
"cuál es el más adecuado para usted, considere los siguientes factores:\n" +
|
||||
"* **Comisiones y cargos**: Evalúe las tarifas por mantenimiento de cuenta, " +
|
||||
"transferencias, retiros en cajeros automáticos de otras redes, y otros servicios.\n" +
|
||||
"* **Tasas de interés**: Compare las tasas de interés ofrecidas en cuentas de " +
|
||||
"ahorro, depósitos a plazo fijo y préstamos.\n" +
|
||||
"* **Servicios y productos**: Verifique si el banco ofrece los productos que " +
|
||||
"necesita, como cuentas corrientes, cuentas de ahorro, tarjetas de crédito, " +
|
||||
"hipotecas, inversiones, etc.\n" +
|
||||
"* **Accesibilidad y conveniencia**: Considere la ubicación de sucursales y " +
|
||||
"cajeros automáticos, la calidad de la banca en línea y móvil, y el servicio al " +
|
||||
"cliente.\n" +
|
||||
"* **Tecnología**: Evalúe la facilidad de uso de sus plataformas digitales, la " +
|
||||
"seguridad y las herramientas de gestión financiera que ofrecen.\n" +
|
||||
"**Ejemplo**: Si usted realiza muchas transacciones en línea y rara vez visita una " +
|
||||
"sucursal, un banco con una excelente aplicación móvil y bajas comisiones por " +
|
||||
"transacciones digitales podría ser ideal. Si, por el contrario, prefiere la " +
|
||||
"atención personalizada, un banco con una red de sucursales amplia y un buen " +
|
||||
"servicio al cliente presencial sería más adecuado.\n" +
|
||||
"**Siguientes pasos**: Le recomendamos investigar y comparar al menos tres bancos " +
|
||||
"diferentes basándose en sus prioridades financieras. Revise sus sitios web, lea " +
|
||||
"las condiciones de sus productos y, si es posible, consulte opiniones de otros " +
|
||||
"usuarios para tomar una decisión informada. \n" +
|
||||
"{response=El 'mejor' banco es subjetivo y depende de sus necesidades financieras " +
|
||||
"personales. Para determinar cuál es el más adecuado para usted, considere los " +
|
||||
"siguientes factores:* **Comisiones y cargos**: Evalúe las tarifas por " +
|
||||
"mantenimiento de cuenta, transferencias, retiros en cajeros automáticos de " +
|
||||
"otras redes, y otros servicios.* **Tasas de interés**: Compare las tasas de " +
|
||||
"interés ofrecidas en cuentas de ahorro, depósitos a plazo fijo y préstamos." +
|
||||
"* **Servicios y productos**: Verifique si el banco ofrece los productos que " +
|
||||
"necesita, como cuentas corrientes, cuentas de ahorro, tarjetas de crédito, " +
|
||||
"hipotecas, inversiones, etc.* **Accesibilidad y conveniencia**: Considere la " +
|
||||
"ubicación de sucursales y cajeros automáticos, la calidad de la banca en línea y " +
|
||||
"móvil, y el servicio al cliente.* **Tecnología**: Evalúe la facilidad de uso " +
|
||||
"de sus plataformas digitales, la seguridad y las herramientas de gestión " +
|
||||
"financiera que ofrecen.**Ejemplo**: Si usted realiza muchas transacciones en línea " +
|
||||
"y rara vez visita una sucursal, un banco con una excelente aplicación móvil y " +
|
||||
"bajas comisiones por transacciones digitales podría ser ideal. Si, por el " +
|
||||
"contrario, prefiere la atención personalizada, un banco con una red de " +
|
||||
"sucursales amplia y un buen servicio al cliente presencial sería más adecuado." +
|
||||
"**Siguientes pasos**: Le recomendamos investigar y comparar al menos tres bancos " +
|
||||
"diferentes basándose en sus prioridades financieras. Revise sus sitios web, lea " +
|
||||
"las condiciones de sus productos y, si es posible, consulte opiniones de otros " +
|
||||
"Gente, tomen una decisión informada., telefono=123456789, pregunta_nueva=NO, " +
|
||||
"usuario_id=user_by_phone_123456789, historial=que son las capsulas?cual es la mejor " +
|
||||
"para mi?, query_inicial=Cual es el mejor banco?, canal=sigma, " +
|
||||
"$request.generative.confirmacion_ayuda=¡Seguro, déjame buscarlo para ti! 😉, " +
|
||||
"query=Cual es el mejor banco?, webhook_success=true, " +
|
||||
"$request.generative.respuesta_algo_mas=¿Te puedo echar la mano con otra cosa? ¡Tú dime! 😎, " +
|
||||
"conversacion_notificacion=false, nickname=John Doe, notificacion= }";
|
||||
|
||||
String expected = "Agent: ¡Seguro, déjame buscarlo para ti! 😉 El 'mejor' banco es " +
|
||||
"subjetivo y depende de sus necesidades financieras personales. Para determinar " +
|
||||
"cuál es el más adecuado para usted, considere los siguientes factores:\n" +
|
||||
"* **Comisiones y cargos**: Evalúe las tarifas por mantenimiento de cuenta, " +
|
||||
"transferencias, retiros en cajeros automáticos de otras redes, y otros servicios.\n" +
|
||||
"* **Tasas de interés**: Compare las tasas de interés ofrecidas en cuentas de " +
|
||||
"ahorro, depósitos a plazo fijo y préstamos.\n" +
|
||||
"* **Servicios y productos**: Verifique si el banco ofrece los productos que " +
|
||||
"necesita, como cuentas corrientes, cuentas de ahorro, tarjetas de crédito, " +
|
||||
"hipotecas, inversiones, etc.\n" +
|
||||
"* **Accesibilidad y conveniencia**: Considere la ubicación de sucursales y " +
|
||||
"cajeros automáticos, la calidad de la banca en línea y móvil, y el servicio al " +
|
||||
"cliente.\n" +
|
||||
"* **Tecnología**: Evalúe la facilidad de uso de sus plataformas digitales, la " +
|
||||
"seguridad y las herramientas de gestión financiera que ofrecen.\n" +
|
||||
"**Ejemplo**: Si usted realiza muchas transacciones en línea y rara vez visita una " +
|
||||
"sucursal, un banco con una excelente aplicación móvil y bajas comisiones por " +
|
||||
"transacciones digitales podría ser ideal. Si, por el contrario, prefiere la " +
|
||||
"atención personalizada, un banco con una red de sucursales amplia y un buen " +
|
||||
"servicio al cliente presencial sería más adecuado.\n" +
|
||||
"**Siguientes pasos**: Le recomendamos investigar y comparar al menos tres bancos " +
|
||||
"diferentes basándose en sus prioridades financieras. Revise sus sitios web, lea " +
|
||||
"las condiciones de sus productos y, si es posible, consulte opiniones de otros " +
|
||||
"usuarios para tomar una decisión informada.";
|
||||
String result = (String) method.invoke(mapper, input);
|
||||
assertEquals(expected, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testToTextWithTruncation() {
|
||||
ConversationContextMapper mapper = new ConversationContextMapper();
|
||||
List<ConversationMessageDTO> messages = new ArrayList<>();
|
||||
for (int i = 0; i < 1000; i++) {
|
||||
messages.add(createMessage("This is message " + i, MessageType.USER));
|
||||
}
|
||||
for (int i = 1000; i < 2000; i++) {
|
||||
messages.add(createMessage("This is message " + i, MessageType.AGENT));
|
||||
}
|
||||
|
||||
String result = mapper.toTextWithTruncation(messages);
|
||||
assertTrue(result.length() > 0);
|
||||
assertTrue(result.getBytes(java.nio.charset.StandardCharsets.UTF_8).length <= 50 * 1024);
|
||||
}
|
||||
|
||||
private ConversationMessageDTO createMessage(String text, MessageType type) {
|
||||
return new ConversationMessageDTO(type, Instant.now(), text, null, null);
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
public void testToTextFromMessages_SystemNotification_ShouldUseParamText() {
|
||||
ConversationContextMapper mapper = new ConversationContextMapper();
|
||||
|
||||
Map<String, Object> params = new java.util.HashMap<>();
|
||||
params.put("notification_text", "Tu estado de cuenta está listo");
|
||||
|
||||
ConversationMessageDTO systemMessage = new ConversationMessageDTO(
|
||||
MessageType.SYSTEM,
|
||||
Instant.now(),
|
||||
"NOTIFICATION",
|
||||
params,
|
||||
"whatsapp"
|
||||
);
|
||||
|
||||
List<ConversationMessageDTO> messages = new java.util.ArrayList<>();
|
||||
messages.add(systemMessage);
|
||||
|
||||
// WHEN
|
||||
String result = mapper.toTextFromMessages(messages);
|
||||
System.out.println(result);
|
||||
// THEN
|
||||
assertEquals("System: Tu estado de cuenta está listo", result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
* Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose.
|
||||
* Your use of it is subject to your agreement with Google.
|
||||
*/
|
||||
|
||||
package com.example.service.conversation;
|
||||
|
||||
import com.example.dto.dialogflow.base.DetectIntentRequestDTO;
|
||||
import com.example.dto.dialogflow.base.DetectIntentResponseDTO;
|
||||
import com.example.dto.dialogflow.conversation.*;
|
||||
import com.example.dto.dialogflow.notification.NotificationDTO;
|
||||
import com.example.mapper.conversation.ConversationEntryMapper;
|
||||
import com.example.mapper.conversation.ExternalConvRequestMapper;
|
||||
import com.example.mapper.messagefilter.ConversationContextMapper;
|
||||
import com.example.mapper.messagefilter.NotificationContextMapper;
|
||||
import com.example.service.base.DialogflowClientService;
|
||||
import com.example.service.base.MessageEntryFilter;
|
||||
import com.example.service.base.NotificationContextResolver;
|
||||
import com.example.service.llm.LlmResponseTunerService;
|
||||
import com.example.service.notification.MemoryStoreNotificationService;
|
||||
import com.example.service.quickreplies.QuickRepliesManagerService;
|
||||
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 reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Collections;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
public class ConversationManagerServiceTest {
|
||||
|
||||
@Mock
|
||||
private ExternalConvRequestMapper externalRequestToDialogflowMapper;
|
||||
@Mock
|
||||
private DialogflowClientService dialogflowServiceClient;
|
||||
@Mock
|
||||
private FirestoreConversationService firestoreConversationService;
|
||||
@Mock
|
||||
private MemoryStoreConversationService memoryStoreConversationService;
|
||||
@Mock
|
||||
private QuickRepliesManagerService quickRepliesManagerService;
|
||||
@Mock
|
||||
private MessageEntryFilter messageEntryFilter;
|
||||
@Mock
|
||||
private MemoryStoreNotificationService memoryStoreNotificationService;
|
||||
@Mock
|
||||
private NotificationContextMapper notificationContextMapper;
|
||||
@Mock
|
||||
private ConversationContextMapper conversationContextMapper;
|
||||
@Mock
|
||||
private DataLossPrevention dataLossPrevention;
|
||||
@Mock
|
||||
private NotificationContextResolver notificationContextResolver;
|
||||
@Mock
|
||||
private LlmResponseTunerService llmResponseTunerService;
|
||||
@Mock
|
||||
private ConversationEntryMapper conversationEntryMapper;
|
||||
|
||||
@InjectMocks
|
||||
private ConversationManagerService conversationManagerService;
|
||||
|
||||
@Test
|
||||
void startNotificationConversation_shouldSaveResolvedContextAndReturnIt() {
|
||||
// Given
|
||||
String userId = "test-user";
|
||||
String userPhoneNumber = "1234567890";
|
||||
String userMessageText = "test message";
|
||||
String sessionId = "test-session";
|
||||
String resolvedContext = "resolved context";
|
||||
|
||||
ConversationContext context = new ConversationContext(userId, null, userMessageText, userPhoneNumber);
|
||||
DetectIntentRequestDTO request = new DetectIntentRequestDTO(null, null);
|
||||
NotificationDTO notification = new NotificationDTO("1", "1234567890", Instant.now(), "test text", "test_event", "es", Collections.emptyMap(), "active");
|
||||
ConversationSessionDTO session = ConversationSessionDTO.create(sessionId, userId, userPhoneNumber);
|
||||
|
||||
when(memoryStoreConversationService.getSessionByTelefono(userPhoneNumber)).thenReturn(Mono.just(session));
|
||||
when(memoryStoreConversationService.getMessages(anyString())).thenReturn(Flux.empty());
|
||||
when(conversationContextMapper.toTextFromMessages(any())).thenReturn("history");
|
||||
when(notificationContextMapper.toText(notification)).thenReturn("notification text");
|
||||
when(notificationContextResolver.resolveContext(anyString(), anyString(), anyString(), anyString(), anyString(), anyString(), anyString()))
|
||||
.thenReturn(resolvedContext);
|
||||
when(llmResponseTunerService.setValue(anyString(), anyString())).thenReturn(Mono.empty());
|
||||
when(memoryStoreConversationService.saveSession(any(ConversationSessionDTO.class))).thenReturn(Mono.empty());
|
||||
when(memoryStoreConversationService.saveMessage(anyString(), any(ConversationMessageDTO.class))).thenReturn(Mono.empty());
|
||||
when(firestoreConversationService.saveSession(any(ConversationSessionDTO.class))).thenReturn(Mono.empty());
|
||||
when(firestoreConversationService.saveMessage(anyString(), any(ConversationMessageDTO.class))).thenReturn(Mono.empty());
|
||||
when(conversationEntryMapper.toConversationMessageDTO(any(ConversationEntryDTO.class))).thenReturn(new ConversationMessageDTO(MessageType.USER, Instant.now(), "text", null, null));
|
||||
when(dialogflowServiceClient.detectIntent(anyString(), any(DetectIntentRequestDTO.class))).thenReturn(Mono.just(new DetectIntentResponseDTO(sessionId, new QueryResultDTO(resolvedContext, null), null)));
|
||||
|
||||
// When
|
||||
Mono<DetectIntentResponseDTO> result = conversationManagerService.startNotificationConversation(context, request, notification);
|
||||
|
||||
// Then
|
||||
StepVerifier.create(result)
|
||||
.expectNextMatches(response -> response.queryResult().responseText().equals(resolvedContext))
|
||||
.verifyComplete();
|
||||
}
|
||||
}
|
||||
@@ -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.integration_testing;
|
||||
|
||||
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("dev")
|
||||
@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,52 @@
|
||||
package com.example.service.integration_testing;
|
||||
|
||||
import com.example.service.base.NotificationContextResolver;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
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 static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("dev")
|
||||
@DisplayName("NotificationContextResolver Live Tests")
|
||||
public class NotificationContextResolverLiveTest {
|
||||
|
||||
private String notificationsJson;
|
||||
private String conversationJson;
|
||||
private String queryInputText;
|
||||
private String metadataJson;
|
||||
|
||||
@Autowired
|
||||
private NotificationContextResolver notificationContextResolver;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
notificationsJson = "Hola :\n" +
|
||||
"Pasó algo con la captura de tu INE y no se completó tu *solicitud de tarjeta de crédito con folio *.\n"
|
||||
+
|
||||
"¡Reinténtalo cuando quieras! Solo toma en cuenta estos consejos:\n" +
|
||||
"🪪 Presenta tu INE original (no copias ni escaneos).\n" +
|
||||
"📅Revisa que esté vigente y sin tachaduras.\n" +
|
||||
"📷 Confirma que la fotografía sea clara.\n" +
|
||||
"🏠 Asegúrate de que la dirección sea legible.\n" +
|
||||
"Estamos listos para recibirte.\n";
|
||||
|
||||
conversationJson = "System: Hola :Pasó algo con la captura de tu INE y no se completó tu *solicitud de tarjeta de crédito con folio *.¡Reinténtalo cuando quieras! Solo toma en cuenta estos consejos:🪪 Presenta tu INE original (no copias ni escaneos).📅Revisa que esté vigente y sin tachaduras.📷 Confirma que la fotografía sea clara.🏠 Asegúrate de que la dirección sea legible.Estamos listos para recibirte.notification_po_contexto=campañaprueba, notification_po_id_campaña=campaña01, notification_po_id_aplicacion=TestSigma, notification_po_id_notificacion=Prueba2";
|
||||
queryInputText = "cual es el id de la notificaion?";
|
||||
metadataJson = "{\"contexto\":\"campañaprueba\",\"id_aplicacion\":\"TestSigma\",\"id_campaña\":\"campaña01\",\"id_notificacion\":\"Prueba2\",\"vigencia\":\"30/09/2025\"}";
|
||||
//metadataJson = "{}";
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should get live response from LLM and print it")
|
||||
public void shouldGetLiveResponseFromLlmAndPrintIt() {
|
||||
String result = notificationContextResolver.resolveContext(queryInputText, notificationsJson, conversationJson,
|
||||
metadataJson, "test_user", "test_session", "1234567890");
|
||||
System.out.println("Live LLM Response: " + result);
|
||||
assertNotNull(result);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user