From fac3550287065a6a070eec9e40490f47a997673d Mon Sep 17 00:00:00 2001 From: PAVEL PALMA Date: Wed, 16 Jul 2025 13:43:46 -0600 Subject: [PATCH] Initial commit --- Dockerfile | 12 ++ README.md | 101 ++----------- pom.xml | 127 ++++++++++++++++ src/main/java/com/example/Orchestrator.java | 44 ++++++ .../com/example/config/OpenApiConfig.java | 23 +++ .../java/com/example/config/RedisConfig.java | 43 ++++++ .../com/example/config/VertexAIConfig.java | 33 +++++ .../controller/ConversationController.java | 39 +++++ .../ConversationSummaryController.java | 49 +++++++ .../com/example/dto/base/BaseRequest.java | 17 +++ .../example/dto/base/ConversationContext.java | 8 ++ .../example/dto/base/NotificationRequest.java | 26 ++++ .../java/com/example/dto/base/UsuarioDTO.java | 9 ++ .../dto/dialogflow/ConversationEntryDTO.java | 35 +++++ .../dto/dialogflow/ConversationEntryType.java | 6 + .../dialogflow/ConversationSessionDTO.java | 44 ++++++ .../dialogflow/DetectIntentRequestDTO.java | 25 ++++ .../dialogflow/DetectIntentResponseDTO.java | 8 ++ .../com/example/dto/dialogflow/IntentDTO.java | 6 + .../example/dto/dialogflow/QueryInputDTO.java | 3 + .../dto/dialogflow/QueryParamsDTO.java | 4 + .../dto/dialogflow/QueryResultDTO.java | 9 ++ .../example/dto/dialogflow/TextInputDTO.java | 3 + .../gemini/ConversationEntrySummaryDTO.java | 48 +++++++ .../gemini/ConversationSessionSummaryDTO.java | 32 +++++ .../gemini/ConversationSummaryRequest.java | 19 +++ .../gemini/ConversationSummaryResponse.java | 8 ++ .../exception/DialogflowClientException.java | 12 ++ .../FirestorePersistenceException.java | 12 ++ .../mapper/DialogflowRequestMapper.java | 79 ++++++++++ .../mapper/DialogflowResponseMapper.java | 76 ++++++++++ .../mapper/FirestoreConversationMapper.java | 132 +++++++++++++++++ .../repository/FirestoreBaseRepository.java | 130 +++++++++++++++++ .../service/ConversationManagerService.java | 136 ++++++++++++++++++ .../service/ConversationSummaryService.java | 121 ++++++++++++++++ .../service/DialogflowClientService.java | 120 ++++++++++++++++ .../service/FirestoreConversationService.java | 93 ++++++++++++ .../example/service/GeminiClientService.java | 45 ++++++ .../MemoryStoreConversationService.java | 73 ++++++++++ .../util/FirestoreTimestampDeserializer.java | 71 +++++++++ .../util/FirestoreTimestampSerializer.java | 31 ++++ .../java/com/example/util/ProtobufUtil.java | 81 +++++++++++ src/main/resources/application.properties | 85 +++++++++++ src/main/resources/static/index.html | 57 ++++++++ .../resources/application-test.properties | 1 + src/test/resources/logback-test.xml | 14 ++ 46 files changed, 2062 insertions(+), 88 deletions(-) create mode 100644 Dockerfile create mode 100644 pom.xml create mode 100644 src/main/java/com/example/Orchestrator.java create mode 100644 src/main/java/com/example/config/OpenApiConfig.java create mode 100644 src/main/java/com/example/config/RedisConfig.java create mode 100644 src/main/java/com/example/config/VertexAIConfig.java create mode 100644 src/main/java/com/example/controller/ConversationController.java create mode 100644 src/main/java/com/example/controller/ConversationSummaryController.java create mode 100644 src/main/java/com/example/dto/base/BaseRequest.java create mode 100644 src/main/java/com/example/dto/base/ConversationContext.java create mode 100644 src/main/java/com/example/dto/base/NotificationRequest.java create mode 100644 src/main/java/com/example/dto/base/UsuarioDTO.java create mode 100644 src/main/java/com/example/dto/dialogflow/ConversationEntryDTO.java create mode 100644 src/main/java/com/example/dto/dialogflow/ConversationEntryType.java create mode 100644 src/main/java/com/example/dto/dialogflow/ConversationSessionDTO.java create mode 100644 src/main/java/com/example/dto/dialogflow/DetectIntentRequestDTO.java create mode 100644 src/main/java/com/example/dto/dialogflow/DetectIntentResponseDTO.java create mode 100644 src/main/java/com/example/dto/dialogflow/IntentDTO.java create mode 100644 src/main/java/com/example/dto/dialogflow/QueryInputDTO.java create mode 100644 src/main/java/com/example/dto/dialogflow/QueryParamsDTO.java create mode 100644 src/main/java/com/example/dto/dialogflow/QueryResultDTO.java create mode 100644 src/main/java/com/example/dto/dialogflow/TextInputDTO.java create mode 100644 src/main/java/com/example/dto/gemini/ConversationEntrySummaryDTO.java create mode 100644 src/main/java/com/example/dto/gemini/ConversationSessionSummaryDTO.java create mode 100644 src/main/java/com/example/dto/gemini/ConversationSummaryRequest.java create mode 100644 src/main/java/com/example/dto/gemini/ConversationSummaryResponse.java create mode 100644 src/main/java/com/example/exception/DialogflowClientException.java create mode 100644 src/main/java/com/example/exception/FirestorePersistenceException.java create mode 100644 src/main/java/com/example/mapper/DialogflowRequestMapper.java create mode 100644 src/main/java/com/example/mapper/DialogflowResponseMapper.java create mode 100644 src/main/java/com/example/mapper/FirestoreConversationMapper.java create mode 100644 src/main/java/com/example/repository/FirestoreBaseRepository.java create mode 100644 src/main/java/com/example/service/ConversationManagerService.java create mode 100644 src/main/java/com/example/service/ConversationSummaryService.java create mode 100644 src/main/java/com/example/service/DialogflowClientService.java create mode 100644 src/main/java/com/example/service/FirestoreConversationService.java create mode 100644 src/main/java/com/example/service/GeminiClientService.java create mode 100644 src/main/java/com/example/service/MemoryStoreConversationService.java create mode 100644 src/main/java/com/example/util/FirestoreTimestampDeserializer.java create mode 100644 src/main/java/com/example/util/FirestoreTimestampSerializer.java create mode 100644 src/main/java/com/example/util/ProtobufUtil.java create mode 100644 src/main/resources/application.properties create mode 100644 src/main/resources/static/index.html create mode 100644 src/test/resources/application-test.properties create mode 100644 src/test/resources/logback-test.xml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..79d6e33 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +# Java 21.0.6 +# 'jammy' refers to Ubuntu 22.04 LTS, which is a stable and widely used base. + +FROM maven:3.9.6-eclipse-temurin-21 AS builder +WORKDIR /app +COPY pom.xml . +COPY src ./src +RUN mvn -B clean install -DskipTests -Dmaven.javadoc.skip=true +FROM eclipse-temurin:21.0.3_9-jre-jammy +COPY --from=builder /app/target/app-jovenes-service-orchestrator-0.0.1-SNAPSHOT.jar app.jar +EXPOSE 8080 +ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/README.md b/README.md index 908e39c..e725a4a 100644 --- a/README.md +++ b/README.md @@ -1,92 +1,17 @@ -# int-layer +*Key Versions & Management:* + +* *Java Version:* `21` +* *Spring Boot Version:* `3.2.5` (defined in the parent POM) +* *Spring Cloud GCP Version:* `5.3.0` (managed via `spring-cloud-gcp-dependencies`) +* *Spring Cloud Version:* `2023.0.0` (managed via `spring-cloud-dependencies`) -## Getting started - -To make it easy for you to get started with GitLab, here's a list of recommended next steps. - -Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)! - -## Add your files - -- [ ] [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files -- [ ] [Add files using the command line](https://docs.gitlab.com/topics/git/add_files/#add-files-to-a-git-repository) or push an existing Git repository with the following command: - -``` -cd existing_repo -git remote add origin https://lnxocpgit1.dev.ocp.banorte.com:5443/desarrollo/evoluci-n-tecnol-gica/ap01194-orq-cog/int-layer.git -git branch -M main -git push -uf origin main +Response Body Development: +```json +"responseId": "e582a35c-157c-4fb0-b96f-be4a0272ee33", + "queryResult": { + "responseText": "¡Hola! Soy Beto, tu asesor financiero de Banorte. ¿En qué puedo ayudarte hoy?", + "parameters": {}, + } ``` -## Integrate with your tools - -- [ ] [Set up project integrations](https://lnxocpgit1.dev.ocp.banorte.com:5443/desarrollo/evoluci-n-tecnol-gica/ap01194-orq-cog/int-layer/-/settings/integrations) - -## Collaborate with your team - -- [ ] [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/) -- [ ] [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html) -- [ ] [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically) -- [ ] [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/) -- [ ] [Set auto-merge](https://docs.gitlab.com/user/project/merge_requests/auto_merge/) - -## Test and Deploy - -Use the built-in continuous integration in GitLab. - -- [ ] [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/) -- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/ee/user/application_security/sast/) -- [ ] [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html) -- [ ] [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/) -- [ ] [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html) - -*** - -# Editing this README - -When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thanks to [makeareadme.com](https://www.makeareadme.com/) for this template. - -## Suggestions for a good README - -Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information. - -## Name -Choose a self-explaining name for your project. - -## Description -Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors. - -## Badges -On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge. - -## Visuals -Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method. - -## Installation -Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection. - -## Usage -Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README. - -## Support -Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc. - -## Roadmap -If you have ideas for releases in the future, it is a good idea to list them in the README. - -## Contributing -State if you are open to contributions and what your requirements are for accepting them. - -For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self. - -You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser. - -## Authors and acknowledgment -Show your appreciation to those who have contributed to the project. - -## License -For open source projects, say how it is licensed. - -## Project status -If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers. diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..339d537 --- /dev/null +++ b/pom.xml @@ -0,0 +1,127 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.0 + + + + com.example + app-jovenes-service-orchestrator + 0.0.1-SNAPSHOT + app-jovenes-service-orchestrator + This serivce handle conversations over Dialogflow and multiple Storage GCP services + + + 21 + 5.4.0 + 2023.0.0 + 6.4.0.RELEASE + + + + + + org.springframework.cloud + spring-cloud-dependencies + ${spring-cloud.version} + pom + import + + + com.google.cloud + spring-cloud-gcp-dependencies + ${spring-cloud-gcp.version} + pom + import + + + com.google.cloud + libraries-bom + 26.37.0 + pom + import + + + + + + + org.springframework.boot + spring-boot-starter-webflux + + + org.springdoc + springdoc-openapi-starter-webflux-ui + 2.5.0 + + + com.google.cloud + spring-cloud-gcp-starter-data-firestore + + + com.google.cloud + spring-cloud-gcp-data-firestore + + + com.google.cloud + spring-cloud-gcp-starter-storage + + + org.springframework.boot + spring-boot-starter-data-redis-reactive + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-starter-test + test + + + com.google.cloud + google-cloud-dialogflow-cx + + + com.google.genai + google-genai + 1.8.0 + + + com.google.protobuf + protobuf-java-util + + + io.projectreactor + reactor-test + test + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + + com.fasterxml.jackson.module + jackson-module-parameter-names + + + com.fasterxml.jackson.module + jackson-module-parameter-names + 2.19.0 + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + \ No newline at end of file diff --git a/src/main/java/com/example/Orchestrator.java b/src/main/java/com/example/Orchestrator.java new file mode 100644 index 0000000..c078aaf --- /dev/null +++ b/src/main/java/com/example/Orchestrator.java @@ -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 indexRouter( + @Value("classpath:/static/index.html") final Resource indexHtml) { + return route(GET("/"), request -> ok().contentType(MediaType.TEXT_HTML).bodyValue(indexHtml)); + } +} diff --git a/src/main/java/com/example/config/OpenApiConfig.java b/src/main/java/com/example/config/OpenApiConfig.java new file mode 100644 index 0000000..9a02397 --- /dev/null +++ b/src/main/java/com/example/config/OpenApiConfig.java @@ -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"))); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/config/RedisConfig.java b/src/main/java/com/example/config/RedisConfig.java new file mode 100644 index 0000000..ea1c100 --- /dev/null +++ b/src/main/java/com/example/config/RedisConfig.java @@ -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 reactiveConversationRedisTemplate( + ReactiveRedisConnectionFactory factory) { + + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + Jackson2JsonRedisSerializer serializer = + new Jackson2JsonRedisSerializer<>(objectMapper, ConversationSessionDTO.class); + + return new ReactiveRedisTemplate<>(factory, RedisSerializationContext + .newSerializationContext(new StringRedisSerializer()) + .value(serializer) + .build()); +} + +@Bean +public ReactiveRedisTemplate reactiveStringRedisTemplate( + ReactiveRedisConnectionFactory factory) { + return new ReactiveRedisTemplate<>(factory, RedisSerializationContext + .newSerializationContext(new StringRedisSerializer()) + .value(new StringRedisSerializer()) + .build()); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/config/VertexAIConfig.java b/src/main/java/com/example/config/VertexAIConfig.java new file mode 100644 index 0000000..1310acf --- /dev/null +++ b/src/main/java/com/example/config/VertexAIConfig.java @@ -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(); + } + +} \ No newline at end of file diff --git a/src/main/java/com/example/controller/ConversationController.java b/src/main/java/com/example/controller/ConversationController.java new file mode 100644 index 0000000..0638944 --- /dev/null +++ b/src/main/java/com/example/controller/ConversationController.java @@ -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 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)); + } + + + +} \ No newline at end of file diff --git a/src/main/java/com/example/controller/ConversationSummaryController.java b/src/main/java/com/example/controller/ConversationSummaryController.java new file mode 100644 index 0000000..a32ead0 --- /dev/null +++ b/src/main/java/com/example/controller/ConversationSummaryController.java @@ -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 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); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/example/dto/base/BaseRequest.java b/src/main/java/com/example/dto/base/BaseRequest.java new file mode 100644 index 0000000..f298c6d --- /dev/null +++ b/src/main/java/com/example/dto/base/BaseRequest.java @@ -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(); +} \ No newline at end of file diff --git a/src/main/java/com/example/dto/base/ConversationContext.java b/src/main/java/com/example/dto/base/ConversationContext.java new file mode 100644 index 0000000..37de5e5 --- /dev/null +++ b/src/main/java/com/example/dto/base/ConversationContext.java @@ -0,0 +1,8 @@ +package com.example.dto.base; + +public record ConversationContext( + String userId, + String sessionId, + String userMessageText, + String primaryPhoneNumber +) {} \ No newline at end of file diff --git a/src/main/java/com/example/dto/base/NotificationRequest.java b/src/main/java/com/example/dto/base/NotificationRequest.java new file mode 100644 index 0000000..6d9b613 --- /dev/null +++ b/src/main/java/com/example/dto/base/NotificationRequest.java @@ -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 + ) { + } +} \ No newline at end of file diff --git a/src/main/java/com/example/dto/base/UsuarioDTO.java b/src/main/java/com/example/dto/base/UsuarioDTO.java new file mode 100644 index 0000000..f101439 --- /dev/null +++ b/src/main/java/com/example/dto/base/UsuarioDTO.java @@ -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 +) {} \ No newline at end of file diff --git a/src/main/java/com/example/dto/dialogflow/ConversationEntryDTO.java b/src/main/java/com/example/dto/dialogflow/ConversationEntryDTO.java new file mode 100644 index 0000000..4302c19 --- /dev/null +++ b/src/main/java/com/example/dto/dialogflow/ConversationEntryDTO.java @@ -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 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 + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/dto/dialogflow/ConversationEntryType.java b/src/main/java/com/example/dto/dialogflow/ConversationEntryType.java new file mode 100644 index 0000000..ca130da --- /dev/null +++ b/src/main/java/com/example/dto/dialogflow/ConversationEntryType.java @@ -0,0 +1,6 @@ +package com.example.dto.dialogflow; + +public enum ConversationEntryType { + USER_MESSAGE, + AGENT_RESPONSE +} \ No newline at end of file diff --git a/src/main/java/com/example/dto/dialogflow/ConversationSessionDTO.java b/src/main/java/com/example/dto/dialogflow/ConversationSessionDTO.java new file mode 100644 index 0000000..458c693 --- /dev/null +++ b/src/main/java/com/example/dto/dialogflow/ConversationSessionDTO.java @@ -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 entries +) { + public ConversationSessionDTO(String sessionId, String userId, String telefono, Instant createdAt, Instant lastModified, List 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 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; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/dto/dialogflow/DetectIntentRequestDTO.java b/src/main/java/com/example/dto/dialogflow/DetectIntentRequestDTO.java new file mode 100644 index 0000000..147fe1f --- /dev/null +++ b/src/main/java/com/example/dto/dialogflow/DetectIntentRequestDTO.java @@ -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() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/dto/dialogflow/DetectIntentResponseDTO.java b/src/main/java/com/example/dto/dialogflow/DetectIntentResponseDTO.java new file mode 100644 index 0000000..7eac05e --- /dev/null +++ b/src/main/java/com/example/dto/dialogflow/DetectIntentResponseDTO.java @@ -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 +) {} \ No newline at end of file diff --git a/src/main/java/com/example/dto/dialogflow/IntentDTO.java b/src/main/java/com/example/dto/dialogflow/IntentDTO.java new file mode 100644 index 0000000..26aa0f4 --- /dev/null +++ b/src/main/java/com/example/dto/dialogflow/IntentDTO.java @@ -0,0 +1,6 @@ +package com.example.dto.dialogflow; + +public record IntentDTO( + String name, + String displayName +) {} \ No newline at end of file diff --git a/src/main/java/com/example/dto/dialogflow/QueryInputDTO.java b/src/main/java/com/example/dto/dialogflow/QueryInputDTO.java new file mode 100644 index 0000000..037f9a1 --- /dev/null +++ b/src/main/java/com/example/dto/dialogflow/QueryInputDTO.java @@ -0,0 +1,3 @@ +package com.example.dto.dialogflow; + +public record QueryInputDTO(TextInputDTO text, String languageCode) {} \ No newline at end of file diff --git a/src/main/java/com/example/dto/dialogflow/QueryParamsDTO.java b/src/main/java/com/example/dto/dialogflow/QueryParamsDTO.java new file mode 100644 index 0000000..ce54ea9 --- /dev/null +++ b/src/main/java/com/example/dto/dialogflow/QueryParamsDTO.java @@ -0,0 +1,4 @@ +package com.example.dto.dialogflow; + +import java.util.Map; +public record QueryParamsDTO(Map parameters) {} diff --git a/src/main/java/com/example/dto/dialogflow/QueryResultDTO.java b/src/main/java/com/example/dto/dialogflow/QueryResultDTO.java new file mode 100644 index 0000000..a8f1c46 --- /dev/null +++ b/src/main/java/com/example/dto/dialogflow/QueryResultDTO.java @@ -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 parameters +) {} \ No newline at end of file diff --git a/src/main/java/com/example/dto/dialogflow/TextInputDTO.java b/src/main/java/com/example/dto/dialogflow/TextInputDTO.java new file mode 100644 index 0000000..12f93a9 --- /dev/null +++ b/src/main/java/com/example/dto/dialogflow/TextInputDTO.java @@ -0,0 +1,3 @@ +package com.example.dto.dialogflow; + +public record TextInputDTO(String text) {} \ No newline at end of file diff --git a/src/main/java/com/example/dto/gemini/ConversationEntrySummaryDTO.java b/src/main/java/com/example/dto/gemini/ConversationEntrySummaryDTO.java new file mode 100644 index 0000000..87e8e93 --- /dev/null +++ b/src/main/java/com/example/dto/gemini/ConversationEntrySummaryDTO.java @@ -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 type, + @JsonProperty("intentDisplayName") String intentDisplayName, + @JsonProperty("parameters") Map 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 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 + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/dto/gemini/ConversationSessionSummaryDTO.java b/src/main/java/com/example/dto/gemini/ConversationSessionSummaryDTO.java new file mode 100644 index 0000000..3e4ec56 --- /dev/null +++ b/src/main/java/com/example/dto/gemini/ConversationSessionSummaryDTO.java @@ -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 entries +) { + @JsonCreator + public ConversationSessionSummaryDTO( + @JsonProperty("sessionId") String sessionId, + @JsonProperty("userId") String userId, + @JsonProperty("startTime") Timestamp startTime, + @JsonProperty("lastUpdated") Timestamp lastUpdated, + @JsonProperty("entries") List entries + ) { + this.sessionId = sessionId; + this.userId = userId; + this.startTime = startTime; + this.lastUpdated = lastUpdated; + this.entries = entries != null ? entries : Collections.emptyList(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/dto/gemini/ConversationSummaryRequest.java b/src/main/java/com/example/dto/gemini/ConversationSummaryRequest.java new file mode 100644 index 0000000..4c0f29b --- /dev/null +++ b/src/main/java/com/example/dto/gemini/ConversationSummaryRequest.java @@ -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 +) {} \ No newline at end of file diff --git a/src/main/java/com/example/dto/gemini/ConversationSummaryResponse.java b/src/main/java/com/example/dto/gemini/ConversationSummaryResponse.java new file mode 100644 index 0000000..918f636 --- /dev/null +++ b/src/main/java/com/example/dto/gemini/ConversationSummaryResponse.java @@ -0,0 +1,8 @@ +package com.example.dto.gemini; + +import jakarta.validation.constraints.NotBlank; + +public record ConversationSummaryResponse( + @NotBlank + String summaryText +) {} \ No newline at end of file diff --git a/src/main/java/com/example/exception/DialogflowClientException.java b/src/main/java/com/example/exception/DialogflowClientException.java new file mode 100644 index 0000000..a30a665 --- /dev/null +++ b/src/main/java/com/example/exception/DialogflowClientException.java @@ -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); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/exception/FirestorePersistenceException.java b/src/main/java/com/example/exception/FirestorePersistenceException.java new file mode 100644 index 0000000..28a3d17 --- /dev/null +++ b/src/main/java/com/example/exception/FirestorePersistenceException.java @@ -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); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/mapper/DialogflowRequestMapper.java b/src/main/java/com/example/mapper/DialogflowRequestMapper.java new file mode 100644 index 0000000..7a50ad0 --- /dev/null +++ b/src/main/java/com/example/mapper/DialogflowRequestMapper.java @@ -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 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; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/mapper/DialogflowResponseMapper.java b/src/main/java/com/example/mapper/DialogflowResponseMapper.java new file mode 100644 index 0000000..9e77d43 --- /dev/null +++ b/src/main/java/com/example/mapper/DialogflowResponseMapper.java @@ -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 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; + } + +} diff --git a/src/main/java/com/example/mapper/FirestoreConversationMapper.java b/src/main/java/com/example/mapper/FirestoreConversationMapper.java new file mode 100644 index 0000000..20f0979 --- /dev/null +++ b/src/main/java/com/example/mapper/FirestoreConversationMapper.java @@ -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 createUpdateMapForSingleEntry(ConversationEntryDTO newEntry) { + Map updates = new HashMap<>(); + // Convert Instant to Firestore Timestamp + Map 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 createNewSessionMapForSingleEntry(String sessionId, String userId, String telefono, ConversationEntryDTO initialEntry) { + Map 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> entriesList = new ArrayList<>(); + entriesList.add(toFirestoreEntryMap(initialEntry)); + sessionMap.put("entries", entriesList); + + return sessionMap; + } + + private Map toFirestoreEntryMap(ConversationEntryDTO entry) { + Map 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> rawEntries = (List>) documentSnapshot.get("entries"); + List entries = new ArrayList<>(); + if (rawEntries != null) { + entries = rawEntries.stream() + .map(this::mapFirestoreEntryMapToConversationEntryDTO) + .collect(Collectors.toList()); + } + return new ConversationSessionDTO(sessionId, userId, telefono, createdAt, lastModified, entries); + } + + + private ConversationEntryDTO mapFirestoreEntryMapToConversationEntryDTO(Map 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 parameters = (Map) 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); + } + +} \ No newline at end of file diff --git a/src/main/java/com/example/repository/FirestoreBaseRepository.java b/src/main/java/com/example/repository/FirestoreBaseRepository.java new file mode 100644 index 0000000..4b62078 --- /dev/null +++ b/src/main/java/com/example/repository/FirestoreBaseRepository.java @@ -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 getDocument(DocumentReference docRef, Class clazz) throws InterruptedException, ExecutionException { + Objects.requireNonNull(docRef, "DocumentReference cannot be null."); + Objects.requireNonNull(clazz, "Class for mapping cannot be null."); + ApiFuture 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 future = docRef.get(); + return future.get(); + } + + public boolean documentExists(DocumentReference docRef) throws InterruptedException, ExecutionException { + Objects.requireNonNull(docRef, "DocumentReference cannot be null."); + ApiFuture 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 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 updates) throws InterruptedException, ExecutionException { + Objects.requireNonNull(docRef, "DocumentReference cannot be null."); + Objects.requireNonNull(updates, "Updates map cannot be null."); + ApiFuture 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; + } + +} \ No newline at end of file diff --git a/src/main/java/com/example/service/ConversationManagerService.java b/src/main/java/com/example/service/ConversationManagerService.java new file mode 100644 index 0000000..df55580 --- /dev/null +++ b/src/main/java/com/example/service/ConversationManagerService.java @@ -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 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 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 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 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); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/service/ConversationSummaryService.java b/src/main/java/com/example/service/ConversationSummaryService.java new file mode 100644 index 0000000..372f900 --- /dev/null +++ b/src/main/java/com/example/service/ConversationSummaryService.java @@ -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 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 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()); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/example/service/DialogflowClientService.java b/src/main/java/com/example/service/DialogflowClientService.java new file mode 100644 index 0000000..541d8ca --- /dev/null +++ b/src/main/java/com/example/service/DialogflowClientService.java @@ -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 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)); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/service/FirestoreConversationService.java b/src/main/java/com/example/service/FirestoreConversationService.java new file mode 100644 index 0000000..26e7995 --- /dev/null +++ b/src/main/java/com/example/service/FirestoreConversationService.java @@ -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 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 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 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 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> + } 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); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/service/GeminiClientService.java b/src/main/java/com/example/service/GeminiClientService.java new file mode 100644 index 0000000..84e9ffc --- /dev/null +++ b/src/main/java/com/example/service/GeminiClientService.java @@ -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."; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/example/service/MemoryStoreConversationService.java b/src/main/java/com/example/service/MemoryStoreConversationService.java new file mode 100644 index 0000000..5c81fc6 --- /dev/null +++ b/src/main/java/com/example/service/MemoryStoreConversationService.java @@ -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 redisTemplate; + private final ReactiveRedisTemplate stringRedisTemplate; + + @Autowired + public MemoryStoreConversationService( + ReactiveRedisTemplate redisTemplate, + ReactiveRedisTemplate stringRedisTemplate) { + this.redisTemplate = redisTemplate; + this.stringRedisTemplate = stringRedisTemplate; + } + + public Mono saveEntry(String userId, String sessionId, ConversationEntryDTO newEntry, String userPhoneNumber) { + String sessionKey = SESSION_KEY_PREFIX + sessionId; + String phoneToSessionKey = PHONE_TO_SESSION_KEY_PREFIX + userPhoneNumber; + logger.info("Attempting to save entry to Redis for 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 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)); + } +} diff --git a/src/main/java/com/example/util/FirestoreTimestampDeserializer.java b/src/main/java/com/example/util/FirestoreTimestampDeserializer.java new file mode 100644 index 0000000..84f7370 --- /dev/null +++ b/src/main/java/com/example/util/FirestoreTimestampDeserializer.java @@ -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 { + + 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 + @SuppressWarnings("unchecked") + Map 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); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/util/FirestoreTimestampSerializer.java b/src/main/java/com/example/util/FirestoreTimestampSerializer.java new file mode 100644 index 0000000..d9385e0 --- /dev/null +++ b/src/main/java/com/example/util/FirestoreTimestampSerializer.java @@ -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 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 { + + @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(); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/example/util/ProtobufUtil.java b/src/main/java/com/example/util/ProtobufUtil.java new file mode 100644 index 0000000..abd8da2 --- /dev/null +++ b/src/main/java/com/example/util/ProtobufUtil.java @@ -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; + } + }; + } +} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 0000000..4996b35 --- /dev/null +++ b/src/main/resources/application.properties @@ -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 \ No newline at end of file diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html new file mode 100644 index 0000000..3498164 --- /dev/null +++ b/src/main/resources/static/index.html @@ -0,0 +1,57 @@ + + + + Spring Data Firestore Sample + + + + + + +

Spring Data Firestore Sample

+ +
+ +

Firestore Control Panel

+ +

+ This section allows you to read User entities in Firestore. + Some values are prefilled as an example of what you can type in. +

+ + + +
+ Show all users with age +
+ Age: + +
+
+ +
+ + + + + + \ No newline at end of file diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties new file mode 100644 index 0000000..edc6d2b --- /dev/null +++ b/src/test/resources/application-test.properties @@ -0,0 +1 @@ +spring.cloud.gcp.firestore.database-id=firestoredb \ No newline at end of file diff --git a/src/test/resources/logback-test.xml b/src/test/resources/logback-test.xml new file mode 100644 index 0000000..5535de3 --- /dev/null +++ b/src/test/resources/logback-test.xml @@ -0,0 +1,14 @@ + + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + +