Initial commit

This commit is contained in:
PAVEL PALMA
2025-07-16 13:43:46 -06:00
parent 54cb86ab65
commit fac3550287
46 changed files with 2062 additions and 88 deletions

View File

@@ -0,0 +1,44 @@
package com.example;
import static org.springframework.web.reactive.function.server.RequestPredicates.GET;
import static org.springframework.web.reactive.function.server.RouterFunctions.route;
import static org.springframework.web.reactive.function.server.ServerResponse.ok;
import com.google.api.gax.core.CredentialsProvider;
import com.google.api.gax.core.NoCredentialsProvider;
import com.google.cloud.spring.data.firestore.repository.config.EnableReactiveFirestoreRepositories;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerResponse;
@SpringBootApplication
@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);
}
@Bean
public RouterFunction<ServerResponse> indexRouter(
@Value("classpath:/static/index.html") final Resource indexHtml) {
return route(GET("/"), request -> ok().contentType(MediaType.TEXT_HTML).bodyValue(indexHtml));
}
}

View File

@@ -0,0 +1,23 @@
package com.example.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.info.License;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class OpenApiConfig {
@Bean
public OpenAPI customOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("Google Middleware API")
.version("1.0")
.description("API documentation. " +
"It provides functionalities for user management, file storage, and more.")
.termsOfService("http://swagger.io/terms/")
.license(new License().name("Apache 2.0").url("http://springdoc.org")));
}
}

View File

@@ -0,0 +1,43 @@
package com.example.config;
import com.example.dto.dialogflow.ConversationSessionDTO;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.ReactiveRedisConnectionFactory;
import org.springframework.data.redis.core.ReactiveRedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration
public class RedisConfig {
@Bean
public ReactiveRedisTemplate<String, ConversationSessionDTO> reactiveConversationRedisTemplate(
ReactiveRedisConnectionFactory factory) {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule());
objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
Jackson2JsonRedisSerializer<ConversationSessionDTO> serializer =
new Jackson2JsonRedisSerializer<>(objectMapper, ConversationSessionDTO.class);
return new ReactiveRedisTemplate<>(factory, RedisSerializationContext
.<String, ConversationSessionDTO>newSerializationContext(new StringRedisSerializer())
.value(serializer)
.build());
}
@Bean
public ReactiveRedisTemplate<String, String> reactiveStringRedisTemplate(
ReactiveRedisConnectionFactory factory) {
return new ReactiveRedisTemplate<>(factory, RedisSerializationContext
.<String, String>newSerializationContext(new StringRedisSerializer())
.value(new StringRedisSerializer())
.build());
}
}

View File

@@ -0,0 +1,33 @@
package com.example.config;
import com.google.genai.Client;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.io.IOException;
@Configuration
public class VertexAIConfig {
private static final Logger logger = LoggerFactory.getLogger(VertexAIConfig.class);
@Value("${google.cloud.project}")
private String projectId;
@Value("${google.cloud.location}")
private String location;
@Bean(destroyMethod = "close")
public Client geminiClient() throws IOException {
logger.info("Initializing Google Gen AI Client. Project: {}, Location: {}", projectId, location);
return Client.builder()
.project(projectId)
.location(location)
.vertexAI(true)
.build();
}
}

View File

@@ -0,0 +1,39 @@
package com.example.controller;
import com.example.dto.dialogflow.DetectIntentRequestDTO;
import com.example.dto.dialogflow.DetectIntentResponseDTO;
import com.example.service.ConversationManagerService;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Mono;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@RestController
@RequestMapping("/api/v1")
public class ConversationController {
private static final Logger logger = LoggerFactory.getLogger(ConversationController.class);
private final ConversationManagerService conversationManagerService;
public ConversationController(ConversationManagerService conversationManagerService) {
this.conversationManagerService = conversationManagerService;
}
@PostMapping("/dialogflow/detect-intent")
public Mono<DetectIntentResponseDTO> detectIntent(@Valid @RequestBody DetectIntentRequestDTO request) {
logger.info("Received request for session: {}", request.sessionId());
return conversationManagerService.manageConversation(request)
.doOnSuccess(response -> logger.info("Successfully processed direct Dialogflow request for session: {}", request.sessionId()))
.doOnError(error -> logger.error("Error processing direct Dialogflow request for session {}: {}", request.sessionId(), error.getMessage(), error));
}
}

View File

@@ -0,0 +1,49 @@
package com.example.controller;
import com.example.dto.gemini.ConversationSummaryRequest;
import com.example.dto.gemini.ConversationSummaryResponse;
import jakarta.validation.Valid;
import com.example.service.ConversationSummaryService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@RestController
@RequestMapping("/api/v1/summary")
public class ConversationSummaryController {
private static final Logger logger = LoggerFactory.getLogger(ConversationSummaryController.class);
private final ConversationSummaryService conversationSummaryService;
public ConversationSummaryController(ConversationSummaryService conversationSummaryService) {
this.conversationSummaryService = conversationSummaryService;
}
@PostMapping("/conversation")
public ResponseEntity<ConversationSummaryResponse> summarizeConversation(
@Valid @RequestBody ConversationSummaryRequest request) {
logger.info("Received request to summarize conversation for session ID: {}",
request.sessionId());
ConversationSummaryResponse response = conversationSummaryService.summarizeConversation(request);
if (response.summaryText() != null &&
(response.summaryText().contains("Error:") ||
response.summaryText().contains("Failed:") ||
response.summaryText().contains("not found") ||
response.summaryText().contains("No conversation provided"))) {
logger.error("Summarization failed for session ID {}: {}", request.sessionId(), response.summaryText());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
} else {
logger.info("Successfully processed summarization request for session ID: {}", request.sessionId());
return ResponseEntity.ok(response);
}
}
}

View File

@@ -0,0 +1,17 @@
package com.example.dto.base;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
include = JsonTypeInfo.As.PROPERTY,
property = "type"
)
@JsonSubTypes({
@JsonSubTypes.Type(value = NotificationRequest.class, name = "NOTIFICATION"),
})
public sealed interface BaseRequest
permits NotificationRequest{
String type();
}

View File

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

View File

@@ -0,0 +1,26 @@
package com.example.dto.base;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.Valid;
@JsonInclude(JsonInclude.Include.NON_NULL)
public record NotificationRequest(
@JsonProperty("requestId") String requestId,
@JsonProperty("sessionId") String sessionId,
@JsonProperty("mensaje") String message,
@JsonProperty("SIC") String SIC,
@Valid Usuario usuario,
@JsonProperty("pantalla_contexto") String pantallaContexto,
@JsonProperty("canal") String canal
) implements BaseRequest {
@Override
public String type() {
return "NOTIFICATION";
}
@JsonInclude(JsonInclude.Include.NON_NULL)
public record Usuario(
String telefono,
String nickname
) {
}
}

View File

@@ -0,0 +1,9 @@
package com.example.dto.base;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.NotBlank;
public record UsuarioDTO(
@JsonProperty("telefono") @NotBlank String telefono,
@JsonProperty("nickname") String nickname
) {}

View File

@@ -0,0 +1,35 @@
package com.example.dto.dialogflow;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.time.Instant;
import java.util.Map;
@JsonInclude(JsonInclude.Include.NON_NULL)
public record ConversationEntryDTO(
ConversationEntryType type,
Instant timestamp,
String text,
String intentDisplayName,
Map<String, Object> parameters,
String webhookStatus,
String canal
) {
public static ConversationEntryDTO forUser(String text) {
return new ConversationEntryDTO(ConversationEntryType.USER_MESSAGE, Instant.now(),
text, null, null, null, null);
}
public static ConversationEntryDTO forAgent(QueryResultDTO agentQueryResult) {
String fulfillmentText = (agentQueryResult != null && agentQueryResult.responseText() != null) ? agentQueryResult.responseText() : "";
return new ConversationEntryDTO(
ConversationEntryType.AGENT_RESPONSE,
Instant.now(),
fulfillmentText,
null,
agentQueryResult.parameters(),
null,
null
);
}
}

View File

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

View File

@@ -0,0 +1,44 @@
package com.example.dto.dialogflow;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@JsonIgnoreProperties(ignoreUnknown = true)
public record 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) {
this.sessionId = sessionId;
this.userId = userId;
this.telefono = telefono;
this.createdAt = createdAt;
this.lastModified = lastModified;
this.entries = Collections.unmodifiableList(new ArrayList<>(entries));
}
public static ConversationSessionDTO create(String sessionId, String userId, String telefono) {
Instant now = Instant.now();
return new ConversationSessionDTO(sessionId, userId, telefono, now, now, Collections.emptyList());
}
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);
}
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 this;
}
}

View File

@@ -0,0 +1,25 @@
package com.example.dto.dialogflow;
import com.example.dto.base.UsuarioDTO;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
@JsonIgnoreProperties(ignoreUnknown = true)
public record DetectIntentRequestDTO(
@JsonProperty("session") String sessionId,
@JsonProperty("queryInput") QueryInputDTO queryInput,
@JsonProperty("queryParams") QueryParamsDTO queryParams,
@JsonProperty("usuario") UsuarioDTO usuario,
String userId
) {
public DetectIntentRequestDTO withSessionId(String newSessionId) {
return new DetectIntentRequestDTO(
newSessionId,
this.queryInput(),
this.queryParams(),
this.usuario(),
this.userId()
);
}
}

View File

@@ -0,0 +1,8 @@
package com.example.dto.dialogflow;
import com.fasterxml.jackson.annotation.JsonProperty;
public record DetectIntentResponseDTO(
@JsonProperty("responseId") String responseId,
@JsonProperty("queryResult") QueryResultDTO queryResult
) {}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
package com.example.dto.dialogflow;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.Map;
public record QueryResultDTO(
@JsonProperty("responseText") String responseText,
@JsonProperty("parameters") Map<String, Object> parameters
) {}

View File

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

View File

@@ -0,0 +1,48 @@
package com.example.dto.gemini;
import com.example.dto.dialogflow.ConversationEntryType;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.google.cloud.Timestamp;
import java.util.Map;
import java.util.Optional;
@JsonInclude(JsonInclude.Include.NON_NULL)
public record ConversationEntrySummaryDTO(
@JsonProperty("text") String text,
@JsonProperty("timestamp") Timestamp timestamp,
Optional<ConversationEntryType> type,
@JsonProperty("intentDisplayName") String intentDisplayName,
@JsonProperty("parameters") Map<String, Object> parameters,
@JsonProperty("webhookStatus") String webhookStatus,
@JsonProperty("canal") String canal
) {
@JsonCreator
public ConversationEntrySummaryDTO(
@JsonProperty("text") String text,
@JsonProperty("timestamp") Timestamp timestamp,
@JsonProperty("type") String typeString,
@JsonProperty("intentDisplayName") String intentDisplayName,
@JsonProperty("parameters") Map<String, Object> parameters,
@JsonProperty("webhookStatus") String webhookStatus,
@JsonProperty("canal") String canal
) {
this(
text,
timestamp,
Optional.ofNullable(typeString).map(t -> {
try {
return ConversationEntryType.valueOf(t);
} catch (IllegalArgumentException e) {
System.err.println("Warning: Invalid ConversationEntryType string during deserialization: " + t);
return null;
}
}),
intentDisplayName,
parameters,
webhookStatus,
canal
);
}
}

View File

@@ -0,0 +1,32 @@
package com.example.dto.gemini;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.google.cloud.Timestamp;
import java.util.Collections;
import java.util.List;
@JsonInclude(JsonInclude.Include.NON_NULL)
public record ConversationSessionSummaryDTO(
@JsonProperty("sessionId") String sessionId,
@JsonProperty("userId") String userId,
@JsonProperty("startTime") Timestamp startTime,
@JsonProperty("lastUpdated") Timestamp lastUpdated,
@JsonProperty("entries") List<ConversationEntrySummaryDTO> entries
) {
@JsonCreator
public ConversationSessionSummaryDTO(
@JsonProperty("sessionId") String sessionId,
@JsonProperty("userId") String userId,
@JsonProperty("startTime") Timestamp startTime,
@JsonProperty("lastUpdated") Timestamp lastUpdated,
@JsonProperty("entries") List<ConversationEntrySummaryDTO> entries
) {
this.sessionId = sessionId;
this.userId = userId;
this.startTime = startTime;
this.lastUpdated = lastUpdated;
this.entries = entries != null ? entries : Collections.emptyList();
}
}

View File

@@ -0,0 +1,19 @@
package com.example.dto.gemini;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
public record ConversationSummaryRequest(
@NotBlank(message = "Session ID is required.")
String sessionId,
@NotBlank(message = "Prompt for summarization is required.")
String prompt,
@Min(value = 0, message = "Temperature must be between 0.0 and 1.0.")
@Max(value = 1, message = "Temperature must be between 0.0 and 1.0.")
Float temperature,
@Min(value = 1, message = "Max Output Tokens must be at least 1.")
Integer maxOutputTokens,
@NotBlank(message = "model is required.")
String modelName
) {}

View File

@@ -0,0 +1,8 @@
package com.example.dto.gemini;
import jakarta.validation.constraints.NotBlank;
public record ConversationSummaryResponse(
@NotBlank
String summaryText
) {}

View File

@@ -0,0 +1,12 @@
package com.example.exception;
public class DialogflowClientException extends RuntimeException {
public DialogflowClientException(String message) {
super(message);
}
public DialogflowClientException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -0,0 +1,12 @@
package com.example.exception;
public class FirestorePersistenceException extends RuntimeException {
public FirestorePersistenceException(String message) {
super(message);
}
public FirestorePersistenceException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -0,0 +1,79 @@
package com.example.mapper;
import com.example.dto.dialogflow.DetectIntentRequestDTO;
import com.example.util.ProtobufUtil;
import com.google.cloud.dialogflow.cx.v3.DetectIntentRequest;
import com.google.cloud.dialogflow.cx.v3.QueryInput;
import com.google.cloud.dialogflow.cx.v3.QueryParameters;
import com.google.cloud.dialogflow.cx.v3.TextInput;
import com.google.protobuf.Struct;
import com.google.protobuf.Value;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.Objects;
@Component
public class DialogflowRequestMapper {
private static final Logger logger = LoggerFactory.getLogger(DialogflowRequestMapper.class);
@org.springframework.beans.factory.annotation.Value("${dialogflow.default-language-code:es}") String defaultLanguageCode;
public DetectIntentRequest.Builder mapToDetectIntentRequestBuilder(DetectIntentRequestDTO requestDto) {
Objects.requireNonNull(requestDto, "DetectIntentRequestDTO cannot be null for mapping.");
logger.debug("Building partial Dialogflow CX DetectIntentRequest Protobuf Builder from DTO (only QueryInput and QueryParams).");
// Build QueryInput from the DTO's text and language code
QueryInput.Builder queryInputBuilder = QueryInput.newBuilder();
if (requestDto.queryInput() != null && requestDto.queryInput().text() != null &&
requestDto.queryInput().text().text() != null && !requestDto.queryInput().text().text().trim().isEmpty()) {
queryInputBuilder.setText(TextInput.newBuilder()
.setText(requestDto.queryInput().text().text())
.build());
queryInputBuilder.setLanguageCode(requestDto.queryInput().languageCode() != null ? requestDto.queryInput().languageCode() : defaultLanguageCode);
} else {
logger.error("Dialogflow query input text is required for building Protobuf query input.");
throw new IllegalArgumentException("Dialogflow query input text is required.");
}
// Build QueryParameters from the DTO's parameters map
QueryParameters.Builder queryParametersBuilder = QueryParameters.newBuilder();
Struct.Builder paramsStructBuilder = Struct.newBuilder();
// Add existing parameters from DTO's queryParams
if (requestDto.queryParams() != null && requestDto.queryParams().parameters() != null) {
for (Map.Entry<String, Object> entry : requestDto.queryParams().parameters().entrySet()) {
Value protobufValue = ProtobufUtil.convertJavaObjectToProtobufValue(entry.getValue());
paramsStructBuilder.putFields(entry.getKey(), protobufValue);
}
}
if (requestDto.usuario() != null && requestDto.usuario().telefono() != null && !requestDto.usuario().telefono().trim().isEmpty()) {
paramsStructBuilder.putFields("telefono", Value.newBuilder().setStringValue(requestDto.usuario().telefono()).build());
logger.debug("Added 'telefono' as a query parameter: {}", requestDto.usuario().telefono());
}
if (paramsStructBuilder.getFieldsCount() > 0) {
queryParametersBuilder.setParameters(paramsStructBuilder.build());
logger.debug("Custom parameters (including telefono if present) added to Protobuf request builder.");
} else {
logger.debug("No custom parameters provided in DTO (or no telefono in usuario).");
}
DetectIntentRequest.Builder detectIntentRequestBuilder = DetectIntentRequest.newBuilder()
.setQueryInput(queryInputBuilder.build());
if (queryParametersBuilder.hasParameters()) {
detectIntentRequestBuilder.setQueryParams(queryParametersBuilder.build());
}
logger.debug("Finished building partial DetectIntentRequest Protobuf Builder.");
return detectIntentRequestBuilder;
}
}

View File

@@ -0,0 +1,76 @@
package com.example.mapper;
import com.example.dto.dialogflow.DetectIntentResponseDTO;
import com.example.dto.dialogflow.QueryResultDTO;
import com.google.cloud.dialogflow.cx.v3.QueryResult;
import com.google.cloud.dialogflow.cx.v3.ResponseMessage;
import com.google.cloud.dialogflow.cx.v3.DetectIntentResponse;
import com.example.util.ProtobufUtil;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
@Component
public class DialogflowResponseMapper {
private static final Logger logger = LoggerFactory.getLogger(DialogflowResponseMapper.class);
public DetectIntentResponseDTO mapFromDialogflowResponse(DetectIntentResponse response, String sessionId) {
logger.info("Starting mapping of Dialogflow DetectIntentResponse for session: {}", sessionId);
String responseId = response.getResponseId();
QueryResult dfQueryResult = response.getQueryResult();
logger.debug("Extracted QueryResult object for session: {}", sessionId);
StringBuilder responseTextBuilder = new StringBuilder();
if (dfQueryResult.getResponseMessagesList().isEmpty()) {
logger.debug("No response messages found in QueryResult for session: {}", sessionId);
}
for (ResponseMessage message : dfQueryResult.getResponseMessagesList()) {
if (message.hasText()) {
logger.debug("Processing text response message for session: {}", sessionId);
for (String text : message.getText().getTextList()) {
if (responseTextBuilder.length() > 0) {
responseTextBuilder.append(" ");
}
responseTextBuilder.append(text);
logger.debug("Appended text segment: '{}' to fulfillment text for session: {}", text, sessionId);
}
} else {
logger.debug("Skipping non-text response message type: {} for session: {}", message.getMessageCase(), sessionId);
}
}
String responseText = responseTextBuilder.toString().trim();
Map<String, Object> parameters = Collections.emptyMap();
if (dfQueryResult.hasParameters()) {
parameters = dfQueryResult.getParameters().getFieldsMap().entrySet().stream()
.collect(Collectors.toMap(
Map.Entry::getKey,
entry -> ProtobufUtil.convertProtobufValueToJavaObject(entry.getValue()),
(oldValue, newValue) -> oldValue, // In case of duplicate keys, keep the old value
LinkedHashMap::new // Preserve insertion order
));
logger.debug("Extracted parameters: {} for session: {}", parameters, sessionId);
} else {
logger.debug("No parameters found in QueryResult for session: {}. Using empty map.", sessionId);
}
QueryResultDTO ourQueryResult = new QueryResultDTO(responseText, parameters);
logger.debug("Internal QueryResult DTO created for session: {}. Details: {}", sessionId, ourQueryResult);
DetectIntentResponseDTO finalResponse = new DetectIntentResponseDTO(responseId, ourQueryResult);
logger.info("Finished mapping DialogflowDetectIntentResponse for session: {}. Full response ID: {}", sessionId, responseId);
return finalResponse;
}
}

View File

@@ -0,0 +1,132 @@
package com.example.mapper;
import com.example.dto.dialogflow.ConversationEntryDTO;
import com.example.dto.dialogflow.ConversationEntryType;
import com.example.dto.dialogflow.ConversationSessionDTO;
import com.google.cloud.Timestamp;
import com.google.cloud.firestore.FieldValue;
import com.google.cloud.firestore.DocumentSnapshot;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Component
public class FirestoreConversationMapper {
private static final Logger logger = LoggerFactory.getLogger(FirestoreConversationMapper.class);
public Map<String, Object> createUpdateMapForSingleEntry(ConversationEntryDTO newEntry) {
Map<String, Object> updates = new HashMap<>();
// Convert Instant to Firestore Timestamp
Map<String, Object> entryMap = toFirestoreEntryMap(newEntry);
updates.put("entries", FieldValue.arrayUnion(entryMap));
// Convert Instant to Firestore Timestamp
updates.put("lastModified", Timestamp.of(java.util.Date.from(Instant.now())));
return updates;
}
public Map<String, Object> createNewSessionMapForSingleEntry(String sessionId, String userId, String telefono, ConversationEntryDTO initialEntry) {
Map<String, Object> sessionMap = new HashMap<>();
sessionMap.put("sessionId", sessionId);
sessionMap.put("userId", userId);
if (telefono != null && !telefono.trim().isEmpty()) {
sessionMap.put("telefono", telefono);
} else {
sessionMap.put("telefono", null);
}
// Convert Instant to Firestore Timestamp
sessionMap.put("createdAt", Timestamp.of(java.util.Date.from(Instant.now())));
sessionMap.put("lastModified", Timestamp.of(java.util.Date.from(Instant.now())));
List<Map<String, Object>> entriesList = new ArrayList<>();
entriesList.add(toFirestoreEntryMap(initialEntry));
sessionMap.put("entries", entriesList);
return sessionMap;
}
private Map<String, Object> toFirestoreEntryMap(ConversationEntryDTO entry) {
Map<String, Object> entryMap = new HashMap<>();
entryMap.put("type", entry.type().name());
entryMap.put("text", entry.text());
// Convert Instant to Firestore Timestamp for storage
entryMap.put("timestamp", Timestamp.of(java.util.Date.from(entry.timestamp())));
if (entry.intentDisplayName() != null) {
entryMap.put("intentDisplayName", entry.intentDisplayName());
}
if (entry.parameters() != null && !entry.parameters().isEmpty()) {
entryMap.put("parameters", entry.parameters());
}
if (entry.webhookStatus() != null) {
entryMap.put("webhookStatus", entry.webhookStatus());
}
if (entry.canal() != null) {
entryMap.put("canal", entry.canal());
}
return entryMap;
}
public ConversationSessionDTO mapFirestoreDocumentToConversationSessionDTO(DocumentSnapshot documentSnapshot) {
if (!documentSnapshot.exists()) {
return null;
}
String sessionId = documentSnapshot.getString("sessionId");
String userId = documentSnapshot.getString("userId");
String telefono = documentSnapshot.getString("telefono");
// Convert Firestore Timestamp to Instant
Timestamp createdAtFirestore = documentSnapshot.getTimestamp("createdAt");
Instant createdAt = (createdAtFirestore != null) ? createdAtFirestore.toDate().toInstant() : null;
// Convert Firestore Timestamp to Instant
Timestamp lastModifiedFirestore = documentSnapshot.getTimestamp("lastModified");
Instant lastModified = (lastModifiedFirestore != null) ? lastModifiedFirestore.toDate().toInstant() : null;
List<Map<String, Object>> rawEntries = (List<Map<String, Object>>) documentSnapshot.get("entries");
List<ConversationEntryDTO> entries = new ArrayList<>();
if (rawEntries != null) {
entries = rawEntries.stream()
.map(this::mapFirestoreEntryMapToConversationEntryDTO)
.collect(Collectors.toList());
}
return new ConversationSessionDTO(sessionId, userId, telefono, createdAt, lastModified, entries);
}
private ConversationEntryDTO mapFirestoreEntryMapToConversationEntryDTO(Map<String, Object> entryMap) {
ConversationEntryType type = null;
Object typeObj = entryMap.get("type");
if (typeObj instanceof String) {
try {
type = ConversationEntryType.valueOf((String) typeObj);
} catch (IllegalArgumentException e) {
logger.warn("Unknown ConversationEntryType encountered: {}. Setting type to null.", typeObj);
}
}
String text = (String) entryMap.get("text");
// Convert Firestore Timestamp to Instant
Timestamp timestampFirestore = (Timestamp) entryMap.get("timestamp");
Instant timestamp = (timestampFirestore != null) ? timestampFirestore.toDate().toInstant() : null;
String intentDisplayName = (String) entryMap.get("intentDisplayName");
Map<String, Object> parameters = (Map<String, Object>) entryMap.get("parameters");
String webhookStatus = (String) entryMap.get("webhookStatus");
String canal = (String) entryMap.get("canal");
return new ConversationEntryDTO(type, timestamp, text, intentDisplayName, parameters, webhookStatus, canal);
}
}

View File

@@ -0,0 +1,130 @@
package com.example.repository;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.module.SimpleModule;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.fasterxml.jackson.module.paramnames.ParameterNamesModule;
import com.google.api.core.ApiFuture;
import com.google.cloud.firestore.DocumentReference;
import com.google.cloud.firestore.DocumentSnapshot;
import com.google.cloud.firestore.Firestore;
import com.google.cloud.firestore.WriteBatch;
import com.google.cloud.firestore.WriteResult;
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;
@Repository
public class FirestoreBaseRepository {
private static final Logger logger = LoggerFactory.getLogger(FirestoreBaseRepository.class);
private final Firestore firestore;
private final ObjectMapper objectMapper;
@Value("${app.id:default-app-id}")
private String appId;
public FirestoreBaseRepository(Firestore firestore, ObjectMapper objectMapper) {
this.firestore = firestore;
this.objectMapper = objectMapper;
// Register JavaTimeModule for standard java.time handling
if (!objectMapper.findModules().stream().anyMatch(m -> m instanceof JavaTimeModule)) {
objectMapper.registerModule(new JavaTimeModule());
}
objectMapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
// Register ParameterNamesModule, crucial for Java Records and classes compiled with -parameters
if (!objectMapper.findModules().stream().anyMatch(m -> m instanceof ParameterNamesModule)) {
objectMapper.registerModule(new ParameterNamesModule());
}
// These specific Timestamp (Google Cloud) deserializers/serializers are for ObjectMapper
// to handle com.google.cloud.Timestamp objects when mapping other types.
// They are generally not the cause of the Redis deserialization error for Instant.
SimpleModule firestoreTimestampModule = new SimpleModule();
firestoreTimestampModule.addDeserializer(com.google.cloud.Timestamp.class, new FirestoreTimestampDeserializer());
firestoreTimestampModule.addSerializer(com.google.cloud.Timestamp.class, new FirestoreTimestampSerializer());
objectMapper.registerModule(firestoreTimestampModule);
logger.info("FirestoreBaseRepository initialized with Firestore client and ObjectMapper. App ID will be: {}", appId);
}
public DocumentReference getDocumentReference(String collectionPath, String documentId) {
Objects.requireNonNull(collectionPath, "Collection path cannot be null.");
Objects.requireNonNull(documentId, "Document ID cannot be null.");
return firestore.collection(collectionPath).document(documentId);
}
public <T> T getDocument(DocumentReference docRef, Class<T> clazz) throws InterruptedException, ExecutionException {
Objects.requireNonNull(docRef, "DocumentReference cannot be null.");
Objects.requireNonNull(clazz, "Class for mapping cannot be null.");
ApiFuture<DocumentSnapshot> future = docRef.get();
DocumentSnapshot document = future.get();
if (document.exists()) {
try {
logger.debug("FirestoreBaseRepository: Raw document data for {}: {}", docRef.getPath(), document.getData());
T result = objectMapper.convertValue(document.getData(), clazz);
return result;
} catch (IllegalArgumentException e) {
logger.error("Failed to convert Firestore document data to {}: {}", clazz.getName(), e.getMessage(), e);
throw new RuntimeException("Failed to convert Firestore document data to " + clazz.getName(), e);
}
}
return null;
}
public DocumentSnapshot getDocumentSnapshot(DocumentReference docRef) throws ExecutionException, InterruptedException {
Objects.requireNonNull(docRef, "DocumentReference cannot be null.");
ApiFuture<DocumentSnapshot> future = docRef.get();
return future.get();
}
public boolean documentExists(DocumentReference docRef) throws InterruptedException, ExecutionException {
Objects.requireNonNull(docRef, "DocumentReference cannot be null.");
ApiFuture<DocumentSnapshot> future = docRef.get();
return future.get().exists();
}
public void setDocument(DocumentReference docRef, Object data) throws InterruptedException, ExecutionException {
Objects.requireNonNull(docRef, "DocumentReference cannot be null.");
Objects.requireNonNull(data, "Data for setting document cannot be null.");
ApiFuture<WriteResult> future = docRef.set(data);
WriteResult writeResult = future.get();
logger.debug("Document set: {} with update time: {}", docRef.getPath(), writeResult.getUpdateTime());
}
public void updateDocument(DocumentReference docRef, Map<String, Object> updates) throws InterruptedException, ExecutionException {
Objects.requireNonNull(docRef, "DocumentReference cannot be null.");
Objects.requireNonNull(updates, "Updates map cannot be null.");
ApiFuture<WriteResult> future = docRef.update(updates);
WriteResult writeResult = future.get();
logger.debug("Document updated: {} with update time: {}", docRef.getPath(), writeResult.getUpdateTime());
}
public WriteBatch createBatch() {
return firestore.batch();
}
public void commitBatch(WriteBatch batch) throws InterruptedException, ExecutionException {
Objects.requireNonNull(batch, "WriteBatch cannot be null.");
batch.commit().get();
logger.debug("Batch committed successfully.");
}
public String getAppId() {
return appId;
}
}

View File

@@ -0,0 +1,136 @@
package com.example.service;
import com.example.dto.dialogflow.DetectIntentRequestDTO;
import com.example.dto.dialogflow.DetectIntentResponseDTO;
import com.example.dto.base.ConversationContext;
import com.example.dto.dialogflow.ConversationEntryDTO;
import com.example.dto.dialogflow.ConversationSessionDTO;
import com.example.dto.base.UsuarioDTO;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.util.UUID;
import java.util.Optional;
@Service
public class ConversationManagerService {
private static final Logger logger = LoggerFactory.getLogger(ConversationManagerService.class);
private final DialogflowClientService dialogflowServiceClient;
private final FirestoreConversationService firestoreConversationService;
private final MemoryStoreConversationService memoryStoreConversationService;
public ConversationManagerService(
DialogflowClientService dialogflowServiceClient,
FirestoreConversationService firestoreConversationService,
MemoryStoreConversationService memoryStoreConversationService) {
this.dialogflowServiceClient = dialogflowServiceClient;
this.firestoreConversationService = firestoreConversationService;
this.memoryStoreConversationService = memoryStoreConversationService;
}
public Mono<DetectIntentResponseDTO> manageConversation(DetectIntentRequestDTO request) {
final ConversationContext context;
try {
context = resolveAndValidateRequest(request);
} catch (IllegalArgumentException e) {
logger.error("Validation error for incoming request: {}", e.getMessage());
return Mono.error(e);
}
final String userId = context.userId();
final String userMessageText = context.userMessageText();
final String userPhoneNumber = context.primaryPhoneNumber();
Mono<ConversationSessionDTO> sessionMono;
if (userPhoneNumber != null && !userPhoneNumber.trim().isEmpty()) {
logger.info("Checking for existing session for phone number: {}", userPhoneNumber);
sessionMono = memoryStoreConversationService.getSessionByTelefono(userPhoneNumber)
.doOnNext(session -> logger.info("Found existing session {} for phone number {}", session.sessionId(), userPhoneNumber))
.switchIfEmpty(Mono.defer(() -> {
String newSessionId = UUID.randomUUID().toString();
logger.info("No existing session found for phone number {}. Creating new session: {}", userPhoneNumber, newSessionId);
return Mono.just(ConversationSessionDTO.create(newSessionId, userId, userPhoneNumber));
}));
} else {
String newSessionId = UUID.randomUUID().toString();
logger.warn("No phone number provided in request. Creating new session: {}", newSessionId);
return Mono.error(new IllegalArgumentException("Phone number is required to manage conversation sessions."));
}
return sessionMono.flatMap(session -> {
final String finalSessionId = session.sessionId();
logger.info("Managing conversation for resolved session: {}", finalSessionId);
ConversationEntryDTO userEntry = ConversationEntryDTO.forUser(userMessageText);
DetectIntentRequestDTO updatedRequest = request.withSessionId(finalSessionId);
return this.persistConversationTurn(userId, finalSessionId, userEntry, userPhoneNumber)
.doOnSuccess(v -> logger.debug("User entry successfully persisted for session {}. Proceeding to Dialogflow...", finalSessionId))
.doOnError(e -> logger.error("Error during user entry persistence for session {}: {}", finalSessionId, e.getMessage(), e))
// After user entry persistence is complete (Mono<Void> emits 'onComplete'),
// then proceed to call Dialogflow.
.then(Mono.defer(() -> { // Use Mono.defer to ensure Dialogflow call is subscribed AFTER persistence
// Call Dialogflow.
return dialogflowServiceClient.detectIntent(finalSessionId, updatedRequest)
.doOnSuccess(response -> {
logger.debug("Received Dialogflow CX response for session {}. Initiating agent response persistence.", finalSessionId);
ConversationEntryDTO agentEntry = ConversationEntryDTO.forAgent(response.queryResult());
// Agent entry persistence can still be backgrounded via .subscribe()
// if its completion isn't strictly required before returning the Dialogflow response.
this.persistConversationTurn(userId, finalSessionId, agentEntry, userPhoneNumber).subscribe(
v -> logger.debug("Background: Agent entry persistence initiated for session {}.", finalSessionId),
e -> logger.error("Background: Error during agent entry persistence for session {}: {}", finalSessionId, e.getMessage(), e)
);
})
.doOnError(error -> logger.error("Overall error during conversation management for session {}: {}", finalSessionId, error.getMessage(), error));
}));
})
.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());
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));
}
private ConversationContext resolveAndValidateRequest(DetectIntentRequestDTO request) {
String primaryPhoneNumber = Optional.ofNullable(request.usuario())
.map(UsuarioDTO::telefono)
.orElse(null);
if (primaryPhoneNumber == null || primaryPhoneNumber.trim().isEmpty()) {
throw new IllegalArgumentException("Phone number (telefono) is required in the 'usuario' field for conversation management.");
}
String resolvedUserId = request.userId();
if (resolvedUserId == null || resolvedUserId.trim().isEmpty()) {
resolvedUserId = "user_by_phone_" + primaryPhoneNumber.replaceAll("[^0-9]", ""); // Derive from phone number
logger.warn("User ID not provided in request. Using derived ID from phone number: {}", resolvedUserId);
}
if (request.queryInput() == null || request.queryInput().text() == null || request.queryInput().text().text() == null || request.queryInput().text().text().trim().isEmpty()) {
throw new IllegalArgumentException("Dialogflow query input text is required.");
}
String userMessageText = request.queryInput().text().text();
return new ConversationContext(resolvedUserId, null, userMessageText, primaryPhoneNumber);
}
}

View File

@@ -0,0 +1,121 @@
package com.example.service;
import com.example.dto.gemini.ConversationSummaryRequest;
import com.example.dto.gemini.ConversationSummaryResponse;
import com.example.dto.gemini.ConversationSessionSummaryDTO;
import com.example.dto.gemini.ConversationEntrySummaryDTO;
import com.example.repository.FirestoreBaseRepository;
import com.google.cloud.firestore.DocumentReference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.stream.Collectors;
@Service
public class ConversationSummaryService {
private static final Logger logger = LoggerFactory.getLogger(ConversationSummaryService.class);
private final GeminiClientService geminiService;
private final FirestoreBaseRepository firestoreBaseRepository;
private static final String CONVERSATION_COLLECTION_PATH_FORMAT = "artifacts/%s/conversations";
private static final String DEFAULT_GEMINI_MODEL_NAME = "gemini-2.0-flash-001";
private static final Float DEFAULT_TEMPERATURE = 0.7f;
private static final Integer DEFAULT_MAX_OUTPUT_TOKENS = 800;
public ConversationSummaryService(GeminiClientService geminiService, FirestoreBaseRepository firestoreBaseRepository) {
this.geminiService = geminiService;
this.firestoreBaseRepository = firestoreBaseRepository;
}
public ConversationSummaryResponse summarizeConversation(ConversationSummaryRequest request) {
if (request == null) {
logger.warn("Summarization request is null.");
return new ConversationSummaryResponse("Request cannot be null.");
}
if (request.sessionId() == null || request.sessionId().isBlank()) {
logger.warn("Session ID is missing in the summarization request.");
return new ConversationSummaryResponse("Session ID is required.");
}
if (request.prompt() == null || request.prompt().isBlank()) {
logger.warn("Prompt for summarization is missing in the request.");
return new ConversationSummaryResponse("Prompt for summarization is required.");
}
String sessionId = request.sessionId();
String summarizationPromptInstruction = request.prompt();
String actualModelName = (request.modelName() != null && !request.modelName().isBlank())
? request.modelName() : DEFAULT_GEMINI_MODEL_NAME;
Float actualTemperature = (request.temperature() != null)
? request.temperature() : DEFAULT_TEMPERATURE;
Integer actualMaxOutputTokens = (request.maxOutputTokens() != null)
? request.maxOutputTokens() : DEFAULT_MAX_OUTPUT_TOKENS;
String collectionPath = String.format(CONVERSATION_COLLECTION_PATH_FORMAT, firestoreBaseRepository.getAppId());
String documentId = sessionId;
logger.info("Fetching conversation from Firestore: Collection='{}', Document='{}'", collectionPath, documentId);
ConversationSessionSummaryDTO sessionSummary;
try {
DocumentReference docRef = firestoreBaseRepository.getDocumentReference(collectionPath, documentId);
sessionSummary = firestoreBaseRepository.getDocument(docRef, ConversationSessionSummaryDTO.class);
logger.debug("Retrieved ConversationSessionSummaryDTO after Firestore fetch: sessionId={}, entries size={}",
sessionSummary != null ? sessionSummary.sessionId() : "null",
sessionSummary != null && sessionSummary.entries() != null ? sessionSummary.entries().size() : "N/A (entries list is null)");
if (sessionSummary == null) {
logger.warn("Firestore document not found or could not be mapped: {}/{}", collectionPath, documentId);
return new ConversationSummaryResponse("Conversation document not found for session ID: " + sessionId);
}
List<ConversationEntrySummaryDTO> entries = sessionSummary.entries();
if (entries == null || entries.isEmpty()) {
logger.warn("No conversation entries found in document {}/{} for session ID: {}",
collectionPath, documentId, sessionId);
return new ConversationSummaryResponse("No conversation messages found in the document for session ID: " + sessionId);
}
List<String> conversationMessages = entries.stream()
.map(entry -> {
String type = entry.type().map(t -> t.name()).orElse("UNKNOWN_TYPE");
String timestampString = entry.timestamp() != null ? entry.timestamp().toDate().toInstant().toString() : "UNKNOWN_TIMESTAMP";
return String.format("[%s - %s] %s", type, timestampString, entry.text());
})
.collect(Collectors.toList());
String formattedConversation = String.join("\n", conversationMessages);
String fullPromptForGemini = summarizationPromptInstruction + "\n\n" + formattedConversation;
logger.info("Sending summarization request to Gemini with custom prompt (first 200 chars): \n{}",
fullPromptForGemini.substring(0, Math.min(fullPromptForGemini.length(), 200)) + "...");
String summaryText = geminiService.generateContent(
fullPromptForGemini,
actualTemperature,
actualMaxOutputTokens,
actualModelName
);
if (summaryText == null || summaryText.trim().isEmpty()) {
logger.warn("Gemini returned an empty or null summary for the conversation.");
return new ConversationSummaryResponse("Could not generate a summary. The model returned no text.");
}
logger.info("Successfully generated summary for session ID: {}", sessionId);
return new ConversationSummaryResponse(summaryText);
} catch (InterruptedException | ExecutionException e) {
logger.error("Error accessing Firestore for session ID {}: {}", sessionId, e.getMessage(), e);
Thread.currentThread().interrupt();
return new ConversationSummaryResponse("Error accessing conversation data: " + e.getMessage());
} catch (Exception e) {
logger.error("An unexpected error occurred during summarization for session ID {}: {}", sessionId, e.getMessage(), e);
return new ConversationSummaryResponse("An unexpected error occurred during summarization: " + e.getMessage());
}
}
}

View File

@@ -0,0 +1,120 @@
package com.example.service;
import com.example.dto.dialogflow.DetectIntentRequestDTO;
import com.example.dto.dialogflow.DetectIntentResponseDTO;
import com.example.mapper.DialogflowRequestMapper;
import com.example.mapper.DialogflowResponseMapper;
import com.example.exception.DialogflowClientException;
import com.google.api.gax.rpc.ApiException;
import com.google.cloud.dialogflow.cx.v3.DetectIntentRequest;
import com.google.cloud.dialogflow.cx.v3.SessionsClient;
import com.google.cloud.dialogflow.cx.v3.SessionName;
import com.google.cloud.dialogflow.cx.v3.SessionsSettings;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
import javax.annotation.PreDestroy;
import java.io.IOException;
import java.util.Objects;
@Service
public class DialogflowClientService {
private static final Logger logger = LoggerFactory.getLogger(DialogflowClientService.class);
private final String dialogflowCxProjectId;
private final String dialogflowCxLocation;
private final String dialogflowCxAgentId;
private final DialogflowRequestMapper dialogflowRequestMapper;
private final DialogflowResponseMapper dialogflowResponseMapper;
private SessionsClient sessionsClient;
public DialogflowClientService(
@org.springframework.beans.factory.annotation.Value("${dialogflow.cx.project-id}") String dialogflowCxProjectId,
@org.springframework.beans.factory.annotation.Value("${dialogflow.cx.location}") String dialogflowCxLocation,
@org.springframework.beans.factory.annotation.Value("${dialogflow.cx.agent-id}") String dialogflowCxAgentId,
DialogflowRequestMapper dialogflowRequestMapper,
DialogflowResponseMapper dialogflowResponseMapper)
throws IOException {
this.dialogflowCxProjectId = dialogflowCxProjectId;
this.dialogflowCxLocation = dialogflowCxLocation;
this.dialogflowCxAgentId = dialogflowCxAgentId;
this.dialogflowRequestMapper = dialogflowRequestMapper;
this.dialogflowResponseMapper = dialogflowResponseMapper;
try {
String regionalEndpoint = String.format("%s-dialogflow.googleapis.com:443", dialogflowCxLocation);
SessionsSettings sessionsSettings = SessionsSettings.newBuilder()
.setEndpoint(regionalEndpoint)
.build();
this.sessionsClient = SessionsClient.create(sessionsSettings);
logger.info("Dialogflow CX SessionsClient initialized successfully for endpoint: {}", regionalEndpoint);
} catch (IOException e) {
logger.error("Failed to create Dialogflow CX SessionsClient: {}", e.getMessage(), e);
throw e;
}
}
@PreDestroy
public void closeSessionsClient() {
if (sessionsClient != null) {
sessionsClient.close();
logger.info("Dialogflow CX SessionsClient closed.");
}
}
public Mono<DetectIntentResponseDTO> detectIntent(
String sessionId,
DetectIntentRequestDTO request) {
Objects.requireNonNull(sessionId, "Dialogflow session ID cannot be null.");
Objects.requireNonNull(request, "Dialogflow request DTO cannot be null.");
logger.info("Initiating detectIntent for session: {}", sessionId);
DetectIntentRequest.Builder detectIntentRequestBuilder;
try {
detectIntentRequestBuilder = dialogflowRequestMapper.mapToDetectIntentRequestBuilder(request);
logger.debug("Obtained partial DetectIntentRequest.Builder from mapper for session: {}", sessionId);
} catch (IllegalArgumentException e) {
logger.error(" Failed to map DTO to partial Protobuf request for session {}: {}", sessionId, e.getMessage());
return Mono.error(new IllegalArgumentException("Invalid Dialogflow request input: " + e.getMessage()));
}
SessionName sessionName = SessionName.newBuilder()
.setProject(dialogflowCxProjectId)
.setLocation(dialogflowCxLocation)
.setAgent(dialogflowCxAgentId)
.setSession(sessionId)
.build();
detectIntentRequestBuilder.setSession(sessionName.toString());
logger.debug("Set session path {} on the request builder for session: {}", sessionName.toString(), sessionId);
// Build the final DetectIntentRequest Protobuf object
DetectIntentRequest detectIntentRequest = detectIntentRequestBuilder.build();
return Mono.fromCallable(() -> {
logger.debug("Calling Dialogflow CX detectIntent for session: {}", sessionId);
return sessionsClient.detectIntent(detectIntentRequest);
})
.onErrorMap(ApiException.class, e -> {
logger.error("Dialogflow CX API error for session {}: status={}, message={}",
sessionId, e.getStatusCode().getCode(), e.getMessage(), e);
return new DialogflowClientException(
"Dialogflow CX API error: " + e.getStatusCode().getCode() + " - " + e.getMessage(), e);
})
.onErrorMap(IOException.class, e -> {
logger.error("IO error when calling Dialogflow CX for session {}: {}", sessionId, e.getMessage(),e);
return new RuntimeException("IO error with Dialogflow CX API: " + e.getMessage(), e);
})
.map(dfResponse -> this.dialogflowResponseMapper.mapFromDialogflowResponse(dfResponse, sessionId));
}
}

View File

@@ -0,0 +1,93 @@
package com.example.service;
import com.example.dto.dialogflow.ConversationEntryDTO;
import com.example.dto.dialogflow.ConversationSessionDTO;
import com.example.exception.FirestorePersistenceException;
import com.example.mapper.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
public class FirestoreConversationService {
private static final Logger logger = LoggerFactory.getLogger(FirestoreConversationService.class);
private static final String CONVERSATION_COLLECTION_PATH_FORMAT = "artifacts/%s/conversations";
private final FirestoreBaseRepository firestoreBaseRepository;
private final FirestoreConversationMapper firestoreConversationMapper;
public FirestoreConversationService(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 {}. Type: {}", sessionId, newEntry.type().name());
return Mono.fromRunnable(() -> {
DocumentReference sessionDocRef = getSessionDocumentReference(sessionId);
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 {}. Type: {}", userId, sessionId, newEntry.type().name());
} else {
// Create: Start a new session with the first entry.
// Pass userId and userPhoneNumber to the mapper to be stored as fields in the document.
Map<String, Object> newSessionMap = firestoreConversationMapper.createNewSessionMapForSingleEntry(sessionId, userId, userPhoneNumber, newEntry);
batch.set(sessionDocRef, newSessionMap);
logger.info("Creating new conversation session with first entry for user {} and session {}. Type: {}", userId, sessionId, newEntry.type().name());
}
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

@@ -0,0 +1,45 @@
package com.example.service;
import com.google.genai.Client;
import com.google.genai.errors.GenAiIOException;
import com.google.genai.types.Content;
import com.google.genai.types.GenerateContentConfig;
import com.google.genai.types.GenerateContentResponse;
import com.google.genai.types.Part;
import org.springframework.stereotype.Service;
@Service
public class GeminiClientService {
private final Client geminiClient;
public GeminiClientService(Client geminiClient) {
this.geminiClient = geminiClient;
}
public String generateContent(String prompt, Float temperature, Integer maxOutputTokens, String modelName) {
try {
Content content = Content.fromParts(Part.fromText(prompt));
GenerateContentConfig config = GenerateContentConfig.builder()
.temperature(temperature)
.maxOutputTokens(maxOutputTokens)
.build();
GenerateContentResponse response = geminiClient.models.generateContent(modelName, content, config);
if (response != null && response.text() != null) {
return response.text();
} else {
return "No content generated or unexpected response structure.";
}
} catch (GenAiIOException e) {
System.err.println("Gemini API I/O error: " + e.getMessage());
e.printStackTrace();
return "Error: An API communication issue occurred: " + e.getMessage();
} catch (Exception e) {
System.err.println("An unexpected error occurred during Gemini content generation: " + e.getMessage());
e.printStackTrace();
return "Error: An unexpected issue occurred during content generation.";
}
}
}

View File

@@ -0,0 +1,73 @@
package com.example.service;
import com.example.dto.dialogflow.ConversationEntryDTO;
import com.example.dto.dialogflow.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 MemoryStoreConversationService {
private static final Logger logger = LoggerFactory.getLogger(MemoryStoreConversationService.class);
private static final String SESSION_KEY_PREFIX = "conversation:session:";
private static final String PHONE_TO_SESSION_KEY_PREFIX = "conversation:phone_to_session:";
private static final Duration SESSION_TTL = Duration.ofHours(24);
private final ReactiveRedisTemplate<String, ConversationSessionDTO> redisTemplate;
private final ReactiveRedisTemplate<String, String> stringRedisTemplate;
@Autowired
public MemoryStoreConversationService(
ReactiveRedisTemplate<String, ConversationSessionDTO> redisTemplate,
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 session {}. Type: {}", sessionId, newEntry.type().name());
return redisTemplate.opsForValue().get(sessionKey)
.defaultIfEmpty(ConversationSessionDTO.create(sessionId, userId, userPhoneNumber))
.flatMap(session -> {
ConversationSessionDTO sessionWithUpdatedTelefono = session.withTelefono(userPhoneNumber);
ConversationSessionDTO updatedSession = sessionWithUpdatedTelefono.withAddedEntry(newEntry);
logger.info("Attempting to set updated session {} with new entry type {} in Redis.", sessionId, newEntry.type().name());
return redisTemplate.opsForValue().set(sessionKey, updatedSession, SESSION_TTL)
.then(stringRedisTemplate.opsForValue().set(phoneToSessionKey, sessionId, SESSION_TTL));
})
.doOnSuccess(success -> logger.info("Successfully saved updated session and phone mapping to Redis for session {}. Entry Type: {}", sessionId, newEntry.type().name()))
.doOnError(e -> logger.error("Error appending entry to Redis for session {}: {}", sessionId, e.getMessage(), e))
.then();
}
public Mono<ConversationSessionDTO> getSessionByTelefono(String telefono) {
if (telefono == null || telefono.trim().isEmpty()) {
return Mono.empty();
}
String phoneToSessionKey = PHONE_TO_SESSION_KEY_PREFIX + telefono;
logger.debug("Attempting to retrieve session ID for phone number {} from Redis.", telefono);
return stringRedisTemplate.opsForValue().get(phoneToSessionKey)
.flatMap(sessionId -> {
logger.debug("Found session ID {} for phone number {}. Retrieving session data.", sessionId, telefono);
return redisTemplate.opsForValue().get(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,71 @@
// src/main/java/com/example/util/FirestoreTimestampDeserializer.java
package com.example.util;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonToken;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.google.cloud.Timestamp;
import java.io.IOException;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Custom Jackson Deserializer for com.google.cloud.Timestamp.
* Handles deserialization from embedded objects (direct Timestamp instances),
* ISO 8601 strings, and JSON objects with "seconds" and "nanos" fields.
*/
public class FirestoreTimestampDeserializer extends JsonDeserializer<Timestamp> {
private static final Logger logger = LoggerFactory.getLogger(FirestoreTimestampDeserializer.class);
@Override
public Timestamp deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
JsonToken token = p.getCurrentToken();
if (token == JsonToken.VALUE_EMBEDDED_OBJECT) {
// This is the ideal case when ObjectMapper.convertValue gets a direct Timestamp object
Object embedded = p.getEmbeddedObject();
if (embedded instanceof Timestamp) {
logger.debug("FirestoreTimestampDeserializer: Deserializing from embedded Timestamp object: {}", embedded);
return (Timestamp) embedded;
}
} else if (token == JsonToken.VALUE_STRING) {
// Handles cases where the timestamp is represented as an ISO 8601 string
String timestampString = p.getText();
try {
logger.debug("FirestoreTimestampDeserializer: Deserializing from String: {}", timestampString);
return Timestamp.parseTimestamp(timestampString);
} catch (IllegalArgumentException e) {
logger.error("FirestoreTimestampDeserializer: Failed to parse timestamp string: '{}'", timestampString, e);
throw new IOException("Failed to parse timestamp string: " + timestampString, e);
}
} else if (token == JsonToken.START_OBJECT) {
// This is crucial for handling the "Cannot deserialize ... from Object value (token JsonToken.START_OBJECT)" error.
// It assumes the object represents { "seconds": X, "nanos": Y }
logger.debug("FirestoreTimestampDeserializer: Deserializing from JSON object.");
// Suppress the unchecked warning here, as we expect a Map<String, Number>
@SuppressWarnings("unchecked")
Map<String, Number> map = p.readValueAs(Map.class);
if (map != null && map.containsKey("seconds") && map.containsKey("nanos")) {
Number secondsNum = map.get("seconds");
Number nanosNum = map.get("nanos");
if (secondsNum != null && nanosNum != null) {
Long seconds = secondsNum.longValue();
Integer nanos = nanosNum.intValue();
return Timestamp.ofTimeSecondsAndNanos(seconds, nanos);
}
}
logger.error("FirestoreTimestampDeserializer: JSON object missing 'seconds' or 'nanos' fields, or fields are not numbers.");
}
// If none of the above formats match, log an error and delegate to default handling
logger.error("FirestoreTimestampDeserializer: Unexpected token type for Timestamp deserialization. Expected Embedded Object, String, or START_OBJECT. Got: {}", token);
// This will likely re-throw an error indicating inability to deserialize.
return (Timestamp) ctxt.handleUnexpectedToken(Timestamp.class, p);
}
}

View File

@@ -0,0 +1,31 @@
// src/main/java/com/example/util/FirestoreTimestampSerializer.java
package com.example.util;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.google.cloud.Timestamp;
import java.io.IOException;
/**
* Custom Jackson Serializer for com.google.cloud.Timestamp.
* This is crucial for ObjectMapper.convertValue to correctly handle Timestamp objects
* when they are encountered in a Map<String, Object> and need to be internally
* serialized before deserialization into a DTO. It converts Timestamp into a
* simple JSON object with "seconds" and "nanos" fields.
*/
public class FirestoreTimestampSerializer extends JsonSerializer<Timestamp> {
@Override
public void serialize(Timestamp value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
if (value == null) {
gen.writeNull();
} else {
// Write Timestamp as a JSON object with seconds and nanos
gen.writeStartObject();
gen.writeNumberField("seconds", value.getSeconds());
gen.writeNumberField("nanos", value.getNanos());
gen.writeEndObject();
}
}
}

View File

@@ -0,0 +1,81 @@
package com.example.util;
import com.google.protobuf.ListValue;
import com.google.protobuf.NullValue;
import com.google.protobuf.Struct;
import com.google.protobuf.Value;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class ProtobufUtil {
private static final Logger logger = LoggerFactory.getLogger(ProtobufUtil.class);
/**
* Converts a Java Object to a Protobuf Value.
* Supports primitive types, String, Map, and List.
* Maps will be converted to Protobuf Structs.
* Lists will be converted to Protobuf ListValues.
*/
public static Value convertJavaObjectToProtobufValue(Object obj) {
if (obj == null) {
return Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build();
} else if (obj instanceof Boolean) {
return Value.newBuilder().setBoolValue((Boolean) obj).build();
} else if (obj instanceof Integer) {
return Value.newBuilder().setNumberValue(((Integer) obj).doubleValue()).build();
} else if (obj instanceof Long) {
return Value.newBuilder().setNumberValue(((Long) obj).doubleValue()).build();
} else if (obj instanceof Double) {
return Value.newBuilder().setNumberValue((Double) obj).build();
} else if (obj instanceof String) {
return Value.newBuilder().setStringValue((String) obj).build();
} else if (obj instanceof Map) {
Struct.Builder structBuilder = Struct.newBuilder();
((Map<?, ?>) obj).forEach((key, val) ->
structBuilder.putFields(String.valueOf(key), convertJavaObjectToProtobufValue(val))
);
return Value.newBuilder().setStructValue(structBuilder.build()).build();
} else if (obj instanceof List) {
ListValue.Builder listValueBuilder = ListValue.newBuilder();
((List<?>) obj).forEach(item ->
listValueBuilder.addValues(convertJavaObjectToProtobufValue(item))
);
return Value.newBuilder().setListValue(listValueBuilder.build()).build();
}
logger.warn("Unsupported type for Protobuf conversion: {}. Converting to String.", obj.getClass().getName());
return Value.newBuilder().setStringValue(obj.toString()).build();
}
/**
* Converts a Protobuf Value to a Java Object.
* Supports Null, Boolean, Number, String, Struct (to Map), and ListValue (to List).
*/
public static Object convertProtobufValueToJavaObject(Value protobufValue) {
return switch (protobufValue.getKindCase()) {
case NULL_VALUE -> null;
case BOOL_VALUE -> protobufValue.getBoolValue();
case NUMBER_VALUE -> protobufValue.getNumberValue();
case STRING_VALUE -> protobufValue.getStringValue();
case STRUCT_VALUE -> protobufValue.getStructValue().getFieldsMap().entrySet().stream()
.collect(Collectors.toMap(
Map.Entry::getKey,
entry -> convertProtobufValueToJavaObject(entry.getValue()),
(oldValue, newValue) -> oldValue,
LinkedHashMap::new
));
case LIST_VALUE -> protobufValue.getListValue().getValuesList().stream()
.map(ProtobufUtil::convertProtobufValueToJavaObject) // Use static method reference
.collect(Collectors.toList());
default -> {
logger.warn("Unsupported Protobuf Value type: {}. Returning null.", protobufValue.getKindCase());
yield null;
}
};
}
}

View File

@@ -0,0 +1,85 @@
# Firestore Configuration Properties
# --------------------------------
# Project ID Configuration
# Use this setting if you want to manually specify a GCP Project instead of inferring
# from your machine's environment.
spring.cloud.gcp.firestore.project-id=app-jovenes
# Credentials Configuration
# Use this setting if you want to manually specify service account credentials instead of inferring
# from the machine's environment for firestore.
#spring.cloud.gcp.firestore.credentials.location=file:{PATH_TO_YOUR_CREDENTIALS_FILE}
# Firestore Emulator Configuration (for local development)
#spring.cloud.gcp.firestore.emulator-host=localhost:8080
spring.cloud.gcp.firestore.emulator.enabled=false
# Firestore Database Configuration
# ------------------------------------------
spring.cloud.gcp.firestore.database-id=app-jovenes-cache-database
spring.cloud.gcp.firestore.host=firestore.googleapis.com
spring.cloud.gcp.firestore.port=443
# Memorystore (Redis) Configuration Properties
# ------------------------------------------
# Basic Connection Settings
#Secret Manager recomendation for credentials
spring.data.redis.host=10.241.0.11
spring.data.redis.port=6379
#spring.data.redis.password=23cb4c76-9d96-4c74-b8c0-778fb364877a
#spring.data.redis.username=default
# Connection Pool Settings
# spring.data.redis.lettuce.pool.max-active=8
# spring.data.redis.lettuce.pool.max-idle=8
# spring.data.redis.lettuce.pool.min-idle=0
# spring.data.redis.lettuce.pool.max-wait=-1ms
# SSL Configuration (if using SSL)
# spring.data.redis.ssl=true
# spring.data.redis.ssl.key-store=classpath:keystore.p12
# spring.data.redis.ssl.key-store-password=your-keystore-password
# Timeout Settings
# spring.data.redis.timeout=2000ms
# spring.data.redis.lettuce.shutdown-timeout=100ms
# Cluster Configuration (if using Redis Cluster)
# spring.data.redis.cluster.nodes=localhost:6379,localhost:6380,localhost:6381
# spring.data.redis.cluster.max-redirects=3
# Sentinel Configuration (if using Redis Sentinel)
# spring.data.redis.sentinel.master=mymaster
# spring.data.redis.sentinel.nodes=localhost:26379,localhost:26380,localhost:26381
# Additional Redis Settings
# spring.data.redis.database=0
# spring.data.redis.client-type=lettuce
# spring.data.redis.lettuce.cluster.refresh.period=1000ms
# Google Cloud StorageConfiguration
# ------------------------------------------
gcs.bucket.name=app-jovenes-bucket
spring.cloud.gcp.project-id=app-jovenes
# Dialogflow CX Configuration
# ------------------------------------------
dialogflow.cx.project-id=app-jovenes
dialogflow.cx.location=us-central1
dialogflow.cx.agent-id=3b9f2354-8556-4363-9e70-fa8283582a3e
dialogflow.default-language-code=es
# =========================================================
# Google Generative AI (Gemini) Configuration
# =========================================================
# Your Google Cloud Project ID where the Vertex AI service is enabled.
google.cloud.project=app-jovenes
# The Google Cloud region where you want to access the Gemini model.
# Common regions: us-central1, europe-west1, asia-northeast1 etc.
google.cloud.location=us-central1
# The name of the Gemini model to use for summarization.
gemini.model.name=gemini-2.0-flash-001

View File

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