UPDATE code 20-Ago

This commit is contained in:
PAVEL PALMA
2025-08-21 00:24:21 -06:00
parent 82eee5f7c0
commit 404f152097
54 changed files with 1851 additions and 597 deletions

BIN
src/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -5,29 +5,13 @@
package com.example;
import com.google.api.gax.core.CredentialsProvider;
import com.google.api.gax.core.NoCredentialsProvider;
import com.google.cloud.spring.data.firestore.repository.config.EnableReactiveFirestoreRepositories;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.transaction.annotation.EnableTransactionManagement;
@SpringBootApplication
@EnableTransactionManagement
@EnableReactiveFirestoreRepositories(basePackages = "com.example.repository")
public class Orchestrator {
@Bean
@ConditionalOnProperty(
value = "spring.cloud.gcp.firestore.emulator.enabled",
havingValue = "true")
public CredentialsProvider googleCredentials() {
return NoCredentialsProvider.create();
}
public static void main(String[] args) {
SpringApplication.run(Orchestrator.class, args);
}

View File

@@ -15,8 +15,13 @@ 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.listener.RedisMessageListenerContainer;
import org.springframework.data.redis.listener.PatternTopic;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import com.example.service.notification.NotificationExpirationListener;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.beans.factory.annotation.Autowired;
/**
* Spring configuration class for setting up Reactive Redis(Memorystore in GCP)
@@ -74,4 +79,14 @@ public ReactiveRedisTemplate<String, NotificationSessionDTO> reactiveNotificatio
.build();
return new ReactiveRedisTemplate<>(factory, context);
}
@Bean
public RedisMessageListenerContainer keyExpirationListenerContainer(
@Autowired RedisConnectionFactory connectionFactory,
@Autowired NotificationExpirationListener notificationExpirationListener) {
RedisMessageListenerContainer listenerContainer = new RedisMessageListenerContainer();
listenerContainer.setConnectionFactory(connectionFactory);
listenerContainer.addMessageListener(notificationExpirationListener, new PatternTopic("__keyevent@*__:expired"));
return listenerContainer;
}
}

View File

@@ -0,0 +1,38 @@
/*
* Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose.
* Your use of it is subject to your agreement with Google.
*/
package com.example.controller;
import com.example.dto.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<Map<String, String>> startSessionAndGetReplies(@Valid @RequestBody QuickReplyScreenRequestDTO request) {
return quickRepliesManagerService.startQuickReplySession(request)
.map(response -> Map.of("responseId", response.responseId()))
.doOnSuccess(response -> logger.info("Successfully processed quick reply request"))
.doOnError(error -> logger.error("Error processing quick reply request: {}", error.getMessage(), error));
}
}

View File

@@ -8,7 +8,16 @@ 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("queryResult") QueryResultDTO queryResult,
@JsonProperty("quick_replies") QuickReplyDTO quickReplies
) {
public DetectIntentResponseDTO(String responseId, QueryResultDTO queryResult) {
this(responseId, queryResult, null);
}
}

View File

@@ -30,13 +30,25 @@ public record ConversationEntryDTO(
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,
agentQueryResult.parameters(),
parameters,
null
);
}
public static ConversationEntryDTO forAgentWithMessage(String text) {
return new ConversationEntryDTO(
ConversationEntryEntity.AGENTE,
ConversationEntryType.CONVERSACION,
Instant.now(),
text,
null,
null
);
}

View File

@@ -6,44 +6,53 @@
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;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonInclude(Include.NON_NULL)
public record ConversationSessionDTO(
String sessionId,
String userId,
String telefono,
Instant createdAt,
Instant lastModified,
List<ConversationEntryDTO> entries
List<ConversationEntryDTO> entries,
String pantallaContexto
) {
public ConversationSessionDTO(String sessionId, String userId, String telefono, Instant createdAt, Instant lastModified, List<ConversationEntryDTO> entries) {
public ConversationSessionDTO(String sessionId, String userId, String telefono, Instant createdAt, Instant lastModified, List<ConversationEntryDTO> entries, String pantallaContexto) {
this.sessionId = sessionId;
this.userId = userId;
this.telefono = telefono;
this.createdAt = createdAt;
this.lastModified = lastModified;
this.entries = Collections.unmodifiableList(new ArrayList<>(entries));
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, Collections.emptyList());
return new ConversationSessionDTO(sessionId, userId, telefono, now, now, Collections.emptyList(), null);
}
public ConversationSessionDTO withAddedEntry(ConversationEntryDTO newEntry) {
List<ConversationEntryDTO> updatedEntries = new ArrayList<>(this.entries);
updatedEntries.add(newEntry);
return new ConversationSessionDTO(this.sessionId, this.userId, this.telefono, this.createdAt, Instant.now(), updatedEntries);
return new ConversationSessionDTO(this.sessionId, this.userId, this.telefono, this.createdAt, Instant.now(), updatedEntries, 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.entries);
return new ConversationSessionDTO(this.sessionId, this.userId, newTelefono, this.createdAt, this.lastModified, this.entries, this.pantallaContexto);
}
return this;
}
public ConversationSessionDTO withPantallaContexto(String pantallaContexto) {
return new ConversationSessionDTO(this.sessionId, this.userId, this.telefono, this.createdAt, this.lastModified, this.entries, pantallaContexto);
}
}

View File

@@ -13,7 +13,8 @@ public record ExternalConvRequestDTO(
@JsonProperty("mensaje") String message,
@JsonProperty("usuario") UsuarioDTO user,
@JsonProperty("canal") String channel,
@JsonProperty("tipo") ConversationEntryType tipo
@JsonProperty("tipo") ConversationEntryType tipo,
@JsonProperty("pantallaContexto") String pantallaContexto //optional field for quick-replies
) {
public ExternalConvRequestDTO {}
}

View File

@@ -21,7 +21,8 @@ public record NotificationDTO(
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
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.");

View File

@@ -0,0 +1,8 @@
package com.example.dto.quickreplies;
import com.fasterxml.jackson.annotation.JsonProperty;
public record QuestionDTO(
@JsonProperty("titulo") String titulo,
@JsonProperty("descripcion") String descripcion
) {}

View File

@@ -0,0 +1,9 @@
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<QuestionDTO> preguntas
) {}

View File

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

View File

@@ -76,15 +76,14 @@ public class DialogflowRequestMapper {
for (Map.Entry<String, Object> entry : requestDto.queryParams().parameters().entrySet()) {
Value protobufValue = ProtobufUtil.convertJavaObjectToProtobufValue(entry.getValue());
paramsStructBuilder.putFields(entry.getKey(), protobufValue);
logger.info("Added session parameter from DTO queryParams: Key='{}', Value='{}'", entry.getKey(),
entry.getValue());
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 (including telefono and canal if present) added to Protobuf request builder.");
logger.debug("All custom session parameters added to Protobuf request builder.");
} else {
logger.debug("No custom session parameters to add to Protobuf request.");
}

View File

@@ -37,6 +37,7 @@ public class FirestoreConversationMapper {
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_PANTALLA_CONTEXTO = "pantallaContexto";
// Constants for fields within the 'mensajes' sub-documents
private static final String FIELD_MESSAGE_ENTITY = "entidad";
@@ -56,6 +57,10 @@ public class FirestoreConversationMapper {
}
public Map<String, Object> createNewSessionMapForSingleEntry(String sessionId, String userId, String telefono, ConversationEntryDTO initialEntry) {
return createNewSessionMapForSingleEntry(sessionId, userId, telefono, initialEntry, null);
}
public Map<String, Object> createNewSessionMapForSingleEntry(String sessionId, String userId, String telefono, ConversationEntryDTO initialEntry, String pantallaContexto) {
Map<String, Object> sessionMap = new HashMap<>();
sessionMap.put(FIELD_SESSION_ID, sessionId);
sessionMap.put(FIELD_USER_ID, userId);
@@ -66,6 +71,10 @@ public class FirestoreConversationMapper {
sessionMap.put(FIELD_PHONE_NUMBER, null);
}
if (pantallaContexto != null) {
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())));
@@ -100,6 +109,7 @@ public class FirestoreConversationMapper {
String sessionId = documentSnapshot.getString(FIELD_SESSION_ID);
String userId = documentSnapshot.getString(FIELD_USER_ID);
String telefono = documentSnapshot.getString(FIELD_PHONE_NUMBER);
String pantallaContexto = documentSnapshot.getString(FIELD_PANTALLA_CONTEXTO);
Timestamp createdAtFirestore = documentSnapshot.getTimestamp(FIELD_CREATED_AT);
Instant createdAt = (createdAtFirestore != null) ? createdAtFirestore.toDate().toInstant() : null;
@@ -115,7 +125,7 @@ public class FirestoreConversationMapper {
.map(this::mapFirestoreEntryMapToConversationEntryDTO)
.collect(Collectors.toList());
}
return new ConversationSessionDTO(sessionId, userId, telefono, createdAt, lastModified, entries);
return new ConversationSessionDTO(sessionId, userId, telefono, createdAt, lastModified, entries, pantallaContexto);
}

View 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.mapper.messagefilter;
import com.example.dto.dialogflow.conversation.ConversationEntryDTO;
import com.example.dto.dialogflow.conversation.ConversationSessionDTO;
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 {
private static final int MESSAGE_LIMIT = 60;
private static final int DAYS_LIMIT = 30;
public String toText(ConversationSessionDTO session) {
if (session == null || session.entries() == null || session.entries().isEmpty()) {
return "";
}
return session.entries().stream()
.map(this::formatEntry)
.collect(Collectors.joining(""));
}
public String toTextWithLimits(ConversationSessionDTO session) {
if (session == null || session.entries() == null || session.entries().isEmpty()) {
return "";
}
Instant thirtyDaysAgo = Instant.now().minus(DAYS_LIMIT, ChronoUnit.DAYS);
List<ConversationEntryDTO> recentEntries = session.entries().stream()
.filter(entry -> entry.timestamp().isAfter(thirtyDaysAgo))
.sorted(Comparator.comparing(ConversationEntryDTO::timestamp).reversed())
.limit(MESSAGE_LIMIT)
.sorted(Comparator.comparing(ConversationEntryDTO::timestamp))
.collect(Collectors.toList());
return recentEntries.stream()
.map(this::formatEntry)
.collect(Collectors.joining(""));
}
private String formatEntry(ConversationEntryDTO entry) {
String prefix = "User: ";
if (entry.entity() != null) {
switch (entry.entity()) {
case AGENTE:
prefix = "Agent: ";
break;
case USUARIO:
default:
prefix = "User: ";
break;
}
}
return prefix + entry.text();
}
}

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

View File

@@ -1,4 +1,7 @@
// src/main/java/com/example/mapper/notification/FirestoreNotificationMapper.java
/*
* 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;
@@ -22,6 +25,7 @@ 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;
@@ -44,7 +48,8 @@ public class FirestoreNotificationMapper {
notificationText,
FIXED_EVENT_NAME,
DEFAULT_LANGUAGE_CODE,
parameters
parameters,
DEFAULT_NOTIFICATION_STATUS
);
}

View File

@@ -0,0 +1,149 @@
/*
* 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<String, Object> createUpdateMapForSingleEntry(ConversationEntryDTO newEntry) {
Map<String, Object> updates = new HashMap<>();
Map<String, Object> entryMap = toFirestoreEntryMap(newEntry);
updates.put(FIELD_MESSAGES, FieldValue.arrayUnion(entryMap));
updates.put(FIELD_LAST_UPDATED, Timestamp.of(java.util.Date.from(Instant.now())));
return updates;
}
public Map<String, Object> createNewSessionMapForSingleEntry(String sessionId, String userId, String telefono, ConversationEntryDTO initialEntry) {
Map<String, Object> sessionMap = new HashMap<>();
sessionMap.put(FIELD_SESSION_ID, sessionId);
sessionMap.put(FIELD_USER_ID, userId);
if (telefono != null && !telefono.trim().isEmpty()) {
sessionMap.put(FIELD_PHONE_NUMBER, telefono);
} else {
sessionMap.put(FIELD_PHONE_NUMBER, null);
}
sessionMap.put(FIELD_CREATED_AT, Timestamp.of(java.util.Date.from(Instant.now())));
sessionMap.put(FIELD_LAST_UPDATED, Timestamp.of(java.util.Date.from(Instant.now())));
List<Map<String, Object>> entriesList = new ArrayList<>();
entriesList.add(toFirestoreEntryMap(initialEntry));
sessionMap.put(FIELD_MESSAGES, entriesList);
return sessionMap;
}
private Map<String, Object> toFirestoreEntryMap(ConversationEntryDTO entry) {
Map<String, Object> entryMap = new HashMap<>();
entryMap.put(FIELD_MESSAGE_ENTITY, entry.entity().name());
entryMap.put(FIELD_MESSAGE_TYPE, entry.type().name());
entryMap.put(FIELD_MESSAGE_TEXT, entry.text());
entryMap.put(FIELD_MESSAGE_TIMESTAMP, Timestamp.of(java.util.Date.from(entry.timestamp())));
if (entry.parameters() != null && !entry.parameters().isEmpty()) {
entryMap.put(FIELD_MESSAGE_PARAMETERS, entry.parameters());
}
if (entry.canal() != null) {
entryMap.put(FIELD_MESSAGE_CHANNEL, entry.canal());
}
return entryMap;
}
public ConversationSessionDTO mapFirestoreDocumentToConversationSessionDTO(DocumentSnapshot documentSnapshot) {
if (!documentSnapshot.exists()) {
return null;
}
String sessionId = documentSnapshot.getString(FIELD_SESSION_ID);
String userId = documentSnapshot.getString(FIELD_USER_ID);
String telefono = documentSnapshot.getString(FIELD_PHONE_NUMBER);
Timestamp createdAtFirestore = documentSnapshot.getTimestamp(FIELD_CREATED_AT);
Instant createdAt = (createdAtFirestore != null) ? createdAtFirestore.toDate().toInstant() : null;
Timestamp lastModifiedFirestore = documentSnapshot.getTimestamp(FIELD_LAST_UPDATED);
Instant lastModified = (lastModifiedFirestore != null) ? lastModifiedFirestore.toDate().toInstant() : null;
List<Map<String, Object>> rawEntries = (List<Map<String, Object>>) documentSnapshot.get(FIELD_MESSAGES);
List<ConversationEntryDTO> entries = new ArrayList<>();
if (rawEntries != null) {
entries = rawEntries.stream()
.map(this::mapFirestoreEntryMapToConversationEntryDTO)
.collect(Collectors.toList());
}
return new ConversationSessionDTO(sessionId, userId, telefono, createdAt, lastModified, entries, null);
}
private ConversationEntryDTO mapFirestoreEntryMapToConversationEntryDTO(Map<String, Object> entryMap) {
ConversationEntryEntity entity = null;
Object entityObj = entryMap.get(FIELD_MESSAGE_ENTITY);
if (entityObj instanceof String) {
try {
entity = ConversationEntryEntity.valueOf((String) entityObj);
} catch (IllegalArgumentException e) {
logger.warn("Unknown ConversationEntryEntity encountered: {}. Setting entity to null.", entityObj);
}
}
ConversationEntryType type = null;
Object typeObj = entryMap.get(FIELD_MESSAGE_TYPE);
if (typeObj instanceof String) {
try {
type = ConversationEntryType.valueOf((String) typeObj);
} catch (IllegalArgumentException e) {
logger.warn("Unknown ConversationEntryType encountered: {}. Setting type to null.", typeObj);
}
}
String text = (String) entryMap.get(FIELD_MESSAGE_TEXT);
Timestamp timestampFirestore = (Timestamp) entryMap.get(FIELD_MESSAGE_TIMESTAMP);
Instant timestamp = (timestampFirestore != null) ? timestampFirestore.toDate().toInstant() : null;
Map<String, Object> parameters = (Map<String, Object>) entryMap.get(FIELD_MESSAGE_PARAMETERS);
String canal = (String) entryMap.get(FIELD_MESSAGE_CHANNEL);
return new ConversationEntryDTO(entity, type, timestamp, text, parameters, canal);
}
}

View File

@@ -5,6 +5,8 @@
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;
@@ -14,26 +16,24 @@ 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.QuerySnapshot;
import com.google.cloud.firestore.WriteBatch;
import com.google.cloud.firestore.WriteResult;
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 java.util.Map;
import java.util.Objects;
import java.util.concurrent.ExecutionException;
import com.example.util.FirestoreTimestampDeserializer;
import com.example.util.FirestoreTimestampSerializer;
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`.
* 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 {
@@ -66,11 +66,15 @@ public class FirestoreBaseRepository {
// 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());
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);
logger.info(
"FirestoreBaseRepository initialized with Firestore client and ObjectMapper. App ID will be: {}",
appId);
}
public DocumentReference getDocumentReference(String collectionPath, String documentId) {
@@ -79,50 +83,76 @@ public class FirestoreBaseRepository {
return firestore.collection(collectionPath).document(documentId);
}
public <T> T getDocument(DocumentReference docRef, Class<T> clazz) throws InterruptedException, ExecutionException {
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());
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);
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 {
public DocumentSnapshot getDocumentSnapshot(DocumentReference docRef)
throws ExecutionException, InterruptedException {
Objects.requireNonNull(docRef, "DocumentReference cannot be null.");
ApiFuture<DocumentSnapshot> future = docRef.get();
return future.get();
}
public boolean documentExists(DocumentReference docRef) throws InterruptedException, ExecutionException {
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 {
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());
logger.debug(
"Document set: {} with update time: {}", docRef.getPath(), writeResult.getUpdateTime());
}
public void updateDocument(DocumentReference docRef, Map<String, Object> updates) throws InterruptedException, ExecutionException {
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());
logger.debug(
"Document updated: {} with update time: {}", docRef.getPath(), writeResult.getUpdateTime());
}
public WriteBatch createBatch() {
@@ -138,5 +168,4 @@ public class FirestoreBaseRepository {
public String getAppId() {
return appId;
}
}

View File

@@ -1,133 +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.repository;
import com.example.dto.dialogflow.notification.NotificationDTO;
import com.example.util.FirestoreTimestampDeserializer;
import com.example.util.FirestoreTimestampSerializer;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.module.SimpleModule;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.fasterxml.jackson.module.paramnames.ParameterNamesModule;
import com.google.cloud.firestore.Firestore;
import com.google.cloud.firestore.DocumentReference;
import com.google.cloud.firestore.DocumentSnapshot;
import com.google.cloud.firestore.SetOptions;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.util.Objects;
import java.util.concurrent.Callable;
/**
* Repository for managing `NotificationDTO` objects in Firestore.
* It provides reactive methods for saving and retrieving notification data,
* handling the serialization and deserialization of the DTOs and managing
* the document paths within a structured collection.
*/
@Repository
public class FirestoreNotificationRepository {
private static final Logger logger = LoggerFactory.getLogger(FirestoreNotificationRepository.class);
private final Firestore firestore;
private final ObjectMapper objectMapper;
@Value("${app.id:default-app-id}")
private String appId;
private final String BASE_COLLECTION_PATH_FORMAT = "artifacts/%s/notifications";
public FirestoreNotificationRepository(Firestore firestore, ObjectMapper objectMapper) {
this.firestore = firestore;
this.objectMapper = objectMapper;
// Ensure ObjectMapper is configured for Java Time and Records, and Firestore
// Timestamps
if (!ObjectMapper.findModules().stream().anyMatch(m -> m instanceof JavaTimeModule)) {
objectMapper.registerModule(new JavaTimeModule());
}
objectMapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
if (!ObjectMapper.findModules().stream().anyMatch(m -> m instanceof ParameterNamesModule)) {
objectMapper.registerModule(new ParameterNamesModule());
}
// These specific Timestamp (Google Cloud) deserializers/serializers are for ObjectMapper
// to handle com.google.cloud.Timestamp objects when mapping other types.
// They are generally not the cause of the Redis deserialization error for Instant.
SimpleModule firestoreTimestampModule = new SimpleModule();
firestoreTimestampModule.addDeserializer(com.google.cloud.Timestamp.class,
new FirestoreTimestampDeserializer());
firestoreTimestampModule.addSerializer(com.google.cloud.Timestamp.class, new FirestoreTimestampSerializer());
objectMapper.registerModule(firestoreTimestampModule);
logger.info("FirestoreNotificationRepository initialized for project: {}", appId);
}
private DocumentReference getDocumentReference(String notificationId) {
String collectionPath = String.format(BASE_COLLECTION_PATH_FORMAT, appId);
return firestore.collection(collectionPath).document(notificationId);
}
public Mono<Void> saveNotification(NotificationDTO notification) {
Objects.requireNonNull(notification.idNotificacion(), "Notification ID cannot be null.");
return Mono.fromCallable((Callable<Void>) () -> {
DocumentReference docRef = getDocumentReference(notification.idNotificacion());
logger.debug("Attempting to save notification with ID {} to Firestore path: {}",
notification.idNotificacion(), docRef.getPath());
docRef.set(notification, SetOptions.merge()).get();
logger.info("Notification with ID {} successfully saved to Firestore.", notification.idNotificacion());
return null;
}).subscribeOn(Schedulers.boundedElastic())
.doOnError(e -> logger.error("Failed to save notification with ID {} to Firestore: {}",
notification.idNotificacion(), e.getMessage(), e));
}
public Mono<NotificationDTO> getNotification(String notificationId) { // Renamed method
Objects.requireNonNull(notificationId, "Notification ID cannot be null for retrieval.");
return Mono.fromCallable((Callable<NotificationDTO>) () -> {
DocumentReference docRef = getDocumentReference(notificationId);
logger.debug("Attempting to retrieve notification with ID {} from Firestore path: {}", notificationId,
docRef.getPath());
DocumentSnapshot document = docRef.get().get(); // Blocking call
if (document.exists()) {
try {
NotificationDTO notification = objectMapper.convertValue(document.getData(), NotificationDTO.class);
logger.info("Notification with ID {} successfully retrieved from Firestore.", notificationId);
return notification;
} catch (IllegalArgumentException e) {
logger.error(
"Failed to convert Firestore document data to Notification for ID {}: {}",
notificationId, e.getMessage(), e);
throw new RuntimeException(
"Failed to convert Firestore document data to Notification for ID "
+ notificationId,
e);
}
} else {
logger.debug("Notification with ID {} not found in Firestore.", notificationId);
return null;
}
}).subscribeOn(Schedulers.boundedElastic())
.doOnError(e -> logger.error("Failed to retrieve notification with ID {} from Firestore: {}",
notificationId, e.getMessage(), e));
}
}

View File

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

View File

@@ -5,68 +5,103 @@
package com.example.service.conversation;
import com.example.mapper.conversation.ExternalConvRequestMapper;
import com.example.service.base.DialogflowClientService;
import com.example.util.SessionIdGenerator;
import com.example.dto.dialogflow.base.DetectIntentRequestDTO;
import com.example.dto.dialogflow.base.DetectIntentResponseDTO;
import com.example.dto.dialogflow.conversation.ConversationContext;
import com.example.dto.dialogflow.conversation.ConversationEntryDTO;
import com.example.dto.dialogflow.conversation.ConversationEntryEntity;
import com.example.dto.dialogflow.conversation.ConversationEntryType;
import com.example.dto.dialogflow.conversation.ConversationSessionDTO;
import com.example.dto.dialogflow.conversation.ExternalConvRequestDTO;
import com.example.dto.dialogflow.notification.NotificationDTO;
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.notification.MemoryStoreNotificationService;
import com.example.service.quickreplies.QuickRepliesManagerService;
import com.example.util.SessionIdGenerator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.time.Duration;
import java.time.Instant;
import java.util.Collections;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
/**
* Service for orchestrating the end-to-end conversation flow.
* It manages user sessions, creating new ones or reusing existing ones
* based on a session reset threshold. The service handles the entire
* conversation turn, from mapping an external request to calling Dialogflow,
* and then persists both user and agent messages using a write-back strategy
* to a primary cache (Redis) and an asynchronous write to Firestore.
*/
@Service
public class ConversationManagerService {
private static final Logger logger = LoggerFactory.getLogger(ConversationManagerService.class);
private static final long SESSION_RESET_THRESHOLD_HOURS = 24;
private static final String CURRENT_PAGE_PARAM = "currentPage";
private final ExternalConvRequestMapper externalRequestToDialogflowMapper;
private final 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;
public ConversationManagerService(
DialogflowClientService dialogflowServiceClient,
FirestoreConversationService firestoreConversationService,
MemoryStoreConversationService memoryStoreConversationService,
ExternalConvRequestMapper externalRequestToDialogflowMapper) {
ExternalConvRequestMapper externalRequestToDialogflowMapper,
QuickRepliesManagerService quickRepliesManagerService,
MessageEntryFilter messageEntryFilter,
MemoryStoreNotificationService memoryStoreNotificationService,
NotificationContextMapper notificationContextMapper,
ConversationContextMapper conversationContextMapper) {
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;
}
public Mono<DetectIntentResponseDTO>manageConversation(ExternalConvRequestDTO Externalrequest) {
public Mono<DetectIntentResponseDTO> manageConversation(ExternalConvRequestDTO externalrequest) {
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);
}
}
return continueManagingConversation(externalrequest);
})
.switchIfEmpty(continueManagingConversation(externalrequest));
}
private Mono<DetectIntentResponseDTO> continueManagingConversation(ExternalConvRequestDTO externalrequest) {
final DetectIntentRequestDTO request;
try {
request = externalRequestToDialogflowMapper.mapExternalRequestToDetectIntentRequest(Externalrequest);
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));
return Mono.error(new IllegalArgumentException(
"Failed to process external request due to mapping error: " + e.getMessage(), e));
}
final ConversationContext context;
@@ -77,83 +112,141 @@ public class ConversationManagerService {
return Mono.error(e);
}
return handleMessageClassification(context, request);
}
private Mono<DetectIntentResponseDTO> 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()
.filter(notification -> "active".equalsIgnoreCase(notification.status()))
.max(java.util.Comparator.comparing(NotificationDTO::timestampCreacion))
.orElse(null))
.filter(Objects::nonNull)
.flatMap((NotificationDTO notification) -> {
String notificationText = notificationContextMapper.toText(notification);
return memoryStoreConversationService.getSessionByTelefono(userPhoneNumber)
.map(conversationContextMapper::toText)
.defaultIfEmpty("")
.flatMap(conversationHistory -> {
String classification = messageEntryFilter.classifyMessage(userMessageText, notificationText, conversationHistory);
if (MessageEntryFilter.CATEGORY_NOTIFICATION.equals(classification)) {
return startNotificationConversation(context, request, notification);
} else {
return continueConversationFlow(context, request);
}
});
})
.switchIfEmpty(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();
Mono<ConversationSessionDTO> sessionMono;
if (userPhoneNumber != null && !userPhoneNumber.isBlank()) {
logger.info("Checking for existing session for phone number: {}", userPhoneNumber);
sessionMono = memoryStoreConversationService.getSessionByTelefono(userPhoneNumber)
.doOnNext(session -> logger.info("Found existing session {} for phone number {}", session.sessionId(), userPhoneNumber))
.switchIfEmpty(Mono.defer(() -> {
String newSessionId = SessionIdGenerator.generateStandardSessionId();
logger.info("No existing session found for phone number {}. Creating new session: {}", userPhoneNumber, newSessionId);
return Mono.just(ConversationSessionDTO.create(newSessionId, userId, userPhoneNumber));
}));
} else {
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 sessionMono.flatMap(session -> {
final String finalSessionId = session.sessionId();
logger.info("Managing conversation for resolved session: {}", finalSessionId);
ConversationEntryDTO userEntry = ConversationEntryDTO.forUser(userMessageText);
final DetectIntentRequestDTO requestToDialogflow;
Instant currentInteractionTimestamp = userEntry.timestamp();
if (session.lastModified() != null &&
Duration.between(session.lastModified(), currentInteractionTimestamp).toHours() >= SESSION_RESET_THRESHOLD_HOURS) {
logger.info("Session {} (last modified: {}) is older than {} hours. Adding '{}' parameter to Dialogflow request.",
session.sessionId(), session.lastModified(), SESSION_RESET_THRESHOLD_HOURS, CURRENT_PAGE_PARAM);
requestToDialogflow = request.withParameter(CURRENT_PAGE_PARAM, true);
logger.info("Primary Check (MemoryStore): Looking up session for phone number: {}", userPhoneNumber);
return memoryStoreConversationService.getSessionByTelefono(userPhoneNumber)
.flatMap(session -> {
Instant now = Instant.now();
if (Duration.between(session.lastModified(), now).toHours() < SESSION_RESET_THRESHOLD_HOURS) {
logger.info("Recent Session Found: Session {} is within the 24-hour threshold. Proceeding to Dialogflow.", session.sessionId());
return processDialogflowRequest(session, request, userId, userMessageText, userPhoneNumber, false);
} else {
requestToDialogflow = request;
logger.info("Old Session Found: Session {} is older than the threshold. Proceeding to full lookup.", session.sessionId());
return fullLookupAndProcess(session, request, userId, userMessageText, userPhoneNumber);
}
})
.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> fullLookupAndProcess(ConversationSessionDTO oldSession, DetectIntentRequestDTO request, String userId, String userMessageText, String userPhoneNumber) {
return firestoreConversationService.getSessionByTelefono(userPhoneNumber)
.map(conversationContextMapper::toTextWithLimits)
.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(CURRENT_PAGE_PARAM, conversationHistory);
return processDialogflowRequest(newSession, newRequest, 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(userId, finalSessionId, userEntry, userPhoneNumber)
.doOnSuccess(v -> logger.debug("User entry successfully persisted for session {}. Proceeding to Dialogflow...", finalSessionId))
.doOnError(e -> logger.error("Error during user entry persistence for session {}: {}", finalSessionId, e.getMessage(), e))
.then(Mono.defer(() -> {
return dialogflowServiceClient.detectIntent(finalSessionId, requestToDialogflow)
.doOnSuccess(response -> {
.then(Mono.defer(() -> dialogflowServiceClient.detectIntent(finalSessionId, request)
.flatMap(response -> {
logger.debug("Received Dialogflow CX response for session {}. Initiating agent response persistence.", finalSessionId);
ConversationEntryDTO agentEntry = ConversationEntryDTO.forAgent(response.queryResult());
this.persistConversationTurn(userId, finalSessionId, agentEntry, userPhoneNumber).subscribe(
v -> logger.debug("Background: Agent entry persistence initiated for session {}.", finalSessionId),
e -> logger.error("Background: Error during agent entry persistence for session {}: {}", finalSessionId, e.getMessage(), e)
);
return persistConversationTurn(userId, finalSessionId, agentEntry, userPhoneNumber)
.thenReturn(response);
})
.doOnError(error -> logger.error("Overall error during conversation management for session {}: {}", finalSessionId, error.getMessage(), error));
.doOnError(error -> logger.error("Overall error during conversation management for session {}: {}", finalSessionId, error.getMessage(), error))
));
}
private 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 memoryStoreNotificationService.getSessionByTelefono(userPhoneNumber)
.switchIfEmpty(Mono.defer(() -> {
String newSessionId = SessionIdGenerator.generateStandardSessionId();
logger.info("No existing notification session found for phone number {}. Creating new session: {}",
userPhoneNumber, newSessionId);
return Mono.just(ConversationSessionDTO.create(newSessionId, userId, userPhoneNumber));
}))
.flatMap(session -> {
final String sessionId = session.sessionId();
ConversationEntryDTO userEntry = ConversationEntryDTO.forUser(userMessageText);
return memoryStoreNotificationService.saveEntry(userId, sessionId, userEntry, userPhoneNumber)
.then(dialogflowServiceClient.detectIntent(sessionId, request)
.doOnSuccess(response -> {
ConversationEntryDTO agentEntry = ConversationEntryDTO.forAgent(response.queryResult());
memoryStoreNotificationService.saveEntry(userId, sessionId, agentEntry, userPhoneNumber).subscribe();
}));
})
.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));
})
.subscribeOn(Schedulers.boundedElastic());
});
}
private Mono<Void> 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());
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.", sessionId, entry.type().name());
firestoreConversationService.saveEntry(userId, sessionId, entry, userPhoneNumber)
.subscribe(
fsVoid -> logger.debug("Asynchronously (Write-Back): Entry successfully saved to Firestore for session {}. Type: {}.",
sessionId, entry.type().name()),
fsError -> logger.error("Asynchronously (Write-Back): Failed to save entry to Firestore for session {}. Type: {}: {}",
sessionId, entry.type().name(), fsError.getMessage(), fsError)
);
})
.doOnError(e -> logger.error("Error during primary Redis write for session {}. Type: {}: {}", sessionId, entry.type().name(), e.getMessage(), e));
.doOnSuccess(v -> logger.info(
"Entry saved to Redis for session {}. Type: {}. Kicking off async Firestore write-back.",
sessionId, entry.type().name()))
.then(firestoreConversationService.saveEntry(userId, sessionId, entry, userPhoneNumber)
.doOnSuccess(fsVoid -> logger.debug(
"Asynchronously (Write-Back): Entry successfully saved to Firestore for session {}. Type: {}.",
sessionId, entry.type().name()))
.doOnError(fsError -> logger.error(
"Asynchronously (Write-Back): Failed to save entry to Firestore for session {}. Type: {}: {}",
sessionId, entry.type().name(), fsError.getMessage(), fsError)))
.doOnError(e -> logger.error("Error during primary Redis write for session {}. Type: {}: {}", sessionId,
entry.type().name(), e.getMessage(), e));
}
private ConversationContext resolveAndValidateRequest(DetectIntentRequestDTO request) {
@@ -166,11 +259,13 @@ private ConversationContext resolveAndValidateRequest(DetectIntentRequestDTO req
if (telefonoObj instanceof String) {
primaryPhoneNumber = (String) telefonoObj;
} else if (telefonoObj != null) {
logger.warn("Parameter 'telefono' in queryParams is not a String (type: {}). Expected String.", telefonoObj.getClass().getName());
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.");
throw new IllegalArgumentException(
"Phone number (telefono) is required in query parameters for conversation management.");
}
String resolvedUserId = null;
@@ -178,12 +273,14 @@ private ConversationContext resolveAndValidateRequest(DetectIntentRequestDTO req
if (userIdObj instanceof String) {
resolvedUserId = (String) userIdObj;
} else if (userIdObj != null) {
logger.warn("Parameter 'userId' in queryParams is not a String (type: {}). Expected String.", userIdObj.getClass().getName());
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);
logger.warn("User ID not provided in query parameters. Using derived ID from phone number: {}",
resolvedUserId);
}
if (request.queryInput() == null || request.queryInput().text() == null ||

View File

@@ -43,6 +43,10 @@ public class FirestoreConversationService {
}
public Mono<Void> saveEntry(String userId, String sessionId, ConversationEntryDTO newEntry, String userPhoneNumber) {
return saveEntry(userId, sessionId, newEntry, userPhoneNumber, null);
}
public Mono<Void> saveEntry(String userId, String sessionId, ConversationEntryDTO newEntry, String userPhoneNumber, String pantallaContexto) {
logger.info("Attempting to save conversation entry to Firestore for session {}. Entity: {}", sessionId, newEntry.entity().name());
return Mono.fromRunnable(() -> {
DocumentReference sessionDocRef = getSessionDocumentReference(sessionId);
@@ -52,12 +56,15 @@ public class FirestoreConversationService {
if (firestoreBaseRepository.documentExists(sessionDocRef)) {
// Update: Append the new entry using arrayUnion and update lastModified
Map<String, Object> updates = firestoreConversationMapper.createUpdateMapForSingleEntry(newEntry);
if (pantallaContexto != null) {
updates.put("pantallaContexto", pantallaContexto);
}
batch.update(sessionDocRef, updates);
logger.info("Appending entry to existing conversation session for user {} and session {}. Entity: {}", userId, sessionId, newEntry.entity().name());
} else {
// Create: Start a new session with the first entry.
// Pass userId and userPhoneNumber to the mapper to be stored as fields in the document.
Map<String, Object> newSessionMap = firestoreConversationMapper.createNewSessionMapForSingleEntry(sessionId, userId, userPhoneNumber, newEntry);
Map<String, Object> newSessionMap = firestoreConversationMapper.createNewSessionMapForSingleEntry(sessionId, userId, userPhoneNumber, newEntry, pantallaContexto);
batch.set(sessionDocRef, newSessionMap);
logger.info("Creating new conversation session with first entry for user {} and session {}. Entity: {}", userId, sessionId, newEntry.entity().name());
}
@@ -94,6 +101,20 @@ public class FirestoreConversationService {
}).subscribeOn(Schedulers.boundedElastic());
}
public Mono<ConversationSessionDTO> getSessionByTelefono(String userPhoneNumber) {
logger.info("Attempting to retrieve conversation session for phone number {}.", userPhoneNumber);
return firestoreBaseRepository.getDocumentsByField(getConversationCollectionPath(), "userPhoneNumber", 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;
}
logger.info("Conversation session not found for phone number {}.", userPhoneNumber);
return null;
});
}
private String getConversationCollectionPath() {
return String.format(CONVERSATION_COLLECTION_PATH_FORMAT, firestoreBaseRepository.getAppId());
}

View File

@@ -29,28 +29,48 @@ public class MemoryStoreConversationService {
private static final Duration SESSION_TTL = Duration.ofHours(24);
private final ReactiveRedisTemplate<String, ConversationSessionDTO> redisTemplate;
private final ReactiveRedisTemplate<String, String> stringRedisTemplate;
@Autowired
public MemoryStoreConversationService(
ReactiveRedisTemplate<String, ConversationSessionDTO> redisTemplate,
ReactiveRedisTemplate<String, String> stringRedisTemplate) {
ReactiveRedisTemplate<String, String> stringRedisTemplate,
FirestoreConversationService firestoreConversationService) {
this.redisTemplate = redisTemplate;
this.stringRedisTemplate = stringRedisTemplate;
}
public Mono<Void> saveEntry(String userId, String sessionId, ConversationEntryDTO newEntry, String userPhoneNumber) {
return saveEntry(userId, sessionId, newEntry, userPhoneNumber, null);
}
public Mono<Void> saveEntry(String userId, String sessionId, ConversationEntryDTO newEntry, String userPhoneNumber, String pantallaContexto) {
String sessionKey = SESSION_KEY_PREFIX + sessionId;
String phoneToSessionKey = PHONE_TO_SESSION_KEY_PREFIX + userPhoneNumber;
logger.info("Attempting to save entry to Redis for session {}. Entity: {}", sessionId, newEntry.entity().name());
return redisTemplate.opsForValue().get(sessionKey)
.defaultIfEmpty(ConversationSessionDTO.create(sessionId, userId, userPhoneNumber))
.flatMap(session -> {
ConversationSessionDTO sessionWithUpdatedTelefono = session.withTelefono(userPhoneNumber);
ConversationSessionDTO updatedSession = sessionWithUpdatedTelefono.withAddedEntry(newEntry);
ConversationSessionDTO sessionWithPantallaContexto = (pantallaContexto != null) ? sessionWithUpdatedTelefono.withPantallaContexto(pantallaContexto) : sessionWithUpdatedTelefono;
ConversationSessionDTO updatedSession = sessionWithPantallaContexto.withAddedEntry(newEntry);
logger.info("Attempting to set updated session {} with new entry entity {} in Redis.", sessionId, newEntry.entity().name());
return redisTemplate.opsForValue().set(sessionKey, updatedSession, SESSION_TTL)
.then(stringRedisTemplate.opsForValue().set(phoneToSessionKey, sessionId, SESSION_TTL))
.then(); // <--- ADD THIS .then() WITHOUT ARGUMENTS
.then();
})
.doOnSuccess(success -> {
logger.info("Successfully saved updated session and phone mapping to Redis for session {}. Entity Type: {}", sessionId, newEntry.entity().name());
})
.doOnSuccess(success -> logger.info("Successfully saved updated session and phone mapping to Redis for session {}. Entity Type: {}", sessionId, newEntry.entity().name()))
.doOnError(e -> logger.error("Error appending entry to Redis for session {}: {}", sessionId, e.getMessage(), e));
}
public Mono<ConversationSessionDTO> getSessionByTelefono(String telefono) {

View File

@@ -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.notification;
import com.example.dto.dialogflow.conversation.ConversationEntryDTO;
import com.example.dto.dialogflow.conversation.ConversationSessionDTO;
import com.example.exception.FirestorePersistenceException;
import com.example.mapper.conversation.FirestoreConversationMapper;
import com.example.repository.FirestoreBaseRepository;
import com.google.cloud.firestore.DocumentReference;
import com.google.cloud.firestore.DocumentSnapshot;
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 for managing notification conversation sessions in Firestore.
* It handles the persistence of conversation entries, either by creating
* a new document for a new session or appending an entry to an existing
* session document using a Firestore batch. The service also provides
* methods for retrieving a complete conversation session from Firestore.
*/
@Service
public class FirestoreNotificationConvService {
private static final Logger logger = LoggerFactory.getLogger(FirestoreNotificationConvService.class);
private static final String CONVERSATION_COLLECTION_PATH_FORMAT = "artifacts/%s/conversation-notifications";
private final FirestoreBaseRepository firestoreBaseRepository;
private final FirestoreConversationMapper firestoreConversationMapper;
public FirestoreNotificationConvService(FirestoreBaseRepository firestoreBaseRepository, FirestoreConversationMapper firestoreConversationMapper) {
this.firestoreBaseRepository = firestoreBaseRepository;
this.firestoreConversationMapper = firestoreConversationMapper;
}
public Mono<Void> saveEntry(String userId, String sessionId, ConversationEntryDTO newEntry, String userPhoneNumber) {
logger.info("Attempting to save conversation entry to Firestore for session {}. Entity: {}", sessionId, newEntry.entity().name());
return Mono.fromRunnable(() -> {
DocumentReference sessionDocRef = getSessionDocumentReference(sessionId);
// Synchronize on the session ID to prevent race conditions when creating a new session.
synchronized (sessionId.intern()) {
WriteBatch batch = firestoreBaseRepository.createBatch();
try {
if (firestoreBaseRepository.documentExists(sessionDocRef)) {
// Update: Append the new entry using arrayUnion and update lastModified
Map<String, Object> updates = firestoreConversationMapper.createUpdateMapForSingleEntry(newEntry);
batch.update(sessionDocRef, updates);
logger.info("Appending entry to existing conversation session for user {} and session {}. Entity: {}", userId, sessionId, newEntry.entity().name());
} else {
// Create: Start a new session with the first entry.
// Pass userId and userPhoneNumber to the mapper to be stored as fields in the document.
Map<String, Object> newSessionMap = firestoreConversationMapper.createNewSessionMapForSingleEntry(sessionId, userId, userPhoneNumber, newEntry);
batch.set(sessionDocRef, newSessionMap);
logger.info("Creating new conversation session with first entry for user {} and session {}. 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 conversation entry to Firestore for session {}: {}", sessionId, e.getMessage(), e);
throw new FirestorePersistenceException("Failed to save conversation entry to Firestore for session " + sessionId, e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
logger.error("Thread interrupted while saving conversation entry to Firestore for session {}: {}", sessionId, e.getMessage(), e);
throw new FirestorePersistenceException("Saving conversation entry was interrupted for session " + sessionId, e);
}
}
}).subscribeOn(Schedulers.boundedElastic()).then();
}
public Mono<ConversationSessionDTO> getConversationSession(String userId, String sessionId) {
logger.info("Attempting to retrieve conversation session for session {} (user ID {} for context).", sessionId, userId);
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; // Or Mono.empty() if this method returned Mono<Optional<ConversationSessionDTO>>
} catch (InterruptedException | ExecutionException e) {
logger.error("Error retrieving conversation session from Firestore for session {}: {}", sessionId, e.getMessage(), e);
throw new FirestorePersistenceException("Failed to retrieve conversation session from Firestore for session " + sessionId, e);
}
}).subscribeOn(Schedulers.boundedElastic());
}
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);
}
}

View File

@@ -1,3 +1,8 @@
/*
* Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose.
* Your use of it is subject to your agreement with Google.
*/
package com.example.service.notification;
import com.example.dto.dialogflow.notification.NotificationDTO;
@@ -7,15 +12,18 @@ 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;
import java.time.Instant;
import java.util.Collections;
import java.util.Map;
import java.util.concurrent.ExecutionException;
@Service
public class FirestoreNotificationService {
@@ -32,58 +40,84 @@ public class FirestoreNotificationService {
public FirestoreNotificationService(
FirestoreBaseRepository firestoreBaseRepository,
FirestoreNotificationMapper firestoreNotificationMapper) {
FirestoreNotificationMapper firestoreNotificationMapper,
MemoryStoreNotificationService memoryStoreNotificationService) {
this.firestoreBaseRepository = firestoreBaseRepository;
this.firestoreNotificationMapper = firestoreNotificationMapper;
}
public Mono<Void> saveOrAppendNotificationEntry(NotificationDTO newEntry) {
return Mono.fromRunnable(() -> {
return Mono.fromRunnable(
() -> {
String phoneNumber = newEntry.telefono();
if (phoneNumber == null || phoneNumber.isBlank()) {
throw new IllegalArgumentException("Phone number is required to manage notification entries.");
throw new IllegalArgumentException(
"Phone number is required to manage notification entries.");
}
// Use the phone number as the document ID for the session.
String notificationSessionId = phoneNumber;
DocumentReference notificationDocRef = getNotificationDocumentReference(notificationSessionId);
Map<String, Object> entryMap = firestoreNotificationMapper.mapNotificationDTOToMap(newEntry);
// 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 = notificationDocRef.get().get().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(
Map<String, Object> updates =
Map.of(
FIELD_MESSAGES, FieldValue.arrayUnion(entryMap),
FIELD_LAST_UPDATED, Timestamp.of(java.util.Date.from(Instant.now()))
);
notificationDocRef.update(updates).get();
logger.info("Successfully appended new entry to notification session {} in Firestore.", notificationSessionId);
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)
);
notificationDocRef.set(newSessionData).get();
logger.info("Successfully created a new notification session {} in Firestore.", notificationSessionId);
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 {}: {}", phoneNumber, e.getMessage(), e);
throw new FirestorePersistenceException("Failed to save notification to Firestore for phone " + phoneNumber, e);
logger.error(
"Error saving notification to Firestore for phone {}: {}",
phoneNumber,
e.getMessage(),
e);
throw new FirestorePersistenceException(
"Failed to save notification to Firestore for phone " + phoneNumber, e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
logger.error("Thread interrupted while saving notification to Firestore for phone {}: {}", phoneNumber, e.getMessage(), e);
throw new FirestorePersistenceException("Saving notification was interrupted for phone " + phoneNumber, e);
logger.error(
"Thread interrupted while saving notification to Firestore for phone {}: {}",
phoneNumber,
e.getMessage(),
e);
throw new FirestorePersistenceException(
"Saving notification was interrupted for phone " + phoneNumber, e);
}
}).subscribeOn(Schedulers.boundedElastic()).then();
}
})
.subscribeOn(Schedulers.boundedElastic())
.then();
}
private String getNotificationCollectionPath() {
@@ -94,4 +128,41 @@ public class FirestoreNotificationService {
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();
}
}

View File

@@ -1,5 +1,7 @@
package com.example.service.notification;
import com.example.dto.dialogflow.conversation.ConversationEntryDTO;
import com.example.dto.dialogflow.conversation.ConversationSessionDTO;
import com.example.dto.dialogflow.notification.NotificationDTO;
import com.example.dto.dialogflow.notification.NotificationSessionDTO;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -19,17 +21,25 @@ public class MemoryStoreNotificationService {
private static final Logger logger = LoggerFactory.getLogger(MemoryStoreNotificationService.class);
private final ReactiveRedisTemplate<String, NotificationSessionDTO> notificationRedisTemplate;
private final ReactiveRedisTemplate<String, ConversationSessionDTO> conversationRedisTemplate;
private final ReactiveRedisTemplate<String, String> stringRedisTemplate;
private final FirestoreNotificationConvService firestoreNotificationConvService;
private static final String NOTIFICATION_KEY_PREFIX = "notification:";
private static final String PHONE_TO_SESSION_KEY_PREFIX = "notification:phone_to_notification:";
private final Duration notificationTtl = Duration.ofHours(24);
private static final String PHONE_TO_NOTIFICATION_SESSION_KEY_PREFIX = "notification:phone_to_notification:";
private static final String CONVERSATION_SESSION_KEY_PREFIX = "conversation-notification:session:";
private static final String PHONE_TO_CONVERSATION_SESSION_KEY_PREFIX = "conversation-notification:phone_to_session:";
private final Duration notificationTtl = Duration.ofMinutes(5);
public MemoryStoreNotificationService(
ReactiveRedisTemplate<String, NotificationSessionDTO> notificationRedisTemplate,
ReactiveRedisTemplate<String, ConversationSessionDTO> conversationRedisTemplate,
ReactiveRedisTemplate<String, String> stringRedisTemplate,
FirestoreNotificationConvService firestoreNotificationConvService,
ObjectMapper objectMapper) {
this.notificationRedisTemplate = notificationRedisTemplate;
this.conversationRedisTemplate = conversationRedisTemplate;
this.stringRedisTemplate = stringRedisTemplate;
this.firestoreNotificationConvService = firestoreNotificationConvService;
}
@@ -38,7 +48,6 @@ public class MemoryStoreNotificationService {
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;
@@ -73,7 +82,7 @@ public class MemoryStoreNotificationService {
private Mono<Boolean> cacheNotificationSession(NotificationSessionDTO session) {
String key = NOTIFICATION_KEY_PREFIX + session.sessionId();
String phoneToSessionKey = PHONE_TO_SESSION_KEY_PREFIX + session.telefono();
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));
@@ -93,7 +102,7 @@ public class MemoryStoreNotificationService {
}
public Mono<String> getNotificationIdForPhone(String phone) {
String key = PHONE_TO_SESSION_KEY_PREFIX + phone;
String key = PHONE_TO_NOTIFICATION_SESSION_KEY_PREFIX + phone;
return stringRedisTemplate.opsForValue().get(key)
.doOnSuccess(sessionId -> {
if (sessionId != null) {
@@ -105,4 +114,53 @@ public class MemoryStoreNotificationService {
.doOnError(e -> logger.error("Error retrieving session ID for phone {} from MemoryStore: {}", phone,
e.getMessage(), e));
}
public Mono<Void> saveEntry(String userId, String sessionId, ConversationEntryDTO newEntry, String userPhoneNumber) {
String sessionKey = CONVERSATION_SESSION_KEY_PREFIX + sessionId;
String phoneToSessionKey = PHONE_TO_CONVERSATION_SESSION_KEY_PREFIX + userPhoneNumber;
logger.info("Attempting to save entry to Redis for session {}. Entity: {}", sessionId, newEntry.entity().name());
return conversationRedisTemplate.opsForValue().get(sessionKey)
.defaultIfEmpty(ConversationSessionDTO.create(sessionId, userId, userPhoneNumber))
.flatMap(session -> {
ConversationSessionDTO sessionWithUpdatedTelefono = session.withTelefono(userPhoneNumber);
ConversationSessionDTO updatedSession = sessionWithUpdatedTelefono.withAddedEntry(newEntry);
logger.info("Attempting to set updated session {} with new entry entity {} in Redis.", sessionId, newEntry.entity().name());
return conversationRedisTemplate.opsForValue().set(sessionKey, updatedSession, notificationTtl)
.then(stringRedisTemplate.opsForValue().set(phoneToSessionKey, sessionId, notificationTtl))
.then();
})
.doOnSuccess(success ->{
logger.info("Successfully saved updated session and phone mapping to Redis for session {}. Entity Type: {}", sessionId, newEntry.entity().name());
firestoreNotificationConvService.saveEntry(userId, sessionId, newEntry, userPhoneNumber)
.subscribe(
fsVoid -> logger.debug(
"Asynchronously (Write-Back): Entry successfully saved to Firestore for session {}. Type: {}.",
sessionId, newEntry.type().name()),
fsError -> logger.error(
"Asynchronously (Write-Back): Failed to save entry to Firestore for session {}. Type: {}: {}",
sessionId, newEntry.type().name(), fsError.getMessage(), fsError));
})
.doOnError(e -> logger.error("Error appending entry to Redis for session {}: {}", sessionId, e.getMessage(), e));
}
public Mono<ConversationSessionDTO> getSessionByTelefono(String telefono) {
if (telefono == null || telefono.isBlank()) {
return Mono.empty();
}
String phoneToSessionKey = PHONE_TO_CONVERSATION_SESSION_KEY_PREFIX + telefono;
logger.debug("Attempting to retrieve session ID for phone number {} from Redis.", telefono);
return stringRedisTemplate.opsForValue().get(phoneToSessionKey)
.flatMap(sessionId -> {
logger.debug("Found session ID {} for phone number {}. Retrieving session data.", sessionId, telefono);
return conversationRedisTemplate.opsForValue().get(CONVERSATION_SESSION_KEY_PREFIX + sessionId);
})
.doOnSuccess(session -> {
if (session != null) {
logger.info("Successfully retrieved session {} by phone number {}.", session.sessionId(), telefono);
} else {
logger.info("No session found in Redis for phone number {}.", telefono);
}
})
.doOnError(e -> logger.error("Error retrieving session by phone number {}: {}", telefono, e.getMessage(), e));
}
}

View File

@@ -0,0 +1,37 @@
/*
* Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose.
* Your use of it is subject to your agreement with Google.
*/
package com.example.service.notification;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.redis.connection.Message;
import org.springframework.data.redis.connection.MessageListener;
import org.springframework.stereotype.Service;
@Service
public class NotificationExpirationListener implements MessageListener {
private static final Logger logger = LoggerFactory.getLogger(NotificationExpirationListener.class);
private final FirestoreNotificationService firestoreNotificationService;
private static final String NOTIFICATION_KEY_PREFIX = "notification:";
public NotificationExpirationListener(FirestoreNotificationService firestoreNotificationService) {
this.firestoreNotificationService = firestoreNotificationService;
}
@Override
public void onMessage(Message message, byte[] pattern) {
String expiredKey = new String(message.getBody());
logger.info("Expired key: " + expiredKey);
if (expiredKey.startsWith(NOTIFICATION_KEY_PREFIX)) {
String sessionId = expiredKey.substring(NOTIFICATION_KEY_PREFIX.length());
firestoreNotificationService.updateNotificationStatus(sessionId, "inactive")
.doOnSuccess(v -> logger.info("Notification status updated to inactive for session: " + sessionId))
.doOnError(e -> logger.error("Error updating notification status for session: " + sessionId, e))
.subscribe();
}
}
}

View File

@@ -3,12 +3,12 @@ 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.ConversationSessionDTO;
import com.example.dto.dialogflow.conversation.QueryInputDTO;
import com.example.dto.dialogflow.conversation.QueryParamsDTO;
import com.example.dto.dialogflow.notification.NotificationDTO;
import com.example.service.base.DialogflowClientService;
import com.example.service.conversation.MemoryStoreConversationService;
import com.example.util.SessionIdGenerator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -30,11 +30,10 @@ public class NotificationManagerService {
private static final String NOTIFICATION_TEXT_PARAM = "notificationText";
private static final String eventName = "notificacion";
private final DialogflowClientService dialogflowClientService;
private final FirestoreNotificationService firestoreNotificationService;
private final MemoryStoreNotificationService memoryStoreNotificationService;
private final MemoryStoreConversationService memoryStoreConversationService;
private final FirestoreNotificationConvService firestoreConversationService;
@Value("${dialogflow.default-language-code:es}")
private String defaultLanguageCode;
@@ -43,11 +42,11 @@ public class NotificationManagerService {
DialogflowClientService dialogflowClientService,
FirestoreNotificationService firestoreNotificationService,
MemoryStoreNotificationService memoryStoreNotificationService,
MemoryStoreConversationService memoryStoreConversationService) {
FirestoreNotificationConvService firestoreConversationService) {
this.dialogflowClientService = dialogflowClientService;
this.firestoreNotificationService = firestoreNotificationService;
this.memoryStoreNotificationService = memoryStoreNotificationService;
this.memoryStoreConversationService = memoryStoreConversationService;
this.firestoreConversationService = firestoreConversationService;
}
public Mono<DetectIntentResponseDTO> processNotification(ExternalNotRequestDTO externalRequest) {
@@ -62,28 +61,39 @@ public class NotificationManagerService {
// 1. Persist the incoming notification entry
String newNotificationId = SessionIdGenerator.generateStandardSessionId();
NotificationDTO newNotificationEntry = new NotificationDTO(newNotificationId, telefono, Instant.now(),
externalRequest.text(),eventName, defaultLanguageCode,Collections.emptyMap()
);
externalRequest.text(), eventName, defaultLanguageCode, Collections.emptyMap(), "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)
);
ignored -> logger.debug(
"Background: Notification entry persistence initiated for phone {} in Firestore.",telefono),
e -> logger.error(
"Background: Error during notification entry persistence for phone {} in Firestore: {}",
telefono, e.getMessage(), e));
});
// 2. Resolve or create a conversation session
Mono<ConversationSessionDTO> sessionMono = memoryStoreConversationService.getSessionByTelefono(telefono)
.doOnNext(session -> logger.info("Found existing conversation session {} for phone number {}", session.sessionId(), telefono))
Mono<ConversationSessionDTO> sessionMono = memoryStoreNotificationService.getSessionByTelefono(telefono)
.doOnNext(session -> logger.info("Found existing conversation session {} for phone number {}",
session.sessionId(), telefono))
.flatMap(session -> {
ConversationEntryDTO systemEntry = ConversationEntryDTO.forSystem(externalRequest.text());
return persistConversationTurn(session.userId(), session.sessionId(), systemEntry, telefono)
.thenReturn(session);
})
.switchIfEmpty(Mono.defer(() -> {
String newSessionId = SessionIdGenerator.generateStandardSessionId();
logger.info("No existing conversation session found for phone number {}. Creating new session: {}",telefono, newSessionId);
return Mono.just(ConversationSessionDTO.create(newSessionId, "user_by_phone_" + telefono, telefono));
String userId = "user_by_phone_" + telefono;
ConversationEntryDTO systemEntry = ConversationEntryDTO.forSystem(externalRequest.text());
return persistConversationTurn(userId, newSessionId, systemEntry, telefono)
.then(Mono.just(ConversationSessionDTO.create(newSessionId, userId, telefono)));
}));
// 3. Send notification text to Dialogflow using the resolved conversation session
// 3. Send notification text to Dialogflow using the resolved conversation
// session
return persistenceMono.then(sessionMono)
.flatMap(session -> {
final String sessionId = session.sessionId();
@@ -99,12 +109,35 @@ public class NotificationManagerService {
DetectIntentRequestDTO detectIntentRequest = new DetectIntentRequestDTO(
queryInput,
new QueryParamsDTO(parameters)
);
new QueryParamsDTO(parameters));
return dialogflowClientService.detectIntent(sessionId, detectIntentRequest);
})
.doOnSuccess(response -> logger.info("Finished processing notification. Dialogflow response received for phone {}.", telefono))
.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(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 memoryStoreNotificationService.saveEntry(userId, sessionId, entry, userPhoneNumber)
.doOnSuccess(v -> {
logger.info(
"Entry saved to Redis for session {}. Type: {}. Kicking off async Firestore write-back.",
sessionId, entry.type().name());
firestoreConversationService.saveEntry(userId, sessionId, entry, userPhoneNumber)
.subscribe(
fsVoid -> logger.debug(
"Asynchronously (Write-Back): Entry successfully saved to Firestore for session {}. Type: {}.",
sessionId, entry.type().name()),
fsError -> logger.error(
"Asynchronously (Write-Back): Failed to save entry to Firestore for session {}. Type: {}: {}",
sessionId, entry.type().name(), fsError.getMessage(), fsError));
})
.doOnError(e -> logger.error("Error during primary Redis write for session {}. Type: {}: {}", sessionId,
entry.type().name(), e.getMessage(), e));
}
}

View File

@@ -0,0 +1,88 @@
/*
* 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<Void> 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<String, Object> 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<String, Object> 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<ConversationSessionDTO> 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);
}
}

View File

@@ -0,0 +1,80 @@
/*
* 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 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 Duration SESSION_TTL = Duration.ofHours(24);
private final ReactiveRedisTemplate<String, ConversationSessionDTO> redisTemplate;
private final ReactiveRedisTemplate<String, String> stringRedisTemplate;
@Autowired
public MemoryStoreQRService(
ReactiveRedisTemplate<String, ConversationSessionDTO> redisTemplate,
ReactiveRedisTemplate<String, String> stringRedisTemplate) {
this.redisTemplate = redisTemplate;
this.stringRedisTemplate = stringRedisTemplate;
}
public Mono<Void> saveEntry(String userId, String sessionId, ConversationEntryDTO newEntry, String userPhoneNumber) {
String sessionKey = SESSION_KEY_PREFIX + sessionId;
String phoneToSessionKey = PHONE_TO_SESSION_KEY_PREFIX + userPhoneNumber;
logger.info("Attempting to save entry to Redis for 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.withAddedEntry(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();
})
.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;
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);
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);
} 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));
}
}

View File

@@ -0,0 +1,104 @@
/*
* 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.ConversationEntryDTO;
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.FirestoreConversationService;
import com.example.service.conversation.MemoryStoreConversationService;
import com.example.util.SessionIdGenerator;
import java.time.Instant;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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;
public QuickRepliesManagerService(
MemoryStoreConversationService memoryStoreConversationService,
FirestoreConversationService firestoreConversationService,
QuickReplyContentService quickReplyContentService) {
this.memoryStoreConversationService = memoryStoreConversationService;
this.firestoreConversationService = firestoreConversationService;
this.quickReplyContentService = quickReplyContentService;
}
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
);
return persistConversationTurn(userId, sessionId, systemEntry, userPhoneNumber, externalRequest.pantallaContexto())
.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: " + userPhoneNumber)))
.flatMap(session -> {
String userId = session.userId();
String sessionId = session.sessionId();
ConversationEntryDTO userEntry = ConversationEntryDTO.forUser(externalRequest.message());
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));
});
});
}
private Mono<Void> 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));
}
}

View File

@@ -0,0 +1,69 @@
/*
* 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 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", 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 -> {
QuestionDTO pregunta = new QuestionDTO(document.getString("titulo"), document.getString("descripcion"));
return new QuickReplyDTO("preguntas sobre " + collectionId, List.of(pregunta));
})
.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();
}));
}
}

View File

@@ -0,0 +1,80 @@
/*
* 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 org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import jakarta.annotation.PostConstruct;
import java.io.IOException;
import java.io.InputStream;
import java.util.Map;
import java.util.concurrent.ExecutionException;
@Component
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";
@Autowired
private FirestoreBaseRepository firestoreBaseRepository;
@Autowired
private ObjectMapper objectMapper;
@PostConstruct
public void importDataOnStartup() {
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> data = objectMapper.readValue(inputStream, new TypeReference<Map<String, Object>>() {});
DocumentReference docRef = firestoreBaseRepository.getDocumentReference(collectionPath, documentId);
if (!firestoreBaseRepository.documentExists(docRef)) {
firestoreBaseRepository.setDocument(docRef, data);
logger.debug("Successfully imported {} to Firestore.", documentId);
} else {
logger.debug("{} already exists in Firestore. Skipping import.", documentId);
}
}
}
}

View File

@@ -0,0 +1,62 @@
# 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 (e.g., API keys, passwords) directly here.
# Use environment variables or a configuration server for production environments.
# - This template can be adapted for logging configuration, database connections,
# and other external service settings.
# =========================================================
# Orchestrator general Configuration
# =========================================================
spring.cloud.gcp.project-id=bnt-orquestador-cognitivo-dev
# =========================================================
# Google Firestore Configuration
# =========================================================
spring.cloud.gcp.firestore.project-id=bnt-orquestador-cognitivo-dev
spring.cloud.gcp.firestore.database-id=bnt-orquestador-cognitivo-firestore-bdo-dev
spring.cloud.gcp.firestore.host=firestore.googleapis.com
spring.cloud.gcp.firestore.port=443
# =========================================================
# Google Memorystore(Redis) Configuration
# =========================================================
spring.data.redis.host=10.33.22.4
spring.data.redis.port=6379
spring.redis.jedis.pool.enabled=true
spring.redis.notify-keyspace-events=Ex
#spring.data.redis.password=23cb4c76-9d96-4c74-b8c0-778fb364877a
#spring.data.redis.username=default
# SSL Configuration (if using SSL)
# spring.data.redis.ssl=true
# spring.data.redis.ssl.key-store=classpath:keystore.p12
# spring.data.redis.ssl.key-store-password=your-keystore-password
# =========================================================
# Google Conversational Agents Configuration
# =========================================================
dialogflow.cx.project-id=bnt-orquestador-cognitivo-dev
dialogflow.cx.location=us-central1
dialogflow.cx.agent-id=5590ff1d-1f66-4777-93f5-1a608f1900ac
dialogflow.default-language-code=es
# =========================================================
# Google Generative AI (Gemini) Configuration
# =========================================================
google.cloud.project=bnt-orquestador-cognitivo-dev
google.cloud.location=us-central1
gemini.model.name=gemini-2.0-flash-001
# =========================================================
# (Gemini) MessageFilter Configuration
# =========================================================
messagefilter.geminimodel=gemini-2.0-flash-001
messagefilter.temperature=0.1f
messagefilter.maxOutputTokens=800
messagefilter.topP= 0.1f
messagefilter.prompt=prompts/message_filter_prompt.txt

View File

@@ -0,0 +1,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.
# =========================================
# 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 (e.g., API keys, passwords) directly here.
# Use environment variables or a configuration server for production environments.
# - This template can be adapted for logging configuration, database connections,
# and other external service settings.
# =========================================================
# Orchestrator general Configuration
# =========================================================
spring.cloud.gcp.project-id=${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=23cb4c76-9d96-4c74-b8c0-778fb364877a
#spring.data.redis.username=default
# SSL Configuration (if using SSL)
# spring.data.redis.ssl=true
# spring.data.redis.ssl.key-store=classpath:keystore.p12
# spring.data.redis.ssl.key-store-password=your-keystore-password
# =========================================================
# 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

View File

@@ -0,0 +1,60 @@
# Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose.
# Your use of it is subject to your agreement with Google.
# =========================================
# 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 (e.g., API keys, passwords) directly here.
# Use environment variables or a configuration server for production environments.
# - This template can be adapted for logging configuration, database connections,
# and other external service settings.
# =========================================================
# Orchestrator general Configuration
# =========================================================
spring.cloud.gcp.project-id=${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=23cb4c76-9d96-4c74-b8c0-778fb364877a
#spring.data.redis.username=default
# SSL Configuration (if using SSL)
# spring.data.redis.ssl=true
# spring.data.redis.ssl.key-store=classpath:keystore.p12
# spring.data.redis.ssl.key-store-password=your-keystore-password
# =========================================================
# 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

View File

@@ -1,64 +1 @@
# 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 (e.g., API keys, passwords) directly here.
# Use environment variables or a configuration server for production environments.
# - This template can be adapted for logging configuration, database connections,
# and other external service settings.
# =========================================================
# Orchestrator general Configuration
# =========================================================
spring.cloud.gcp.project-id=bnt-orquestador-cognitivo-dev
# Firestore Emulator Configuration (for local development)
#spring.cloud.gcp.firestore.emulator-host=localhost:8080
spring.cloud.gcp.firestore.emulator.enabled=false
# =========================================================
# Google Firestore Configuration
# =========================================================
spring.cloud.gcp.firestore.project-id=bnt-orquestador-cognitivo-dev
spring.cloud.gcp.firestore.database-id=bnt-orquestador-cognitivo-firestore-bdo-dev
spring.cloud.gcp.firestore.host=firestore.googleapis.com
spring.cloud.gcp.firestore.port=443
# =========================================================
# Google Memorystore(Redis) Configuration
# =========================================================
spring.data.redis.host=10.33.22.4
spring.data.redis.port=6379
#spring.data.redis.password=23cb4c76-9d96-4c74-b8c0-778fb364877a
#spring.data.redis.username=default
# SSL Configuration (if using SSL)
# spring.data.redis.ssl=true
# spring.data.redis.ssl.key-store=classpath:keystore.p12
# spring.data.redis.ssl.key-store-password=your-keystore-password
# =========================================================
# Google Conversational Agents Configuration
# =========================================================
dialogflow.cx.project-id=bnt-orquestador-cognitivo-dev
dialogflow.cx.location=us-central1
dialogflow.cx.agent-id=5590ff1d-1f66-4777-93f5-1a608f1900ac
dialogflow.default-language-code=es
# =========================================================
# Google Generative AI (Gemini) Configuration
# =========================================================
google.cloud.project=bnt-orquestador-cognitivo-dev
google.cloud.location=us-central1
gemini.model.name=gemini-2.0-flash-001
# =========================================================
# (Gemini) MessageFilter Configuration
# =========================================================
messagefilter.geminimodel=gemini-2.0-flash-001
messagefilter.temperature=0.1f
messagefilter.maxOutputTokens=800
messagefilter.topP= 0.1f
messagefilter.prompt=prompts/message_filter_prompt.txt
spring.profiles.active=dev

View File

@@ -0,0 +1 @@
{"titulo": "Capsulas"}

View File

@@ -0,0 +1 @@
{"titulo": "Descubre"}

View File

@@ -0,0 +1 @@
{"titulo": "Detalle TDC"}

View File

@@ -0,0 +1 @@
{"titulo": "Detalle TDD"}

View File

@@ -0,0 +1 @@
{"titulo": "Finanzas"}

View File

@@ -0,0 +1 @@
{"titulo": "Home"}

View File

@@ -0,0 +1 @@
{"titulo": "Inversiones"}

View File

@@ -0,0 +1 @@
{"titulo": "Lealtad"}

View File

@@ -0,0 +1,4 @@
{
"titulo": "Payment History",
"descripcion": "View your recent payments"
}

View File

@@ -0,0 +1 @@
{"titulo": "Prestamos"}

View File

@@ -0,0 +1 @@
{"titulo": "Retiro sin tarjeta"}

View File

@@ -0,0 +1 @@
{"titulo": "Transferencia"}

View File

@@ -2,7 +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;
package com.example.service.integration_testing;
import com.example.service.base.MessageEntryFilter;
import com.example.util.PerformanceTimer;

View File

@@ -1,4 +1,4 @@
package com.example.service;
package com.example.service.unit_testing;
import com.example.dto.dialogflow.base.DetectIntentRequestDTO;
import com.example.dto.dialogflow.base.DetectIntentResponseDTO;

View File

@@ -2,7 +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;
package com.example.service.unit_testing;
import com.example.service.base.GeminiClientService;
import com.example.service.base.MessageEntryFilter;