Initial commit

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

12
Dockerfile Normal file
View File

@@ -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"]

101
README.md
View File

@@ -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 Response Body Development:
```json
To make it easy for you to get started with GitLab, here's a list of recommended next steps. "responseId": "e582a35c-157c-4fb0-b96f-be4a0272ee33",
"queryResult": {
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)! "responseText": "¡Hola! Soy Beto, tu asesor financiero de Banorte. ¿En qué puedo ayudarte hoy?",
"parameters": {},
## 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
``` ```
## 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.

127
pom.xml Normal file
View File

@@ -0,0 +1,127 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.0</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>app-jovenes-service-orchestrator</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>app-jovenes-service-orchestrator</name>
<description>This serivce handle conversations over Dialogflow and multiple Storage GCP services</description>
<properties>
<java.version>21</java.version>
<spring-cloud-gcp.version>5.4.0</spring-cloud-gcp.version>
<spring-cloud.version>2023.0.0</spring-cloud.version>
<lettuce.version>6.4.0.RELEASE</lettuce.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>com.google.cloud</groupId>
<artifactId>spring-cloud-gcp-dependencies</artifactId>
<version>${spring-cloud-gcp.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>com.google.cloud</groupId>
<artifactId>libraries-bom</artifactId>
<version>26.37.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webflux-ui</artifactId>
<version>2.5.0</version>
</dependency>
<dependency>
<groupId>com.google.cloud</groupId>
<artifactId>spring-cloud-gcp-starter-data-firestore</artifactId>
</dependency>
<dependency>
<groupId>com.google.cloud</groupId>
<artifactId>spring-cloud-gcp-data-firestore</artifactId>
</dependency>
<dependency>
<groupId>com.google.cloud</groupId>
<artifactId>spring-cloud-gcp-starter-storage</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis-reactive</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.google.cloud</groupId>
<artifactId>google-cloud-dialogflow-cx</artifactId>
</dependency>
<dependency>
<groupId>com.google.genai</groupId>
<artifactId>google-genai</artifactId>
<version>1.8.0</version>
</dependency>
<dependency>
<groupId>com.google.protobuf</groupId>
<artifactId>protobuf-java-util</artifactId>
</dependency>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.module</groupId>
<artifactId>jackson-module-parameter-names</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.module</groupId>
<artifactId>jackson-module-parameter-names</artifactId>
<version>2.19.0</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,44 @@
package com.example;
import static org.springframework.web.reactive.function.server.RequestPredicates.GET;
import static org.springframework.web.reactive.function.server.RouterFunctions.route;
import static org.springframework.web.reactive.function.server.ServerResponse.ok;
import com.google.api.gax.core.CredentialsProvider;
import com.google.api.gax.core.NoCredentialsProvider;
import com.google.cloud.spring.data.firestore.repository.config.EnableReactiveFirestoreRepositories;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerResponse;
@SpringBootApplication
@EnableTransactionManagement
@EnableReactiveFirestoreRepositories(basePackages = "com.example.repository")
public class Orchestrator {
@Bean
@ConditionalOnProperty(
value = "spring.cloud.gcp.firestore.emulator.enabled",
havingValue = "true")
public CredentialsProvider googleCredentials() {
return NoCredentialsProvider.create();
}
public static void main(String[] args) {
SpringApplication.run(Orchestrator.class, args);
}
@Bean
public RouterFunction<ServerResponse> indexRouter(
@Value("classpath:/static/index.html") final Resource indexHtml) {
return route(GET("/"), request -> ok().contentType(MediaType.TEXT_HTML).bodyValue(indexHtml));
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,44 @@
package com.example.dto.dialogflow;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@JsonIgnoreProperties(ignoreUnknown = true)
public record ConversationSessionDTO(
String sessionId,
String userId,
String telefono,
Instant createdAt,
Instant lastModified,
List<ConversationEntryDTO> entries
) {
public ConversationSessionDTO(String sessionId, String userId, String telefono, Instant createdAt, Instant lastModified, List<ConversationEntryDTO> entries) {
this.sessionId = sessionId;
this.userId = userId;
this.telefono = telefono;
this.createdAt = createdAt;
this.lastModified = lastModified;
this.entries = Collections.unmodifiableList(new ArrayList<>(entries));
}
public static ConversationSessionDTO create(String sessionId, String userId, String telefono) {
Instant now = Instant.now();
return new ConversationSessionDTO(sessionId, userId, telefono, now, now, Collections.emptyList());
}
public ConversationSessionDTO withAddedEntry(ConversationEntryDTO newEntry) {
List<ConversationEntryDTO> updatedEntries = new ArrayList<>(this.entries);
updatedEntries.add(newEntry);
return new ConversationSessionDTO(this.sessionId, this.userId, this.telefono, this.createdAt, Instant.now(), updatedEntries);
}
public ConversationSessionDTO withTelefono(String newTelefono) {
if (newTelefono != null && !newTelefono.equals(this.telefono)) {
return new ConversationSessionDTO(this.sessionId, this.userId, newTelefono, this.createdAt, this.lastModified, this.entries);
}
return this;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,93 @@
package com.example.service;
import com.example.dto.dialogflow.ConversationEntryDTO;
import com.example.dto.dialogflow.ConversationSessionDTO;
import com.example.exception.FirestorePersistenceException;
import com.example.mapper.FirestoreConversationMapper;
import com.example.repository.FirestoreBaseRepository;
import com.google.cloud.firestore.DocumentReference;
import com.google.cloud.firestore.DocumentSnapshot;
import com.google.cloud.firestore.WriteBatch;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.util.Map;
import java.util.concurrent.ExecutionException;
@Service
public class FirestoreConversationService {
private static final Logger logger = LoggerFactory.getLogger(FirestoreConversationService.class);
private static final String CONVERSATION_COLLECTION_PATH_FORMAT = "artifacts/%s/conversations";
private final FirestoreBaseRepository firestoreBaseRepository;
private final FirestoreConversationMapper firestoreConversationMapper;
public FirestoreConversationService(FirestoreBaseRepository firestoreBaseRepository, FirestoreConversationMapper firestoreConversationMapper) {
this.firestoreBaseRepository = firestoreBaseRepository;
this.firestoreConversationMapper = firestoreConversationMapper;
}
public Mono<Void> saveEntry(String userId, String sessionId, ConversationEntryDTO newEntry, String userPhoneNumber) {
logger.info("Attempting to save conversation entry to Firestore for session {}. Type: {}", sessionId, newEntry.type().name());
return Mono.fromRunnable(() -> {
DocumentReference sessionDocRef = getSessionDocumentReference(sessionId);
WriteBatch batch = firestoreBaseRepository.createBatch();
try {
if (firestoreBaseRepository.documentExists(sessionDocRef)) {
// Update: Append the new entry using arrayUnion and update lastModified
Map<String, Object> updates = firestoreConversationMapper.createUpdateMapForSingleEntry(newEntry);
batch.update(sessionDocRef, updates);
logger.info("Appending entry to existing conversation session for user {} and session {}. Type: {}", userId, sessionId, newEntry.type().name());
} else {
// Create: Start a new session with the first entry.
// Pass userId and userPhoneNumber to the mapper to be stored as fields in the document.
Map<String, Object> newSessionMap = firestoreConversationMapper.createNewSessionMapForSingleEntry(sessionId, userId, userPhoneNumber, newEntry);
batch.set(sessionDocRef, newSessionMap);
logger.info("Creating new conversation session with first entry for user {} and session {}. Type: {}", userId, sessionId, newEntry.type().name());
}
firestoreBaseRepository.commitBatch(batch);
logger.info("Successfully committed batch for session {} to Firestore.", sessionId);
} catch (ExecutionException e) {
logger.error("Error saving conversation entry to Firestore for session {}: {}", sessionId, e.getMessage(), e);
throw new FirestorePersistenceException("Failed to save conversation entry to Firestore for session " + sessionId, e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
logger.error("Thread interrupted while saving conversation entry to Firestore for session {}: {}", sessionId, e.getMessage(), e);
throw new FirestorePersistenceException("Saving conversation entry was interrupted for session " + sessionId, e);
}
}).subscribeOn(Schedulers.boundedElastic()).then();
}
public Mono<ConversationSessionDTO> getConversationSession(String userId, String sessionId) {
logger.info("Attempting to retrieve conversation session for session {} (user ID {} for context).", sessionId, userId);
return Mono.fromCallable(() -> {
DocumentReference sessionDocRef = getSessionDocumentReference(sessionId);
try {
DocumentSnapshot documentSnapshot = firestoreBaseRepository.getDocumentSnapshot(sessionDocRef);
if (documentSnapshot != null && documentSnapshot.exists()) {
ConversationSessionDTO sessionDTO = firestoreConversationMapper.mapFirestoreDocumentToConversationSessionDTO(documentSnapshot);
logger.info("Successfully retrieved and mapped conversation session for session {}.", sessionId);
return sessionDTO;
}
logger.info("Conversation session not found for session {}.", sessionId);
return null; // Or Mono.empty() if this method returned Mono<Optional<ConversationSessionDTO>>
} catch (InterruptedException | ExecutionException e) {
logger.error("Error retrieving conversation session from Firestore for session {}: {}", sessionId, e.getMessage(), e);
throw new FirestorePersistenceException("Failed to retrieve conversation session from Firestore for session " + sessionId, e);
}
}).subscribeOn(Schedulers.boundedElastic());
}
private String getConversationCollectionPath() {
return String.format(CONVERSATION_COLLECTION_PATH_FORMAT, firestoreBaseRepository.getAppId());
}
private DocumentReference getSessionDocumentReference(String sessionId) {
String collectionPath = getConversationCollectionPath();
return firestoreBaseRepository.getDocumentReference(collectionPath, sessionId);
}
}

View File

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

View File

@@ -0,0 +1,73 @@
package com.example.service;
import com.example.dto.dialogflow.ConversationEntryDTO;
import com.example.dto.dialogflow.ConversationSessionDTO;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.ReactiveRedisTemplate;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
import java.time.Duration;
@Service
public class MemoryStoreConversationService {
private static final Logger logger = LoggerFactory.getLogger(MemoryStoreConversationService.class);
private static final String SESSION_KEY_PREFIX = "conversation:session:";
private static final String PHONE_TO_SESSION_KEY_PREFIX = "conversation:phone_to_session:";
private static final Duration SESSION_TTL = Duration.ofHours(24);
private final ReactiveRedisTemplate<String, ConversationSessionDTO> redisTemplate;
private final ReactiveRedisTemplate<String, String> stringRedisTemplate;
@Autowired
public MemoryStoreConversationService(
ReactiveRedisTemplate<String, ConversationSessionDTO> redisTemplate,
ReactiveRedisTemplate<String, String> stringRedisTemplate) {
this.redisTemplate = redisTemplate;
this.stringRedisTemplate = stringRedisTemplate;
}
public Mono<Void> saveEntry(String userId, String sessionId, ConversationEntryDTO newEntry, String userPhoneNumber) {
String sessionKey = SESSION_KEY_PREFIX + sessionId;
String phoneToSessionKey = PHONE_TO_SESSION_KEY_PREFIX + userPhoneNumber;
logger.info("Attempting to save entry to Redis for session {}. Type: {}", sessionId, newEntry.type().name());
return redisTemplate.opsForValue().get(sessionKey)
.defaultIfEmpty(ConversationSessionDTO.create(sessionId, userId, userPhoneNumber))
.flatMap(session -> {
ConversationSessionDTO sessionWithUpdatedTelefono = session.withTelefono(userPhoneNumber);
ConversationSessionDTO updatedSession = sessionWithUpdatedTelefono.withAddedEntry(newEntry);
logger.info("Attempting to set updated session {} with new entry type {} in Redis.", sessionId, newEntry.type().name());
return redisTemplate.opsForValue().set(sessionKey, updatedSession, SESSION_TTL)
.then(stringRedisTemplate.opsForValue().set(phoneToSessionKey, sessionId, SESSION_TTL));
})
.doOnSuccess(success -> logger.info("Successfully saved updated session and phone mapping to Redis for session {}. Entry Type: {}", sessionId, newEntry.type().name()))
.doOnError(e -> logger.error("Error appending entry to Redis for session {}: {}", sessionId, e.getMessage(), e))
.then();
}
public Mono<ConversationSessionDTO> getSessionByTelefono(String telefono) {
if (telefono == null || telefono.trim().isEmpty()) {
return Mono.empty();
}
String phoneToSessionKey = PHONE_TO_SESSION_KEY_PREFIX + telefono;
logger.debug("Attempting to retrieve session ID for phone number {} from Redis.", telefono);
return stringRedisTemplate.opsForValue().get(phoneToSessionKey)
.flatMap(sessionId -> {
logger.debug("Found session ID {} for phone number {}. Retrieving session data.", sessionId, telefono);
return redisTemplate.opsForValue().get(SESSION_KEY_PREFIX + sessionId);
})
.doOnSuccess(session -> {
if (session != null) {
logger.info("Successfully retrieved session {} by phone number {}.", session.sessionId(), telefono);
} else {
logger.info("No session found in Redis for phone number {}.", telefono);
}
})
.doOnError(e -> logger.error("Error retrieving session by phone number {}: {}", telefono, e.getMessage(), e));
}
}

View File

@@ -0,0 +1,71 @@
// src/main/java/com/example/util/FirestoreTimestampDeserializer.java
package com.example.util;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonToken;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.google.cloud.Timestamp;
import java.io.IOException;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Custom Jackson Deserializer for com.google.cloud.Timestamp.
* Handles deserialization from embedded objects (direct Timestamp instances),
* ISO 8601 strings, and JSON objects with "seconds" and "nanos" fields.
*/
public class FirestoreTimestampDeserializer extends JsonDeserializer<Timestamp> {
private static final Logger logger = LoggerFactory.getLogger(FirestoreTimestampDeserializer.class);
@Override
public Timestamp deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
JsonToken token = p.getCurrentToken();
if (token == JsonToken.VALUE_EMBEDDED_OBJECT) {
// This is the ideal case when ObjectMapper.convertValue gets a direct Timestamp object
Object embedded = p.getEmbeddedObject();
if (embedded instanceof Timestamp) {
logger.debug("FirestoreTimestampDeserializer: Deserializing from embedded Timestamp object: {}", embedded);
return (Timestamp) embedded;
}
} else if (token == JsonToken.VALUE_STRING) {
// Handles cases where the timestamp is represented as an ISO 8601 string
String timestampString = p.getText();
try {
logger.debug("FirestoreTimestampDeserializer: Deserializing from String: {}", timestampString);
return Timestamp.parseTimestamp(timestampString);
} catch (IllegalArgumentException e) {
logger.error("FirestoreTimestampDeserializer: Failed to parse timestamp string: '{}'", timestampString, e);
throw new IOException("Failed to parse timestamp string: " + timestampString, e);
}
} else if (token == JsonToken.START_OBJECT) {
// This is crucial for handling the "Cannot deserialize ... from Object value (token JsonToken.START_OBJECT)" error.
// It assumes the object represents { "seconds": X, "nanos": Y }
logger.debug("FirestoreTimestampDeserializer: Deserializing from JSON object.");
// Suppress the unchecked warning here, as we expect a Map<String, Number>
@SuppressWarnings("unchecked")
Map<String, Number> map = p.readValueAs(Map.class);
if (map != null && map.containsKey("seconds") && map.containsKey("nanos")) {
Number secondsNum = map.get("seconds");
Number nanosNum = map.get("nanos");
if (secondsNum != null && nanosNum != null) {
Long seconds = secondsNum.longValue();
Integer nanos = nanosNum.intValue();
return Timestamp.ofTimeSecondsAndNanos(seconds, nanos);
}
}
logger.error("FirestoreTimestampDeserializer: JSON object missing 'seconds' or 'nanos' fields, or fields are not numbers.");
}
// If none of the above formats match, log an error and delegate to default handling
logger.error("FirestoreTimestampDeserializer: Unexpected token type for Timestamp deserialization. Expected Embedded Object, String, or START_OBJECT. Got: {}", token);
// This will likely re-throw an error indicating inability to deserialize.
return (Timestamp) ctxt.handleUnexpectedToken(Timestamp.class, p);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
spring.cloud.gcp.firestore.database-id=firestoredb

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<!-- encoders are assigned the type
ch.qos.logback.classic.encoder.PatternLayoutEncoder by default -->
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="STDOUT" />
</root>
</configuration>