diff --git a/pom.xml b/pom.xml index d762ca8..35267b4 100644 --- a/pom.xml +++ b/pom.xml @@ -119,6 +119,10 @@ com.google.api gax + + com.google.cloud + google-cloud-dlp + diff --git a/src/.DS_Store b/src/.DS_Store deleted file mode 100644 index 7b5c75f..0000000 Binary files a/src/.DS_Store and /dev/null differ diff --git a/src/main/java/com/example/config/DlpConfig.java b/src/main/java/com/example/config/DlpConfig.java new file mode 100644 index 0000000..8bbd9a2 --- /dev/null +++ b/src/main/java/com/example/config/DlpConfig.java @@ -0,0 +1,15 @@ +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(); + } +} diff --git a/src/main/java/com/example/controller/ConversationSummaryController.java b/src/main/java/com/example/controller/ConversationSummaryController.java index 7e54a94..e73e1b5 100644 --- a/src/main/java/com/example/controller/ConversationSummaryController.java +++ b/src/main/java/com/example/controller/ConversationSummaryController.java @@ -31,6 +31,7 @@ public class ConversationSummaryController { this.conversationSummaryService = conversationSummaryService; } + @PostMapping("/conversation") public ResponseEntity summarizeConversation( @Valid @RequestBody ConversationSummaryRequest request) { diff --git a/src/main/java/com/example/dto/dialogflow/conversation/QueryInputDTO.java b/src/main/java/com/example/dto/dialogflow/conversation/QueryInputDTO.java index 4545b1a..b56bc1b 100644 --- a/src/main/java/com/example/dto/dialogflow/conversation/QueryInputDTO.java +++ b/src/main/java/com/example/dto/dialogflow/conversation/QueryInputDTO.java @@ -5,6 +5,8 @@ package com.example.dto.dialogflow.conversation; + + import com.example.dto.dialogflow.notification.EventInputDTO; public record QueryInputDTO( diff --git a/src/main/java/com/example/dto/dialogflow/notification/EventInputDTO.java b/src/main/java/com/example/dto/dialogflow/notification/EventInputDTO.java index 0501fcb..23317cc 100644 --- a/src/main/java/com/example/dto/dialogflow/notification/EventInputDTO.java +++ b/src/main/java/com/example/dto/dialogflow/notification/EventInputDTO.java @@ -7,4 +7,4 @@ package com.example.dto.dialogflow.notification; public record EventInputDTO( String event -) {} +) {} \ No newline at end of file diff --git a/src/main/java/com/example/dto/quickreplies/QuestionDTO.java b/src/main/java/com/example/dto/quickreplies/QuestionDTO.java index 148eba9..198464b 100644 --- a/src/main/java/com/example/dto/quickreplies/QuestionDTO.java +++ b/src/main/java/com/example/dto/quickreplies/QuestionDTO.java @@ -1,8 +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("descripcion") String descripcion, + @JsonProperty("respuesta") String respuesta +) {} \ No newline at end of file diff --git a/src/main/java/com/example/dto/quickreplies/QuickReplyDTO.java b/src/main/java/com/example/dto/quickreplies/QuickReplyDTO.java index 465cff4..9f690ef 100644 --- a/src/main/java/com/example/dto/quickreplies/QuickReplyDTO.java +++ b/src/main/java/com/example/dto/quickreplies/QuickReplyDTO.java @@ -1,9 +1,7 @@ package com.example.dto.quickreplies; - import com.fasterxml.jackson.annotation.JsonProperty; import java.util.List; - public record QuickReplyDTO( @JsonProperty("header") String header, @JsonProperty("preguntas") List preguntas -) {} +) {} \ No newline at end of file diff --git a/src/main/java/com/example/mapper/conversation/DialogflowRequestMapper.java b/src/main/java/com/example/mapper/conversation/DialogflowRequestMapper.java index adceff6..d587d78 100644 --- a/src/main/java/com/example/mapper/conversation/DialogflowRequestMapper.java +++ b/src/main/java/com/example/mapper/conversation/DialogflowRequestMapper.java @@ -8,8 +8,9 @@ package com.example.mapper.conversation; import com.example.dto.dialogflow.base.DetectIntentRequestDTO; import com.example.dto.dialogflow.conversation.QueryInputDTO; import com.example.util.ProtobufUtil; -import com.google.cloud.dialogflow.cx.v3.DetectIntentRequest; import com.google.cloud.dialogflow.cx.v3.EventInput; +import com.google.cloud.dialogflow.cx.v3.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; diff --git a/src/main/java/com/example/mapper/conversation/FirestoreConversationMapper.java b/src/main/java/com/example/mapper/conversation/FirestoreConversationMapper.java index a9431bd..a35a285 100644 --- a/src/main/java/com/example/mapper/conversation/FirestoreConversationMapper.java +++ b/src/main/java/com/example/mapper/conversation/FirestoreConversationMapper.java @@ -39,6 +39,7 @@ public class FirestoreConversationMapper { private static final String FIELD_MESSAGES = "mensajes"; private static final String FIELD_PANTALLA_CONTEXTO = "pantallaContexto"; + // Constants for fields within the 'mensajes' sub-documents private static final String FIELD_MESSAGE_ENTITY = "entidad"; private static final String FIELD_MESSAGE_TYPE = "tipo"; @@ -75,6 +76,8 @@ public class FirestoreConversationMapper { sessionMap.put(FIELD_PANTALLA_CONTEXTO, pantallaContexto); } + + sessionMap.put(FIELD_CREATED_AT, Timestamp.of(java.util.Date.from(Instant.now()))); sessionMap.put(FIELD_LAST_UPDATED, Timestamp.of(java.util.Date.from(Instant.now()))); @@ -85,6 +88,8 @@ public class FirestoreConversationMapper { return sessionMap; } + + private Map toFirestoreEntryMap(ConversationEntryDTO entry) { Map entryMap = new HashMap<>(); entryMap.put(FIELD_MESSAGE_ENTITY, entry.entity().name()); @@ -125,6 +130,7 @@ public class FirestoreConversationMapper { .map(this::mapFirestoreEntryMapToConversationEntryDTO) .collect(Collectors.toList()); } + return new ConversationSessionDTO(sessionId, userId, telefono, createdAt, lastModified, entries, pantallaContexto); } diff --git a/src/main/java/com/example/mapper/quickreplies/FirestoreQuickReplyMapper.java b/src/main/java/com/example/mapper/quickreplies/FirestoreQuickReplyMapper.java deleted file mode 100644 index f320bf5..0000000 --- a/src/main/java/com/example/mapper/quickreplies/FirestoreQuickReplyMapper.java +++ /dev/null @@ -1,149 +0,0 @@ -/* - * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. - * Your use of it is subject to your agreement with Google. - */ - -package com.example.mapper.quickreplies; - -import com.example.dto.dialogflow.conversation.ConversationEntryDTO; -import com.example.dto.dialogflow.conversation.ConversationEntryEntity; -import com.example.dto.dialogflow.conversation.ConversationEntryType; -import com.example.dto.dialogflow.conversation.ConversationSessionDTO; -import com.google.cloud.Timestamp; -import com.google.cloud.firestore.FieldValue; -import com.google.cloud.firestore.DocumentSnapshot; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.stereotype.Component; - -import java.time.Instant; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -@Component -public class FirestoreQuickReplyMapper { - - private static final Logger logger = LoggerFactory.getLogger(FirestoreQuickReplyMapper.class); - - private static final String FIELD_SESSION_ID = "session_id"; - private static final String FIELD_USER_ID = "usuario_id"; - private static final String FIELD_PHONE_NUMBER = "telefono"; - private static final String FIELD_CREATED_AT = "fechaCreacion"; - private static final String FIELD_LAST_UPDATED = "ultimaActualizacion"; - private static final String FIELD_MESSAGES = "mensajes"; - - private static final String FIELD_MESSAGE_ENTITY = "entidad"; - private static final String FIELD_MESSAGE_TYPE = "tipo"; - private static final String FIELD_MESSAGE_TEXT = "mensaje"; - private static final String FIELD_MESSAGE_TIMESTAMP = "tiempo"; - private static final String FIELD_MESSAGE_PARAMETERS = "parametros"; - private static final String FIELD_MESSAGE_CHANNEL = "canal"; - - - public Map createUpdateMapForSingleEntry(ConversationEntryDTO newEntry) { - Map updates = new HashMap<>(); - Map entryMap = toFirestoreEntryMap(newEntry); - updates.put(FIELD_MESSAGES, FieldValue.arrayUnion(entryMap)); - updates.put(FIELD_LAST_UPDATED, Timestamp.of(java.util.Date.from(Instant.now()))); - return updates; - } - - public Map createNewSessionMapForSingleEntry(String sessionId, String userId, String telefono, ConversationEntryDTO initialEntry) { - Map sessionMap = new HashMap<>(); - sessionMap.put(FIELD_SESSION_ID, sessionId); - sessionMap.put(FIELD_USER_ID, userId); - - if (telefono != null && !telefono.trim().isEmpty()) { - sessionMap.put(FIELD_PHONE_NUMBER, telefono); - } else { - sessionMap.put(FIELD_PHONE_NUMBER, null); - } - - sessionMap.put(FIELD_CREATED_AT, Timestamp.of(java.util.Date.from(Instant.now()))); - sessionMap.put(FIELD_LAST_UPDATED, Timestamp.of(java.util.Date.from(Instant.now()))); - - List> entriesList = new ArrayList<>(); - entriesList.add(toFirestoreEntryMap(initialEntry)); - sessionMap.put(FIELD_MESSAGES, entriesList); - - return sessionMap; - } - - private Map toFirestoreEntryMap(ConversationEntryDTO entry) { - Map entryMap = new HashMap<>(); - entryMap.put(FIELD_MESSAGE_ENTITY, entry.entity().name()); - entryMap.put(FIELD_MESSAGE_TYPE, entry.type().name()); - entryMap.put(FIELD_MESSAGE_TEXT, entry.text()); - entryMap.put(FIELD_MESSAGE_TIMESTAMP, Timestamp.of(java.util.Date.from(entry.timestamp()))); - - if (entry.parameters() != null && !entry.parameters().isEmpty()) { - entryMap.put(FIELD_MESSAGE_PARAMETERS, entry.parameters()); - } - if (entry.canal() != null) { - entryMap.put(FIELD_MESSAGE_CHANNEL, entry.canal()); - } - return entryMap; - } - - public ConversationSessionDTO mapFirestoreDocumentToConversationSessionDTO(DocumentSnapshot documentSnapshot) { - if (!documentSnapshot.exists()) { - return null; - } - - String sessionId = documentSnapshot.getString(FIELD_SESSION_ID); - String userId = documentSnapshot.getString(FIELD_USER_ID); - String telefono = documentSnapshot.getString(FIELD_PHONE_NUMBER); - - Timestamp createdAtFirestore = documentSnapshot.getTimestamp(FIELD_CREATED_AT); - Instant createdAt = (createdAtFirestore != null) ? createdAtFirestore.toDate().toInstant() : null; - - Timestamp lastModifiedFirestore = documentSnapshot.getTimestamp(FIELD_LAST_UPDATED); - Instant lastModified = (lastModifiedFirestore != null) ? lastModifiedFirestore.toDate().toInstant() : null; - - List> rawEntries = (List>) documentSnapshot.get(FIELD_MESSAGES); - - List entries = new ArrayList<>(); - if (rawEntries != null) { - entries = rawEntries.stream() - .map(this::mapFirestoreEntryMapToConversationEntryDTO) - .collect(Collectors.toList()); - } - return new ConversationSessionDTO(sessionId, userId, telefono, createdAt, lastModified, entries, null); - } - - -private ConversationEntryDTO mapFirestoreEntryMapToConversationEntryDTO(Map entryMap) { - ConversationEntryEntity entity = null; - Object entityObj = entryMap.get(FIELD_MESSAGE_ENTITY); - if (entityObj instanceof String) { - try { - entity = ConversationEntryEntity.valueOf((String) entityObj); - } catch (IllegalArgumentException e) { - logger.warn("Unknown ConversationEntryEntity encountered: {}. Setting entity to null.", entityObj); - } - } - - ConversationEntryType type = null; - Object typeObj = entryMap.get(FIELD_MESSAGE_TYPE); - if (typeObj instanceof String) { - try { - type = ConversationEntryType.valueOf((String) typeObj); - } catch (IllegalArgumentException e) { - logger.warn("Unknown ConversationEntryType encountered: {}. Setting type to null.", typeObj); - } - } - - String text = (String) entryMap.get(FIELD_MESSAGE_TEXT); - - Timestamp timestampFirestore = (Timestamp) entryMap.get(FIELD_MESSAGE_TIMESTAMP); - Instant timestamp = (timestampFirestore != null) ? timestampFirestore.toDate().toInstant() : null; - - Map parameters = (Map) entryMap.get(FIELD_MESSAGE_PARAMETERS); - String canal = (String) entryMap.get(FIELD_MESSAGE_CHANNEL); - - return new ConversationEntryDTO(entity, type, timestamp, text, parameters, canal); - } -} diff --git a/src/main/java/com/example/service/conversation/ConversationManagerService.java b/src/main/java/com/example/service/conversation/ConversationManagerService.java index 0d8e43a..a563e8b 100644 --- a/src/main/java/com/example/service/conversation/ConversationManagerService.java +++ b/src/main/java/com/example/service/conversation/ConversationManagerService.java @@ -2,9 +2,7 @@ * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. * Your use of it is subject to your agreement with Google. */ - package com.example.service.conversation; - import com.example.dto.dialogflow.base.DetectIntentRequestDTO; import com.example.dto.dialogflow.base.DetectIntentResponseDTO; import com.example.dto.dialogflow.conversation.ConversationContext; @@ -24,22 +22,20 @@ import com.example.service.quickreplies.QuickRepliesManagerService; 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; - @Service public class ConversationManagerService { private static final Logger logger = LoggerFactory.getLogger(ConversationManagerService.class); private static final long SESSION_RESET_THRESHOLD_HOURS = 24; private static final String CURRENT_PAGE_PARAM = "currentPage"; - private final ExternalConvRequestMapper externalRequestToDialogflowMapper; private final DialogflowClientService dialogflowServiceClient; private final FirestoreConversationService firestoreConversationService; @@ -49,6 +45,9 @@ public class ConversationManagerService { private final MemoryStoreNotificationService memoryStoreNotificationService; private final NotificationContextMapper notificationContextMapper; private final ConversationContextMapper conversationContextMapper; + private final DataLossPrevention dataLossPrevention; + private final String dlpTemplateCompleteFlow; + private final String dlpTemplatePersistFlow; public ConversationManagerService( DialogflowClientService dialogflowServiceClient, @@ -59,7 +58,10 @@ public class ConversationManagerService { MessageEntryFilter messageEntryFilter, MemoryStoreNotificationService memoryStoreNotificationService, NotificationContextMapper notificationContextMapper, - ConversationContextMapper conversationContextMapper) { + ConversationContextMapper conversationContextMapper, + DataLossPrevention dataLossPrevention, + @Value("${google.cloud.dlp.dlpTemplateCompleteFlow}") String dlpTemplateCompleteFlow, + @Value("${google.cloud.dlp.dlpTemplatePersistFlow}") String dlpTemplatePersistFlow) { this.dialogflowServiceClient = dialogflowServiceClient; this.firestoreConversationService = firestoreConversationService; this.memoryStoreConversationService = memoryStoreConversationService; @@ -69,28 +71,31 @@ public class ConversationManagerService { this.memoryStoreNotificationService = memoryStoreNotificationService; this.notificationContextMapper = notificationContextMapper; this.conversationContextMapper = conversationContextMapper; + this.dataLossPrevention = dataLossPrevention; + this.dlpTemplateCompleteFlow = dlpTemplateCompleteFlow; + this.dlpTemplatePersistFlow = dlpTemplatePersistFlow; } - public Mono 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 -> { - if (session != null && !session.entries().isEmpty()) { - ConversationEntryDTO lastEntry = session.entries().get(session.entries().size() - 1); - if (lastEntry.entity() == ConversationEntryEntity.SISTEMA && lastEntry.type() == ConversationEntryType.INICIO) { - logger.info("Detected 'SISTEMA' and 'INICIO' values in last session entry. Delegating to QuickRepliesManagerService."); - ExternalConvRequestDTO updatedRequest = new ExternalConvRequestDTO( - externalrequest.message(), - externalrequest.user(), - externalrequest.channel(), - externalrequest.tipo(), - session.pantallaContexto() - ); - return quickRepliesManagerService.manageConversation(updatedRequest); - } + + if (session != null && session.pantallaContexto() != null && !session.pantallaContexto().isBlank()) { + logger.info("Detected 'pantallaContexto' in session. Delegating to QuickRepliesManagerService."); + return quickRepliesManagerService.manageConversation(obfuscatedRequest); } - return continueManagingConversation(externalrequest); + return continueManagingConversation(obfuscatedRequest); }) - .switchIfEmpty(continueManagingConversation(externalrequest)); + .switchIfEmpty(continueManagingConversation(obfuscatedRequest)); + }); } private Mono continueManagingConversation(ExternalConvRequestDTO externalrequest) { @@ -114,11 +119,9 @@ public class ConversationManagerService { return handleMessageClassification(context, request); } - private Mono handleMessageClassification(ConversationContext context, DetectIntentRequestDTO request) { final String userPhoneNumber = context.primaryPhoneNumber(); final String userMessageText = context.userMessageText(); - return memoryStoreNotificationService.getNotificationIdForPhone(userPhoneNumber) .flatMap(notificationId -> memoryStoreNotificationService.getCachedNotificationSession(notificationId)) .map(notificationSession -> notificationSession.notificaciones().stream() @@ -142,7 +145,6 @@ public class ConversationManagerService { }) .switchIfEmpty(continueConversationFlow(context, request)); } - private Mono continueConversationFlow(ConversationContext context, DetectIntentRequestDTO request) { final String userId = context.userId(); final String userMessageText = context.userMessageText(); @@ -190,7 +192,11 @@ public class ConversationManagerService { private Mono processDialogflowRequest(ConversationSessionDTO session, DetectIntentRequestDTO request, String userId, String userMessageText, String userPhoneNumber, boolean newSession) { final String finalSessionId = session.sessionId(); - ConversationEntryDTO userEntry = ConversationEntryDTO.forUser(userMessageText); + + return dataLossPrevention.getObfuscatedString(userMessageText, dlpTemplatePersistFlow) + .flatMap(obfuscatedUserMessageText -> { + + ConversationEntryDTO userEntry = ConversationEntryDTO.forUser(obfuscatedUserMessageText); return this.persistConversationTurn(userId, finalSessionId, userEntry, userPhoneNumber) .doOnSuccess(v -> logger.debug("User entry successfully persisted for session {}. Proceeding to Dialogflow...", finalSessionId)) @@ -204,13 +210,12 @@ public class ConversationManagerService { }) .doOnError(error -> logger.error("Overall error during conversation management for session {}: {}", finalSessionId, error.getMessage(), error)) )); + }); } - private Mono startNotificationConversation(ConversationContext context, DetectIntentRequestDTO request, NotificationDTO notification) { final String userId = context.userId(); final String userMessageText = context.userMessageText(); final String userPhoneNumber = context.primaryPhoneNumber(); - return memoryStoreNotificationService.getSessionByTelefono(userPhoneNumber) .switchIfEmpty(Mono.defer(() -> { String newSessionId = SessionIdGenerator.generateStandardSessionId(); @@ -229,11 +234,9 @@ public class ConversationManagerService { })); }); } - private Mono persistConversationTurn(String userId, String sessionId, ConversationEntryDTO entry,String userPhoneNumber) { logger.debug("Starting Write-Back persistence for session {}. Type: {}. Writing to Redis first.", sessionId, entry.type().name()); - return memoryStoreConversationService.saveEntry(userId, sessionId, entry, userPhoneNumber) .doOnSuccess(v -> logger.info( "Entry saved to Redis for session {}. Type: {}. Kicking off async Firestore write-back.", @@ -248,12 +251,10 @@ public class ConversationManagerService { .doOnError(e -> logger.error("Error during primary Redis write for session {}. Type: {}: {}", sessionId, entry.type().name(), e.getMessage(), e)); } - private ConversationContext resolveAndValidateRequest(DetectIntentRequestDTO request) { Map params = Optional.ofNullable(request.queryParams()) .map(queryParamsDTO -> queryParamsDTO.parameters()) .orElse(Collections.emptyMap()); - String primaryPhoneNumber = null; Object telefonoObj = params.get("telefono"); // Get from map if (telefonoObj instanceof String) { @@ -262,12 +263,10 @@ public class ConversationManagerService { logger.warn("Parameter 'telefono' in queryParams is not a String (type: {}). Expected String.", telefonoObj.getClass().getName()); } - if (primaryPhoneNumber == null || primaryPhoneNumber.trim().isEmpty()) { throw new IllegalArgumentException( "Phone number (telefono) is required in query parameters for conversation management."); } - String resolvedUserId = null; Object userIdObj = params.get("usuario_id"); if (userIdObj instanceof String) { @@ -276,18 +275,15 @@ public class ConversationManagerService { logger.warn("Parameter 'userId' in query_params is not a String (type: {}). Expected String.", userIdObj.getClass().getName()); } - if (resolvedUserId == null || resolvedUserId.trim().isEmpty()) { resolvedUserId = "user_by_phone_" + primaryPhoneNumber.replaceAll("[^0-9]", ""); logger.warn("User ID not provided in query parameters. Using derived ID from phone number: {}", resolvedUserId); } - if (request.queryInput() == null || request.queryInput().text() == null || request.queryInput().text().text() == null || request.queryInput().text().text().trim().isEmpty()) { throw new IllegalArgumentException("Dialogflow query input text is required."); } - String userMessageText = request.queryInput().text().text(); return new ConversationContext(resolvedUserId, null, userMessageText, primaryPhoneNumber); } diff --git a/src/main/java/com/example/service/conversation/DataLossPrevention.java b/src/main/java/com/example/service/conversation/DataLossPrevention.java new file mode 100644 index 0000000..8db4883 --- /dev/null +++ b/src/main/java/com/example/service/conversation/DataLossPrevention.java @@ -0,0 +1,7 @@ +package com.example.service.conversation; + +import reactor.core.publisher.Mono; + +public interface DataLossPrevention { + Mono getObfuscatedString(String textToInspect, String templateId); +} diff --git a/src/main/java/com/example/service/conversation/DataLossPreventionImpl.java b/src/main/java/com/example/service/conversation/DataLossPreventionImpl.java new file mode 100644 index 0000000..371c0c0 --- /dev/null +++ b/src/main/java/com/example/service/conversation/DataLossPreventionImpl.java @@ -0,0 +1,98 @@ +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; + +@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 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 futureResponse = dlpServiceClient.inspectContentCallable() + .futureCall(request); + + return Mono.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); + }); + } +} diff --git a/src/main/java/com/example/service/conversation/FirestoreConversationService.java b/src/main/java/com/example/service/conversation/FirestoreConversationService.java index 3b36db0..5ddc7b5 100644 --- a/src/main/java/com/example/service/conversation/FirestoreConversationService.java +++ b/src/main/java/com/example/service/conversation/FirestoreConversationService.java @@ -115,6 +115,8 @@ public class FirestoreConversationService { }); } + + private String getConversationCollectionPath() { return String.format(CONVERSATION_COLLECTION_PATH_FORMAT, firestoreBaseRepository.getAppId()); } diff --git a/src/main/java/com/example/service/conversation/MemoryStoreConversationService.java b/src/main/java/com/example/service/conversation/MemoryStoreConversationService.java index f9bad99..0b554af 100644 --- a/src/main/java/com/example/service/conversation/MemoryStoreConversationService.java +++ b/src/main/java/com/example/service/conversation/MemoryStoreConversationService.java @@ -93,4 +93,10 @@ public class MemoryStoreConversationService { }) .doOnError(e -> logger.error("Error retrieving session by phone number {}: {}", telefono, e.getMessage(), e)); } + + public Mono updateSession(ConversationSessionDTO session) { + String sessionKey = SESSION_KEY_PREFIX + session.sessionId(); + logger.info("Attempting to update session {} in Redis.", session.sessionId()); + return redisTemplate.opsForValue().set(sessionKey, session, SESSION_TTL).then(); + } } \ No newline at end of file diff --git a/src/main/java/com/example/service/quickreplies/FirestoreQRService.java b/src/main/java/com/example/service/quickreplies/FirestoreQRService.java deleted file mode 100644 index f95a5fd..0000000 --- a/src/main/java/com/example/service/quickreplies/FirestoreQRService.java +++ /dev/null @@ -1,88 +0,0 @@ -/* - * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. - * Your use of it is subject to your agreement with Google. - */ - -package com.example.service.quickreplies; - -import com.example.dto.dialogflow.conversation.ConversationEntryDTO; -import com.example.dto.dialogflow.conversation.ConversationSessionDTO; -import com.example.exception.FirestorePersistenceException; -import com.example.mapper.quickreplies.FirestoreQuickReplyMapper; -import com.example.repository.FirestoreBaseRepository; -import com.google.cloud.firestore.DocumentReference; -import com.google.cloud.firestore.WriteBatch; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.stereotype.Service; -import reactor.core.publisher.Mono; -import reactor.core.scheduler.Schedulers; - -import java.util.Map; -import java.util.concurrent.ExecutionException; - -@Service -public class FirestoreQRService { - - private static final Logger logger = LoggerFactory.getLogger(FirestoreQRService.class); - private static final String CONVERSATION_COLLECTION_PATH_FORMAT = "artifacts/%s/quick-replies-conversations"; - private final FirestoreBaseRepository firestoreBaseRepository; - private final FirestoreQuickReplyMapper firestoreQuickReplyMapper; - - public FirestoreQRService(FirestoreBaseRepository firestoreBaseRepository, FirestoreQuickReplyMapper firestoreQuickReplyMapper) { - this.firestoreBaseRepository = firestoreBaseRepository; - this.firestoreQuickReplyMapper = firestoreQuickReplyMapper; - } - - public Mono saveEntry(String userId, String sessionId, ConversationEntryDTO newEntry, String userPhoneNumber) { - logger.info("Attempting to save quick reply entry to Firestore for session {}. Entity: {}", sessionId, newEntry.entity().name()); - return Mono.fromRunnable(() -> { - DocumentReference sessionDocRef = getSessionDocumentReference(sessionId); - WriteBatch batch = firestoreBaseRepository.createBatch(); - - try { - if (firestoreBaseRepository.documentExists(sessionDocRef)) { - Map updates = firestoreQuickReplyMapper.createUpdateMapForSingleEntry(newEntry); - batch.update(sessionDocRef, updates); - logger.info("Appending entry to existing quick reply session for user {} and session {}. Entity: {}", userId, sessionId, newEntry.entity().name()); - } else { - Map newSessionMap = firestoreQuickReplyMapper.createNewSessionMapForSingleEntry(sessionId, userId, userPhoneNumber, newEntry); - batch.set(sessionDocRef, newSessionMap); - logger.info("Creating new quick reply session with first entry for user {} and session {}. Entity: {}", userId, sessionId, newEntry.entity().name()); - } - firestoreBaseRepository.commitBatch(batch); - logger.info("Successfully committed batch for session {} to Firestore.", sessionId); - } catch (ExecutionException e) { - logger.error("Error saving quick reply entry to Firestore for session {}: {}", sessionId, e.getMessage(), e); - throw new FirestorePersistenceException("Failed to save quick reply entry to Firestore for session " + sessionId, e); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - logger.error("Thread interrupted while saving quick reply entry to Firestore for session {}: {}", sessionId, e.getMessage(), e); - throw new FirestorePersistenceException("Saving quick reply entry was interrupted for session " + sessionId, e); - } - }).subscribeOn(Schedulers.boundedElastic()).then(); - } - - public Mono getSessionByTelefono(String userPhoneNumber) { - logger.info("Attempting to retrieve quick reply session for phone number {}.", userPhoneNumber); - return firestoreBaseRepository.getDocumentsByField(getConversationCollectionPath(), "userPhoneNumber", userPhoneNumber) - .map(documentSnapshot -> { - if (documentSnapshot != null && documentSnapshot.exists()) { - ConversationSessionDTO sessionDTO = firestoreQuickReplyMapper.mapFirestoreDocumentToConversationSessionDTO(documentSnapshot); - logger.info("Successfully retrieved and mapped quick reply session for session {}.", sessionDTO.sessionId()); - return sessionDTO; - } - logger.info("Quick reply session not found for phone number {}.", userPhoneNumber); - return null; - }); - } - - private String getConversationCollectionPath() { - return String.format(CONVERSATION_COLLECTION_PATH_FORMAT, firestoreBaseRepository.getAppId()); - } - - private DocumentReference getSessionDocumentReference(String sessionId) { - String collectionPath = getConversationCollectionPath(); - return firestoreBaseRepository.getDocumentReference(collectionPath, sessionId); - } -} diff --git a/src/main/java/com/example/service/quickreplies/MemoryStoreQRService.java b/src/main/java/com/example/service/quickreplies/MemoryStoreQRService.java index dc3410b..fbb0abb 100644 --- a/src/main/java/com/example/service/quickreplies/MemoryStoreQRService.java +++ b/src/main/java/com/example/service/quickreplies/MemoryStoreQRService.java @@ -33,11 +33,13 @@ public class MemoryStoreQRService { this.stringRedisTemplate = stringRedisTemplate; } - public Mono saveEntry(String userId, String sessionId, ConversationEntryDTO newEntry, String userPhoneNumber) { + public Mono saveEntry(String userId, String sessionId, ConversationEntryDTO newEntry, + String userPhoneNumber) { String sessionKey = SESSION_KEY_PREFIX + sessionId; String phoneToSessionKey = PHONE_TO_SESSION_KEY_PREFIX + userPhoneNumber; - logger.info("Attempting to save entry to Redis for quick reply session {}. Entity: {}", sessionId, newEntry.entity().name()); + 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)) @@ -45,16 +47,20 @@ public class MemoryStoreQRService { ConversationSessionDTO sessionWithUpdatedTelefono = session.withTelefono(userPhoneNumber); ConversationSessionDTO updatedSession = sessionWithUpdatedTelefono.withAddedEntry(newEntry); - logger.info("Attempting to set updated quick reply session {} with new entry entity {} in Redis.", sessionId, newEntry.entity().name()); + logger.info("Attempting to set updated quick reply session {} with new entry entity {} in Redis.", + sessionId, newEntry.entity().name()); return redisTemplate.opsForValue().set(sessionKey, updatedSession, SESSION_TTL) .then(stringRedisTemplate.opsForValue().set(phoneToSessionKey, sessionId, SESSION_TTL)) .then(); }) .doOnSuccess(success -> { - logger.info("Successfully saved updated quick reply session and phone mapping to Redis for session {}. Entity Type: {}", sessionId, newEntry.entity().name()); + 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)); + .doOnError(e -> logger.error("Error appending entry to Redis for quick reply session {}: {}", sessionId, + e.getMessage(), e)); } public Mono getSessionByTelefono(String telefono) { @@ -65,16 +71,19 @@ public class MemoryStoreQRService { logger.debug("Attempting to retrieve quick reply session ID for phone number {} from Redis.", telefono); return stringRedisTemplate.opsForValue().get(phoneToSessionKey) .flatMap(sessionId -> { - logger.debug("Found quick reply session ID {} for phone number {}. Retrieving session data.", sessionId, telefono); + logger.debug("Found quick reply session ID {} for phone number {}. Retrieving session data.", + sessionId, telefono); return redisTemplate.opsForValue().get(SESSION_KEY_PREFIX + sessionId); }) .doOnSuccess(session -> { if (session != null) { - logger.info("Successfully retrieved quick reply session {} by phone number {}.", session.sessionId(), telefono); + logger.info("Successfully retrieved quick reply session {} by phone number {}.", + session.sessionId(), telefono); } else { logger.info("No quick reply session found in Redis for phone number {}.", telefono); } }) - .doOnError(e -> logger.error("Error retrieving quick reply session by phone number {}: {}", telefono, e.getMessage(), e)); + .doOnError(e -> logger.error("Error retrieving quick reply session by phone number {}: {}", telefono, + e.getMessage(), e)); } } \ No newline at end of file diff --git a/src/main/java/com/example/service/quickreplies/QuickRepliesManagerService.java b/src/main/java/com/example/service/quickreplies/QuickRepliesManagerService.java index 44f0443..adb42b8 100644 --- a/src/main/java/com/example/service/quickreplies/QuickRepliesManagerService.java +++ b/src/main/java/com/example/service/quickreplies/QuickRepliesManagerService.java @@ -11,42 +11,50 @@ import com.example.dto.dialogflow.conversation.ConversationEntryEntity; import com.example.dto.dialogflow.conversation.ConversationEntryType; import com.example.dto.dialogflow.conversation.ExternalConvRequestDTO; import com.example.dto.quickreplies.QuickReplyScreenRequestDTO; +import com.example.service.conversation.DataLossPrevention; +import com.example.dto.quickreplies.QuestionDTO; +import com.example.dto.quickreplies.QuickReplyDTO; 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 org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import reactor.core.publisher.Mono; @Service public class QuickRepliesManagerService { - private static final Logger logger = LoggerFactory.getLogger(QuickRepliesManagerService.class); - private final MemoryStoreConversationService memoryStoreConversationService; private final FirestoreConversationService firestoreConversationService; private final QuickReplyContentService quickReplyContentService; + private final DataLossPrevention dataLossPrevention; + private final String dlpTemplatePersistFlow; public QuickRepliesManagerService( MemoryStoreConversationService memoryStoreConversationService, FirestoreConversationService firestoreConversationService, - QuickReplyContentService quickReplyContentService) { + QuickReplyContentService quickReplyContentService, + DataLossPrevention dataLossPrevention, + @Value("${google.cloud.dlp.dlpTemplatePersistFlow}") String dlpTemplatePersistFlow) { this.memoryStoreConversationService = memoryStoreConversationService; this.firestoreConversationService = firestoreConversationService; this.quickReplyContentService = quickReplyContentService; + this.dataLossPrevention = dataLossPrevention; + this.dlpTemplatePersistFlow = dlpTemplatePersistFlow; } public Mono startQuickReplySession(QuickReplyScreenRequestDTO externalRequest) { String userPhoneNumber = externalRequest.user().telefono(); if (userPhoneNumber == null || userPhoneNumber.isBlank()) { logger.warn("No phone number provided in request. Cannot manage conversation session without it."); - return Mono.error(new IllegalArgumentException("Phone number is required to manage conversation sessions.")); + return 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)) @@ -58,10 +66,9 @@ public class QuickRepliesManagerService { Instant.now(), "Pantalla :" + externalRequest.pantallaContexto() + " Agregada a la conversacion :", null, - null - ); - - return persistConversationTurn(userId, sessionId, systemEntry, userPhoneNumber, externalRequest.pantallaContexto()) + null); + return persistConversationTurn(userId, sessionId, systemEntry, userPhoneNumber, + externalRequest.pantallaContexto()) .then(quickReplyContentService.getQuickReplies(externalRequest.pantallaContexto())) .map(quickReplyDTO -> new DetectIntentResponseDTO(sessionId, null, quickReplyDTO)); }); @@ -71,34 +78,93 @@ public class QuickRepliesManagerService { 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 Mono + .error(new IllegalArgumentException("Phone number is required to manage conversation sessions.")); } return memoryStoreConversationService.getSessionByTelefono(userPhoneNumber) - .switchIfEmpty(Mono.error(new IllegalStateException("No quick reply session found for phone number: " + userPhoneNumber))) + .switchIfEmpty(Mono.error( + new IllegalStateException("No quick reply session found for phone number: " + userPhoneNumber))) .flatMap(session -> { String userId = session.userId(); String sessionId = session.sessionId(); - ConversationEntryDTO userEntry = ConversationEntryDTO.forUser(externalRequest.message()); + return dataLossPrevention.getObfuscatedString(externalRequest.message(), dlpTemplatePersistFlow) + .flatMap(obfuscatedMessage -> { + ConversationEntryDTO userEntry = ConversationEntryDTO.forUser(obfuscatedMessage); - return persistConversationTurn(userId, sessionId, userEntry, userPhoneNumber, session.pantallaContexto()) - .then(quickReplyContentService.getQuickReplies(session.pantallaContexto())) - .flatMap(quickReplyDTO -> { - ConversationEntryDTO agentEntry = ConversationEntryDTO.forAgentWithMessage(quickReplyDTO.toString()); - return persistConversationTurn(userId, sessionId, agentEntry, userPhoneNumber, session.pantallaContexto()) - .thenReturn(new DetectIntentResponseDTO(sessionId, null, quickReplyDTO)); - }); + long userMessagesCount = session.entries().stream() + .filter(e -> e.entity() == ConversationEntryEntity.USUARIO) + .count(); + + 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(userId, sessionId, userEntry, userPhoneNumber, + session.pantallaContexto()) + .then(quickReplyContentService.getQuickReplies(session.pantallaContexto())) + .flatMap(quickReplyDTO -> { + ConversationEntryDTO agentEntry = ConversationEntryDTO + .forAgentWithMessage(quickReplyDTO.toString()); + return persistConversationTurn(userId, sessionId, agentEntry, userPhoneNumber, + session.pantallaContexto()) + .thenReturn(new DetectIntentResponseDTO(sessionId, null, quickReplyDTO)); + }); + } 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(userId, sessionId, userEntry, userPhoneNumber, + session.pantallaContexto()) + .then(quickReplyContentService.getQuickReplies(session.pantallaContexto())) + .flatMap(quickReplyDTO -> { + List matchedPreguntas = quickReplyDTO.preguntas().stream() + .filter(p -> p.titulo().equalsIgnoreCase(externalRequest.message().trim())) + .toList(); + + QuickReplyDTO responseQuickReplyDTO; + if (!matchedPreguntas.isEmpty()) { + responseQuickReplyDTO = new QuickReplyDTO(quickReplyDTO.header(), + matchedPreguntas); + } else { + responseQuickReplyDTO = new QuickReplyDTO(quickReplyDTO.header(), + Collections.emptyList()); + } + + // End the quick reply flow by clearing the pantallaContexto + return memoryStoreConversationService + .updateSession(session.withPantallaContexto(null)) + .then(persistConversationTurn(userId, sessionId, + ConversationEntryDTO.forAgentWithMessage( + responseQuickReplyDTO.toString()), + userPhoneNumber, null)) + .thenReturn(new DetectIntentResponseDTO(sessionId, null, + responseQuickReplyDTO)); + }); + } 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", Collections.emptyList())))); + } + }); }); } - private Mono persistConversationTurn(String userId, String sessionId, ConversationEntryDTO entry, String userPhoneNumber, String pantallaContexto) { - logger.debug("Starting Write-Back persistence for quick reply session {}. Type: {}. Writing to Redis first.", sessionId, entry.type().name()); - + private Mono persistConversationTurn(String userId, String sessionId, ConversationEntryDTO entry, + String userPhoneNumber, String pantallaContexto) { + logger.debug("Starting Write-Back persistence for quick reply session {}. Type: {}. Writing to Redis first.", + sessionId, entry.type().name()); return memoryStoreConversationService.saveEntry(userId, sessionId, entry, userPhoneNumber, pantallaContexto) - .doOnSuccess(v -> logger.info("Entry saved to Redis for quick reply session {}. Type: {}. Kicking off async Firestore write-back.", sessionId, entry.type().name())) - .then(firestoreConversationService.saveEntry(userId, sessionId, entry, userPhoneNumber, pantallaContexto) - .doOnSuccess(fsVoid -> logger.debug("Asynchronously (Write-Back): Entry successfully saved to Firestore for quick reply session {}. Type: {}.", sessionId, entry.type().name())) - .doOnError(fsError -> logger.error("Asynchronously (Write-Back): Failed to save entry to Firestore for quick reply session {}. Type: {}: {}", sessionId, entry.type().name(), fsError.getMessage(), fsError))) - .doOnError(e -> logger.error("Error during primary Redis write for quick reply session {}. Type: {}: {}", sessionId, entry.type().name(), e.getMessage(), e)); + .doOnSuccess(v -> logger.info( + "Entry saved to Redis for quick reply session {}. Type: {}. Kicking off async Firestore write-back.", + sessionId, entry.type().name())) + .then(firestoreConversationService + .saveEntry(userId, sessionId, entry, userPhoneNumber, pantallaContexto) + .doOnSuccess(fsVoid -> logger.debug( + "Asynchronously (Write-Back): Entry successfully saved to Firestore for quick reply session {}. Type: {}.", + sessionId, entry.type().name())) + .doOnError(fsError -> logger.error( + "Asynchronously (Write-Back): Failed to save entry to Firestore for quick reply session {}. Type: {}: {}", + sessionId, entry.type().name(), fsError.getMessage(), fsError))) + .doOnError( + e -> logger.error("Error during primary Redis write for quick reply session {}. Type: {}: {}", + sessionId, entry.type().name(), e.getMessage(), e)); } -} +} \ No newline at end of file diff --git a/src/main/java/com/example/service/quickreplies/QuickReplyContentService.java b/src/main/java/com/example/service/quickreplies/QuickReplyContentService.java index 972b271..9da8e9a 100644 --- a/src/main/java/com/example/service/quickreplies/QuickReplyContentService.java +++ b/src/main/java/com/example/service/quickreplies/QuickReplyContentService.java @@ -9,33 +9,27 @@ 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 getQuickReplies(String collectionId) { logger.info("Fetching quick replies from Firestore for document: {}", collectionId); - if (collectionId == null || collectionId.isBlank()) { logger.warn("collectionId is null or empty. Returning empty quick replies."); return Mono.just(new QuickReplyDTO("empty", Collections.emptyList())); } - return Mono.fromCallable(() -> { try { return firestore.collection("artifacts") @@ -50,8 +44,12 @@ public class QuickReplyContentService { }) .filter(DocumentSnapshot::exists) .map(document -> { - QuestionDTO pregunta = new QuestionDTO(document.getString("titulo"), document.getString("descripcion")); - return new QuickReplyDTO("preguntas sobre " + collectionId, List.of(pregunta)); + String header = document.getString("header"); + List> preguntasData = (List>) document.get("preguntas"); + List preguntas = preguntasData.stream() + .map(p -> new QuestionDTO((String) p.get("titulo"), (String) p.get("descripcion"), (String) p.get("respuesta"))) + .toList(); + return new QuickReplyDTO(header, preguntas); }) .doOnSuccess(quickReplyDTO -> { if (quickReplyDTO != null) { @@ -66,4 +64,4 @@ public class QuickReplyContentService { return Mono.empty(); })); } -} +} \ No newline at end of file diff --git a/src/main/java/com/example/util/TextObfuscator.java b/src/main/java/com/example/util/TextObfuscator.java new file mode 100644 index 0000000..a402251 --- /dev/null +++ b/src/main/java/com/example/util/TextObfuscator.java @@ -0,0 +1,82 @@ +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 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 "STREET_ADDRESS": + 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; + } + } + 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); + } +} diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties index ecc6fef..2efab4a 100644 --- a/src/main/resources/application-dev.properties +++ b/src/main/resources/application-dev.properties @@ -59,4 +59,9 @@ messagefilter.geminimodel=gemini-2.0-flash-001 messagefilter.temperature=0.1f messagefilter.maxOutputTokens=800 messagefilter.topP= 0.1f -messagefilter.prompt=prompts/message_filter_prompt.txt \ No newline at end of file +messagefilter.prompt=prompts/message_filter_prompt.txt +# ========================================================= +# (DLP) Configuration +# ========================================================= +google.cloud.dlp.dlpTemplateCompleteFlow=IMC_INSPECT_USC +google.cloud.dlp.dlpTemplatePersistFlow=IMC_INSPECT_NAME \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 257b306..cbb42d2 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1 +1 @@ -spring.profiles.active=dev \ No newline at end of file +spring.profiles.active=dev diff --git a/src/main/resources/quick-replies/pagos.json b/src/main/resources/quick-replies/pagos.json index b6a0351..c9dd901 100644 --- a/src/main/resources/quick-replies/pagos.json +++ b/src/main/resources/quick-replies/pagos.json @@ -1,4 +1,15 @@ { - "titulo": "Payment History", - "descripcion": "View your recent payments" -} + "header": "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" + } + ] +} \ No newline at end of file diff --git a/src/test/java/com/example/mapper/conversation/DialogflowRequestMapperTest.java b/src/test/java/com/example/mapper/conversation/DialogflowRequestMapperTest.java new file mode 100644 index 0000000..b7685e8 --- /dev/null +++ b/src/test/java/com/example/mapper/conversation/DialogflowRequestMapperTest.java @@ -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 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()); + } +} diff --git a/src/test/java/com/example/mapper/conversation/DialogflowResponseMapperTest.java b/src/test/java/com/example/mapper/conversation/DialogflowResponseMapperTest.java new file mode 100644 index 0000000..049c9a7 --- /dev/null +++ b/src/test/java/com/example/mapper/conversation/DialogflowResponseMapperTest.java @@ -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 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 parameters = queryResultDTO.parameters(); + assertNotNull(parameters); + assertEquals(0, parameters.size()); + } +} diff --git a/src/test/java/com/example/service/unit_testing/QuickReplyContentServiceTest.java b/src/test/java/com/example/service/unit_testing/QuickReplyContentServiceTest.java new file mode 100644 index 0000000..e12b9ee --- /dev/null +++ b/src/test/java/com/example/service/unit_testing/QuickReplyContentServiceTest.java @@ -0,0 +1,118 @@ + +package com.example.service.unit_testing; + +import com.example.dto.quickreplies.QuestionDTO; +import com.example.dto.quickreplies.QuickReplyDTO; +import com.example.service.quickreplies.QuickReplyContentService; +import com.google.api.core.ApiFuture; +import com.google.cloud.firestore.CollectionReference; +import com.google.cloud.firestore.DocumentReference; +import com.google.cloud.firestore.DocumentSnapshot; +import com.google.cloud.firestore.Firestore; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutionException; + +import static org.mockito.Mockito.when; + +public class QuickReplyContentServiceTest { + + @Mock + private Firestore firestore; + + @Mock + private CollectionReference collectionReference; + + @Mock + private DocumentReference documentReference; + + @Mock + private ApiFuture apiFuture; + + @Mock + private DocumentSnapshot documentSnapshot; + + @InjectMocks + private QuickReplyContentService quickReplyContentService; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + } + + @Test + void getQuickReplies_success() throws ExecutionException, InterruptedException { + // Given + String collectionId = "home"; + String header = "home_header"; + List> preguntas = Collections.singletonList( + Map.of("titulo", "title", "descripcion", "description", "respuesta", "response") + ); + List questionDTOs = Collections.singletonList( + new QuestionDTO("title", "description", "response") + ); + QuickReplyDTO expected = new QuickReplyDTO(header, questionDTOs); + + when(firestore.collection("artifacts")).thenReturn(collectionReference); + when(collectionReference.document("default-app-id")).thenReturn(documentReference); + when(documentReference.collection("quick-replies")).thenReturn(collectionReference); + when(collectionReference.document(collectionId)).thenReturn(documentReference); + when(documentReference.get()).thenReturn(apiFuture); + when(apiFuture.get()).thenReturn(documentSnapshot); + when(documentSnapshot.exists()).thenReturn(true); + when(documentSnapshot.getString("header")).thenReturn(header); + when(documentSnapshot.get("preguntas")).thenReturn(preguntas); + + // When + Mono result = quickReplyContentService.getQuickReplies(collectionId); + + // Then + StepVerifier.create(result) + .expectNext(expected) + .verifyComplete(); + } + + @Test + void getQuickReplies_emptyWhenNotFound() throws ExecutionException, InterruptedException { + // Given + String collectionId = "non-existent-collection"; + + when(firestore.collection("artifacts")).thenReturn(collectionReference); + when(collectionReference.document("default-app-id")).thenReturn(documentReference); + when(documentReference.collection("quick-replies")).thenReturn(collectionReference); + when(collectionReference.document(collectionId)).thenReturn(documentReference); + when(documentReference.get()).thenReturn(apiFuture); + when(apiFuture.get()).thenReturn(documentSnapshot); + when(documentSnapshot.exists()).thenReturn(false); + + // When + Mono result = quickReplyContentService.getQuickReplies(collectionId); + + // Then + StepVerifier.create(result) + .verifyComplete(); + } + + @Test + void getQuickReplies_emptyWhenCollectionIdIsBlank() { + // Given + String collectionId = ""; + + // When + Mono result = quickReplyContentService.getQuickReplies(collectionId); + + // Then + StepVerifier.create(result) + .expectNext(new QuickReplyDTO("empty", Collections.emptyList())) + .verifyComplete(); + } +}