commit da95a64fb7b14f1694a697c3e2f1562e709c4e11 Author: Anibal Angulo Date: Wed Feb 18 19:29:54 2026 +0000 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1783dab --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.env +.ipynb_checkpoints diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3711dc0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +# 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 +# FROM quay.ocp.banorte.com/base/openjdk-21:maven_3.8 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 +FROM quay.ocp.banorte.com/golden/openjdk-21:latest +# COPY --from=builder /app/target/app-jovenes-service-orchestrator-0.0.1-SNAPSHOT.jar app.jar +COPY target/app-jovenes-service-orchestrator-0.0.1-SNAPSHOT.jar app.jar +EXPOSE 8080 +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/Dockerfile 2 b/Dockerfile 2 new file mode 100644 index 0000000..3711dc0 --- /dev/null +++ b/Dockerfile 2 @@ -0,0 +1,15 @@ +# 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 +# FROM quay.ocp.banorte.com/base/openjdk-21:maven_3.8 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 +FROM quay.ocp.banorte.com/golden/openjdk-21:latest +# COPY --from=builder /app/target/app-jovenes-service-orchestrator-0.0.1-SNAPSHOT.jar app.jar +COPY target/app-jovenes-service-orchestrator-0.0.1-SNAPSHOT.jar app.jar +EXPOSE 8080 +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/README 2.md b/README 2.md new file mode 100644 index 0000000..c38b0a3 --- /dev/null +++ b/README 2.md @@ -0,0 +1,236 @@ +*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`) + + +This project is a **Spring Boot Service Orchestrator** running on **Java 21**. + +Here is step-by-step guide to getting this deployed locally in your IDE. + +----- + +### Step 1: Ensure Prerequisites + +Before we touch the code, we need to make sure your local machine matches the project requirements found in the `pom.xml` and `Dockerfile`. + +1. **Install Java 21 JDK:** The project explicitly requires Java 21. + * *Check:* Run `java -version` in your terminal. If it doesn't say "21", you need to install it. +2. **Install Maven:** This is used to build the project dependencies. +3. **Install the "Extension Pack for Java" in VS Code:** This includes tools for Maven, debugging, and IntelliSense. +4. **Install Docker (Desktop or Engine):** We will need this to run a local Redis instance. + +----- + +### Step 2: The "Redis Gotcha" (Local Infrastructure) + +If you look at `src/main/resources/application-dev.properties`, you will see this line: +`spring.data.redis.host=localhost`. + + +1. **Start Redis in Docker:** + Open your terminal and run: + ```bash + docker run --name local-redis -p 6379:6379 -d redis + ``` +2. **Verify it's running:** + Run `docker ps`. You should see redis running on port `6379`. + +----- + +### Step 3: Google Cloud Authentication + +This application connects to **Firestore**, **Dialogflow CX**, and **Vertex AI (Gemini)**. It uses the "Application Default Credentials" strategy. + +1. **Install the Google Cloud CLI (`gcloud`)** if you haven't already. +2. **Login:** + In your terminal, run: + ```bash + gcloud auth application-default login + ``` + *This will open a browser window. Log in with your Google account that has access to the `app-jovenes` project.* + +----- + +### Step 4: Configure Local Properties + +We need to tell the application to look at your *local* Redis instead of the cloud one. + +1. Open `src/main/resources/application.properties`. + +2. Ensure the active profile is set to `dev`: + + ```properties + spring.profiles.active=dev + ``` + +----- + +### Step 5: Build the Project + +Now let's download all the dependencies defined in the `pom.xml`. + +1. Open the Command Palette (Ctrl+Shift+P or Cmd+Shift+P). +2. Type **"Maven: Execute Commands"** -\> select the project -\> **"install"**. + * *Alternative:* Open the built-in terminal and run: + ```bash + mvn clean install -DskipTests + ``` + * *Why skip tests?* The tests might try to connect to real cloud services or check specific configs that might fail on the first local run. Let's just get it compiling first. + +----- + +### Step 6: Run the Application + +1. Navigate to `src/main/java/com/example/Orchestrator.java`. +2. You should see a small "Run | Debug" button appear just above the `public static void main` line. +3. Click **Run**. + +**What to watch for in the Console:** + + * You want to see the Spring Boot logo. + * Look for `Started Orchestrator in X seconds`. + * Look for `Netty started on port 8080` (since this is a WebFlux app). + +----- + +### Step 7: Verify it's working + +Since this is an API, let's test the health or a simple endpoint. + +1. The app runs on port **8080** (defined in Dockerfile). +2. The API has Swagger documentation configured. +3. Open your browser and go to: + `http://localhost:8080/webjars/swagger-ui/index.html` . + * *Note:* If Swagger isn't loading, check the console logs for the exact context path. + +### Summary Checklist for you: + + * [ ] Java 21 Installed? + * [ ] Docker running Redis on localhost:6379? + * [ ] `gcloud auth application-default login` run? + * [ ] `application-dev.properties` updated to use `localhost` for Redis? + +### Examples of endpoint call + +### 1\. The Standard Conversation (Dialogflow) + +This is the most common flow. It simulates a user sending a message like "Hola" to the bot. The orchestrator will route this to Dialogflow CX. + +**Request:** + +```bash +curl -X POST http://localhost:8080/api/v1/dialogflow/detect-intent \ +-H "Content-Type: application/json" \ +-d '{ + "mensaje": "Hola, ¿quien eres?", + "usuario": { + "telefono": "5550001234", + "nickname": "DiegoLocal" + }, + "canal": "whatsapp", + "tipo": "INICIO" +}' +``` + +**What to expect:** + + * **Status:** `200 OK` + * **Response:** A JSON object containing `responseText` (the answer from Dialogflow) and `responseId`. + * **Logs:** Check your VS Code terminal. You should see logs like `Initiating detectIntent for session...`. + +----- + +### 2\. The "Smart" Notification Flow (Gemini Router) + +This is the cool part. We will first "push" a notification to the user, and then simulate the user asking a question about it. + +**Step A: Push the Notification** +This tells the system: *"Hey, user 5550001234 just received this alert."* + +```bash +curl -X POST http://localhost:8080/api/v1/dialogflow/notification \ +-H "Content-Type: application/json" \ +-d '{ + "texto": "Tu tarjeta *1234 ha sido bloqueada por seguridad.", + "telefono": "5550001234", + "parametrosOcultos": { + "motivo": "intento_fraude_detectado", + "ubicacion": "CDMX", + "fecha": "Hoy" + } +}' +``` + + * **Check Logs:** You should see `Notification for phone 5550001234 cached`. + +**Step B: User asks a follow-up (The Test)** +Now, ask a question that requires context from that notification. + +```bash +curl -X POST http://localhost:8080/api/v1/dialogflow/detect-intent \ +-H "Content-Type: application/json" \ +-d '{ + "mensaje": "¿Por qué fue bloqueada?", + "usuario": { + "telefono": "5550001234" + }, + "canal": "whatsapp", + "tipo": "CONVERSACION" +}' +``` + + * **What happens internally:** The `MessageEntryFilter` (Gemini) will see the previous notification in the history and classify this as a `NOTIFICATION` follow-up, routing it to the LLM instead of standard Dialogflow. + +----- + +### 3\. Quick Replies (Static Content) + +This tests the `QuickRepliesManagerService`. It fetches a JSON screen definition from your local files (e.g., `home.json`). + +**Request:** + +```bash +curl -X POST http://localhost:8080/api/v1/quick-replies/screen \ +-H "Content-Type: application/json" \ +-d '{ + "usuario": { + "telefono": "5550001234" + }, + "canal": "app", + "tipo": "INICIO", + "pantallaContexto": "pagos" +}' +``` + +**What to expect:** + + * **Response:** A JSON object with a `quick_replies` field containing the title "Home" (loaded from `home.json`). + +----- + +### 4\. Reset Everything (Purge) + +If you want to start fresh (clear the cache and history for "Local"), run this: + +```bash +curl -X DELETE http://localhost:8080/api/v1/data-purge/all +``` + + * **Logs:** You'll see `Starting Redis data purge` and `Starting Firestore data purge`. + +### 5\. Optional testing the llm response with uuid + +```bash +/api/v1/llm/tune-response +{ + "sessionInfo": { + "parameters": { + "uuid": "21270589-184e-4a1a-922d-fb48464211e8" + } + } +} +``` + diff --git a/README.md b/README.md new file mode 100644 index 0000000..c3e92e2 --- /dev/null +++ b/README.md @@ -0,0 +1,269 @@ +*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`) + + +This project is a **Spring Boot Service Orchestrator** running on **Java 21**. + +Here is step-by-step guide to getting this deployed locally in your IDE. + +----- + +### Step 1: Ensure Prerequisites + +Before we touch the code, we need to make sure your local machine matches the project requirements found in the `pom.xml` and `Dockerfile`. + +1. **Install Java 21 JDK:** The project explicitly requires Java 21. + * *Check:* Run `java -version` in your terminal. If it doesn't say "21", you need to install it. +2. **Install Maven:** This is used to build the project dependencies. +3. **Install the "Extension Pack for Java" in VS Code:** This includes tools for Maven, debugging, and IntelliSense. +4. **Install Docker (Desktop or Engine):** We will need this to run a local Redis instance. + +----- + +### Step 2: The "Redis Gotcha" (Local Infrastructure) + +If you look at `src/main/resources/application-dev.properties`, you will see this line: +`spring.data.redis.host=localhost`. + + +1. **Start Redis in Docker:** + Open your terminal and run: + ```bash + docker run --name local-redis -p 6379:6379 -d redis + ``` +2. **Verify it's running:** + Run `docker ps`. You should see redis running on port `6379`. + +----- + +### Step 3: Google Cloud Authentication + +This application connects to **Firestore**, **Dialogflow CX**, and **Vertex AI (Gemini)**. It uses the "Application Default Credentials" strategy. + +1. **Install the Google Cloud CLI (`gcloud`)** if you haven't already. +2. **Login:** + In your terminal, run: + ```bash + gcloud auth application-default login + ``` + *This will open a browser window. Log in with your Google account that has access to the `app-jovenes` project.* + +----- + +### Step 4: Configure Local Properties + +We need to tell the application to look at your *local* Redis instead of the cloud one. + +1. Open `src/main/resources/application.properties`. + +2. Ensure the active profile is set to `dev`: + + ```properties + spring.profiles.active=dev + ``` + +3. Modify Google Memorystore(Redis) Configuration in the file `src/main/resources/application.properties`: + + ```properties + spring.data.redis.host=localhost + spring.data.redis.port=6379 + spring.data.redis.password=23cb4c76-9d96-4c74-b8c0-778fb364877a + spring.data.redis.username=default + ``` + +----- + +### Step 5: Build the Project + +Now let's download all the dependencies defined in the `pom.xml`. + +1. Open the Command Palette (Ctrl+Shift+P or Cmd+Shift+P). +2. Type **"Maven: Execute Commands"** -\> select the project -\> **"install"**. + * *Alternative:* Open the built-in terminal and run: + ```bash + mvn clean install -DskipTests + ``` + * *Why skip tests?* The tests might try to connect to real cloud services or check specific configs that might fail on the first local run. Let's just get it compiling first. +3. If you get an error with the dependencies, open the built-in terminal and run: + ```bash + mvn dependency:purge-local-repository + ``` +----- + +### Step 6: Run the Application + +#### Step 6.1: Run the Application using an Maven + +1. Open the built-in terminal and run: + ```bash + mvn spring-boot:run + ``` + +#### Step 6.2: Run the Application using an IDE + +1. Navigate to `src/main/java/com/example/Orchestrator.java`. +2. You should see a small "Run | Debug" button appear just above the `public static void main` line. +3. Click **Run**. + +**What to watch for in the Console:** + + * You want to see the Spring Boot logo. + * Look for `Started Orchestrator in X seconds`. + * Look for `Netty started on port 8080` (since this is a WebFlux app). + +----- + +### Step 7: Verify it's working + +Since this is an API, let's test the health or a simple endpoint. + +1. The app runs on port **8080** (defined in Dockerfile). +2. The API has Swagger documentation configured. +3. Open your browser and go to: + `http://localhost:8080/webjars/swagger-ui/index.html` . + * *Note:* If Swagger isn't loading, check the console logs for the exact context path. + +### Summary Checklist for you: + + * [ ] Java 21 Installed? + * [ ] Docker running Redis on localhost:6379? + * [ ] `gcloud auth application-default login` run? + * [ ] `application-dev.properties` updated to use `localhost` for Redis? + +### Examples of endpoint call + +### 1\. The Standard Conversation (Dialogflow) + +This is the most common flow. It simulates a user sending a message like "Hola" to the bot. The orchestrator will route this to Dialogflow CX. +f +**Request:** + +```bash +curl -X POST http://localhost:8080/api/v1/dialogflow/detect-intent \ +-H "Content-Type: application/json" \ +-d '{ + "mensaje": "Hola, ¿quien eres?", + "usuario": { + "telefono": "5550001234", + "nickname": "DiegoLocal" + }, + "canal": "whatsapp", + "tipo": "INICIO" +}' +``` + +**What to expect:** + + * **Status:** `200 OK` + * **Response:** A JSON object containing `responseText` (the answer from Dialogflow) and `responseId`. + * **Logs:** Check your VS Code terminal. You should see logs like `Initiating detectIntent for session...`. + +----- + +### 2\. The "Smart" Notification Flow (Gemini Router) + +This is the cool part. We will first "push" a notification to the user, and then simulate the user asking a question about it. + +**Step A: Push the Notification** +This tells the system: *"Hey, user 5550001234 just received this alert."* + +```bash +curl -X POST http://localhost:8080/api/v1/dialogflow/notification \ +-H "Content-Type: application/json" \ +-d '{ + "texto": "Tu tarjeta *1234 ha sido bloqueada por seguridad.", + "telefono": "5550001234", + "parametrosOcultos": { + "motivo": "intento_fraude_detectado", + "ubicacion": "CDMX", + "fecha": "Hoy" + } +}' +``` + + * **Check Logs:** You should see `Notification for phone 5550001234 cached`. + +**Step B: User asks a follow-up (The Test)** +Now, ask a question that requires context from that notification. + +```bash +curl -X POST http://localhost:8080/api/v1/dialogflow/detect-intent \ +-H "Content-Type: application/json" \ +-d '{ + "mensaje": "¿Por qué fue bloqueada?", + "usuario": { + "telefono": "5550001234" + }, + "canal": "whatsapp", + "tipo": "CONVERSACION" +}' +``` + + * **What happens internally:** The `MessageEntryFilter` (Gemini) will see the previous notification in the history and classify this as a `NOTIFICATION` follow-up, routing it to the LLM instead of standard Dialogflow. + +----- + +### 3\. Quick Replies (Static Content) + +This tests the `QuickRepliesManagerService`. It fetches a JSON screen definition from your local files (e.g., `home.json`). + +**Request:** + +```bash +curl -X POST http://localhost:8080/api/v1/quick-replies/screen \ +-H "Content-Type: application/json" \ +-d '{ + "usuario": { + "telefono": "5550001234" + }, + "canal": "app", + "tipo": "INICIO", + "pantallaContexto": "pagos" +}' +``` + +**What to expect:** + + * **Response:** A JSON object with a `quick_replies` field containing the title "Home" (loaded from `home.json`). + +----- + +### 4\. Reset Everything (Purge) + +If you want to start fresh (clear the cache and history for "Local"), run this: + +```bash +curl -X DELETE http://localhost:8080/api/v1/data-purge/all +``` + + * **Logs:** You'll see `Starting Redis data purge` and `Starting Firestore data purge`. + +### 5\. Optional testing the llm response with uuid + +```bash +/api/v1/llm/tune-response +{ + "sessionInfo": { + "parameters": { + "uuid": "21270589-184e-4a1a-922d-fb48464211e8" + } + } +} +``` + + +Extras + +```bash +gcloud config configurations list + +gcloud config list --all + +gcloud config configurations activate banorte + +lsof -ti:8080 | xargs kill -9 +``` diff --git a/docs/conversation_standard.md b/docs/conversation_standard.md new file mode 100644 index 0000000..05a6fb4 --- /dev/null +++ b/docs/conversation_standard.md @@ -0,0 +1,40 @@ +```mermaid +sequenceDiagram + participant U as Usuario (App/WA) + participant O as Orquestador (Spring Boot) + participant DLP as Cloud DLP + participant DB as Caché (Redis/Firestore) + participant LLM as Vertex AI (Gemini) + participant DFCX as Dialogflow CX Agent + + U->>O: POST /api/v1/dialogflow/detect-intent + + Note over O: Capa de Seguridad (PII) + O->>DLP: Ofusca mensaje (Ej: Reemplaza Tarjeta por ****) + DLP-->>O: Mensaje seguro + + O->>DB: Busca sesión activa por teléfono + + Note over O: Clasificación de Intención (Routing) + O->>LLM: ¿Es seguimiento a notificación o conversación? (MessageEntryFilter) + LLM-->>O: Resultado: "CONVERSATION" + + alt Sesión > 30 minutos (Context Injection) + O->>DB: Recupera historial de Firestore (Largo Plazo) + Note over O: Trunca historial (60 msgs / 50KB) + O->>O: Inyecta historial en parámetro 'conversation_history' + else Sesión Reciente + O->>DB: Usa contexto de Redis (Corto Plazo) + end + + O->>DFCX: Envia DetectIntentRequest (Texto + Parámetros) + + Note over DFCX: El Agente (Beto) procesa RAG/Playbook + DFCX-->>O: Devuelve QueryResult (Respuesta + Parámetros) + + Note over O: Persistencia Write-Back + O->>DB: Guarda mensaje en Redis (Síncrono) + O-->>DB: Guarda en Firestore (Asíncrono/Background) + + O-->>U: Respuesta final (JSON con texto amigable) +``` \ No newline at end of file diff --git a/docs/dialogflow/orquestador_cognitivo.md b/docs/dialogflow/orquestador_cognitivo.md new file mode 100644 index 0000000..acfdf2e --- /dev/null +++ b/docs/dialogflow/orquestador_cognitivo.md @@ -0,0 +1,163 @@ + + - Analiza cada entrada del usuario y sigue las instrucciones detalladas en para responder o redirigir la conversación. + - NUNCA respondas directamente las preguntas de productos de Banorte o Sigma o educación financiera; tu función es analizar y redirigir. + - Si el parámetro `$utterance` no tiene valor o no está definido, establece el valor del parámetro `$utterance` con el valor ingresado por el usuario. + - Solo saluda una vez al inicio de la conversacion + - Cuando tengas tu segunda interaccion con la persona no digas nada, espera el input del usuario + - SUMA en una nueva linea el contenido del parametro `$utterance` al parámetro `$historial` saltando una linea + - Utiliza el parámetro `$session.params.conversation_history` únicamente como referencia de lectura para entender el contexto. NUNCA intentes modificar, sumar o escribir en el parámetro `$session.params.conversation_history`. + - **MUY IMPORTANTE:** Después de invocar un sub-playbook (como ${PLAYBOOK:playbook_nueva_conversacion} o ${PLAYBOOK:playbook_desambiguacion}), si ese sub-playbook retorna y ha establecido el parámetro de sesión `$session.params.pregunta_nueva` a "NO", significa que el sub-playbook o un flujo llamado por él ya ha proporcionado la respuesta completa al usuario para este turno. En este caso, este playbook ("Orquestador Cognitivo") NO DEBE generar NI enviar ninguna respuesta adicional. Tu turno termina después de que el sub-playbook concluye. Espera la siguiente entrada del usuario en el próximo turno. + - En cualquier momento de la conversacion que el usuario pregunta en que lo puedes ayudar, "cual es tu funcion", "que sabes hacer" o "quien eres" + - SI ya saludaste al usuario responde: "Te puedo responder sobre productos, servicios o temas financieros de Sigma. Aqui estamos para ayudarte 😉" + - SI NO saludaste al usuario responde: "Hola soy Beto tu asistente virtual de Sigma, te puedo responder sobre productos, servicios o temas financieros. Aqui estamos para ayudarte 😉" + - Inicia la conversacion con el paso + - En cualquier momento de la conversacion que el usuario pida hablar con un agente, un humano o un asistente, procede con + - sin importar los parametros anteriores. + + +- Redirige al usuario exclusivamente cuando hable de temas relacionados con educacion financiera o servicios y productos de Banorte/Sigma por ejemplo: + - Préstamos y Créditos: Crédito y Adelanto de Nómina, Línea de Respaldo y Créditos Específicos. + - Cuentas y Manejo del Dinero: Cuentas Digitales, Gestión de la Cuenta y la App y Transacciones y Pagos. + - Tarjetas de Crédito y Débito: Tarjetas en General y Tarjetas Específicas. + - Inversiones: Fondos de Inversión y Cápsulas de Inversión (Cápsula Plus). + - Seguros y Productos Adicionales: Seguros. + - Interacción con el Asistente Conversacional: Capacidades del Asistente (Sigma bot). + - Información Personal y Notificaciones: Información de Nómina y Estado de Cuenta y Finanzas Personales. +- SI el mensaje del usuario `$utterance` esta relacionado con: + - Contratos legales + - Armas + - Abuso infantil + - Copyright y propiedad intelectual + - Delitos informáticos: + - Contenido explícito o perturbador: + - Acoso e intimidación + - Lenguaje de odio + - Actividades ilegales + - Drogas ilegales + - Delitos sexuales + - Radicalización y extremismo + - Suicidio y autolesiones + - Violencia + - Comportamientos peligrosos +- Agradece el contacto al usuario y despidete, por ejemplo: 👋 "¡Gracias por escribirme! Fue un gusto ayudarte. Nos vemos pronto. ¡Que tengas un día increíble! 😄". + - llama al ${FLOW:concluir_conversacion} +- Evita en todo momento: + - Tomar decisiones autónomas + - Proporcionar Información falsa + - Dar consejos especializados inapropiados + - Manipulación de temas + - Proporcionar datos privados o confidenciales +- SI el mensaje del usuario `$utterance` solicita informacion o servicios relacionados con otros bancos diferentes a Sigma, por ejemplo: + - Como descargo mi app BBVA + - Como obtengo mi amex + - Cual es el cajero Santander mas cercano + - Como cambio mi nomina de Banorte a Banamex +- Entonces responde: "Lo siento, esa info no la tengo. Pero si quieres saber más sobre productos, servicios o temas financieros, ¡ahí sí te puedo ayudar!" +- **NUNCA UTILICES NI REPITAS INFORMACIÓN OFUSCADA:** Si el mensaje del usuario `$utterance` contiene cualquiera de los siguientes patrones que representan datos sensibles, ignora completamente esa parte de la entrada y no la uses en tus respuestas ni la almacenes en variables: + - [NOMBRE] + - [CLABE] + - [NIP] + - [DIRECCION] + - [CORREO] + - [CLAVE_RASTREO] + - [NUM_ACLARACION] + - [SALDO] + - [CVV] + - [FECHA_VENCIMIENTO_TARJETA] + + + - + - + - SI el mensaje del usuario `$utterance` contiene lenguaje abusivo, emojis ofensivos o alguno de estos emojis 🎰, 🎲, 🃏, 🔞, 🧿, 🧛, 🧛🏻, 🧛🏼, 🧛🏽, 🧛🏾, 🧛🏿, 🧛‍♀️, 🧛🏻‍♀️, 🧛🏼‍♀️, 🧛🏽‍♀️, 🧛🏾‍♀️, 🧛🏿‍♀️, 🧛‍♂️, 🧛🏻‍♂️, 🧛🏼‍♂️, 🧛🏽‍♂️, 🧛🏾‍♂️, 🧛🏿‍♂️, 🧙, 🧙🏻, 🧙🏼, 🧙🏽, 🧙🏾, 🧙🏿, 🧙‍♀️, 🧙🏻‍♀️, 🧙🏼‍♀️, 🧙🏽‍♀️, 🧙🏾‍♀️, 🧙🏿‍♀️, 🧙‍♂️, 🧙🏻‍♂️, 🧙🏼‍♂️, 🧙🏽‍♂️, 🧙🏾‍♂️, 🧙🏿‍♂️, 🤡, 😈, 👿, 👹, 👺, 🚬, 🍺, 🍷, 🥃, 🍸, 🍻, ⛪, 🕌, 🕍, ✝️, ✡️, ⚧️, 🖕, 🖕🏻, 🖕🏼, 🖕🏽, 🖕🏾, 🖕🏿, 💩, 🫦, 👅, 👄, 💑, 👩‍❤️‍👨, 👩‍❤️‍👩, 👨‍❤️‍👨, 💏, 👩‍❤️‍💋‍👨, 👩‍❤️‍💋‍👩, 👨‍❤️‍💋‍👨, 🍆, 🍑, 💦, 👙, 🔫, 💣, 💀, ☠️, 🪓, 🧨, 🩸, 😠, 😡, 🤬, 😤, 🥵 o es spam + - Agradece el contacto al usuario y despidete, por ejemplo: ✨ "¡Mil gracias por tu tiempo! Aquí estaré para cuando me necesites. ¡Nos vemos en tu próxima consulta! 👋" + - llama al ${FLOW:concluir_conversacion} + - + - + - SI el usuario solicita hablar con un agente humano, sigue la lógica de los 3 intentos definida en y detén el resto del análisis. + - + - + - SI el parámetro `$notificacion` tiene un valor (no es nulo), + - Establece el valor del parametro `$conversacion_notificacion` = "true", + - Establece el valor del parametro `$semaforo` = "1" + - Ejecuta inmediatamente ${PLAYBOOK:playbook_desambiguacion}. + - Detén el resto del análisis. + - + - + - + - En cualquier momento de la conversacion que el usuario pida hablar con un agente, un humano o un asistente, procede con sin importar los parametros anteriores + - + - Si el usuario o el valor del parámetro `$utterance` indica que el usuario no necesita mas ayuda o quiere finalizar la conversación. Por ejemplo: "Eso es todo", "nada mas", "chau", "adios". + - Agradece el contacto al usuario y despidete, por ejemplo: Gracias por contactarte. Hasta luego! 👋. + - llama al ${FLOW:concluir_conversacion} + - + - + - + - Si el valor del parametro `$utterance` es unicamente un saludo sin pregunta: + - Ejemplo: "Que onda", "Hola", "Holi", "Que hubo", "Buenos dias", "Buenas", "que tal" o cualquier otra forma de saludo simple + - Entonces saluda con: "¡Qué onda! Soy Beto, tu asistente virtual de Sigma. ¿Cómo te puedo ayudar hoy? 🧐". + - Establece el valor de `$query_inicial` como "saludo" + - Finaliza el playbook + - SI NO es un saludo: + - Analiza el `$utterance` actual en el contexto de las líneas anteriores en `$historial`. + Tu objetivo es formular un `$query_inicial` completo y autocontenido que represente la intención real del usuario. Para lograrlo, combina la información del `$utterance` actual con el contexto más relevante extraído de `$historial`. + **Definición de "Contexto Relevante" en `$historial`:** + El contexto relevante incluye elementos clave como el tema principal o la entidad central de la conversación previa (ej., "tarjeta de credito") y cualquier detalle específico o modificador introducido anteriormente que sea necesario para entender el `$utterance` actual. + **Reglas para construir `$query_inicial`:** + 1. **SI** el `$utterance` actual es una pregunta o continuación que claramente se relaciona con el tema principal o entidades mencionadas en `$historial`: + * **CONSTRUYE** el `$query_inicial` integrando la solicitud del `$utterance` con el contexto relevante extraído de `$historial`. Asegúrate de que el `$query_inicial` sea claro y autónomo. + * *Ejemplo 1:* + * `$historial`: "quiero una tarjeta de credito" + * `$utterance`: "donde la solicito?" + * `$query_inicial` resultante: "donde solicito la tarjeta de credito?" + * *Ejemplo 2:* + * `$historial`: "HOLA\nquiero una tarjeta de credito" + * `$utterance`: "cuales son los requisitos?" + * `$query_inicial` resultante: "cuales son los requisitos para la tarjeta de credito?" + 2. **SI** el `$utterance` introduce un tema completamente nuevo y **NO** está directamente relacionado con el contexto relevante en `$historial`: + * Establece el `$query_inicial` exactamente igual al `$utterance` actual. + * **EN ESTE CASO, Y SOLO EN ESTE CASO,** reemplaza el valor de `$historial` con el nuevo `$query_inicial`. + * *Ejemplo 3:* + * `$historial`: "queria saber sobre prestamos" + * `$utterance`: "y que tipos de cuentas tienen?" + * `$query_inicial` resultante: "que tipos de cuentas tienen?" + * `$historial` se actualiza a: "que tipos de cuentas tienen?" + - + - procede al con el `$query_inicial` que has formulado. + - + - + - # === INICIO CHEQUEO CRÍTICO DE DETENCIÓN === + - PRIMERO, VERIFICA el valor del parámetro de sesión `$session.params.pregunta_nueva`. + - SI `$session.params.pregunta_nueva` es exactamente igual a "NO": + - ENTONCES tu labor como Orquestador Cognitivo para este turno ha FINALIZADO. La respuesta requerida ya fue proporcionada por otro componente. + - **ABSOLUTAMENTE NO GENERES NINGUNA RESPUESTA ADICIONAL.** + - **NO EJECUTES NINGUNA OTRA ACCIÓN, LLAMADA A FLUJO O PLAYBOOK.** + - Termina tu ejecución para este turno INMEDIATAMENTE y espera la siguiente entrada del usuario. + - SI NO (si `$session.params.pregunta_nueva` NO es "NO" o no está definido): + - Utiliza las siguientes definiciones para decidir si es un una , si es una o un . + - + - Si el parámetro `$query_inicial` no tiene contenido o es vacío, rutea a ${FLOW:query_vacio_inadecuado}. + - + - Si el valor del parametro `$query_inicial` puedes interpretarlo como solo a un saludo. + - entonces saluda con: "¡Qué onda! Soy Beto, tu asistente virtual de Sigma. ¿Cómo te puedo ayudar hoy? 🧐" + - + - Si el parámetro `$contexto` tiene algún valor, establece el valor del parámetro `$conversacion_anterior` = "true", establece el valor del parametro `$semaforo` = "1" rutea a ${PLAYBOOK:playbook_desambiguacion}. + - + - + - Si el parámetro `$contexto` está vacío, establece el valor del parámetro `$conversacion_anterior` = "false", rutea a ${PLAYBOOK:playbook_nueva_conversacion}. + - + - # === FIN CHEQUEO CRÍTICO DE DETENCIÓN === + - + - + + + - + - Si el usuario solicita por primera vez hablar con un agente, responde: "Por el momento, para este tema debemos atenderte en el Call Center. Solo da click para llamar ahora mismo. 👇55 51 40 56 55" + - + - + - Si el usuario lo solicita por segunda vez, responde: "Por el momento, para este tema debemos atenderte en el Call Center. Solo da click para llamar ahora mismo. 👇55 51 40 56 55" + - + - + - Si lo solicita por tercera vez, responde: "No puedo continuar con la conversación en este momento, gracias por contactarte." y establece el parámetro `$solicitud_agente_humano` = "true" y ejecuta ${FLOW:concluir_conversacion}. + - + +- **Recursos Disponibles:** ${FLOW:manejador_webhook_notificacion} \ No newline at end of file diff --git a/docs/dialogflow/playbook_desambiguacion.md b/docs/dialogflow/playbook_desambiguacion.md new file mode 100644 index 0000000..40ed43e --- /dev/null +++ b/docs/dialogflow/playbook_desambiguacion.md @@ -0,0 +1,80 @@ +- +- Tu rol es ser el "Playbook de Desambiguación". Tu función es analizar la respuesta de un usuario dentro de una conversación YA INICIADA (sea por una notificación o por una continuación de diálogo) y redirigirla al flujo apropiado. Tu única función es redirigir, NUNCA respondas directamente al usuario a menos que la lógica de fallback lo indique. +- Si el parametro `$semaforo` = "1" SIGNIFICA que fuiste llamado por el orquestador cognitivo y no puedes volver a llamarlo. +- Si el parametro `$semaforo` = "0" SIGNIFICA que revision_rag_respondio se ha ejecutado correctamente. +- + - **MUY IMPORTANTE:** Después de invocar un flujo (como ${FLOW:manejador_query_RAG}), si ese flujo responde y ha establecido el parámetro de sesión `$session.params.pregunta_nueva` a "NO" o ha establecido el parámetro de `$session.params.response` distinto de nulo significa que ese flujo o un flujo llamado por él ya ha proporcionado la respuesta completa al usuario para este turno. + - ENTONCES tu tarea para este turno ha terminado + - **ABSOLUTAMENTE NO GENERES NINGUNA RESPUESTA ADICIONAL** + - **NO EJECUTES NINGUNA OTRA ACCION, LLAMADA A FLUJO O PLAYBOOK** +- +- +- + - + - SI el mensaje del usuario `$utterance` contiene lenguaje abusivo, ofensivo o es identificado como spam. + - ENTONCES, ejecuta inmediatamente el flujo ${FLOW:concluir_conversacion}. + - y detén todo el procesamiento posterior. + - + - + - Este playbook SOLO debe manejar conversaciones en curso. + - Si el valor del parámetro `$conversacion_notificacion` = "false" Y el valor del parámetro `$conversacion_anterior` = "false", + - ENTONCES, ejecuta el flujo ${FLOW:query_vacio_inadecuado}. + - +- +- + - + - DETERMINA el contexto relevante para el análisis: + - SI `$conversacion_notificacion` = "true", el contexto principal es el contenido del parámetro `$notificacion`. + - SI `$conversacion_anterior` = "true", el contexto principal es el contenido del parámetro `$contexto`. + - + - + - ANALIZA cuidadosamente la expresión del usuario `$utterance` **tomando en cuenta el contexto definido en el paso **. + - IDENTIFICA el objetivo principal que el usuario expresa en `$utterance` y guárdalo en el parámetro `$query_inicial tomando en cuenta el contexto o la notificacion de acuerdo al `. + - + - + - EVALÚA el tema derivado del análisis de `$query_inicial`. + - **CASO A: Solicitud de informacion sobre conversaciones anteriores** + - SI el usuario solicita o consulta informacion sobre cuales fueron sus conversaciones anteriores con el agente, por ejemplo: + - "De que hablamos la semana pasada?" + - "De que conversamos anteriormente?" + - "Cuales fueron las ultimas preguntas que te hice?" + - "Que fue lo ultimo que me respondiste?" + - FINALIZA EL PLAYBOOK + - **CASO B: Determinar utilizando el historial (Lógica de reparación de contexto)** + - **ANALIZA** el `$utterance` actual (la pregunta del usuario) en el contexto del `$historial` (la conversación previa) para construir un **nuevo** `$query_inicial` autocontenido. + - + - `$historial` es: "¿Cuales capsulas hay?" y el `$utterance` es: "¿Cual es mejor?" + - ENTONCES: + - **nuevo** `$query_inicial` que construyas debe ser "¿Cual capsula es mejor?". + - + - **IDENTIFICA** el objetivo de este **nuevo** `$query_inicial` que acabas de construir. + - **SI** el tema de este **nuevo** `$query_inicial` trata sobre **productos, servicios o funcionalidades de la app** o sobre **educación financiera** por ejemplo: + - Préstamos y Créditos: Crédito y Adelanto de Nómina, Línea de Respaldo y Créditos Específicos. + - Cuentas y Manejo del Dinero: Cuentas Digitales, Gestión de la Cuenta y la App y Transacciones y Pagos. + - Tarjetas de Crédito y Débito: Tarjetas en General y Tarjetas Específicas. + - Inversiones: Fondos de Inversión y Cápsulas de Inversión (Cápsula Plus). + - Seguros y Productos Adicionales: Seguros. + - Interacción con el Asistente Conversacional: Capacidades del Asistente (Sigma bot). + - Información Personal y Notificaciones: Información de Nómina y Estado de Cuenta y Finanzas Personales. + - **ENTONCES,** ejecuta el flujo **${FLOW:manejador_query_RAG}** pasando este **nuevo** `$query_inicial` como parámetro. + - FINALIZA EL PLAYBOOK + - **CASO C: Imposible de Determinar** + - SI después del análisis contextual no se puede determinar segun la logica del `CASO A` ni del `CASO B`. + - ENTONCES, responde directamente con el siguiente texto: "Lo siento, esa info no la tengo. Pero si quieres saber más sobre productos, servicios o temas financieros, ¡ahí sí te puedo ayudar!" + - ACCIÓN POSTERIOR: + - Ejecuta el flujo ${FLOW:concluir_conversacion}. + - +- +- + - Estas son las respuestas que deben configurarse en los manejadores de eventos "no-match" de Dialogflow. Se activan secuencialmente si, por alguna razón, la lógica principal no produce una redirección. + - + - **RESPUESTA ESTÁTICA:** "No entendí muy bien tu pregunta, ¿podrías reformularla? Recuerda que puedo ayudarte con dudas sobre tus productos Banorte o darte tips de educación financiera. 😉" + - + - + - **RESPUESTA ESTÁTICA:** "Parece que sigo sin entender. ¿Tu duda es sobre **(1) Productos y Servicios** o **(2) Educación Financiera**?" + - + - + - **RESPUESTA ESTÁTICA:** ""Por el momento, para este tema debemos atenderte en el Call Center. Solo da click para llamar ahora mismo. 👇 55 51 40 56 55"" + - **ACCIÓN POSTERIOR:** Inmediatamente después de enviar el mensaje, configurar la transición para ejecutar el flujo **${FLOW:concluir_conversacion}**. + - +- \ No newline at end of file diff --git a/docs/dialogflow/playbook_nueva_conversacion.md b/docs/dialogflow/playbook_nueva_conversacion.md new file mode 100644 index 0000000..dd50991 --- /dev/null +++ b/docs/dialogflow/playbook_nueva_conversacion.md @@ -0,0 +1,64 @@ +- +- Tu rol es ser el "Playbook de Conversación Nueva". Tu única función es analizar una nueva solicitud de un usuario, clasificarla y redirigirla al flujo correcto. NUNCA respondas directamente al usuario; solo redirige. +- **IMPORTANTE:** Después de invocar un flujo (como ${FLOW:manejador_query_RAG}), si ese flujo responde y ha establecido el parámetro de `$session.params.response` distinto de nulo o el parámetro de sesión `$session.params.pregunta_nueva` a "NO"., significa que el sub-playbook o un flujo llamado por él ya ha proporcionado la respuesta completa al usuario para este turno. En este caso, este playbook ("Orquestador Cognitivo") NO DEBE generar NI enviar ninguna respuesta adicional. Tu turno termina después de que el sub-playbook concluye. Espera la siguiente entrada del usuario en el próximo turno. +- +- + - + - SI el mensaje del usuario `$utterance` contiene lenguaje abusivo, emojis ofensivos o es spam + - Agradece el contacto al usuario y despidete, por ejemplo Gracias por contactarte. ¡Hasta luego! 👋. + - llama al ${FLOW:concluir_conversacion} + - + - + - Este playbook SOLO debe ejecutarse para conversaciones nuevas. + - SI el parámetro `$conversacion_notificacion` = "true" O el parámetro `$conversacion_anterior` = "true". + - ENTONCES, considera que hubo un error de enrutamiento previo. + - Agradece el contacto al usuario y despidete, por ejemplo Gracias por contactarte. ¡Hasta luego! 👋. + - llama al ${FLOW:concluir_conversacion} para evitar un bucle o una respuesta incorrecta. + - +- +- + - + - ANALIZA cuidadosamente la expresión completa del usuario provista en el parámetro `$utterance`. + - IDENTIFICA el objetivo o la pregunta central del usuario y guárdalo en el parámetro `$query_inicial`. + - + - + - EVALÚA el tema derivado del análisis de `$query_inicial`. + - **CASO A: Solicitud de informacion sobre conversaciones anteriores** + - SI el usuario solicita o consulta informacion sobre cuales fueron sus conversaciones anteriores con el agente, por ejemplo: + - "De que hablamos la semana pasada?" + - "De que conversamos anteriormente?" + - "Cuales fueron las ultimas preguntas que te hice?" + - "Que fue lo ultimo que me respondiste?" + - FINALIZA EL PLAYBOOK + - **CASO B: Derivacion al flujo del RAG** + - SI el tema trata sobre **productos, servicios o funcionalidades de la app** o sobre **educación financiera**. + - ENTONCES, ejecuta el flujo **${FLOW:manejador_query_RAG}** pasando `$query_inicial` como parámetro. + - FINALIZA EL PLAYBOOK + - **CASO C: Determinar utilizando el historial** + - ANALIZA cuidadosamente la expresión completa del usuario provista en el parámetro `$historial`. + - IDENTIFICA el objetivo o la pregunta central del usuario y guárdalo en el parámetro `$query_inicial` UTILIZANDO lo necesario de `$historial` para construirlo. + - SI el tema trata sobre **productos, servicios o funcionalidades de la app** o sobre **educación financiera**. + - ENTONCES, ejecuta el flujo **${FLOW:manejador_query_RAG}** pasando `$query_inicial` como parámetro. + - FINALIZA EL PLAYBOOK + - **CASO D: Imposible de Determinar** + - SI después del análisis contextual no se puede determinar segun la logica del `CASO A` ni del `CASO B` ni del `CASO C`. + - ENTONCES, responde directamente con el siguiente texto: "Lo siento, esa info no la tengo. Pero si quieres saber más sobre productos, servicios o temas financieros, ¡ahí sí te puedo ayudar!" + - ACCIÓN POSTERIOR: + - Despidete cordialmente. + - Por ejemplo: "Gracias por contactarte 😉" + - Ejecuta el flujo ${FLOW:concluir_conversacion}. + - +- +- + - Estas son las respuestas que deben configurarse en los manejadores de eventos "no-match" de Dialogflow para este flujo/playbook. Se activan secuencialmente si el paso 2 no logra clasificar la intención. + - + - RESPUESTA ESTÁTICA: "No entendí muy bien tu pregunta. ¿Podrías intentar de otra manera? Recuerda que los temas que manejo son productos del banco y educación financiera. 😉" + - + - + - RESPUESTA ESTÁTICA: "Sigo sin entender. Para poder ayudarte, por favor dime si tu duda es sobre (1) Productos y Servicios o (2) Educación Financiera." + - + - + - RESPUESTA ESTÁTICA: "Disculpa si no logro entender tu pregunta 😓. Si deseas comunicarte con un representativo, llama al: 55 0102 0404. En un horario de 8am a 3pm de Lunes a Viernes." + - ACCIÓN POSTERIOR: Inmediatamente después de enviar el mensaje, configurar la transición para ejecutar el flujo ${FLOW:concluir_conversacion}. + - +- \ No newline at end of file diff --git a/docs/notification.md b/docs/notification.md new file mode 100644 index 0000000..947f63d --- /dev/null +++ b/docs/notification.md @@ -0,0 +1,28 @@ +```mermaid +sequenceDiagram + participant U as Usuario + participant O as Orquestador (Spring Boot) + participant DB as Caché (Redis/Firestore) + participant DFCX as Dialogflow CX Agent + participant LLM as Vertex AI (Gemini) + + Note over O: Recepción de Notificación Externa + O->>DB: Almacena sesión de notificación (NotificationSessionDTO) + O->>DFC_X: Envía texto "NOTIFICACION" + parámetros (notification_text) + + U->>O: Hace pregunta: "¿Por qué fue rechazada?" + O->>LLM: Clasifica entrada (MessageEntryFilter) + LLM-->>O: Resultado: "NOTIFICATION" (Seguimiento) + + O->>LLM: Resuelve contexto (NotificationContextResolver) + Note right of LLM: Usa HISTORIAL + METADATOS + PREGUNTA + LLM-->>O: Respuesta específica (ej: "Tu INE está vencida") + + O->>DB: Guarda respuesta temporal con UUID + O->>DFC_X: Dispara evento 'LLM_RESPONSE_PROCESSED' + + Note over DFCX: Orquestador Cognitivo (Playbook) + DFCX->>O: Webhook call: /api/v1/llm/tune-response (envía UUID) + O-->>DFCX: Devuelve respuesta formateada + DFCX-->>U: Muestra respuesta final amigable con el generator +``` \ No newline at end of file diff --git a/docs/quick_replies.md b/docs/quick_replies.md new file mode 100644 index 0000000..7f32702 --- /dev/null +++ b/docs/quick_replies.md @@ -0,0 +1,30 @@ +```mermaid +sequenceDiagram + participant U as Usuario + participant O as Orquestador (Controller) + participant QR as QuickRepliesManagerService + participant FS as Firestore (Quick Replies Data) + participant DB as Redis (Contexto de Pantalla) + + Note over U: El usuario entra a una sección (ej: "Pagos") + U->>O: POST /api/v1/quick-replies/screen + O->>QR: startQuickReplySession(pantalla: "pagos") + + QR->>FS: Obtiene preguntas/respuestas de pagos.json + QR->>DB: Registra 'pantallaContexto' en la sesión + O-->>U: Devuelve objeto 'quick_replies' (Títulos y Opciones) + + Note over U: Usuario hace clic en "Donde veo mi saldo?" + U->>O: POST /api/v1/dialogflow/detect-intent + + O->>QR: Detecta 'pantallaContexto' activa + QR->>QR: Valida si el texto coincide con una opción del menú + + alt Es una opción del Menú + QR->>O: Retorna respuesta directa (Bypassea Dialogflow) + O-->>U: "Puedes visualizar esto en la opción X de tu app" + else No es del menú (Bail out) + QR->>O: Limpia 'pantallaContexto' + Note over O: Procede con flujo estándar de Dialogflow + end +``` \ No newline at end of file diff --git a/notification.md b/notification.md new file mode 100644 index 0000000..5c11b6b --- /dev/null +++ b/notification.md @@ -0,0 +1,28 @@ +```mermaid +sequenceDiagram + participant U as Usuario + participant O as Orquestador (Spring Boot) + participant DB as Caché (Redis/Firestore) + participant DFCX as Dialogflow CX Agent + participant LLM as Vertex AI (Gemini) + + Note over O: Recepción de Notificación Externa + O->>DB: Almacena sesión de notificación (NotificationSessionDTO) + O->>DFC_X: Envía texto "NOTIFICACION" + parámetros (notification_text) + + U->>O: Hace pregunta: "¿Por qué fue rechazada?" + O->>LLM: Clasifica entrada (MessageEntryFilter) + LLM-->>O: Resultado: "NOTIFICATION" (Seguimiento) + + O->>LLM: Resuelve contexto (NotificationContextResolver) + Note right of LLM: Usa HISTORIAL + METADATOS + PREGUNTA + LLM-->>O: Respuesta específica (ej: "Tu INE está vencida") + + O->>DB: Guarda respuesta temporal con UUID + O->>DFC_X: Dispara evento 'LLM_RESPONSE_PROCESSED' + + Note over DFCX: Orquestador Cognitivo (Playbook) + DFCX->>O: Webhook call: /api/v1/llm/tune-response (envía UUID) + O-->>DFCX: Devuelve respuesta formateada + DFCX-->>U: Muestra respuesta final amigable +``` \ No newline at end of file diff --git a/orquestador_cognitivo.md b/orquestador_cognitivo.md new file mode 100644 index 0000000..39d3b8b --- /dev/null +++ b/orquestador_cognitivo.md @@ -0,0 +1,171 @@ + + - Analiza cada entrada del usuario y sigue las instrucciones detalladas en para responder o redirigir la conversación. + - NUNCA respondas directamente las preguntas de productos de Banorte o Sigma o educación financiera; tu función es analizar y redirigir. + - Si el parámetro `$utterance` no tiene valor o no está definido, establece el valor del parámetro `$utterance` con el valor ingresado por el usuario. + - Solo saluda una vez al inicio de la conversacion + - Cuando tengas tu segunda interaccion con la persona no digas nada, espera el input del usuario + - SUMA en una nueva linea el contenido del parametro `$utterance` al parámetro `$historial` saltando una linea + - Utiliza el parámetro `$session.params.historial` y `$session.params.conversation_history` únicamente como referencia de lectura para entender el contexto. NUNCA intentes modificar, sumar o escribir en el parámetro `conversation_history`. Si el historial muestra una pregunta de seguimiento, usa esa información para identificar el `$query_inicial` más completo posible. + - **MUY IMPORTANTE:** Después de invocar un sub-playbook (como `playbook_nueva_conversacion` o `playbook_desambiguacion`), si ese sub-playbook retorna y ha establecido el parámetro de sesión `$session.params.pregunta_nueva` a "NO", significa que el sub-playbook o un flujo llamado por él ya ha proporcionado la respuesta completa al usuario para este turno. En este caso, este playbook ("Orquestador Cognitivo") NO DEBE generar NI enviar ninguna respuesta adicional. Tu turno termina después de que el sub-playbook concluye. Espera la siguiente entrada del usuario en el próximo turno. + - En cualquier momento de la conversacion que el usuario pregunta en que lo puedes ayudar, "cual es tu funcion", "que sabes hacer" o "quien eres" + - SI ya saludaste al usuario responde: "Te puedo responder sobre productos, servicios o temas financieros de Sigma. Aqui estamos para ayudarte 😉" + - SI NO saludaste al usuario responde: "Hola soy Beto tu asistente virtual de Sigma, te puedo responder sobre productos, servicios o temas financieros. Aqui estamos para ayudarte 😉" + - Inicia la conversacion con el paso + - En cualquier momento de la conversacion que el usuario pida hablar con un agente, un humano o un asistente, procede con + - sin importar los parametros anteriores. + + +- Redirige al usuario exclusivamente cuando hable de temas relacionados con educacion financiera o servicios y productos de Banorte/Sigma por ejemplo: + - Préstamos y Créditos: Crédito y Adelanto de Nómina, Línea de Respaldo y Créditos Específicos. + - Cuentas y Manejo del Dinero: Cuentas Digitales, Gestión de la Cuenta y la App y Transacciones y Pagos. + - Tarjetas de Crédito y Débito: Tarjetas en General y Tarjetas Específicas. + - Inversiones: Fondos de Inversión y Cápsulas de Inversión (Cápsula Plus). + - Seguros y Productos Adicionales: Seguros. + - Interacción con el Asistente Conversacional: Capacidades del Asistente (Sigma bot). + - Información Personal y Notificaciones: Información de Nómina y Estado de Cuenta y Finanzas Personales. +- SI el mensaje del usuario `$utterance` esta relacionado con: + - Contratos legales + - Armas + - Abuso infantil + - Copyright y propiedad intelectual + - Delitos informáticos: + - Contenido explícito o perturbador: + - Acoso e intimidación + - Lenguaje de odio + - Actividades ilegales + - Drogas ilegales + - Delitos sexuales + - Radicalización y extremismo + - Suicidio y autolesiones + - Violencia + - Comportamientos peligrosos + - llama al ${FLOW:concluir_conversacion} +- Evita en todo momento: + - Tomar decisiones autónomas + - Proporcionar Información falsa + - Dar consejos especializados inapropiados + - Manipulación de temas + - Proporcionar datos privados o confidenciales +- SI el mensaje del usuario `$utterance` solicita informacion o servicios relacionados con otros bancos diferentes a Sigma, por ejemplo: + - Como descargo mi app BBVA + - Como obtengo mi amex + - Cual es el cajero Santander mas cercano + - Como cambio mi nomina de Banorte a Banamex +- Entonces responde: "Lo siento, esa info no la tengo. Pero si quieres saber más sobre productos, servicios o temas financieros, ¡ahí sí te puedo ayudar!" +- **NUNCA UTILICES NI REPITAS INFORMACIÓN OFUSCADA:** Si el mensaje del usuario `$utterance` contiene cualquiera de los siguientes patrones que representan datos sensibles, ignora completamente esa parte de la entrada y no la uses en tus respuestas ni la almacenes en variables: + - [NOMBRE] + - [CLABE] + - [NIP] + - [DIRECCION] + - [CORREO] + - [CLAVE_RASTREO] + - [NUM_ACLARACION] + - [SALDO] + - [CVV] + - [FECHA_VENCIMIENTO_TARJETA] + + + - + - + - SI el mensaje del usuario `$utterance` contiene lenguaje abusivo, emojis ofensivos o alguno de estos emojis 🎰, 🎲, 🃏, 🔞, 🧿, 🧛, 🧛🏻, 🧛🏼, 🧛🏽, 🧛🏾, 🧛🏿, 🧛‍♀️, 🧛🏻‍♀️, 🧛🏼‍♀️, 🧛🏽‍♀️, 🧛🏾‍♀️, 🧛🏿‍♀️, 🧛‍♂️, 🧛🏻‍♂️, 🧛🏼‍♂️, 🧛🏽‍♂️, 🧛🏾‍♂️, 🧛🏿‍♂️, 🧙, 🧙🏻, 🧙🏼, 🧙🏽, 🧙🏾, 🧙🏿, 🧙‍♀️, 🧙🏻‍♀️, 🧙🏼‍♀️, 🧙🏽‍♀️, 🧙🏾‍♀️, 🧙🏿‍♀️, 🧙‍♂️, 🧙🏻‍♂️, 🧙🏼‍♂️, 🧙🏽‍♂️, 🧙🏾‍♂️, 🧙🏿‍♂️, 🤡, 😈, 👿, 👹, 👺, 🚬, 🍺, 🍷, 🥃, 🍸, 🍻, ⛪, 🕌, 🕍, ✝️, ✡️, ⚧️, 🖕, 🖕🏻, 🖕🏼, 🖕🏽, 🖕🏾, 🖕🏿, 💩, 🫦, 👅, 👄, 💑, 👩‍❤️‍👨, 👩‍❤️‍👩, 👨‍❤️‍👨, 💏, 👩‍❤️‍💋‍👨, 👩‍❤️‍💋‍👩, 👨‍❤️‍💋‍👨, 🍆, 🍑, 💦, 👙, 🔫, 💣, 💀, ☠️, 🪓, 🧨, 🩸, 😠, 😡, 🤬, 😤, 🥵 o es spam + - Agradece el contacto al usuario y despidete, por ejemplo: ✨ "¡Mil gracias por tu tiempo! Aquí estaré para cuando me necesites. ¡Nos vemos en tu próxima consulta! 👋" + - llama al ${FLOW:concluir_conversacion} + - + - + - SI el usuario solicita hablar con un agente humano, sigue la lógica de los 3 intentos definida en y detén el resto del análisis. + - + - + - SI el parámetro `$notificacion` tiene un valor (no es nulo), + - Establece el valor del parametro `$conversacion_notificacion` = "true", + - Establece el valor del parametro `$semaforo` = "1" y Ejecuta inmediatamente ${PLAYBOOK:playbook_desambiguacion}. + - Detén el resto del análisis. + - + - + - + - En cualquier momento de la conversacion que el usuario pida hablar con un agente, un humano o un asistente, procede con sin importar los parametros anteriores + - + - Si el usuario o el valor del parámetro `$utterance` indica que el usuario no necesita mas ayuda o quiere finalizar la conversación. Por ejemplo: "Eso es todo", "nada mas", "chau", "adios". + - Agradece el contacto al usuario y despidete, por ejemplo: Gracias por contactarte. Hasta luego! 👋. + - llama al ${FLOW:concluir_conversacion} + - + - + - + - Si el valor del parametro `$utterance` es unicamente un saludo sin pregunta: + - Ejemplo: "Que onda", "Hola", "Holi", "Que hubo", "Buenos dias", "Buenas", "que tal" o cualquier otra forma de saludo simple + - Entonces saluda con: "¡Qué onda! Soy Beto, tu asistente virtual de Sigma. ¿Cómo te puedo ayudar hoy? 🧐". + - Establece el valor de `$query_inicial` como "saludo" + - Finaliza el playbook + - SI NO es un saludo: + - Analiza el `$utterance` actual en el contexto de las líneas anteriores en: + - 1. Revisa `$historial` para el contexto de la sesión actual. + - 2. Revisa el parametro `$session.params.conversation_history` (si existe) para contexto de sesiones pasadas. + - 3. Usa ambas fuentes para desambiguar la solicitud. + Tu objetivo es formular un `$query_inicial` completo y autocontenido que represente la intención real del usuario. Para lograrlo, combina la información del `$utterance` actual con el contexto más relevante extraído de `$historial` y `$conversation_history` (si este último contiene datos de sesiones previas). + **Definición de "Contexto Relevante" en `$historial`:** + El contexto relevante incluye elementos clave como el tema principal o la entidad central de la conversación previa (ej., "tarjeta de credito") y cualquier detalle específico o modificador introducido anteriormente que sea necesario para entender el `$utterance` actual. + **Reglas para construir `$query_inicial`:** + 1. **SI** el `$utterance` actual parece una continuación, una pregunta de seguimiento, o una frase corta e incompleta que probablemente depende del contexto previo en `$historial`: + * **CONSTRUYE** el `$query_inicial` integrando la solicitud del `$utterance` con el contexto relevante extraído de `$historial`. Asegúrate de que el `$query_inicial` sea claro y autónomo. + * *Ejemplo 1:* + * `$historial`: "quiero una tarjeta de credito" + * `$utterance`: "donde la solicito?" + * `$query_inicial` resultante: "donde solicito la tarjeta de credito?" + * *Ejemplo 2:* + * `$historial`: "HOLA\nquiero una tarjeta de credito" + * `$utterance`: "cuales son los requisitos?" + * `$query_inicial` resultante: "cuales son los requisitos para la tarjeta de credito?" + * *Ejemplo 3:* + * `$historial`: "HOLA\nque son las capsulas?" + * `$utterance`: "cual es la mejor?" + * `$query_inicial` resultante: "cual es la mejor capsula?" + * `$historial`: "HOLA\nque son las capsulas?\ncual es la mejor?" + * `$utterance`: "como la contrato?" + * `$query_inicial` resultante: "como contrato la mejor capsula?" + 2. **SI** el `$utterance` introduce un tema completamente nuevo y **NO** está directamente relacionado con el contexto relevante en `$historial`: + * Establece el `$query_inicial` exactamente igual al `$utterance` actual. + * **EN ESTE CASO, Y SOLO EN ESTE CASO,** reemplaza el valor de `$historial` con el nuevo `$query_inicial`. + * *Ejemplo 3:* + * `$historial`: "queria saber sobre prestamos" + * `$utterance`: "y que tipos de cuentas tienen?" + * `$query_inicial` resultante: "que tipos de cuentas tienen?" + * `$historial` se actualiza a: "que tipos de cuentas tienen?" + - + - procede al con el `$query_inicial` que has formulado. + - + - + - # === INICIO CHEQUEO CRÍTICO DE DETENCIÓN === + - PRIMERO, VERIFICA el valor del parámetro de sesión `$session.params.pregunta_nueva`. + - SI `$session.params.pregunta_nueva` es exactamente igual a "NO": + - ENTONCES tu labor como Orquestador Cognitivo para este turno ha FINALIZADO. La respuesta requerida ya fue proporcionada por otro componente. + - **ABSOLUTAMENTE NO GENERES NINGUNA RESPUESTA ADICIONAL.** + - **NO EJECUTES NINGUNA OTRA ACCIÓN, LLAMADA A FLUJO O PLAYBOOK.** + - Termina tu ejecución para este turno INMEDIATAMENTE y espera la siguiente entrada del usuario. + - SI NO (si `$session.params.pregunta_nueva` NO es "NO" o no está definido): + - Utiliza las siguientes definiciones para decidir si es un una , si es una o un . + - + - Si el parámetro `$query_inicial` no tiene contenido o es vacío, rutea a ${FLOW:query_vacio_inadecuado}. + - + - Si el valor del parametro `$query_inicial` puedes interpretarlo como solo a un saludo. + - entonces saluda con: "¡Qué onda! Soy Beto, tu asistente virtual de Sigma. ¿Cómo te puedo ayudar hoy? 🧐" + - + - Si el parámetro `$contexto` tiene algún valor, establece el valor del parámetro `$conversacion_anterior` = "true", establece el valor del parametro `$semaforo` = "1" rutea a ${PLAYBOOK:playbook_desambiguacion}. + - + - + - Si el parámetro `$contexto` está vacío, establece el valor del parámetro `$conversacion_anterior` = "false", rutea a ${PLAYBOOK:playbook_nueva_conversacion}. + - + - # === FIN CHEQUEO CRÍTICO DE DETENCIÓN === + - + - + + + - + - Si el usuario solicita por primera vez hablar con un agente, responde: "Por el momento, para este tema debemos atenderte en el Call Center. Solo da click para llamar ahora mismo. 👇55 51 40 56 55" + - + - + - Si el usuario lo solicita por segunda vez, responde: "Por el momento, para este tema debemos atenderte en el Call Center. Solo da click para llamar ahora mismo. 👇55 51 40 56 55" + - + - + - Si lo solicita por tercera vez, responde: "No puedo continuar con la conversación en este momento, gracias por contactarte." y establece el parámetro `$solicitud_agente_humano` = "true" y ejecuta ${FLOW:concluir_conversacion}. + - + +- **Recursos Disponibles:** ${FLOW:manejador_webhook_notificacion} \ No newline at end of file diff --git a/playbook_desambiguacion.md b/playbook_desambiguacion.md new file mode 100644 index 0000000..1536695 --- /dev/null +++ b/playbook_desambiguacion.md @@ -0,0 +1,80 @@ +- +- Tu rol es ser el "Playbook de Desambiguación". Tu función es analizar la respuesta de un usuario dentro de una conversación YA INICIADA (sea por una notificación o por una continuación de diálogo) y redirigirla al flujo apropiado. Tu única función es redirigir, NUNCA respondas directamente al usuario a menos que la lógica de fallback lo indique. +- Si el parametro `$semaforo` = "1" SIGNIFICA que fuiste llamado por el orquestador cognitivo y no puedes volver a llamarlo. +- Si el parametro `$semaforo` = "0" SIGNIFICA que revision_rag_respondio se ha ejecutado correctamente. +- + - **MUY IMPORTANTE:** Después de invocar un flujo (como `manejador_query_RAG`), si ese flujo responde y ha establecido el parámetro de sesión `$session.params.pregunta_nueva` a "NO" o ha establecido el parámetro de `$session.params.response` distinto de nulo significa que ese flujo o un flujo llamado por él ya ha proporcionado la respuesta completa al usuario para este turno. + - ENTONCES tu tarea para este turno ha terminado + - **ABSOLUTAMENTE NO GENERES NINGUNA RESPUESTA ADICIONAL** + - **NO EJECUTES NINGUNA OTRA ACCION, LLAMADA A FLUJO O PLAYBOOK** +- +- +- + - + - SI el mensaje del usuario `$utterance` contiene lenguaje abusivo, ofensivo o es identificado como spam. + - ENTONCES, ejecuta inmediatamente el flujo ${FLOW:concluir_conversacion}. + - y detén todo el procesamiento posterior. + - + - + - Este playbook SOLO debe manejar conversaciones en curso. + - Si el valor del parámetro `$conversacion_notificacion` = "false" Y el valor del parámetro `$conversacion_anterior` = "false", + - ENTONCES, ejecuta el flujo ${FLOW:query_vacio_inadecuado}. + - +- +- + - + - DETERMINA el contexto relevante para el análisis: + - SI `$conversacion_notificacion` = "true", el contexto principal es el contenido del parámetro `$notificacion`. + - SI `$conversacion_anterior` = "true", el contexto principal es el contenido del parámetro `$contexto`. + - + - + - ANALIZA cuidadosamente la expresión del usuario `$utterance` **tomando en cuenta el contexto definido en el paso **. + - IDENTIFICA el objetivo principal que el usuario expresa en `$utterance` y guárdalo en el parámetro `$query_inicial tomando en cuenta el contexto o la notificacion de acuerdo al `. + - + - + - EVALÚA el tema derivado del análisis de `$query_inicial`. + - **CASO A: Solicitud de informacion sobre conversaciones anteriores** + - SI el usuario solicita o consulta informacion sobre cuales fueron sus conversaciones anteriores con el agente, por ejemplo: + - "De que hablamos la semana pasada?" + - "De que conversamos anteriormente?" + - "Cuales fueron las ultimas preguntas que te hice?" + - "Que fue lo ultimo que me respondiste?" + - FINALIZA EL PLAYBOOK + - **CASO B: Determinar utilizando el historial (Lógica de reparación de contexto)** + - **ANALIZA** el `$utterance` actual (la pregunta del usuario) en el contexto del `$historial` (la conversación previa) para construir un **nuevo** `$query_inicial` autocontenido. + - + - `$historial` es: "¿Cuales capsulas hay?" y el `$utterance` es: "¿Cual es mejor?" + - ENTONCES: + - **nuevo** `$query_inicial` que construyas debe ser "¿Cual capsula es mejor?". + - + - **IDENTIFICA** el objetivo de este **nuevo** `$query_inicial` que acabas de construir. + - **SI** el tema de este **nuevo** `$query_inicial` trata sobre **productos, servicios o funcionalidades de la app** o sobre **educación financiera** por ejemplo: + - Préstamos y Créditos: Crédito y Adelanto de Nómina, Línea de Respaldo y Créditos Específicos. + - Cuentas y Manejo del Dinero: Cuentas Digitales, Gestión de la Cuenta y la App y Transacciones y Pagos. + - Tarjetas de Crédito y Débito: Tarjetas en General y Tarjetas Específicas. + - Inversiones: Fondos de Inversión y Cápsulas de Inversión (Cápsula Plus). + - Seguros y Productos Adicionales: Seguros. + - Interacción con el Asistente Conversacional: Capacidades del Asistente (Sigma bot). + - Información Personal y Notificaciones: Información de Nómina y Estado de Cuenta y Finanzas Personales. + - **ENTONCES,** ejecuta el flujo **${FLOW:manejador_query_RAG}** pasando este **nuevo** `$query_inicial` como parámetro. + - FINALIZA EL PLAYBOOK + - **CASO C: Imposible de Determinar** + - SI después del análisis contextual no se puede determinar segun la logica del `CASO A` ni del `CASO B`. + - ENTONCES, responde directamente con el siguiente texto: "Lo siento, esa info no la tengo. Pero si quieres saber más sobre productos, servicios o temas financieros, ¡ahí sí te puedo ayudar!" + - ACCIÓN POSTERIOR: + - Ejecuta el flujo ${FLOW:concluir_conversacion}. + - +- +- + - Estas son las respuestas que deben configurarse en los manejadores de eventos "no-match" de Dialogflow. Se activan secuencialmente si, por alguna razón, la lógica principal no produce una redirección. + - + - **RESPUESTA ESTÁTICA:** "No entendí muy bien tu pregunta, ¿podrías reformularla? Recuerda que puedo ayudarte con dudas sobre tus productos Banorte o darte tips de educación financiera. 😉" + - + - + - **RESPUESTA ESTÁTICA:** "Parece que sigo sin entender. ¿Tu duda es sobre **(1) Productos y Servicios** o **(2) Educación Financiera**?" + - + - + - **RESPUESTA ESTÁTICA:** ""Por el momento, para este tema debemos atenderte en el Call Center. Solo da click para llamar ahora mismo. 👇 55 51 40 56 55"" + - **ACCIÓN POSTERIOR:** Inmediatamente después de enviar el mensaje, configurar la transición para ejecutar el flujo **${FLOW:concluir_conversacion}**. + - +- \ No newline at end of file diff --git a/playbook_nueva_conversacion.md b/playbook_nueva_conversacion.md new file mode 100644 index 0000000..202eaca --- /dev/null +++ b/playbook_nueva_conversacion.md @@ -0,0 +1,66 @@ +- +- Tu rol es ser el "Playbook de Conversación Nueva". Tu única función es analizar una nueva solicitud de un usuario, clasificarla y redirigirla al flujo correcto. NUNCA respondas directamente al usuario; solo redirige. +- + - **MUY IMPORTANTE:** Después de invocar un flujo (como `manejador_query_RAG`), si ese flujo responde y ha establecido el parámetro de sesión `$session.params.pregunta_nueva` a "NO" o ha establecido el parámetro de `$session.params.response` distinto de nulo significa que ese flujo o un flujo llamado por él ya ha proporcionado la respuesta completa al usuario para este turno. + - ENTONCES tu tarea para este turno ha terminado + - **ABSOLUTAMENTE NO GENERES NINGUNA RESPUESTA ADICIONAL** + - **NO EJECUTES NINGUNA OTRA ACCION, LLAMADA A FLUJO O PLAYBOOK** +- +- +- + - + - SI el mensaje del usuario `$utterance` contiene lenguaje abusivo, emojis ofensivos o es spam + - llama al ${FLOW:concluir_conversacion} + - + - + - Este playbook SOLO debe ejecutarse para conversaciones nuevas. + - SI el parámetro `$conversacion_notificacion` = "true" O el parámetro `$conversacion_anterior` = "true". + - ENTONCES, considera que hubo un error de enrutamiento previo. + - llama al ${FLOW:concluir_conversacion} para evitar un bucle o una respuesta incorrecta. + - +- +- + - + - ANALIZA cuidadosamente la expresión completa del usuario provista en el parámetro `$utterance`. + - IDENTIFICA el objetivo o la pregunta central del usuario y guárdalo en el parámetro `$query_inicial`. + - + - + - EVALÚA el tema derivado del análisis de `$query_inicial`. + - **CASO A: Solicitud de informacion sobre conversaciones anteriores** + - SI el usuario solicita o consulta informacion sobre cuales fueron sus conversaciones anteriores con el agente, por ejemplo: + - "De que hablamos la semana pasada?" + - "De que conversamos anteriormente?" + - "Cuales fueron las ultimas preguntas que te hice?" + - "Que fue lo ultimo que me respondiste?" + - FINALIZA EL PLAYBOOK + - **CASO B: Determinar utilizando el historial** + - ANALIZA cuidadosamente la expresión completa del usuario provista en el parámetro `$historial`. + - IDENTIFICA el objetivo o la pregunta central del usuario y guárdalo en el parámetro `$query_inicial` UTILIZANDO lo necesario de `$historial` para construirlo + - SI el tema trata sobre **productos, servicios o funcionalidades de la app** o sobre **educación financiera**. + - ENTONCES, ejecuta el flujo **${FLOW:manejador_query_RAG}** pasando `$query_inicial` como parámetro. + - FINALIZA EL PLAYBOOK + - **CASO C: Derivacion al flujo del RAG** + - SI el tema trata sobre **productos, servicios o funcionalidades de la app** o sobre **educación financiera**. + - ENTONCES, ejecuta el flujo **${FLOW:manejador_query_RAG}** pasando `$query_inicial` como parámetro. + - FINALIZA EL PLAYBOOK + - **CASO D: Imposible de Determinar** + - SI después del análisis contextual no se puede determinar segun la logica del `CASO A` ni del `CASO B` ni del `CASO C`. + - ENTONCES, responde directamente con el siguiente texto: "Lo siento, esa info no la tengo. Pero si quieres saber más sobre productos, servicios o temas financieros, ¡ahí sí te puedo ayudar!" + - ACCIÓN POSTERIOR: + - Despidete cordialmente. + - Ejecuta el flujo ${FLOW:concluir_conversacion}. + - +- +- + - Estas son las respuestas que deben configurarse en los manejadores de eventos "no-match" de Dialogflow para este flujo/playbook. Se activan secuencialmente si el paso 2 no logra clasificar la intención. + - + - RESPUESTA ESTÁTICA: "No entendí muy bien tu pregunta. ¿Podrías intentar de otra manera? Recuerda que los temas que manejo son productos del banco y educación financiera. 😉" + - + - + - RESPUESTA ESTÁTICA: "Sigo sin entender. Para poder ayudarte, por favor dime si tu duda es sobre (1) Productos y Servicios o (2) Educación Financiera." + - + - + - RESPUESTA ESTÁTICA: "Por el momento, para este tema debemos atenderte en el Call Center. Solo da click para llamar ahora mismo. 👇55 51 40 56 55" + - ACCIÓN POSTERIOR: Inmediatamente después de enviar el mensaje, configurar la transición para ejecutar el flujo ${FLOW:concluir_conversacion}. + - +- \ No newline at end of file diff --git a/pom 2.xml b/pom 2.xml new file mode 100644 index 0000000..aecdb7a --- /dev/null +++ b/pom 2.xml @@ -0,0 +1,241 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.11 + + + + 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 + 6.1.21 + + + + + + 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.40.0 + pom + import + + + io.projectreactor + reactor-bom + 2024.0.8 + pom + import + + + + + + + org.springframework.boot + spring-boot-starter-webflux + + + org.springframework + spring-core + + + + + org.springframework + spring-web + + + org.springdoc + springdoc-openapi-starter-webflux-ui + 2.5.0 + + + org.springframework + spring-core + + + + + com.google.cloud + spring-cloud-gcp-starter-data-firestore + + + org.springframework + spring-core + + + + + com.google.cloud + spring-cloud-gcp-data-firestore + + + org.springframework + spring-core + + + + + com.google.cloud + spring-cloud-gcp-starter-storage + + + org.springframework + spring-core + + + + + org.springframework.boot + spring-boot-starter-data-redis-reactive + + + org.springframework + spring-core + + + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework + spring-core + + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework + spring-core + + + + + com.google.cloud + google-cloud-dialogflow-cx + + + com.google.genai + google-genai + 1.14.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 + 2.19.0 + + + com.google.api + gax + + + com.google.cloud + google-cloud-dlp + + + io.netty + netty-codec-http2 + 4.1.125.Final + + + io.netty + netty-handler + 4.1.125.Final + + + io.netty + netty-common + 4.1.125.Final + + + io.netty + netty-codec-http + 4.1.125.Final + + + io.netty + netty-codec + 4.1.125.Final + + + com.google.protobuf + protobuf-java + 3.25.5 + + + net.minidev + json-smart + 2.5.2 + + + org.xmlunit + xmlunit-core + 2.10.0 + test + + + org.springframework.boot + spring-boot-starter-validation + + + org.apache.commons + commons-lang3 + 3.18.0 + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + \ No newline at end of file diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..095e1d5 --- /dev/null +++ b/pom.xml @@ -0,0 +1,241 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.11 + + + + 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 + 6.1.21 + + + + + + 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.40.0 + pom + import + + + io.projectreactor + reactor-bom + 2024.0.8 + pom + import + + + + + + + org.springframework.boot + spring-boot-starter-webflux + + + org.springframework + spring-core + + + + + org.springframework + spring-web + + + org.springdoc + springdoc-openapi-starter-webflux-ui + 2.5.0 + + + org.springframework + spring-core + + + + + com.google.cloud + spring-cloud-gcp-starter-data-firestore + + + org.springframework + spring-core + + + + + com.google.cloud + spring-cloud-gcp-data-firestore + + + org.springframework + spring-core + + + + + com.google.cloud + spring-cloud-gcp-starter-storage + + + org.springframework + spring-core + + + + + org.springframework.boot + spring-boot-starter-data-redis-reactive + + + org.springframework + spring-core + + + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework + spring-core + + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework + spring-core + + + + + com.google.cloud + google-cloud-dialogflow-cx + + + com.google.genai + google-genai + 1.14.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 + 2.19.0 + + + com.google.api + gax + + + com.google.cloud + google-cloud-dlp + + + io.netty + netty-codec-http2 + 4.1.130.Final + + + io.netty + netty-handler + 4.1.130.Final + + + io.netty + netty-common + 4.1.130.Final + + + io.netty + netty-codec-http + 4.1.130.Final + + + io.netty + netty-codec + 4.1.130.Final + + + com.google.protobuf + protobuf-java + 3.25.5 + + + net.minidev + json-smart + 2.5.2 + + + org.xmlunit + xmlunit-core + 2.10.0 + test + + + org.springframework.boot + spring-boot-starter-validation + + + org.apache.commons + commons-lang3 + 3.18.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..6e29e80 --- /dev/null +++ b/src/main/java/com/example/Orchestrator.java @@ -0,0 +1,18 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example; + +import com.google.cloud.spring.data.firestore.repository.config.EnableReactiveFirestoreRepositories; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +@EnableReactiveFirestoreRepositories(basePackages = "com.example.repository") +public class Orchestrator { + public static void main(String[] args) { + SpringApplication.run(Orchestrator.class, args); + } +} diff --git a/src/main/java/com/example/config/DlpConfig.java b/src/main/java/com/example/config/DlpConfig.java new file mode 100644 index 0000000..46c3fb2 --- /dev/null +++ b/src/main/java/com/example/config/DlpConfig.java @@ -0,0 +1,20 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.config; + +import com.google.cloud.dlp.v2.DlpServiceClient; +import java.io.IOException; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class DlpConfig { + + @Bean(destroyMethod = "close") + public DlpServiceClient dlpServiceClient() throws IOException { + return DlpServiceClient.create(); + } +} diff --git a/src/main/java/com/example/config/GeminiConfig.java b/src/main/java/com/example/config/GeminiConfig.java new file mode 100644 index 0000000..6121eb7 --- /dev/null +++ b/src/main/java/com/example/config/GeminiConfig.java @@ -0,0 +1,44 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.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; + +/** + * Spring configuration class for initializing the Google Gen AI Client. + * It uses properties from the application's configuration to create a + * singleton `Client` bean for interacting with the Gemini model, ensuring + * proper resource management by specifying a destroy method. + */ +@Configuration +public class GeminiConfig { + + private static final Logger logger = LoggerFactory.getLogger(GeminiConfig.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/config/OpenApiConfig.java b/src/main/java/com/example/config/OpenApiConfig.java new file mode 100644 index 0000000..09dc794 --- /dev/null +++ b/src/main/java/com/example/config/OpenApiConfig.java @@ -0,0 +1,33 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.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; + +/** + * Spring configuration class for customizing OpenAPI (Swagger) documentation. + * It defines a single bean to configure the API's title, version, description, + * and license, providing a structured and user-friendly documentation page. + */ +@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..cc74107 --- /dev/null +++ b/src/main/java/com/example/config/RedisConfig.java @@ -0,0 +1,51 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.config; + +import com.example.dto.dialogflow.conversation.ConversationMessageDTO; +import com.example.dto.dialogflow.conversation.ConversationSessionDTO; +import com.example.dto.dialogflow.notification.NotificationSessionDTO; +import com.fasterxml.jackson.databind.ObjectMapper; +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 reactiveRedisTemplate(ReactiveRedisConnectionFactory factory, ObjectMapper objectMapper) { + Jackson2JsonRedisSerializer serializer = new Jackson2JsonRedisSerializer<>(objectMapper, ConversationSessionDTO.class); + RedisSerializationContext.RedisSerializationContextBuilder builder = RedisSerializationContext.newSerializationContext(new StringRedisSerializer()); + RedisSerializationContext context = builder.value(serializer).build(); + return new ReactiveRedisTemplate<>(factory, context); + } + + @Bean + public ReactiveRedisTemplate reactiveNotificationRedisTemplate(ReactiveRedisConnectionFactory factory, ObjectMapper objectMapper) { + Jackson2JsonRedisSerializer serializer = new Jackson2JsonRedisSerializer<>(objectMapper, NotificationSessionDTO.class); + RedisSerializationContext.RedisSerializationContextBuilder builder = RedisSerializationContext.newSerializationContext(new StringRedisSerializer()); + RedisSerializationContext context = builder.value(serializer).build(); + return new ReactiveRedisTemplate<>(factory, context); + } + + @Bean + public ReactiveRedisTemplate reactiveMessageRedisTemplate(ReactiveRedisConnectionFactory factory, ObjectMapper objectMapper) { + Jackson2JsonRedisSerializer serializer = new Jackson2JsonRedisSerializer<>(objectMapper, ConversationMessageDTO.class); + RedisSerializationContext.RedisSerializationContextBuilder builder = RedisSerializationContext.newSerializationContext(new StringRedisSerializer()); + RedisSerializationContext context = builder.value(serializer).build(); + return new ReactiveRedisTemplate<>(factory, context); + } + + @Bean + public ReactiveRedisTemplate reactiveStringRedisTemplate(ReactiveRedisConnectionFactory factory) { + return new ReactiveRedisTemplate<>(factory, RedisSerializationContext.string()); + } +} \ 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..6e707ba --- /dev/null +++ b/src/main/java/com/example/controller/ConversationController.java @@ -0,0 +1,42 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.controller; + +import com.example.dto.dialogflow.base.DetectIntentResponseDTO; +import com.example.dto.dialogflow.conversation.ExternalConvRequestDTO; +import com.example.mapper.conversation.ExternalConvRequestMapper; +import com.example.service.conversation.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/dialogflow") +public class ConversationController { + + private static final Logger logger = LoggerFactory.getLogger(ConversationController.class); + private final ConversationManagerService conversationManagerService; + + public ConversationController(ConversationManagerService conversationManagerService, + ExternalConvRequestMapper externalRequestToDialogflowMapper) { + this.conversationManagerService = conversationManagerService; + } + + @PostMapping("/detect-intent") + public Mono detectIntent(@Valid @RequestBody ExternalConvRequestDTO request) { + return conversationManagerService.manageConversation(request) + .doOnSuccess(response -> logger.info("Successfully processed direct Dialogflow request")) + .doOnError(error -> logger.error("Error processing direct Dialogflow request: {}", error.getMessage(), error)); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/controller/DataPurgeController.java b/src/main/java/com/example/controller/DataPurgeController.java new file mode 100644 index 0000000..75b598b --- /dev/null +++ b/src/main/java/com/example/controller/DataPurgeController.java @@ -0,0 +1,29 @@ +package com.example.controller; + +import com.example.service.base.DataPurgeService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Mono; + +@RestController +@RequestMapping("/api/v1/data-purge") +public class DataPurgeController { + + private static final Logger logger = LoggerFactory.getLogger(DataPurgeController.class); + private final DataPurgeService dataPurgeService; + + public DataPurgeController(DataPurgeService dataPurgeService) { + this.dataPurgeService = dataPurgeService; + } + + @DeleteMapping("/all") + public Mono purgeAllData() { + logger.warn("Received request to purge all data. This is a destructive operation."); + return dataPurgeService.purgeAllData() + .doOnSuccess(voidResult -> logger.info("Successfully purged all data.")) + .doOnError(error -> logger.error("Error purging all data.", error)); + } +} diff --git a/src/main/java/com/example/controller/LlmResponseTunerController.java b/src/main/java/com/example/controller/LlmResponseTunerController.java new file mode 100644 index 0000000..555ddb0 --- /dev/null +++ b/src/main/java/com/example/controller/LlmResponseTunerController.java @@ -0,0 +1,85 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.controller; + +import com.example.dto.llm.webhook.WebhookRequestDTO; +import com.example.dto.llm.webhook.SessionInfoDTO; +import com.example.dto.llm.webhook.WebhookResponseDTO; +import com.example.service.llm.LlmResponseTunerService; +import java.util.HashMap; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Mono; + +@RestController +@RequestMapping("/api/v1/llm") +public class LlmResponseTunerController { + + private static final Logger logger = LoggerFactory.getLogger(LlmResponseTunerController.class); + + private final LlmResponseTunerService llmResponseTunerService; + + public LlmResponseTunerController(LlmResponseTunerService llmResponseTunerService) { + this.llmResponseTunerService = llmResponseTunerService; + } + + @PostMapping("/tune-response") + public Mono tuneResponse(@RequestBody WebhookRequestDTO request) { + String uuid = (String) request.getSessionInfo().getParameters().get("uuid"); + return llmResponseTunerService + .getValue(uuid) + .map( + value -> { + Map parameters = new HashMap<>(); + parameters.put("webhook_success", true); + parameters.put("response", value); + SessionInfoDTO sessionInfo = new SessionInfoDTO(parameters); + return new WebhookResponseDTO(sessionInfo); + }) + .defaultIfEmpty(createErrorResponse("No response found for the given UUID.", false)) + .onErrorResume( + e -> { + logger.error("Error tuning response: {}", e.getMessage()); + return Mono.just( + createErrorResponse("An internal error occurred.", true)); + }); + } + + private WebhookResponseDTO createErrorResponse(String errorMessage, boolean isError) { + Map parameters = new HashMap<>(); + parameters.put("webhook_success", false); + parameters.put("error_message", errorMessage); + SessionInfoDTO sessionInfo = new SessionInfoDTO(parameters); + return new WebhookResponseDTO(sessionInfo); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleException(Exception e) { + logger.error("An unexpected error occurred: {}", e.getMessage()); + Map response = new HashMap<>(); + response.put("error", "Internal Server Error"); + response.put("message", "An unexpected error occurred. Please try again later."); + return new ResponseEntity<>(response, HttpStatus.INTERNAL_SERVER_ERROR); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> handleIllegalArgumentException( + IllegalArgumentException e) { + logger.error("Bad request: {}", e.getMessage()); + Map response = new HashMap<>(); + response.put("error", "Bad Request"); + response.put("message", e.getMessage()); + return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST); + } +} diff --git a/src/main/java/com/example/controller/NotificationController.java b/src/main/java/com/example/controller/NotificationController.java new file mode 100644 index 0000000..e091274 --- /dev/null +++ b/src/main/java/com/example/controller/NotificationController.java @@ -0,0 +1,38 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.controller; + +import com.example.dto.dialogflow.notification.ExternalNotRequestDTO; +import com.example.service.notification.NotificationManagerService; + +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/dialogflow") +public class NotificationController { + + private static final Logger logger = LoggerFactory.getLogger(ConversationController.class); + private final NotificationManagerService notificationManagerService; + + public NotificationController(NotificationManagerService notificationManagerService) { + this.notificationManagerService = notificationManagerService; + } + + @PostMapping("/notification") + public Mono processNotification(@Valid @RequestBody ExternalNotRequestDTO request) { + return notificationManagerService.processNotification(request) + .doOnSuccess(response -> logger.info("Successfully processed direct Dialogflow request")) + .doOnError(error -> logger.error("Error processing direct Dialogflow request: {}", error.getMessage(), error)) + .then(); + } +} diff --git a/src/main/java/com/example/controller/QuickRepliesController.java b/src/main/java/com/example/controller/QuickRepliesController.java new file mode 100644 index 0000000..497afc4 --- /dev/null +++ b/src/main/java/com/example/controller/QuickRepliesController.java @@ -0,0 +1,38 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.controller; + +import com.example.dto.dialogflow.base.DetectIntentResponseDTO; +import com.example.dto.quickreplies.QuickReplyScreenRequestDTO; +import com.example.service.quickreplies.QuickRepliesManagerService; +import jakarta.validation.Valid; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Mono; + +@RestController +@RequestMapping("/api/v1/quick-replies") +public class QuickRepliesController { + + private static final Logger logger = LoggerFactory.getLogger(QuickRepliesController.class); + private final QuickRepliesManagerService quickRepliesManagerService; + + public QuickRepliesController(QuickRepliesManagerService quickRepliesManagerService) { + this.quickRepliesManagerService = quickRepliesManagerService; + } + + @PostMapping("/screen") + public Mono startSessionAndGetReplies(@Valid @RequestBody QuickReplyScreenRequestDTO request) { + return quickRepliesManagerService.startQuickReplySession(request) + .doOnSuccess(response -> logger.info("Successfully processed quick reply request")) + .doOnError(error -> logger.error("Error processing quick reply request: {}", error.getMessage(), error)); + } +} diff --git a/src/main/java/com/example/dto/dialogflow/base/DetectIntentRequestDTO.java b/src/main/java/com/example/dto/dialogflow/base/DetectIntentRequestDTO.java new file mode 100644 index 0000000..a4b6b8f --- /dev/null +++ b/src/main/java/com/example/dto/dialogflow/base/DetectIntentRequestDTO.java @@ -0,0 +1,40 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.dto.dialogflow.base; + +import com.example.dto.dialogflow.conversation.QueryInputDTO; +import com.example.dto.dialogflow.conversation.QueryParamsDTO; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record DetectIntentRequestDTO( + @JsonProperty("queryInput") QueryInputDTO queryInput, + @JsonProperty("queryParams") QueryParamsDTO queryParams +) { + +public DetectIntentRequestDTO withParameter(String key, Object value) { + // Create a new QueryParamsDTO with the updated session parameter + QueryParamsDTO updatedQueryParams = this.queryParams().withSessionParameter(key, value); + + // Return a new DetectIntentRequestDTO instance with the updated QueryParamsDTO + return new DetectIntentRequestDTO( + this.queryInput(), + updatedQueryParams + ); +} + +public DetectIntentRequestDTO withParameters(java.util.Map parameters) { + // Create a new QueryParamsDTO with the updated session parameters + QueryParamsDTO updatedQueryParams = this.queryParams().withSessionParameters(parameters); + + // Return a new DetectIntentRequestDTO instance with the updated QueryParamsDTO + return new DetectIntentRequestDTO( + this.queryInput(), + updatedQueryParams + ); +} +} \ No newline at end of file diff --git a/src/main/java/com/example/dto/dialogflow/base/DetectIntentResponseDTO.java b/src/main/java/com/example/dto/dialogflow/base/DetectIntentResponseDTO.java new file mode 100644 index 0000000..ac16a2f --- /dev/null +++ b/src/main/java/com/example/dto/dialogflow/base/DetectIntentResponseDTO.java @@ -0,0 +1,23 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.dto.dialogflow.base; + +import com.example.dto.dialogflow.conversation.QueryResultDTO; +import com.fasterxml.jackson.annotation.JsonProperty; + +import com.example.dto.quickreplies.QuickReplyDTO; +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record DetectIntentResponseDTO( + @JsonProperty("responseId") String responseId, + @JsonProperty("queryResult") QueryResultDTO queryResult, + @JsonProperty("quick_replies") QuickReplyDTO quickReplies +) { + public DetectIntentResponseDTO(String responseId, QueryResultDTO queryResult) { + this(responseId, queryResult, null); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/dto/dialogflow/conversation/ConversationContext.java b/src/main/java/com/example/dto/dialogflow/conversation/ConversationContext.java new file mode 100644 index 0000000..def56e3 --- /dev/null +++ b/src/main/java/com/example/dto/dialogflow/conversation/ConversationContext.java @@ -0,0 +1,13 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.dto.dialogflow.conversation; + +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/dialogflow/conversation/ConversationEntryDTO.java b/src/main/java/com/example/dto/dialogflow/conversation/ConversationEntryDTO.java new file mode 100644 index 0000000..df3a0d1 --- /dev/null +++ b/src/main/java/com/example/dto/dialogflow/conversation/ConversationEntryDTO.java @@ -0,0 +1,110 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.dto.dialogflow.conversation; + +import com.fasterxml.jackson.annotation.JsonInclude; +import java.time.Instant; +import java.util.Map; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record ConversationEntryDTO( + ConversationEntryEntity entity, + ConversationEntryType type, + Instant timestamp, + String text, + Map parameters, + String canal +) { + public static ConversationEntryDTO forUser(String text) { + return new ConversationEntryDTO( + ConversationEntryEntity.USUARIO, + ConversationEntryType.CONVERSACION, + Instant.now(), + text, + null, + null); + } + + public static ConversationEntryDTO forUser(String text, Map parameters) { + return new ConversationEntryDTO( + ConversationEntryEntity.USUARIO, + ConversationEntryType.CONVERSACION, + Instant.now(), + text, + parameters, + null); + } + + public static ConversationEntryDTO forAgent(QueryResultDTO agentQueryResult) { + String fulfillmentText = (agentQueryResult != null && agentQueryResult.responseText() != null) ? agentQueryResult.responseText() : ""; + Map parameters = (agentQueryResult != null) ? agentQueryResult.parameters() : null; + + return new ConversationEntryDTO( + ConversationEntryEntity.AGENTE, + ConversationEntryType.CONVERSACION, + Instant.now(), + fulfillmentText, + parameters, + null + ); + } + + public static ConversationEntryDTO forAgentWithMessage(String text) { + return new ConversationEntryDTO( + ConversationEntryEntity.AGENTE, + ConversationEntryType.CONVERSACION, + Instant.now(), + text, + null, + null + ); + } + public static ConversationEntryDTO forSystem(String text) { + return new ConversationEntryDTO( + ConversationEntryEntity.SISTEMA, + ConversationEntryType.CONVERSACION, + Instant.now(), + text, + null, + null + ); + } + + + + public static ConversationEntryDTO forSystem(String text, Map parameters) { + return new ConversationEntryDTO( + ConversationEntryEntity.SISTEMA, + ConversationEntryType.CONVERSACION, + Instant.now(), + text, + parameters, + null + ); + } + + public static ConversationEntryDTO forLlmConversation(String text) { + return new ConversationEntryDTO( + ConversationEntryEntity.LLM, + ConversationEntryType.CONVERSACION, + Instant.now(), + text, + null, + null + ); + } + + public static ConversationEntryDTO forLlmConversation(String text, Map parameters) { + return new ConversationEntryDTO( + ConversationEntryEntity.LLM, + ConversationEntryType.CONVERSACION, + Instant.now(), + text, + parameters, + null + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/dto/dialogflow/conversation/ConversationEntryEntity.java b/src/main/java/com/example/dto/dialogflow/conversation/ConversationEntryEntity.java new file mode 100644 index 0000000..e79a62a --- /dev/null +++ b/src/main/java/com/example/dto/dialogflow/conversation/ConversationEntryEntity.java @@ -0,0 +1,13 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.dto.dialogflow.conversation; + +public enum ConversationEntryEntity { + USUARIO, + AGENTE, + SISTEMA, + LLM +} \ No newline at end of file diff --git a/src/main/java/com/example/dto/dialogflow/conversation/ConversationEntryType.java b/src/main/java/com/example/dto/dialogflow/conversation/ConversationEntryType.java new file mode 100644 index 0000000..5ef10cf --- /dev/null +++ b/src/main/java/com/example/dto/dialogflow/conversation/ConversationEntryType.java @@ -0,0 +1,12 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.dto.dialogflow.conversation; + +public enum ConversationEntryType { + INICIO, + CONVERSACION, + LLM +} \ No newline at end of file diff --git a/src/main/java/com/example/dto/dialogflow/conversation/ConversationMessageDTO.java b/src/main/java/com/example/dto/dialogflow/conversation/ConversationMessageDTO.java new file mode 100644 index 0000000..af8da0c --- /dev/null +++ b/src/main/java/com/example/dto/dialogflow/conversation/ConversationMessageDTO.java @@ -0,0 +1,20 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.dto.dialogflow.conversation; + +import com.fasterxml.jackson.annotation.JsonInclude; +import java.time.Instant; +import java.util.Map; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record ConversationMessageDTO( + MessageType type, + Instant timestamp, + String text, + Map parameters, + String canal +) { +} \ No newline at end of file diff --git a/src/main/java/com/example/dto/dialogflow/conversation/ConversationSessionDTO.java b/src/main/java/com/example/dto/dialogflow/conversation/ConversationSessionDTO.java new file mode 100644 index 0000000..ce1c9ed --- /dev/null +++ b/src/main/java/com/example/dto/dialogflow/conversation/ConversationSessionDTO.java @@ -0,0 +1,53 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.dto.dialogflow.conversation; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import java.time.Instant; + +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonInclude(Include.NON_NULL) +public record ConversationSessionDTO( + String sessionId, + String userId, + String telefono, + Instant createdAt, + Instant lastModified, + String lastMessage, + String pantallaContexto +) { + public ConversationSessionDTO(String sessionId, String userId, String telefono, Instant createdAt, Instant lastModified, String lastMessage, String pantallaContexto) { + this.sessionId = sessionId; + this.userId = userId; + this.telefono = telefono; + this.createdAt = createdAt; + this.lastModified = lastModified; + this.lastMessage = lastMessage; + this.pantallaContexto = pantallaContexto; + } + + public static ConversationSessionDTO create(String sessionId, String userId, String telefono) { + Instant now = Instant.now(); + return new ConversationSessionDTO(sessionId, userId, telefono, now, now, null, null); + } + + public ConversationSessionDTO withLastMessage(String lastMessage) { + return new ConversationSessionDTO(this.sessionId, this.userId, this.telefono, this.createdAt, Instant.now(), lastMessage, this.pantallaContexto); + } + + public ConversationSessionDTO withTelefono(String newTelefono) { + if (newTelefono != null && !newTelefono.equals(this.telefono)) { + return new ConversationSessionDTO(this.sessionId, this.userId, newTelefono, this.createdAt, this.lastModified, this.lastMessage, this.pantallaContexto); + } + return this; + } + + public ConversationSessionDTO withPantallaContexto(String pantallaContexto) { + return new ConversationSessionDTO(this.sessionId, this.userId, this.telefono, this.createdAt, this.lastModified, this.lastMessage, pantallaContexto); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/dto/dialogflow/conversation/ExternalConvRequestDTO.java b/src/main/java/com/example/dto/dialogflow/conversation/ExternalConvRequestDTO.java new file mode 100644 index 0000000..cece1e1 --- /dev/null +++ b/src/main/java/com/example/dto/dialogflow/conversation/ExternalConvRequestDTO.java @@ -0,0 +1,20 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.dto.dialogflow.conversation; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record ExternalConvRequestDTO( + @JsonProperty("mensaje") String message, + @JsonProperty("usuario") UsuarioDTO user, + @JsonProperty("canal") String channel, + @JsonProperty("tipo") ConversationEntryType tipo, + @JsonProperty("pantallaContexto") String pantallaContexto //optional field for quick-replies +) { + public ExternalConvRequestDTO {} + } \ No newline at end of file diff --git a/src/main/java/com/example/dto/dialogflow/conversation/MessageType.java b/src/main/java/com/example/dto/dialogflow/conversation/MessageType.java new file mode 100644 index 0000000..9393c93 --- /dev/null +++ b/src/main/java/com/example/dto/dialogflow/conversation/MessageType.java @@ -0,0 +1,13 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.dto.dialogflow.conversation; + +public enum MessageType { + USER, + AGENT, + SYSTEM, + LLM +} \ No newline at end of file diff --git a/src/main/java/com/example/dto/dialogflow/conversation/QueryInputDTO.java b/src/main/java/com/example/dto/dialogflow/conversation/QueryInputDTO.java new file mode 100644 index 0000000..b56bc1b --- /dev/null +++ b/src/main/java/com/example/dto/dialogflow/conversation/QueryInputDTO.java @@ -0,0 +1,16 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.dto.dialogflow.conversation; + + + +import com.example.dto.dialogflow.notification.EventInputDTO; + +public record QueryInputDTO( + TextInputDTO text, // Can be null if using event + EventInputDTO event, + String languageCode // REQUIRED for both text and event inputs +) {} \ No newline at end of file diff --git a/src/main/java/com/example/dto/dialogflow/conversation/QueryParamsDTO.java b/src/main/java/com/example/dto/dialogflow/conversation/QueryParamsDTO.java new file mode 100644 index 0000000..cefceda --- /dev/null +++ b/src/main/java/com/example/dto/dialogflow/conversation/QueryParamsDTO.java @@ -0,0 +1,34 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.dto.dialogflow.conversation; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record QueryParamsDTO( + @JsonProperty("parameters") Map parameters) { + + public QueryParamsDTO { + parameters = Objects.requireNonNullElseGet(parameters, HashMap::new); + parameters = new HashMap<>(parameters); + } + + public QueryParamsDTO withSessionParameter(String key, Object value) { + Map updatedParams = new HashMap<>(this.parameters()); + updatedParams.put(key, value); + return new QueryParamsDTO(updatedParams); + } + + public QueryParamsDTO withSessionParameters(Map parameters) { + Map updatedParams = new HashMap<>(this.parameters()); + updatedParams.putAll(parameters); + return new QueryParamsDTO(updatedParams); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/dto/dialogflow/conversation/QueryResultDTO.java b/src/main/java/com/example/dto/dialogflow/conversation/QueryResultDTO.java new file mode 100644 index 0000000..485d893 --- /dev/null +++ b/src/main/java/com/example/dto/dialogflow/conversation/QueryResultDTO.java @@ -0,0 +1,14 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.dto.dialogflow.conversation; + +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/conversation/TextInputDTO.java b/src/main/java/com/example/dto/dialogflow/conversation/TextInputDTO.java new file mode 100644 index 0000000..b4546ee --- /dev/null +++ b/src/main/java/com/example/dto/dialogflow/conversation/TextInputDTO.java @@ -0,0 +1,8 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.dto.dialogflow.conversation; + +public record TextInputDTO(String text) {} \ No newline at end of file diff --git a/src/main/java/com/example/dto/dialogflow/conversation/UsuarioDTO.java b/src/main/java/com/example/dto/dialogflow/conversation/UsuarioDTO.java new file mode 100644 index 0000000..11bc796 --- /dev/null +++ b/src/main/java/com/example/dto/dialogflow/conversation/UsuarioDTO.java @@ -0,0 +1,14 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.dto.dialogflow.conversation; + +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/notification/EventInputDTO.java b/src/main/java/com/example/dto/dialogflow/notification/EventInputDTO.java new file mode 100644 index 0000000..23317cc --- /dev/null +++ b/src/main/java/com/example/dto/dialogflow/notification/EventInputDTO.java @@ -0,0 +1,10 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.dto.dialogflow.notification; + +public record EventInputDTO( + String event +) {} \ No newline at end of file diff --git a/src/main/java/com/example/dto/dialogflow/notification/ExternalNotRequestDTO.java b/src/main/java/com/example/dto/dialogflow/notification/ExternalNotRequestDTO.java new file mode 100644 index 0000000..ae1cae7 --- /dev/null +++ b/src/main/java/com/example/dto/dialogflow/notification/ExternalNotRequestDTO.java @@ -0,0 +1,18 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.dto.dialogflow.notification; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record ExternalNotRequestDTO( + @JsonProperty("texto") String text, + @JsonProperty("telefono") String phoneNumber, + @JsonProperty("parametrosOcultos") java.util.Map hiddenParameters +) { + public ExternalNotRequestDTO { + } +} \ No newline at end of file diff --git a/src/main/java/com/example/dto/dialogflow/notification/NotificationDTO.java b/src/main/java/com/example/dto/dialogflow/notification/NotificationDTO.java new file mode 100644 index 0000000..7c3cb16 --- /dev/null +++ b/src/main/java/com/example/dto/dialogflow/notification/NotificationDTO.java @@ -0,0 +1,33 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ +package com.example.dto.dialogflow.notification; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.time.Instant; +import java.util.Map; +import java.util.Objects; + +/** + * Represents a notification record to be stored in Firestore and cached in + * Redis. + */ +@JsonIgnoreProperties(ignoreUnknown = true) // Ignorar campos adicionales durante la deserialización +public record NotificationDTO( + String idNotificacion, // ID único para esta notificación (ej. el sessionId usado con Dialogflow) + String telefono, + Instant timestampCreacion, // Momento en que la notificación fue procesada + String texto, // 'texto' original de NotificationRequestDTO (si aplica) + String nombreEventoDialogflow, // Nombre del evento enviado a Dialogflow (ej. "tu Estado de cuenta listo") + String codigoIdiomaDialogflow, // Código de idioma usado para el evento + Map parametros, // Parámetros de sesión finales después del procesamiento de// Dialogflow + String status +) { + public NotificationDTO { + Objects.requireNonNull(idNotificacion, "Notification ID cannot be null."); + Objects.requireNonNull(timestampCreacion, "Notification timestamp cannot be null."); + Objects.requireNonNull(nombreEventoDialogflow, "Dialogflow event name cannot be null."); + Objects.requireNonNull(codigoIdiomaDialogflow, "Dialogflow language code cannot be null."); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/dto/dialogflow/notification/NotificationSessionDTO.java b/src/main/java/com/example/dto/dialogflow/notification/NotificationSessionDTO.java new file mode 100644 index 0000000..8169ddb --- /dev/null +++ b/src/main/java/com/example/dto/dialogflow/notification/NotificationSessionDTO.java @@ -0,0 +1,24 @@ +// src/main/java/com/example/dto/dialogflow/notification/NotificationSessionDTO.java +package com.example.dto.dialogflow.notification; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.time.Instant; +import java.util.List; +import java.util.Objects; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record NotificationSessionDTO( + String sessionId, // The unique session identifier (e.g., the phone number) + String telefono, // The phone number for this session + Instant fechaCreacion, // When the session was first created + Instant ultimaActualizacion, // When the session was last updated + List notificaciones // List of individual notification events +) { + public NotificationSessionDTO { + Objects.requireNonNull(sessionId, "Session ID cannot be null."); + Objects.requireNonNull(telefono, "Phone number cannot be null."); + Objects.requireNonNull(fechaCreacion, "Creation timestamp cannot be null."); + Objects.requireNonNull(ultimaActualizacion, "Last updated timestamp cannot be null."); + Objects.requireNonNull(notificaciones, "Notifications list cannot be null."); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/dto/llm/webhook/SessionInfoDTO.java b/src/main/java/com/example/dto/llm/webhook/SessionInfoDTO.java new file mode 100644 index 0000000..f21a543 --- /dev/null +++ b/src/main/java/com/example/dto/llm/webhook/SessionInfoDTO.java @@ -0,0 +1,23 @@ +package com.example.dto.llm.webhook; + +import java.util.Map; + +public class SessionInfoDTO { + + private Map parameters; + + public SessionInfoDTO() { + } + + public SessionInfoDTO(Map parameters) { + this.parameters = parameters; + } + + public Map getParameters() { + return parameters; + } + + public void setParameters(Map parameters) { + this.parameters = parameters; + } +} diff --git a/src/main/java/com/example/dto/llm/webhook/WebhookRequestDTO.java b/src/main/java/com/example/dto/llm/webhook/WebhookRequestDTO.java new file mode 100644 index 0000000..a646c16 --- /dev/null +++ b/src/main/java/com/example/dto/llm/webhook/WebhookRequestDTO.java @@ -0,0 +1,17 @@ +package com.example.dto.llm.webhook; + +public class WebhookRequestDTO { + + private SessionInfoDTO sessionInfo; + + public WebhookRequestDTO() { + } + + public SessionInfoDTO getSessionInfo() { + return sessionInfo; + } + + public void setSessionInfo(SessionInfoDTO sessionInfo) { + this.sessionInfo = sessionInfo; + } +} diff --git a/src/main/java/com/example/dto/llm/webhook/WebhookResponseDTO.java b/src/main/java/com/example/dto/llm/webhook/WebhookResponseDTO.java new file mode 100644 index 0000000..9014739 --- /dev/null +++ b/src/main/java/com/example/dto/llm/webhook/WebhookResponseDTO.java @@ -0,0 +1,24 @@ +package com.example.dto.llm.webhook; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class WebhookResponseDTO { + + @JsonProperty("sessionInfo") + private SessionInfoDTO sessionInfo; + + public WebhookResponseDTO() { + } + + public WebhookResponseDTO(SessionInfoDTO sessionInfo) { + this.sessionInfo = sessionInfo; + } + + public SessionInfoDTO getSessionInfo() { + return sessionInfo; + } + + public void setSessionInfo(SessionInfoDTO sessionInfo) { + this.sessionInfo = sessionInfo; + } +} diff --git a/src/main/java/com/example/dto/quickreplies/QuestionDTO.java b/src/main/java/com/example/dto/quickreplies/QuestionDTO.java new file mode 100644 index 0000000..198464b --- /dev/null +++ b/src/main/java/com/example/dto/quickreplies/QuestionDTO.java @@ -0,0 +1,7 @@ +package com.example.dto.quickreplies; +import com.fasterxml.jackson.annotation.JsonProperty; +public record QuestionDTO( + @JsonProperty("titulo") String titulo, + @JsonProperty("descripcion") String descripcion, + @JsonProperty("respuesta") String respuesta +) {} \ No newline at end of file diff --git a/src/main/java/com/example/dto/quickreplies/QuickReplyDTO.java b/src/main/java/com/example/dto/quickreplies/QuickReplyDTO.java new file mode 100644 index 0000000..cf1b97c --- /dev/null +++ b/src/main/java/com/example/dto/quickreplies/QuickReplyDTO.java @@ -0,0 +1,10 @@ +package com.example.dto.quickreplies; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; +public record QuickReplyDTO( + @JsonProperty("header") String header, + @JsonProperty("body") String body, + @JsonProperty("button") String button, + @JsonProperty("header_section") String headerSection, + @JsonProperty("preguntas") List preguntas +) {} \ No newline at end of file diff --git a/src/main/java/com/example/dto/quickreplies/QuickReplyScreenRequestDTO.java b/src/main/java/com/example/dto/quickreplies/QuickReplyScreenRequestDTO.java new file mode 100644 index 0000000..acb0105 --- /dev/null +++ b/src/main/java/com/example/dto/quickreplies/QuickReplyScreenRequestDTO.java @@ -0,0 +1,14 @@ +package com.example.dto.quickreplies; + +import com.example.dto.dialogflow.conversation.ConversationEntryType; +import com.example.dto.dialogflow.conversation.UsuarioDTO; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record QuickReplyScreenRequestDTO( + @JsonProperty("usuario") UsuarioDTO user, + @JsonProperty("canal") String channel, + @JsonProperty("tipo") ConversationEntryType tipo, + @JsonProperty("pantallaContexto") String pantallaContexto +) {} 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..47badd5 --- /dev/null +++ b/src/main/java/com/example/exception/DialogflowClientException.java @@ -0,0 +1,17 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.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..b53abcc --- /dev/null +++ b/src/main/java/com/example/exception/FirestorePersistenceException.java @@ -0,0 +1,17 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.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/exception/GeminiClientException.java b/src/main/java/com/example/exception/GeminiClientException.java new file mode 100644 index 0000000..fed2b7b --- /dev/null +++ b/src/main/java/com/example/exception/GeminiClientException.java @@ -0,0 +1,17 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.exception; + +public class GeminiClientException extends Exception { + + public GeminiClientException(String message) { + super(message); + } + + public GeminiClientException(String message, Throwable cause) { + super(message, cause); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/exception/GlobalExceptionHandler.java b/src/main/java/com/example/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..b253197 --- /dev/null +++ b/src/main/java/com/example/exception/GlobalExceptionHandler.java @@ -0,0 +1,49 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.exception; + +import java.util.HashMap; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; + +@ControllerAdvice +public class GlobalExceptionHandler { + + private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + @ExceptionHandler(DialogflowClientException.class) + public ResponseEntity> handleDialogflowClientException( + DialogflowClientException ex) { + Map error = new HashMap<>(); + error.put("error", "Error communicating with Dialogflow"); + error.put("message", ex.getMessage()); + logger.error("DialogflowClientException: {}", ex.getMessage()); + return new ResponseEntity<>(error, HttpStatus.SERVICE_UNAVAILABLE); + } + + @ExceptionHandler(GeminiClientException.class) + public ResponseEntity> handleGeminiClientException(GeminiClientException ex) { + Map error = new HashMap<>(); + error.put("error", "Error communicating with Gemini"); + error.put("message", ex.getMessage()); + logger.error("GeminiClientException: {}", ex.getMessage()); + return new ResponseEntity<>(error, HttpStatus.SERVICE_UNAVAILABLE); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleAllExceptions(Exception ex) { + Map error = new HashMap<>(); + error.put("error", "Internal Server Error"); + error.put("message", ex.getMessage()); + logger.error("An unexpected error occurred: {}", ex.getMessage(), ex); + return new ResponseEntity<>(error, HttpStatus.INTERNAL_SERVER_ERROR); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/mapper/conversation/ConversationEntryMapper.java b/src/main/java/com/example/mapper/conversation/ConversationEntryMapper.java new file mode 100644 index 0000000..f995ce1 --- /dev/null +++ b/src/main/java/com/example/mapper/conversation/ConversationEntryMapper.java @@ -0,0 +1,32 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.mapper.conversation; + +import com.example.dto.dialogflow.conversation.ConversationEntryDTO; +import com.example.dto.dialogflow.conversation.ConversationMessageDTO; +import com.example.dto.dialogflow.conversation.MessageType; +import org.springframework.stereotype.Component; + +@Component +public class ConversationEntryMapper { + + public ConversationMessageDTO toConversationMessageDTO(ConversationEntryDTO entry) { + MessageType type = switch (entry.entity()) { + case USUARIO -> MessageType.USER; + case AGENTE -> MessageType.AGENT; + case SISTEMA -> MessageType.SYSTEM; + case LLM -> MessageType.LLM; + }; + + return new ConversationMessageDTO( + type, + entry.timestamp(), + entry.text(), + entry.parameters(), + entry.canal() + ); + } +} diff --git a/src/main/java/com/example/mapper/conversation/ConversationMessageMapper.java b/src/main/java/com/example/mapper/conversation/ConversationMessageMapper.java new file mode 100644 index 0000000..ec167c1 --- /dev/null +++ b/src/main/java/com/example/mapper/conversation/ConversationMessageMapper.java @@ -0,0 +1,50 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.mapper.conversation; +import com.google.cloud.Timestamp; + +import com.example.dto.dialogflow.conversation.ConversationMessageDTO; +import com.example.dto.dialogflow.conversation.MessageType; +import org.springframework.stereotype.Component; + +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; + +@Component +public class ConversationMessageMapper { + + public Map toMap(ConversationMessageDTO message) { + Map map = new HashMap<>(); + map.put("entidad", message.type().name()); + map.put("tiempo", message.timestamp()); + map.put("mensaje", message.text()); + if (message.parameters() != null) { + map.put("parametros", message.parameters()); + } + if (message.canal() != null) { + map.put("canal", message.canal()); + } + return map; + } + + public ConversationMessageDTO fromMap(Map map) { + Object timeObject = map.get("tiempo"); + Instant timestamp = null; + if (timeObject instanceof Timestamp) { + timestamp = ((Timestamp) timeObject).toDate().toInstant(); + } else if (timeObject instanceof Instant) { + timestamp = (Instant) timeObject; + } + return new ConversationMessageDTO( + MessageType.valueOf((String) map.get("entidad")), + timestamp, + (String) map.get("mensaje"), + (Map) map.get("parametros"), + (String) map.get("canal") + ); + } +} diff --git a/src/main/java/com/example/mapper/conversation/DialogflowRequestMapper.java b/src/main/java/com/example/mapper/conversation/DialogflowRequestMapper.java new file mode 100644 index 0000000..d587d78 --- /dev/null +++ b/src/main/java/com/example/mapper/conversation/DialogflowRequestMapper.java @@ -0,0 +1,102 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.mapper.conversation; + +import com.example.dto.dialogflow.base.DetectIntentRequestDTO; +import com.example.dto.dialogflow.conversation.QueryInputDTO; +import com.example.util.ProtobufUtil; +import com.google.cloud.dialogflow.cx.v3.EventInput; +import com.google.cloud.dialogflow.cx.v3.DetectIntentRequest; + +import com.google.cloud.dialogflow.cx.v3.QueryInput; +import com.google.cloud.dialogflow.cx.v3.QueryParameters; +import com.google.cloud.dialogflow.cx.v3.TextInput; +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; + +/** + * Spring component responsible for mapping a custom `DetectIntentRequestDTO` + * into a Dialogflow CX `DetectIntentRequest.Builder`. It handles the conversion + * of user text or event inputs and the serialization of custom session parameters, + * ensuring the data is in the correct Protobuf format for API communication. + */ +@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)."); + QueryInput.Builder queryInputBuilder = QueryInput.newBuilder(); + QueryInputDTO queryInputDTO = requestDto.queryInput(); + + String languageCodeToSet = (queryInputDTO.languageCode() != null + && !queryInputDTO.languageCode().trim().isEmpty()) + ? queryInputDTO.languageCode() + : defaultLanguageCode; + queryInputBuilder.setLanguageCode(languageCodeToSet); + logger.debug("Setting languageCode for QueryInput to: {}", languageCodeToSet); + + if (queryInputDTO.text() != null && queryInputDTO.text().text() != null + && !queryInputDTO.text().text().trim().isEmpty()) { + queryInputBuilder.setText(TextInput.newBuilder() + .setText(queryInputDTO.text().text()) + .build()); + logger.debug("Mapped text input for QueryInput: '{}'", queryInputDTO.text().text()); + + } else if (queryInputDTO.event() != null && queryInputDTO.event().event() != null + && !queryInputDTO.event().event().trim().isEmpty()) { + queryInputBuilder.setEvent(EventInput.newBuilder() + .setEvent(queryInputDTO.event().event()) + .build()); + logger.debug("Mapped event input for QueryInput: '{}'", queryInputDTO.event().event()); + + } else { + logger.error("Dialogflow query input (either text or event) is required and must not be empty."); + throw new IllegalArgumentException("Dialogflow query input (either text or event) is required."); + } + + QueryParameters.Builder queryParametersBuilder = QueryParameters.newBuilder(); + Struct.Builder paramsStructBuilder = Struct.newBuilder(); + + 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); + logger.debug("Added session parameter from DTO queryParams: Key='{}', Value='{}'", + entry.getKey(),entry.getValue()); + } + } + + if (paramsStructBuilder.getFieldsCount() > 0) { + queryParametersBuilder.setParameters(paramsStructBuilder.build()); + logger.debug("All custom session parameters added to Protobuf request builder."); + } else { + logger.debug("No custom session parameters to add to Protobuf request."); + } + + 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/conversation/DialogflowResponseMapper.java b/src/main/java/com/example/mapper/conversation/DialogflowResponseMapper.java new file mode 100644 index 0000000..e8b01fb --- /dev/null +++ b/src/main/java/com/example/mapper/conversation/DialogflowResponseMapper.java @@ -0,0 +1,100 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.mapper.conversation; + +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.dto.dialogflow.base.DetectIntentResponseDTO; +import com.example.dto.dialogflow.conversation.QueryResultDTO; +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; + +/** + * Spring component responsible for mapping a Dialogflow CX API response + * (`DetectIntentResponse`) to a simplified custom DTO (`DetectIntentResponseDTO`). + * It extracts and consolidates the fulfillment text, and converts Protobuf + * session parameters into standard Java objects, providing a clean and + * decoupled interface for consuming Dialogflow results. + */ +@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 = new LinkedHashMap<>(); // Inicializamos vacío para evitar NPEs después + + if (dfQueryResult.hasParameters()) { + // Usamos un forEach en lugar de Collectors.toMap para tener control total sobre nulos + dfQueryResult.getParameters().getFieldsMap().forEach((key, value) -> { + try { + Object convertedValue = ProtobufUtil.convertProtobufValueToJavaObject(value); + + // Si el valor convertido es nulo, decidimos qué hacer. + // Lo mejor es poner un String vacío o ignorarlo para que no explote tu lógica. + if (convertedValue != null) { + parameters.put(key, convertedValue); + } else { + logger.warn("El parámetro '{}' devolvió un valor nulo al convertir. Se ignorará.", key); + // Opcional: parameters.put(key, ""); + } + } catch (Exception e) { + logger.error("Error convirtiendo el parámetro '{}' de Protobuf a Java: {}", key, e.getMessage()); + } + }); + + 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/conversation/ExternalConvRequestMapper.java b/src/main/java/com/example/mapper/conversation/ExternalConvRequestMapper.java new file mode 100644 index 0000000..730e2c1 --- /dev/null +++ b/src/main/java/com/example/mapper/conversation/ExternalConvRequestMapper.java @@ -0,0 +1,85 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.mapper.conversation; + +import com.example.dto.dialogflow.base.DetectIntentRequestDTO; +import com.example.dto.dialogflow.conversation.ExternalConvRequestDTO; +import com.example.dto.dialogflow.conversation.QueryInputDTO; +import com.example.dto.dialogflow.conversation.QueryParamsDTO; +import com.example.dto.dialogflow.conversation.TextInputDTO; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +/** + * Spring component responsible for mapping a simplified, external API request + * into a structured `DetectIntentRequestDTO` for Dialogflow. It processes + * user messages and relevant context, such as phone numbers and channel information, + * and populates the `QueryInputDTO` and `QueryParamsDTO` fields required for + * a Dialogflow API call. + */ +@Component +public class ExternalConvRequestMapper { + + private static final Logger logger = LoggerFactory.getLogger(ExternalConvRequestMapper.class); + private static final String DEFAULT_LANGUAGE_CODE = "es"; + + public DetectIntentRequestDTO mapExternalRequestToDetectIntentRequest(ExternalConvRequestDTO externalRequest) { + Objects.requireNonNull(externalRequest, "ExternalRequestDTO cannot be null for mapping."); + + if (externalRequest.message() == null || externalRequest.message().isBlank()) { + throw new IllegalArgumentException("External request 'mensaje' (message) is required."); + } + TextInputDTO textInput = new TextInputDTO(externalRequest.message()); + QueryInputDTO queryInputDTO = new QueryInputDTO(textInput,null,DEFAULT_LANGUAGE_CODE); + + // 2. Map ALL relevant external fields into QueryParamsDTO.parameters + Map parameters = new HashMap<>(); + + String primaryPhoneNumber = null; + if (externalRequest.user() != null && externalRequest.user().telefono() != null + && !externalRequest.user().telefono().isBlank()) { + primaryPhoneNumber = externalRequest.user().telefono(); + parameters.put("telefono", primaryPhoneNumber); + } + + if (primaryPhoneNumber == null || primaryPhoneNumber.isBlank()) { + throw new IllegalArgumentException( + "Phone number is required in the 'usuario' field for conversation management."); + } + + String resolvedUserId = null; + // Derive from phone number if not provided by 'userId' parameter + resolvedUserId = "user_by_phone_" + primaryPhoneNumber.replaceAll("[^0-9]", ""); + parameters.put("usuario_id", resolvedUserId); // Ensure derived ID is also in params + logger.warn("User ID not provided in external request. Using derived ID from phone number: {}", resolvedUserId); + + + if (externalRequest.channel() != null && !externalRequest.channel().isBlank()) { + parameters.put("canal", externalRequest.channel()); + logger.debug("Mapped 'canal' from external request: {}", externalRequest.channel()); + } + + if (externalRequest.user() != null && externalRequest.user().nickname() != null + && !externalRequest.user().nickname().isBlank()) { + parameters.put("nickname", externalRequest.user().nickname()); + logger.debug("Mapped 'nickname' from external request: {}", externalRequest.user().nickname()); + } + if (externalRequest.tipo() != null) { + parameters.put("tipo", externalRequest.tipo()); + logger.debug("Mapped 'tipo' from external request: {}", externalRequest.tipo()); + } + + QueryParamsDTO queryParamsDTO = new QueryParamsDTO(parameters); + + // 3. Construct the final DetectIntentRequestDTO + return new DetectIntentRequestDTO(queryInputDTO, queryParamsDTO); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/mapper/conversation/FirestoreConversationMapper.java b/src/main/java/com/example/mapper/conversation/FirestoreConversationMapper.java new file mode 100644 index 0000000..0398b48 --- /dev/null +++ b/src/main/java/com/example/mapper/conversation/FirestoreConversationMapper.java @@ -0,0 +1,53 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.mapper.conversation; + +import com.example.dto.dialogflow.conversation.ConversationSessionDTO; +import com.google.cloud.Timestamp; +import com.google.cloud.firestore.DocumentSnapshot; +import org.springframework.stereotype.Component; + +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; + +@Component +public class FirestoreConversationMapper { + + public ConversationSessionDTO mapFirestoreDocumentToConversationSessionDTO(DocumentSnapshot document) { + if (document == null || !document.exists()) { + return null; + } + + Timestamp createdAtTimestamp = document.getTimestamp("fechaCreacion"); + Timestamp lastModifiedTimestamp = document.getTimestamp("ultimaActualizacion"); + + Instant createdAt = (createdAtTimestamp != null) ? createdAtTimestamp.toDate().toInstant() : null; + Instant lastModified = (lastModifiedTimestamp != null) ? lastModifiedTimestamp.toDate().toInstant() : null; + + return new ConversationSessionDTO( + document.getString("sessionId"), + document.getString("userId"), + document.getString("telefono"), + createdAt, + lastModified, + document.getString("ultimoMensaje"), + document.getString("pantallaContexto") + ); + } + + public Map createSessionMap(ConversationSessionDTO session) { + Map sessionMap = new HashMap<>(); + sessionMap.put("sessionId", session.sessionId()); + sessionMap.put("userId", session.userId()); + sessionMap.put("telefono", session.telefono()); + sessionMap.put("fechaCreacion", session.createdAt()); + sessionMap.put("ultimaActualizacion", session.lastModified()); + sessionMap.put("ultimoMensaje", session.lastMessage()); + sessionMap.put("pantallaContexto", session.pantallaContexto()); + return sessionMap; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/mapper/messagefilter/ConversationContextMapper.java b/src/main/java/com/example/mapper/messagefilter/ConversationContextMapper.java new file mode 100644 index 0000000..39448a1 --- /dev/null +++ b/src/main/java/com/example/mapper/messagefilter/ConversationContextMapper.java @@ -0,0 +1,119 @@ +package com.example.mapper.messagefilter; + +import com.example.dto.dialogflow.conversation.ConversationMessageDTO; +import com.example.dto.dialogflow.conversation.ConversationSessionDTO; +import com.example.dto.dialogflow.conversation.MessageType; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Collectors; + +@Component +public class ConversationContextMapper { + + @Value("${conversation.context.message.limit:60}") + private int messageLimit; + + @Value("${conversation.context.days.limit:30}") + private int daysLimit; + + private static final int MAX_HISTORY_BYTES = 50 * 1024; // 50 KB + + private static final String NOTIFICATION_TEXT_PARAM = "notification_text"; + + public String toText(ConversationSessionDTO session, List messages) { + if (messages == null || messages.isEmpty()) { + return ""; + } + return toTextFromMessages(messages); + } + + public String toTextWithLimits(ConversationSessionDTO session, List messages) { + if (messages == null || messages.isEmpty()) { + return ""; + } + + Instant thirtyDaysAgo = Instant.now().minus(daysLimit, ChronoUnit.DAYS); + + List recentEntries = messages.stream() + .filter(entry -> entry.timestamp() != null && entry.timestamp().isAfter(thirtyDaysAgo)) + .sorted(Comparator.comparing(ConversationMessageDTO::timestamp).reversed()) + .limit(messageLimit) + .sorted(Comparator.comparing(ConversationMessageDTO::timestamp)) + .collect(Collectors.toList()); + return toTextWithTruncation(recentEntries); + } + + public String toTextFromMessages(List messages) { + return messages.stream() + .map(this::formatEntry) + .collect(Collectors.joining("\n")); + } + + public String toTextWithTruncation(List messages) { + if (messages == null || messages.isEmpty()) { + return ""; + } + + StringBuilder textBlock = new StringBuilder(); + List formattedMessages = messages.stream() + .map(this::formatEntry) + .collect(Collectors.toList()); + + for (int i = formattedMessages.size() - 1; i >= 0; i--) { + String message = formattedMessages.get(i) + "\n"; + if (textBlock.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8).length + message.getBytes(java.nio.charset.StandardCharsets.UTF_8).length > MAX_HISTORY_BYTES) { + break; + } + textBlock.insert(0, message); + } + + return textBlock.toString().trim(); + } + + private String formatEntry(ConversationMessageDTO entry) { + String prefix = "User: "; + String content = entry.text(); + + if (entry.type() != null) { + switch (entry.type()) { + case AGENT: + prefix = "Agent: "; + break; + case SYSTEM: + prefix = "System: "; + // fix: add notification in the conversation. + if (entry.parameters() != null && entry.parameters().containsKey(NOTIFICATION_TEXT_PARAM)) { + Object paramText = entry.parameters().get(NOTIFICATION_TEXT_PARAM); + if (paramText != null && !paramText.toString().isBlank()) { + content = paramText.toString(); + } + } + break; + case LLM: + prefix = "System: "; + break; + case USER: + default: + prefix = "User: "; + break; + } + } + + String text = prefix + content; + + if (entry.type() == MessageType.AGENT) { + text = cleanAgentMessage(text); + } + + return text; + } + + private String cleanAgentMessage(String message) { + return message.replaceAll("\\s*\\{.*\\}\\s*$", "").trim(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/mapper/messagefilter/NotificationContextMapper.java b/src/main/java/com/example/mapper/messagefilter/NotificationContextMapper.java new file mode 100644 index 0000000..a9c30fb --- /dev/null +++ b/src/main/java/com/example/mapper/messagefilter/NotificationContextMapper.java @@ -0,0 +1,33 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.mapper.messagefilter; + +import com.example.dto.dialogflow.notification.NotificationDTO; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.stream.Collectors; + +@Component +public class NotificationContextMapper { + + public String toText(NotificationDTO notification) { + if (notification == null || notification.texto() == null) { + return ""; + } + return notification.texto(); + } + + public String toText(List notifications) { + if (notifications == null || notifications.isEmpty()) { + return ""; + } + return notifications.stream() + .map(NotificationDTO::texto) + .filter(texto -> texto != null && !texto.isBlank()) + .collect(Collectors.joining("\n")); + } +} diff --git a/src/main/java/com/example/mapper/notification/ExternalNotRequestMapper.java b/src/main/java/com/example/mapper/notification/ExternalNotRequestMapper.java new file mode 100644 index 0000000..1f42f87 --- /dev/null +++ b/src/main/java/com/example/mapper/notification/ExternalNotRequestMapper.java @@ -0,0 +1,68 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.mapper.notification; + +import com.example.dto.dialogflow.notification.ExternalNotRequestDTO; +import com.example.dto.dialogflow.base.DetectIntentRequestDTO; +import com.example.dto.dialogflow.conversation.QueryInputDTO; +import com.example.dto.dialogflow.conversation.QueryParamsDTO; +import com.example.dto.dialogflow.conversation.TextInputDTO; + + +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +/** + * Spring component for mapping an external notification request to a Dialogflow `DetectIntentRequestDTO`. + * This class takes a simplified `ExternalNotRequestDTO` and converts it into the structured + * DTO required for a Dialogflow API call, specifically for triggering a notification event. + * It ensures required parameters like the phone number are present and populates the + * request with event-specific details. + */ +@Component +public class ExternalNotRequestMapper { + private static final String LANGUAGE_CODE = "es"; + private static final String TELEPHONE_PARAM_NAME = "telefono"; + private static final String NOTIFICATION_TEXT_PARAM = "notification_text"; + private static final String NOTIFICATION_LABEL = "NOTIFICACION"; + private static final String PREFIX_PO_PARAM = "notification_po_"; + + + + public DetectIntentRequestDTO map(ExternalNotRequestDTO request) { + Objects.requireNonNull(request, "NotificationRequestDTO cannot be null for mapping."); + + if (request.phoneNumber() == null || request.phoneNumber().isEmpty()) { + throw new IllegalArgumentException("Phone numbers is required and cannot be empty in NotificationRequestDTO."); + } + String phoneNumber = request.phoneNumber(); + + Map parameters = new HashMap<>(); + parameters.put(TELEPHONE_PARAM_NAME, phoneNumber); + parameters.put(NOTIFICATION_TEXT_PARAM, request.text()); + + + if (request.hiddenParameters() != null && !request.hiddenParameters().isEmpty()) { + StringBuilder poBuilder = new StringBuilder(); + request.hiddenParameters().forEach((key, value) -> { + parameters.put(PREFIX_PO_PARAM + key, value); + poBuilder.append(key).append(": ").append(value).append("\n"); + }); + parameters.put("po", poBuilder.toString()); + } + + TextInputDTO textInput = new TextInputDTO(NOTIFICATION_LABEL); + QueryInputDTO queryInput = new QueryInputDTO(textInput, null, LANGUAGE_CODE); + + + QueryParamsDTO queryParams = new QueryParamsDTO(parameters); + + return new DetectIntentRequestDTO(queryInput, queryParams); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/mapper/notification/FirestoreNotificationMapper.java b/src/main/java/com/example/mapper/notification/FirestoreNotificationMapper.java new file mode 100644 index 0000000..78479e6 --- /dev/null +++ b/src/main/java/com/example/mapper/notification/FirestoreNotificationMapper.java @@ -0,0 +1,73 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ +package com.example.mapper.notification; + +import com.example.dto.dialogflow.notification.NotificationDTO; +import org.springframework.stereotype.Component; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.cloud.firestore.DocumentSnapshot; + +import java.time.Instant; +import java.util.Map; +import java.util.Objects; + +/** + * Spring component for mapping notification data between application DTOs and Firestore documents. + * This class handles the transformation of Dialogflow event details and notification metadata + * into a `NotificationDTO` for persistence and provides methods to serialize and deserialize + * this DTO to and from Firestore-compatible data structures. + */ +@Component +public class FirestoreNotificationMapper { + + private static final String DEFAULT_LANGUAGE_CODE = "es"; + private static final String FIXED_EVENT_NAME = "notificacion"; + private final ObjectMapper objectMapper; + private static final String DEFAULT_NOTIFICATION_STATUS="ACTIVE"; + + public FirestoreNotificationMapper(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + public NotificationDTO mapToFirestoreNotification( + String notificationId, + String telephone, + String notificationText, + Map parameters) { + + Objects.requireNonNull(notificationId, "Notification ID cannot be null for mapping."); + Objects.requireNonNull(notificationText, "Notification text cannot be null for mapping."); + Objects.requireNonNull(parameters, "Dialogflow parameters map cannot be null."); + + return new NotificationDTO( + notificationId, + telephone, + Instant.now(), + notificationText, + FIXED_EVENT_NAME, + DEFAULT_LANGUAGE_CODE, + parameters, + DEFAULT_NOTIFICATION_STATUS + ); + } + + public NotificationDTO mapFirestoreDocumentToNotificationDTO(DocumentSnapshot documentSnapshot) { + Objects.requireNonNull(documentSnapshot, "DocumentSnapshot cannot be null for mapping."); + if (!documentSnapshot.exists()) { + throw new IllegalArgumentException("DocumentSnapshot does not exist."); + } + try { + return objectMapper.convertValue(documentSnapshot.getData(), NotificationDTO.class); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException( + "Failed to convert Firestore document data to NotificationDTO for ID " + documentSnapshot.getId(), e); + } + } + + public Map mapNotificationDTOToMap(NotificationDTO notificationDTO) { + Objects.requireNonNull(notificationDTO, "NotificationDTO cannot be null for mapping to map."); + return objectMapper.convertValue(notificationDTO, new com.fasterxml.jackson.core.type.TypeReference>() {}); + } +} \ 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..772e1e6 --- /dev/null +++ b/src/main/java/com/example/repository/FirestoreBaseRepository.java @@ -0,0 +1,229 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.repository; + +import com.example.util.FirestoreTimestampDeserializer; +import com.example.util.FirestoreTimestampSerializer; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.fasterxml.jackson.module.paramnames.ParameterNamesModule; +import com.google.api.core.ApiFuture; +import com.google.cloud.firestore.DocumentReference; +import com.google.cloud.firestore.DocumentSnapshot; +import com.google.cloud.firestore.Firestore; +import com.google.cloud.firestore.Query; +import com.google.cloud.firestore.QueryDocumentSnapshot; +import com.google.cloud.firestore.QuerySnapshot; +import com.google.cloud.firestore.WriteBatch; +import com.google.cloud.firestore.WriteResult; +import com.google.cloud.firestore.CollectionReference; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ExecutionException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Repository; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * A base repository for performing low-level operations with Firestore. It provides a generic + * interface for common data access tasks such as getting document references, performing reads, + * writes, and batched updates. This class also handles the serialization and deserialization of + * Java objects to and from Firestore documents using an `ObjectMapper`. + */ +@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 Flux getDocuments(String collectionPath) { + return Flux.create(sink -> { + ApiFuture future = firestore.collection(collectionPath).get(); + future.addListener(() -> { + try { + QuerySnapshot querySnapshot = future.get(); + if (querySnapshot != null) { + querySnapshot.getDocuments().forEach(sink::next); + } + sink.complete(); + } catch (InterruptedException | ExecutionException e) { + sink.error(e); + } + }, Runnable::run); + }); + } + + public Mono getDocumentsByField( + String collectionPath, String fieldName, String value) { + return Mono.fromCallable( + () -> { + Query query = firestore.collection(collectionPath).whereEqualTo(fieldName, value); + ApiFuture future = query.get(); + QuerySnapshot querySnapshot = future.get(); + if (!querySnapshot.isEmpty()) { + return querySnapshot.getDocuments().get(0); + } + return null; + }); + } + + public boolean documentExists(DocumentReference docRef) + throws InterruptedException, ExecutionException { + Objects.requireNonNull(docRef, "DocumentReference cannot be null."); + ApiFuture 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 void deleteDocument(DocumentReference docRef) + throws InterruptedException, ExecutionException { + Objects.requireNonNull(docRef, "DocumentReference cannot be null."); + ApiFuture future = docRef.delete(); + WriteResult writeResult = future.get(); + logger.debug( + "Document deleted: {} 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; + } + + public void deleteCollection(String collectionPath, int batchSize) { + try { + CollectionReference collection = firestore.collection(collectionPath); + ApiFuture future = collection.limit(batchSize).get(); + int deleted = 0; + // future.get() blocks on document retrieval + List documents = future.get().getDocuments(); + while (!documents.isEmpty()) { + for (QueryDocumentSnapshot document : documents) { + document.getReference().delete(); + ++deleted; + } + future = collection.limit(batchSize).get(); + documents = future.get().getDocuments(); + } + logger.info("Deleted {} documents from collection {}", deleted, collectionPath); + } catch (Exception e) { + logger.error("Error deleting collection: " + e.getMessage(), e); + throw new RuntimeException("Error deleting collection", e); + } + } + + public void deleteDocumentAndSubcollections(DocumentReference docRef, String subcollection) + throws ExecutionException, InterruptedException { + deleteCollection(docRef.collection(subcollection).getPath(), 50); + deleteDocument(docRef); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/service/base/DataPurgeService.java b/src/main/java/com/example/service/base/DataPurgeService.java new file mode 100644 index 0000000..2044852 --- /dev/null +++ b/src/main/java/com/example/service/base/DataPurgeService.java @@ -0,0 +1,214 @@ + +package com.example.service.base; + + + +import com.example.repository.FirestoreBaseRepository; + +import com.google.cloud.firestore.CollectionReference; + +import com.google.cloud.firestore.Firestore; + +import com.google.cloud.firestore.QueryDocumentSnapshot; + +import java.util.List; + +import org.slf4j.Logger; + +import org.slf4j.LoggerFactory; + +import org.springframework.beans.factory.annotation.Autowired; + +import org.springframework.beans.factory.annotation.Qualifier; + +import org.springframework.data.redis.core.ReactiveRedisTemplate; + +import org.springframework.stereotype.Service; + +import reactor.core.publisher.Mono; + +import reactor.core.scheduler.Schedulers; + + + +@Service + +public class DataPurgeService { + + + + private static final Logger logger = LoggerFactory.getLogger(DataPurgeService.class); + + + + private final ReactiveRedisTemplate redisTemplate; + + private final FirestoreBaseRepository firestoreBaseRepository; + + + + private final Firestore firestore; + + + + @Autowired + + public DataPurgeService( + + @Qualifier("reactiveRedisTemplate") ReactiveRedisTemplate redisTemplate, + + FirestoreBaseRepository firestoreBaseRepository, Firestore firestore) { + + this.redisTemplate = redisTemplate; + + this.firestoreBaseRepository = firestoreBaseRepository; + + this.firestore = firestore; + + } + + + + public Mono purgeAllData() { + + return purgeRedis() + + .then(purgeFirestore()); + + } + + + + private Mono purgeRedis() { + + logger.info("Starting Redis data purge."); + + return redisTemplate.getConnectionFactory().getReactiveConnection().serverCommands().flushAll() + + .doOnSuccess(v -> logger.info("Successfully purged all data from Redis.")) + + .doOnError(e -> logger.error("Error purging data from Redis.", e)) + + .then(); + + } + + + + private Mono purgeFirestore() { + + logger.info("Starting Firestore data purge."); + + return Mono.fromRunnable(() -> { + + try { + + String appId = firestoreBaseRepository.getAppId(); + + String conversationsCollectionPath = String.format("artifacts/%s/conversations", appId); + + String notificationsCollectionPath = String.format("artifacts/%s/notifications", appId); + + + + // Delete 'mensajes' sub-collections in 'conversations' + + logger.info("Deleting 'mensajes' sub-collections from '{}'", conversationsCollectionPath); + + try { + + List conversationDocuments = firestore.collection(conversationsCollectionPath).get().get().getDocuments(); + + for (QueryDocumentSnapshot document : conversationDocuments) { + + String messagesCollectionPath = document.getReference().getPath() + "/mensajes"; + + logger.info("Deleting sub-collection: {}", messagesCollectionPath); + + firestoreBaseRepository.deleteCollection(messagesCollectionPath, 50); + + } + + } catch (Exception e) { + + if (e.getMessage().contains("NOT_FOUND")) { + + logger.warn("Collection '{}' not found, skipping.", conversationsCollectionPath); + + } else { + + throw e; + + } + + } + + + + // Delete the 'conversations' collection + + logger.info("Deleting collection: {}", conversationsCollectionPath); + + try { + + firestoreBaseRepository.deleteCollection(conversationsCollectionPath, 50); + + } catch (Exception e) { + + if (e.getMessage().contains("NOT_FOUND")) { + + logger.warn("Collection '{}' not found, skipping.", conversationsCollectionPath); + + } + + else { + + throw e; + + } + + } + + + + // Delete the 'notifications' collection + + logger.info("Deleting collection: {}", notificationsCollectionPath); + + try { + + firestoreBaseRepository.deleteCollection(notificationsCollectionPath, 50); + + } catch (Exception e) { + + if (e.getMessage().contains("NOT_FOUND")) { + + logger.warn("Collection '{}' not found, skipping.", notificationsCollectionPath); + + } else { + + throw e; + + } + + } + + + + logger.info("Successfully purged Firestore collections."); + + } catch (Exception e) { + + logger.error("Error purging Firestore collections.", e); + + throw new RuntimeException("Failed to purge Firestore collections.", e); + + } + + }).subscribeOn(Schedulers.boundedElastic()).then(); + + } + +} + + diff --git a/src/main/java/com/example/service/base/DialogflowClientService.java b/src/main/java/com/example/service/base/DialogflowClientService.java new file mode 100644 index 0000000..5c90f8c --- /dev/null +++ b/src/main/java/com/example/service/base/DialogflowClientService.java @@ -0,0 +1,167 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.service.base; + +import com.example.mapper.conversation.DialogflowRequestMapper; +import com.example.mapper.conversation.DialogflowResponseMapper; +import com.example.dto.dialogflow.base.DetectIntentRequestDTO; +import com.example.dto.dialogflow.base.DetectIntentResponseDTO; +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.QueryParameters; +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; +import reactor.util.retry.Retry; + +/** + * Service for interacting with the Dialogflow CX API to detect user DetectIntent. + * It encapsulates the low-level API calls, handling request mapping from DTOs, + * managing the `SessionsClient`, and translating API responses into DTOs, + * all within a reactive programming context. + */ +@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); + logger.info("Dialogflow CX SessionsClient initialized successfully for agent - Test Agent version: {}", dialogflowCxAgentId); + } 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); + + QueryParameters.Builder queryParamsBuilder; + if (detectIntentRequestBuilder.hasQueryParams()) { + queryParamsBuilder = detectIntentRequestBuilder.getQueryParams().toBuilder(); + } else { + queryParamsBuilder = QueryParameters.newBuilder(); + } + + detectIntentRequestBuilder.setQueryParams(queryParamsBuilder.build()); + + // 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); + }) + + .retryWhen(reactor.util.retry.Retry.backoff(3, java.time.Duration.ofSeconds(1)) + .filter(throwable -> { + if (throwable instanceof ApiException apiException) { + com.google.api.gax.rpc.StatusCode.Code code = apiException.getStatusCode().getCode(); + boolean isRetryable = code == com.google.api.gax.rpc.StatusCode.Code.INTERNAL || + code == com.google.api.gax.rpc.StatusCode.Code.UNAVAILABLE; + if (isRetryable) { + logger.warn("Retrying Dialogflow CX call for session {} due to transient error: {}", sessionId, code); + } + return isRetryable; + } + return false; + }) + .doBeforeRetry(retrySignal -> logger.debug("Retry attempt #{} for session {}", + retrySignal.totalRetries() + 1, sessionId)) + .onRetryExhaustedThrow((retrySpec, retrySignal) -> { + logger.error("Dialogflow CX retries exhausted for session {}", sessionId); + return retrySignal.failure(); + }) + ) + .onErrorMap(ApiException.class, e -> { + String statusCode = e.getStatusCode().getCode().name(); + String message = e.getMessage(); + String detailedLog = message; + + if (e.getCause() instanceof io.grpc.StatusRuntimeException grpcEx) { + detailedLog = String.format("Status: %s, Message: %s, Trailers: %s", + grpcEx.getStatus().getCode(), + grpcEx.getStatus().getDescription(), + grpcEx.getTrailers()); + } + + logger.error("Dialogflow CX API error for session {}: details={}", + sessionId, detailedLog, e); + + return new DialogflowClientException( + "Dialogflow CX API error: " + statusCode + " - " + message, e); + }) + .map(dfResponse -> this.dialogflowResponseMapper.mapFromDialogflowResponse(dfResponse, sessionId)); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/service/base/GeminiClientService.java b/src/main/java/com/example/service/base/GeminiClientService.java new file mode 100644 index 0000000..ef46ec1 --- /dev/null +++ b/src/main/java/com/example/service/base/GeminiClientService.java @@ -0,0 +1,60 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.service.base; + +import com.example.exception.GeminiClientException; +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.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +/** + * Service for interacting with the Gemini API to generate content. + * It encapsulates the low-level API calls, handling prompt configuration, + * and error management to provide a clean and robust content generation interface. + */ +@Service +public class GeminiClientService { + + private static final Logger logger = LoggerFactory.getLogger(GeminiClientService.class); + private final Client geminiClient; + + public GeminiClientService(Client geminiClient) { + this.geminiClient = geminiClient; + } + + public String generateContent(String prompt, Float temperature, Integer maxOutputTokens, String modelName,Float topP) throws GeminiClientException { + try { + Content content = Content.fromParts(Part.fromText(prompt)); + GenerateContentConfig config = GenerateContentConfig.builder() + .temperature(temperature) + .maxOutputTokens(maxOutputTokens) + .topP(topP) + .build(); + + logger.debug("Sending request to Gemini model '{}'", modelName); + GenerateContentResponse response = geminiClient.models.generateContent(modelName, content, config); + + if (response != null && response.text() != null) { + return response.text(); + } else { + logger.warn("Gemini returned no content or an unexpected response structure for model '{}'.", modelName); + throw new GeminiClientException("No content generated or unexpected response structure."); + } + } catch (GenAiIOException e) { + logger.error("Gemini API I/O error while calling model '{}': {}", modelName, e.getMessage(), e); + throw new GeminiClientException("An API communication issue occurred: " + e.getMessage(), e); + } catch (Exception e) { + logger.error("An unexpected error occurred during Gemini content generation for model '{}': {}", modelName, e.getMessage(), e); + throw new GeminiClientException("An unexpected issue occurred during content generation.", e); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/example/service/base/MessageEntryFilter.java b/src/main/java/com/example/service/base/MessageEntryFilter.java new file mode 100644 index 0000000..171812b --- /dev/null +++ b/src/main/java/com/example/service/base/MessageEntryFilter.java @@ -0,0 +1,128 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.service.base; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import jakarta.annotation.PostConstruct; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Objects; +import java.io.InputStream; + +/** + * Classifies a user's text input into a predefined category using a Gemini + * model. + * It analyzes the user's query in the context of a conversation history and any + * relevant notifications to determine if the message is part of the ongoing + * dialogue + * or an interruption. The classification is used to route the request to the + * appropriate handler (e.g., a standard conversational flow or a specific + * notification processor). + */ +@Service +public class MessageEntryFilter { + + private static final Logger logger = LoggerFactory.getLogger(MessageEntryFilter.class); + private final GeminiClientService geminiService; + + @Value("${messagefilter.geminimodel:gemini-2.0-flash-001}") + private String geminiModelNameClassifier; + + @Value("${messagefilter.temperature:0.1f}") + private Float classifierTemperature; + + @Value("${messagefilter.maxOutputTokens:10}") + private Integer classifierMaxOutputTokens; + + @Value("${messagefilter.topP:0.1f}") + private Float classifierTopP; + + @Value("${messagefilter.prompt:prompts/message_filter_prompt.txt}") + private String promptFilePath; + + public static final String CATEGORY_CONVERSATION = "CONVERSATION"; + public static final String CATEGORY_NOTIFICATION = "NOTIFICATION"; + public static final String CATEGORY_UNKNOWN = "UNKNOWN"; + public static final String CATEGORY_ERROR = "ERROR"; + + private String promptTemplate; + + public MessageEntryFilter(GeminiClientService geminiService) { + this.geminiService = Objects.requireNonNull(geminiService, + "GeminiClientService cannot be null for MessageEntryFilter."); + } + + @PostConstruct + public void loadPromptTemplate() { + try (InputStream inputStream = getClass().getClassLoader().getResourceAsStream(promptFilePath)) { + if (inputStream == null) { + throw new IOException("Resource not found: " + promptFilePath); + } + byte[] fileBytes = inputStream.readAllBytes(); + this.promptTemplate = new String(fileBytes, StandardCharsets.UTF_8); + logger.info("Successfully loaded prompt template from '" + promptFilePath + "'."); + } catch (IOException e) { + logger.error("Failed to load prompt template from '" + promptFilePath + + "'. Please ensure the file exists. Error: " + e.getMessage()); + throw new IllegalStateException("Could not load prompt template.", e); + } + } + public String classifyMessage(String queryInputText, String notificationsJson, String conversationJson) { + if (queryInputText == null || queryInputText.isBlank()) { + logger.warn("Query input text for classification is null or blank. Returning {}.", CATEGORY_UNKNOWN); + return CATEGORY_UNKNOWN; + } + + String interruptingNotification = (notificationsJson != null && !notificationsJson.isBlank()) ? + notificationsJson : "No interrupting notification."; + + String conversationHistory = (conversationJson != null && !conversationJson.isBlank()) ? + conversationJson : "No conversation history."; + + String classificationPrompt = String.format( + this.promptTemplate, + conversationHistory, + interruptingNotification, + queryInputText + ); + + logger.debug("Sending classification request to Gemini for input (first 100 chars): '{}'...", + queryInputText.substring(0, Math.min(queryInputText.length(), 100))); + + try { + String geminiResponse = geminiService.generateContent( + classificationPrompt, + classifierTemperature, + classifierMaxOutputTokens, + geminiModelNameClassifier, + classifierTopP + ); + + String resultCategory = switch (geminiResponse != null ? geminiResponse.strip().toUpperCase() : "") { + case CATEGORY_CONVERSATION -> { + logger.info("Classified as {}.", CATEGORY_CONVERSATION); + yield CATEGORY_CONVERSATION; + } + case CATEGORY_NOTIFICATION -> { + logger.info("Classified as {}.", CATEGORY_NOTIFICATION); + yield CATEGORY_NOTIFICATION; + } + default -> { + logger.warn("Gemini returned an unrecognised classification or was null/blank: '{}'. Expected '{}' or '{}'. Returning {}.", + geminiResponse, CATEGORY_CONVERSATION, CATEGORY_NOTIFICATION, CATEGORY_UNKNOWN); + yield CATEGORY_UNKNOWN; + } + }; + return resultCategory; + } catch (Exception e) { + logger.error("An error occurred during Gemini content generation for message classification.", e); + return CATEGORY_UNKNOWN; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/example/service/base/NotificationContextResolver.java b/src/main/java/com/example/service/base/NotificationContextResolver.java new file mode 100644 index 0000000..a0392a8 --- /dev/null +++ b/src/main/java/com/example/service/base/NotificationContextResolver.java @@ -0,0 +1,128 @@ +/* +* Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. +* Your use of it is subject to your agreement with Google. +*/ + +package com.example.service.base; + +import com.example.service.notification.MemoryStoreNotificationService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import jakarta.annotation.PostConstruct; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Objects; + +/** + * Resolves the conversational context of a user query by leveraging a large + * language model (LLM). This service evaluates a user's question in the context + * of a specific notification and conversation history, then decides if the + * query + * can be answered by the LLM or if it should be handled by a standard + * Dialogflow agent. + * The class loads an LLM prompt from an external file and dynamically + * formats it with a user's query and other context to drive its decision-making + * process. + */ +@Service +public class NotificationContextResolver { + + private static final Logger logger = LoggerFactory.getLogger(NotificationContextResolver.class); + private final GeminiClientService geminiService; + + @Value("${notificationcontext.geminimodel:gemini-2.0-flash-001}") + private String geminiModelNameResolver; + + @Value("${notificationcontext.temperature:0.1f}") + private Float resolverTemperature; + + @Value("${notificationcontext.maxOutputTokens:1024}") + private Integer resolverMaxOutputTokens; + + @Value("${notificationcontext.topP:0.1f}") + private Float resolverTopP; + + @Value("${notificationcontext.prompt:prompts/notification_context_resolver.txt}") + private String promptFilePath; + + public static final String CATEGORY_DIALOGFLOW = "DIALOGFLOW"; + + private String promptTemplate; + + public NotificationContextResolver(GeminiClientService geminiService, + MemoryStoreNotificationService memoryStoreNotificationService) { + this.geminiService = Objects.requireNonNull(geminiService, + "GeminiClientService cannot be null for NotificationContextResolver."); + } + + @PostConstruct + public void loadPromptTemplate() { + try (InputStream inputStream = getClass().getClassLoader().getResourceAsStream(promptFilePath)) { + if (inputStream == null) { + throw new IOException("Resource not found: " + promptFilePath); + } + byte[] fileBytes = inputStream.readAllBytes(); + this.promptTemplate = new String(fileBytes, StandardCharsets.UTF_8); + logger.info("Successfully loaded prompt template from '" + promptFilePath + "'."); + } catch (IOException e) { + logger.error("Failed to load prompt template from '" + promptFilePath + + "'. Please ensure the file exists. Error: " + e.getMessage()); + throw new IllegalStateException("Could not load prompt template.", e); + } + } + + public String resolveContext(String queryInputText, String notificationsJson, String conversationJson, + String metadata, String userId, String sessionId, String userPhoneNumber) { + logger.debug("resolveContext -> queryInputText: {}, notificationsJson: {}, conversationJson: {}, metadata: {}", + queryInputText, notificationsJson, conversationJson, metadata); + if (queryInputText == null || queryInputText.isBlank()) { + logger.warn("Query input text for context resolution is null or blank.", CATEGORY_DIALOGFLOW); + return CATEGORY_DIALOGFLOW; + } + + String notificationContent = (notificationsJson != null && !notificationsJson.isBlank()) ? notificationsJson + : "No metadata in notification."; + + String conversationHistory = (conversationJson != null && !conversationJson.isBlank()) ? conversationJson + : "No conversation history."; + + String contextPrompt = String.format( + this.promptTemplate, + conversationHistory, + notificationContent, + metadata, + queryInputText); + + logger.debug("Sending context resolution request to Gemini for input (first 100 chars): '{}'...", + queryInputText.substring(0, Math.min(queryInputText.length(), 100))); + + try { + String geminiResponse = geminiService.generateContent( + contextPrompt, + resolverTemperature, + resolverMaxOutputTokens, + geminiModelNameResolver, + resolverTopP); + + if (geminiResponse != null && !geminiResponse.isBlank()) { + if (geminiResponse.trim().equalsIgnoreCase(CATEGORY_DIALOGFLOW)) { + logger.debug("Resolved to {}. Input: '{}'", CATEGORY_DIALOGFLOW, queryInputText); + return CATEGORY_DIALOGFLOW; + } else { + logger.debug("Resolved to a specific response. Input: '{}'", queryInputText); + return geminiResponse; + } + } else { + logger.warn("Gemini returned a null or blank response", + queryInputText, CATEGORY_DIALOGFLOW); + return CATEGORY_DIALOGFLOW; + } + } catch (Exception e) { + logger.error("An error occurred during Gemini content generation for context resolution.", e); + return CATEGORY_DIALOGFLOW; + } + } +} diff --git a/src/main/java/com/example/service/conversation/ConversationHistoryService.java b/src/main/java/com/example/service/conversation/ConversationHistoryService.java new file mode 100644 index 0000000..57096de --- /dev/null +++ b/src/main/java/com/example/service/conversation/ConversationHistoryService.java @@ -0,0 +1,78 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.service.conversation; + +import com.example.dto.dialogflow.conversation.ConversationMessageDTO; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Range; +import org.springframework.data.redis.core.ReactiveRedisTemplate; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; + +/** +Service for managing the lifecycle and data hygiene of conversation histories stored in MemoryStore. +It encapsulates the logic for pruning conversation logs to enforce data retention policies. +Its primary function, pruneHistory, operates on a Redis Sorted Set (ZSET) for a given session, +performing two main tasks: + +1) removing all messages older than a configurable time limit (e.g., 30 days) +based on their timestamp score, + +2) trimming the remaining set to a maximum message count +(e.g., 60) by removing the oldest entries, all within a reactive programming context. +*/ +@Service +public class ConversationHistoryService { + + private static final Logger logger = LoggerFactory.getLogger(ConversationHistoryService.class); + + private static final String MESSAGES_KEY_PREFIX = "conversation:messages:"; + + private final ReactiveRedisTemplate messageRedisTemplate; + + @Value("${conversation.context.message.limit:60}") + private int messageLimit; + + @Value("${conversation.context.days.limit:30}") + private int daysLimit; + + @Autowired + public ConversationHistoryService(ReactiveRedisTemplate messageRedisTemplate) { + this.messageRedisTemplate = messageRedisTemplate; + } + + public Mono pruneHistory(String sessionId) { + logger.info("Pruning history for sessionId: {}", sessionId); + String messagesKey = MESSAGES_KEY_PREFIX + sessionId; + + Instant cutoff = Instant.now().minus(daysLimit, ChronoUnit.DAYS); + Range scoreRange = Range.of(Range.Bound.inclusive(0d), Range.Bound.inclusive((double) cutoff.toEpochMilli())); + logger.info("Removing messages older than {} for sessionId: {}", cutoff, sessionId); + Mono removeByScore = messageRedisTemplate.opsForZSet().removeRangeByScore(messagesKey, scoreRange) + .doOnSuccess(count -> logger.info("Removed {} old messages for sessionId: {}", count, sessionId)); + + + Mono trimToSize = messageRedisTemplate.opsForZSet().size(messagesKey) + .flatMap(size -> { + if (size > messageLimit) { + logger.info("Current message count {} exceeds limit {} for sessionId: {}. Trimming...", size, messageLimit, sessionId); + Range rankRange = Range.of(Range.Bound.inclusive(0L), Range.Bound.inclusive(size - messageLimit - 1)); + return messageRedisTemplate.opsForZSet().removeRange(messagesKey, rankRange) + .doOnSuccess(count -> logger.info("Trimmed {} messages for sessionId: {}", count, sessionId)); + } + return Mono.just(0L); + }); + return removeByScore.then(trimToSize).then() + .doOnSuccess(v -> logger.info("Successfully pruned history for sessionId: {}", sessionId)) + .doOnError(e -> logger.error("Error pruning history for sessionId: {}", sessionId, e)); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/service/conversation/ConversationManagerService.java b/src/main/java/com/example/service/conversation/ConversationManagerService.java new file mode 100644 index 0000000..148c299 --- /dev/null +++ b/src/main/java/com/example/service/conversation/ConversationManagerService.java @@ -0,0 +1,422 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.service.conversation; + +import com.example.dto.dialogflow.base.DetectIntentRequestDTO; +import com.example.dto.dialogflow.base.DetectIntentResponseDTO; +import com.example.dto.dialogflow.conversation.*; +import com.example.dto.dialogflow.notification.EventInputDTO; +import com.example.dto.dialogflow.notification.NotificationDTO; +import com.example.mapper.conversation.ConversationEntryMapper; +import com.example.mapper.conversation.ExternalConvRequestMapper; +import com.example.mapper.messagefilter.ConversationContextMapper; +import com.example.mapper.messagefilter.NotificationContextMapper; +import com.example.service.base.DialogflowClientService; +import com.example.service.base.MessageEntryFilter; +import com.example.service.base.NotificationContextResolver; +import com.example.service.notification.MemoryStoreNotificationService; +import com.example.service.quickreplies.QuickRepliesManagerService; +import com.example.service.llm.LlmResponseTunerService; +import com.example.util.SessionIdGenerator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; +import java.time.Duration; +import java.time.Instant; +import java.util.Collections; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import java.util.stream.Collectors; + +/** +Service acting as the central orchestrator for managing user conversations. +It integrates Data Loss Prevention (DLP) for message obfuscation, multi-stage routing, +hybrid AI logic, and a reactive write-back persistence layer for conversation history. + +Routes traffic based on session context: +If a 'pantallaContexto' (screen context) is present, it delegates to the QuickRepliesManagerService. +Otherwise, it uses a Gemini-based MessageEntryFilter to classify the message against +active notifications and history, routing to one of two main flows: +a) Standard Conversation (proceedWithConversation): Handles regular dialogue, + +managing 30-minute session timeouts and injecting conversation history parameter to Dialogflow. + +b) Notifications (startNotificationConversation): +It first asks a Gemini model (NotificationContextResolver) if it can answer the +query. If yes, it saves the LLM's response and sends an 'LLM_RESPONSE_PROCESSED' +event to Dialogflow. If no ("DIALOGFLOW"), it sends the user's original text +to Dialogflow for intent matching. + +All conversation turns (user, agent, and LLM) are persisted using a reactive write-back +cache pattern, saving to Memorystore (Redis) first and then asynchronously to a +Firestore subcollection data model (persistConversationTurn). +*/ +@Service +public class ConversationManagerService { + private static final Logger logger = LoggerFactory.getLogger(ConversationManagerService.class); + + private static final long SESSION_RESET_THRESHOLD_MINUTES = 30; + private static final long SCREEN_CONTEXT_TIMEOUT_MINUTES = 10; // fix for the quick replies screen + private static final String CONV_HISTORY_PARAM = "conversation_history"; + private static final String HISTORY_PARAM = "historial"; + private final ExternalConvRequestMapper externalRequestToDialogflowMapper; + private final DialogflowClientService dialogflowServiceClient; + private final FirestoreConversationService firestoreConversationService; + private final MemoryStoreConversationService memoryStoreConversationService; + private final QuickRepliesManagerService quickRepliesManagerService; + private final MessageEntryFilter messageEntryFilter; + private final MemoryStoreNotificationService memoryStoreNotificationService; + private final NotificationContextMapper notificationContextMapper; + private final ConversationContextMapper conversationContextMapper; + private final DataLossPrevention dataLossPrevention; + private final String dlpTemplateCompleteFlow; + + private final NotificationContextResolver notificationContextResolver; + private final LlmResponseTunerService llmResponseTunerService; + private final ConversationEntryMapper conversationEntryMapper; + + public ConversationManagerService( + DialogflowClientService dialogflowServiceClient, + FirestoreConversationService firestoreConversationService, + MemoryStoreConversationService memoryStoreConversationService, + ExternalConvRequestMapper externalRequestToDialogflowMapper, + QuickRepliesManagerService quickRepliesManagerService, + MessageEntryFilter messageEntryFilter, + MemoryStoreNotificationService memoryStoreNotificationService, + NotificationContextMapper notificationContextMapper, + ConversationContextMapper conversationContextMapper, + DataLossPrevention dataLossPrevention, + NotificationContextResolver notificationContextResolver, + LlmResponseTunerService llmResponseTunerService, + ConversationEntryMapper conversationEntryMapper, + @Value("${google.cloud.dlp.dlpTemplateCompleteFlow}") String dlpTemplateCompleteFlow) { + this.dialogflowServiceClient = dialogflowServiceClient; + this.firestoreConversationService = firestoreConversationService; + this.memoryStoreConversationService = memoryStoreConversationService; + this.externalRequestToDialogflowMapper = externalRequestToDialogflowMapper; + this.quickRepliesManagerService = quickRepliesManagerService; + this.messageEntryFilter = messageEntryFilter; + this.memoryStoreNotificationService = memoryStoreNotificationService; + this.notificationContextMapper = notificationContextMapper; + this.conversationContextMapper = conversationContextMapper; + this.dataLossPrevention = dataLossPrevention; + this.dlpTemplateCompleteFlow = dlpTemplateCompleteFlow; + this.notificationContextResolver = notificationContextResolver; + this.llmResponseTunerService = llmResponseTunerService; + this.conversationEntryMapper = conversationEntryMapper; + + } + + public Mono manageConversation(ExternalConvRequestDTO externalrequest) { + return dataLossPrevention.getObfuscatedString(externalrequest.message(), dlpTemplateCompleteFlow) + .flatMap(obfuscatedMessage -> { + ExternalConvRequestDTO obfuscatedRequest = new ExternalConvRequestDTO( + obfuscatedMessage, + externalrequest.user(), + externalrequest.channel(), + externalrequest.tipo(), + externalrequest.pantallaContexto()); + return memoryStoreConversationService.getSessionByTelefono(externalrequest.user().telefono()) + .flatMap(session -> { + boolean isContextStale = false; + if (session.lastModified() != null) { + long minutesSinceLastUpdate = java.time.Duration.between(session.lastModified(), java.time.Instant.now()).toMinutes(); + if (minutesSinceLastUpdate > SCREEN_CONTEXT_TIMEOUT_MINUTES) { + isContextStale = true; + } + } + if (session != null && session.pantallaContexto() != null && !session.pantallaContexto().isBlank() && !isContextStale) { + logger.info("Detected 'pantallaContexto' in session. Delegating to QuickRepliesManagerService."); + return quickRepliesManagerService.manageConversation(obfuscatedRequest); + } + // Remove the old QR and continue as normal conversation. + if (isContextStale && session.pantallaContexto() != null) { + logger.info("Detected STALE 'pantallaContexto'. Ignoring and proceeding with normal flow."); + } + return continueManagingConversation(obfuscatedRequest); + }) + .switchIfEmpty(continueManagingConversation(obfuscatedRequest)); + }); + } + + private Mono continueManagingConversation(ExternalConvRequestDTO externalrequest) { + final DetectIntentRequestDTO request; + try { + request = externalRequestToDialogflowMapper.mapExternalRequestToDetectIntentRequest(externalrequest); + logger.debug("Successfully pre-mapped ExternalRequestDTO to DetectIntentRequestDTO"); + } catch (IllegalArgumentException e) { + logger.error("Error during pre-mapping: {}", e.getMessage()); + return Mono.error(new IllegalArgumentException( + "Failed to process external request due to mapping error: " + e.getMessage(), e)); + } + + Map params = Optional.ofNullable(request.queryParams()) + .map(queryParamsDTO -> queryParamsDTO.parameters()) + .orElse(Collections.emptyMap()); + + Object telefonoObj = params.get("telefono"); + if (!(telefonoObj instanceof String) || ((String) telefonoObj).isBlank()) { + logger.error("Critical error: parameter is missing, not a String, or blank after mapping."); + return Mono.error(new IllegalStateException("Internal error: parameter is invalid.")); + } + + String primaryPhoneNumber = (String) telefonoObj; + String resolvedUserId = params.get("usuario_id") instanceof String ? (String) params.get("usuario_id") : null; + String userMessageText = request.queryInput().text().text(); + final ConversationContext context = new ConversationContext(resolvedUserId, null, userMessageText, primaryPhoneNumber); + + return continueConversationFlow(context, request); + } + + private Mono continueConversationFlow(ConversationContext context, + DetectIntentRequestDTO request) { + final String userId = context.userId(); + final String userMessageText = context.userMessageText(); + final String userPhoneNumber = context.primaryPhoneNumber(); + + if (userPhoneNumber == null || userPhoneNumber.isBlank()) { + logger.warn("No phone number provided in request. Cannot manage conversation session without it."); + return Mono + .error(new IllegalArgumentException("Phone number is required to manage conversation sessions.")); + } + + logger.info("Primary Check (MemoryStore): Looking up session for phone number: {}", userPhoneNumber); + return memoryStoreConversationService.getSessionByTelefono(userPhoneNumber) + .flatMap(session -> handleMessageClassification(context, request, session)) + .switchIfEmpty(Mono.defer(() -> { + logger.info("No session found in MemoryStore. Performing full lookup to Firestore."); + return fullLookupAndProcess(null, request, userId, userMessageText, userPhoneNumber); + })) + .onErrorResume(e -> { + logger.error("Overall error handling conversation in ConversationManagerService: {}", + e.getMessage(), e); + return Mono + .error(new RuntimeException("Failed to process conversation due to an internal error.", e)); + }); + } + + private Mono handleMessageClassification(ConversationContext context, + DetectIntentRequestDTO request, ConversationSessionDTO session) { + final String userPhoneNumber = context.primaryPhoneNumber(); + final String userMessageText = context.userMessageText(); + + return memoryStoreNotificationService.getNotificationIdForPhone(userPhoneNumber) + .flatMap(notificationId -> memoryStoreNotificationService.getCachedNotificationSession(notificationId)) + .map(notificationSession -> notificationSession.notificaciones().stream() + .filter(notification -> "active".equalsIgnoreCase(notification.status())) + .max(java.util.Comparator.comparing(NotificationDTO::timestampCreacion)) + .orElse(null)) + .filter(Objects::nonNull) + .flatMap((NotificationDTO notification) -> { + return memoryStoreConversationService.getMessages(session.sessionId()).collectList() + .map(conversationContextMapper::toTextFromMessages) + .defaultIfEmpty("") + .flatMap(conversationHistory -> { + String notificationText = notificationContextMapper.toText(notification); + String classification = messageEntryFilter.classifyMessage(userMessageText, notificationText, + conversationHistory); + if (MessageEntryFilter.CATEGORY_NOTIFICATION.equals(classification)) { + return startNotificationConversation(context, request, notification); + } else { + return proceedWithConversation(context, request, session); + } + }); + }) + .switchIfEmpty(proceedWithConversation(context, request, session)); + } + + private Mono proceedWithConversation(ConversationContext context, + DetectIntentRequestDTO request, ConversationSessionDTO session) { + Instant now = Instant.now(); + if (Duration.between(session.lastModified(), now).toMinutes() < SESSION_RESET_THRESHOLD_MINUTES) { + logger.info("Recent Session Found: Session {} is within the 30-minute threshold. Proceeding to Dialogflow.", + session.sessionId()); + return processDialogflowRequest(session, request, context.userId(), context.userMessageText(), + context.primaryPhoneNumber(), false); + } else { + + + logger.info( + "Old Session Found: Session {} is older than the 30-minute threshold.", + session.sessionId()); + // Generar un nuevo ID de sesión + String newSessionId = SessionIdGenerator.generateStandardSessionId(); + logger.info("Creating new session {} from old session {} due to timeout.", newSessionId, session.sessionId()); + + // Crear un nuevo DTO de sesión basado en la antigua, pero con el nuevo ID + ConversationSessionDTO newSession = ConversationSessionDTO.create(newSessionId, context.userId(), context.primaryPhoneNumber()); + + return memoryStoreConversationService.getMessages(session.sessionId()) + .collectList() + // Adding use the TextWithLimits to truncate according to business rule 30 days/60 messages + .map(messages -> conversationContextMapper.toTextWithLimits(session, messages)) + .defaultIfEmpty("") + .flatMap(conversationHistory -> { + // Inject historial (max 60 msgs / 30 días / 50KB) + DetectIntentRequestDTO newRequest = request.withParameter(CONV_HISTORY_PARAM, conversationHistory); + return processDialogflowRequest(newSession, newRequest, context.userId(), context.userMessageText(), + context.primaryPhoneNumber(), false); + }); + } + } + + private Mono fullLookupAndProcess(ConversationSessionDTO oldSession, + DetectIntentRequestDTO request, String userId, String userMessageText, String userPhoneNumber) { + return firestoreConversationService.getSessionByTelefono(userPhoneNumber) + .flatMap(session -> firestoreConversationService.getMessages(session.sessionId()).collectList() + .map(conversationContextMapper::toTextFromMessages) + .defaultIfEmpty("") + .flatMap(conversationHistory -> { + String newSessionId = SessionIdGenerator.generateStandardSessionId(); + logger.info("Creating new session {} after full lookup.", newSessionId); + ConversationSessionDTO newSession = ConversationSessionDTO.create(newSessionId, userId, + userPhoneNumber); + DetectIntentRequestDTO newRequest = request.withParameter(CONV_HISTORY_PARAM, conversationHistory); + return processDialogflowRequest(newSession, newRequest, userId, userMessageText, userPhoneNumber, + true); + })) + .switchIfEmpty(Mono.defer(() -> { + String newSessionId = SessionIdGenerator.generateStandardSessionId(); + logger.info("Creating new session {} after full lookup.", newSessionId); + ConversationSessionDTO newSession = ConversationSessionDTO.create(newSessionId, userId, + userPhoneNumber); + return processDialogflowRequest(newSession, request, userId, userMessageText, userPhoneNumber, + true); + })); + } + + private Mono processDialogflowRequest(ConversationSessionDTO session, + DetectIntentRequestDTO request, String userId, String userMessageText, String userPhoneNumber, + boolean newSession) { + final String finalSessionId = session.sessionId(); + + ConversationEntryDTO userEntry = ConversationEntryDTO.forUser(userMessageText); + + return this.persistConversationTurn(session, conversationEntryMapper.toConversationMessageDTO(userEntry)) + .doOnSuccess(v -> logger.debug( + "User entry successfully persisted for session {}. Proceeding to Dialogflow...", + finalSessionId)) + .doOnError(e -> logger.error("Error during user entry persistence for session {}: {}", finalSessionId, + e.getMessage(), e)) + .then(Mono.defer(() -> dialogflowServiceClient.detectIntent(finalSessionId, request) + .flatMap(response -> { + logger.debug( + "RTest eceived Dialogflow CX response for session {}. Initiating agent response persistence.", + finalSessionId); + ConversationEntryDTO agentEntry = ConversationEntryDTO.forAgent(response.queryResult()); + return persistConversationTurn(session, conversationEntryMapper.toConversationMessageDTO(agentEntry)) + .thenReturn(response); + }) + .doOnError( + error -> logger.error("Overall error during conversation management for session {}: {}", + finalSessionId, error.getMessage(), error)))); + } + + public Mono startNotificationConversation(ConversationContext context, + DetectIntentRequestDTO request, NotificationDTO notification) { + final String userId = context.userId(); + final String userMessageText = context.userMessageText(); + final String userPhoneNumber = context.primaryPhoneNumber(); + + return memoryStoreConversationService.getSessionByTelefono(userPhoneNumber) + .switchIfEmpty(Mono.defer(() -> { + String newSessionId = SessionIdGenerator.generateStandardSessionId(); + logger.warn("No existing conversation session found for notification reply on phone {}. This is unexpected. Creating new session: {}", + userPhoneNumber, newSessionId); + return Mono.just(ConversationSessionDTO.create(newSessionId, userId, userPhoneNumber)); + })) + .flatMap(session -> { + final String sessionId = session.sessionId(); + return memoryStoreConversationService.getMessages(sessionId).collectList() + .map(conversationContextMapper::toTextFromMessages) + .defaultIfEmpty("") + .flatMap(conversationHistory -> { + String notificationText = notificationContextMapper.toText(notification); + + Map filteredParams = notification.parametros().entrySet().stream() + .filter(entry -> entry.getKey().startsWith("notification_po_")) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + + String resolvedContext = notificationContextResolver.resolveContext(userMessageText, + notificationText, conversationHistory, filteredParams.toString(), userId, sessionId, + userPhoneNumber); + if (!resolvedContext.trim().toUpperCase().contains(NotificationContextResolver.CATEGORY_DIALOGFLOW)) { + String uuid = UUID.randomUUID().toString(); + llmResponseTunerService.setValue(uuid, resolvedContext).subscribe(); + + ConversationEntryDTO userEntry = ConversationEntryDTO.forUser(userMessageText, + notification.parametros()); + ConversationEntryDTO llmEntry = ConversationEntryDTO.forLlmConversation(resolvedContext, + notification.parametros()); + + return persistConversationTurn(session, conversationEntryMapper.toConversationMessageDTO(userEntry)) + .then(persistConversationTurn(session, conversationEntryMapper.toConversationMessageDTO(llmEntry))) + .then(Mono.defer(() -> { + EventInputDTO eventInput = new EventInputDTO("LLM_RESPONSE_PROCESSED"); + QueryInputDTO queryInput = new QueryInputDTO(null, eventInput, + request.queryInput().languageCode()); + DetectIntentRequestDTO newRequest = new DetectIntentRequestDTO(queryInput, + request.queryParams()) + .withParameter("llm_reponse_uuid", uuid); + + return dialogflowServiceClient.detectIntent(sessionId, newRequest) + .flatMap(response -> { + ConversationEntryDTO agentEntry = ConversationEntryDTO + .forAgent(response.queryResult()); + return persistConversationTurn(session, conversationEntryMapper.toConversationMessageDTO(agentEntry)) + .thenReturn(response); + }); + })); + } else { + ConversationEntryDTO userEntry = ConversationEntryDTO.forUser(userMessageText, + notification.parametros()); + + DetectIntentRequestDTO finalRequest; + Instant now = Instant.now(); + if (Duration.between(session.lastModified(), now).toMinutes() < SESSION_RESET_THRESHOLD_MINUTES) { + finalRequest = request.withParameters(notification.parametros()); + } else { + finalRequest = request.withParameter(CONV_HISTORY_PARAM, conversationHistory) + .withParameters(notification.parametros()); + } + return persistConversationTurn(session, conversationEntryMapper.toConversationMessageDTO(userEntry)) + .then(dialogflowServiceClient.detectIntent(sessionId, finalRequest) + .flatMap(response -> { + ConversationEntryDTO agentEntry = ConversationEntryDTO + .forAgent(response.queryResult()); + return persistConversationTurn(session, conversationEntryMapper.toConversationMessageDTO(agentEntry)) + .thenReturn(response); + })); + } + }); + }); + } + + private Mono persistConversationTurn(ConversationSessionDTO session, ConversationMessageDTO message) { + logger.debug("Starting Write-Back persistence for session {}. Type: {}. Writing to Redis first.", session.sessionId(), + message.type().name()); + ConversationSessionDTO updatedSession = session.withLastMessage(message.text()); + return memoryStoreConversationService.saveSession(updatedSession) + .then(memoryStoreConversationService.saveMessage(session.sessionId(), message)) + .doOnSuccess(v -> logger.info( + "Entry saved to Redis for session {}. Type: {}. Kicking off async Firestore write-back.", + session.sessionId(), message.type().name())) + .then(firestoreConversationService.saveSession(updatedSession) + .then(firestoreConversationService.saveMessage(session.sessionId(), message)) + .doOnSuccess(fsVoid -> logger.debug( + "Asynchronously (Write-Back): Entry successfully saved to Firestore for session {}. Type: {}.", + session.sessionId(), message.type().name())) + .doOnError(fsError -> logger.error( + "Asynchronously (Write-Back): Failed to save entry to Firestore for session {}. Type: {}: {}", + session.sessionId(), message.type().name(), fsError.getMessage(), fsError))) + .doOnError(e -> logger.error("Error during primary Redis write for session {}. Type: {}: {}", session.sessionId(), + message.type().name(), e.getMessage(), e)); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/service/conversation/DataLossPrevention.java b/src/main/java/com/example/service/conversation/DataLossPrevention.java new file mode 100644 index 0000000..cad28d3 --- /dev/null +++ b/src/main/java/com/example/service/conversation/DataLossPrevention.java @@ -0,0 +1,12 @@ +/* +* Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. +* Your use of it is subject to your agreement with Google. +*/ + +package com.example.service.conversation; + +import reactor.core.publisher.Mono; + +public interface DataLossPrevention { + Mono getObfuscatedString(String textToInspect, String templateId); +} diff --git a/src/main/java/com/example/service/conversation/DataLossPreventionImpl.java b/src/main/java/com/example/service/conversation/DataLossPreventionImpl.java new file mode 100644 index 0000000..f1a2f9b --- /dev/null +++ b/src/main/java/com/example/service/conversation/DataLossPreventionImpl.java @@ -0,0 +1,111 @@ +/* +* Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. +* Your use of it is subject to your agreement with Google. +*/ + +package com.example.service.conversation; + +import com.google.api.core.ApiFuture; +import com.google.api.core.ApiFutureCallback; +import com.google.api.core.ApiFutures; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import com.google.cloud.dlp.v2.DlpServiceClient; +import com.google.privacy.dlp.v2.ByteContentItem; +import com.google.privacy.dlp.v2.ContentItem; +import com.google.privacy.dlp.v2.InspectConfig; +import com.google.privacy.dlp.v2.InspectContentRequest; +import com.google.privacy.dlp.v2.InspectContentResponse; +import com.google.privacy.dlp.v2.Likelihood; +import com.google.privacy.dlp.v2.LocationName; +import com.google.protobuf.ByteString; +import com.example.util.TextObfuscator; + +import reactor.core.publisher.Mono; + +/** +Implements a data loss prevention service by integrating with the +Google Cloud Data Loss Prevention (DLP) API. This service is responsible for +scanning a given text input to identify and obfuscate sensitive information based on +a specified DLP template. If the DLP API detects sensitive findings, the +original text is obfuscated to protect user data; otherwise, the original +text is returned. +*/ +@Service +public class DataLossPreventionImpl implements DataLossPrevention { + + private static final Logger logger = LoggerFactory.getLogger(DataLossPreventionImpl.class); + + private final String projectId; + private final String location; + private final DlpServiceClient dlpServiceClient; + + public DataLossPreventionImpl( + DlpServiceClient dlpServiceClient, + @Value("${google.cloud.project}") String projectId, + @Value("${google.cloud.location}") String location) { + this.dlpServiceClient = dlpServiceClient; + this.projectId = projectId; + this.location = location; + } + + @Override + public Mono getObfuscatedString(String text, String templateId) { + ByteContentItem byteContentItem = ByteContentItem.newBuilder() + .setType(ByteContentItem.BytesType.TEXT_UTF8) + .setData(ByteString.copyFromUtf8(text)) + .build(); + ContentItem contentItem = ContentItem.newBuilder().setByteItem(byteContentItem).build(); + + Likelihood minLikelihood = Likelihood.VERY_UNLIKELY; + + InspectConfig.FindingLimits findingLimits = InspectConfig.FindingLimits.newBuilder().setMaxFindingsPerItem(0) + .build(); + + InspectConfig inspectConfig = InspectConfig.newBuilder() + .setMinLikelihood(minLikelihood) + .setLimits(findingLimits) + .setIncludeQuote(true) + .build(); + + String inspectTemplateName = String.format("projects/%s/locations/%s/inspectTemplates/%s", projectId, location, + templateId); + InspectContentRequest request = InspectContentRequest.newBuilder() + .setParent(LocationName.of(projectId, location).toString()) + .setInspectTemplateName(inspectTemplateName) + .setInspectConfig(inspectConfig) + .setItem(contentItem) + .build(); + + ApiFuture futureResponse = dlpServiceClient.inspectContentCallable() + .futureCall(request); + + return Mono.create( + sink -> ApiFutures.addCallback( + futureResponse, + new ApiFutureCallback<>() { + @Override + public void onFailure(Throwable t) { + sink.error(t); + } + + @Override + public void onSuccess(InspectContentResponse result) { + sink.success(result); + } + }, + Runnable::run)) + .map(response -> { + logger.info("DLP {} Findings: {}", templateId, response.getResult().getFindingsCount()); + return response.getResult().getFindingsCount() > 0 + ? TextObfuscator.obfuscate(response, text) + : text; + }).onErrorResume(e -> { + e.printStackTrace(); + return Mono.just(text); + }); + } +} diff --git a/src/main/java/com/example/service/conversation/FirestoreConversationService.java b/src/main/java/com/example/service/conversation/FirestoreConversationService.java new file mode 100644 index 0000000..91e352a --- /dev/null +++ b/src/main/java/com/example/service/conversation/FirestoreConversationService.java @@ -0,0 +1,137 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.service.conversation; + +import com.example.dto.dialogflow.conversation.ConversationMessageDTO; +import com.example.dto.dialogflow.conversation.ConversationSessionDTO; +import com.example.exception.FirestorePersistenceException; +import com.example.mapper.conversation.ConversationMessageMapper; +import com.example.mapper.conversation.FirestoreConversationMapper; +import com.example.repository.FirestoreBaseRepository; +import com.google.cloud.firestore.DocumentReference; +import com.google.cloud.firestore.DocumentSnapshot; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +import java.util.Objects; +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 static final String MESSAGES_SUBCOLLECTION = "mensajes"; + private final FirestoreBaseRepository firestoreBaseRepository; + private final FirestoreConversationMapper firestoreConversationMapper; + private final ConversationMessageMapper conversationMessageMapper; + + public FirestoreConversationService(FirestoreBaseRepository firestoreBaseRepository, FirestoreConversationMapper firestoreConversationMapper, ConversationMessageMapper conversationMessageMapper) { + this.firestoreBaseRepository = firestoreBaseRepository; + this.firestoreConversationMapper = firestoreConversationMapper; + this.conversationMessageMapper = conversationMessageMapper; + } + + public Mono saveSession(ConversationSessionDTO session) { + return Mono.fromRunnable(() -> { + DocumentReference sessionDocRef = getSessionDocumentReference(session.sessionId()); + try { + firestoreBaseRepository.setDocument(sessionDocRef, firestoreConversationMapper.createSessionMap(session)); + } catch (ExecutionException | InterruptedException e) { + handleException(e, session.sessionId()); + } + }).subscribeOn(Schedulers.boundedElastic()).then(); + } + + public Mono saveMessage(String sessionId, ConversationMessageDTO message) { + return Mono.fromRunnable(() -> { + DocumentReference messageDocRef = getSessionDocumentReference(sessionId).collection(MESSAGES_SUBCOLLECTION).document(); + try { + firestoreBaseRepository.setDocument(messageDocRef, conversationMessageMapper.toMap(message)); + } catch (ExecutionException | InterruptedException e) { + handleException(e, sessionId); + } + }).subscribeOn(Schedulers.boundedElastic()).then(); + } + + public Flux getMessages(String sessionId) { + String messagesPath = getConversationCollectionPath() + "/" + sessionId + "/" + MESSAGES_SUBCOLLECTION; + return firestoreBaseRepository.getDocuments(messagesPath) + .map(documentSnapshot -> { + if (documentSnapshot != null && documentSnapshot.exists()) { + return conversationMessageMapper.fromMap(documentSnapshot.getData()); + } + return null; + }) + .filter(Objects::nonNull); + } + + public Mono getConversationSession(String sessionId) { + logger.info("Attempting to retrieve conversation session for session {}.", sessionId); + 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; + } catch (InterruptedException | ExecutionException e) { + handleException(e, sessionId); + return null; + } + }).subscribeOn(Schedulers.boundedElastic()); + } + + public Mono getSessionByTelefono(String userPhoneNumber) { + return firestoreBaseRepository.getDocumentsByField(getConversationCollectionPath(), "telefono", userPhoneNumber) + .map(documentSnapshot -> { + if (documentSnapshot != null && documentSnapshot.exists()) { + ConversationSessionDTO sessionDTO = firestoreConversationMapper.mapFirestoreDocumentToConversationSessionDTO(documentSnapshot); + logger.info("Successfully retrieved and mapped conversation session for session {}.", sessionDTO.sessionId()); + return sessionDTO; + } + return null; + }); + } + + public Mono deleteSession(String sessionId) { + logger.info("Attempting to delete conversation session for session {}.", sessionId); + return Mono.fromRunnable(() -> { + DocumentReference sessionDocRef = getSessionDocumentReference(sessionId); + try { + firestoreBaseRepository.deleteDocumentAndSubcollections(sessionDocRef, MESSAGES_SUBCOLLECTION); + logger.info("Successfully deleted conversation session for session {}.", sessionId); + } catch (InterruptedException | ExecutionException e) { + handleException(e, sessionId); + } + }).subscribeOn(Schedulers.boundedElastic()).then(); + } + + 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); + } + + private void handleException(Exception e, String sessionId) { + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + logger.error("Error processing Firestore operation for session {}: {}", sessionId, e.getMessage(), e); + throw new FirestorePersistenceException("Failed to process Firestore operation for session " + sessionId, e); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/service/conversation/MemoryStoreConversationService.java b/src/main/java/com/example/service/conversation/MemoryStoreConversationService.java new file mode 100644 index 0000000..f731c53 --- /dev/null +++ b/src/main/java/com/example/service/conversation/MemoryStoreConversationService.java @@ -0,0 +1,108 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.service.conversation; + +import com.example.dto.dialogflow.conversation.ConversationMessageDTO; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Range; +import org.springframework.data.redis.core.ReactiveRedisTemplate; +import org.springframework.stereotype.Service; +import com.example.dto.dialogflow.conversation.ConversationSessionDTO; +import reactor.core.publisher.Flux; +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 String MESSAGES_KEY_PREFIX = "conversation:messages:"; + private static final Duration SESSION_TTL = Duration.ofDays(30); + private final ReactiveRedisTemplate redisTemplate; + private final ReactiveRedisTemplate stringRedisTemplate; + private final ReactiveRedisTemplate messageRedisTemplate; + private final ConversationHistoryService conversationHistoryService; + + + @Autowired + public MemoryStoreConversationService( + ReactiveRedisTemplate redisTemplate, + ReactiveRedisTemplate stringRedisTemplate, + ReactiveRedisTemplate messageRedisTemplate, + ConversationHistoryService conversationHistoryService) { + this.redisTemplate = redisTemplate; + this.stringRedisTemplate = stringRedisTemplate; + this.messageRedisTemplate = messageRedisTemplate; + this.conversationHistoryService = conversationHistoryService; + } + + + public Mono saveMessage(String sessionId, ConversationMessageDTO message) { + String messagesKey = MESSAGES_KEY_PREFIX + sessionId; + double score = message.timestamp().toEpochMilli(); + return messageRedisTemplate.opsForZSet().add(messagesKey, message, score) + .then(conversationHistoryService.pruneHistory(sessionId)); + } + + public Mono saveSession(ConversationSessionDTO session) { + String sessionKey = SESSION_KEY_PREFIX + session.sessionId(); + String phoneToSessionKey = PHONE_TO_SESSION_KEY_PREFIX + session.telefono(); + return redisTemplate.opsForValue().set(sessionKey, session, SESSION_TTL) + .then(stringRedisTemplate.opsForValue().set(phoneToSessionKey, session.sessionId(), SESSION_TTL)) + .then(); + } + + public Flux getMessages(String sessionId) { + String messagesKey = MESSAGES_KEY_PREFIX + sessionId; + return messageRedisTemplate.opsForZSet().range(messagesKey, Range.of(Range.Bound.inclusive(0L), Range.Bound.inclusive(-1L))); + } + + public Mono getSessionByTelefono(String telefono) { + if (telefono == null || telefono.isBlank()) { + return Mono.empty(); + } + String phoneToSessionKey = PHONE_TO_SESSION_KEY_PREFIX + telefono; + return stringRedisTemplate.opsForValue().get(phoneToSessionKey) + .flatMap(sessionId -> redisTemplate.opsForValue().get(SESSION_KEY_PREFIX + sessionId)) + .doOnSuccess(session -> { + if (session != null) { + logger.info("Successfully retrieved session by phone number"); + } else { + logger.info("No session found in Redis for phone number."); + } + }) + .doOnError(e -> logger.error("Error retrieving session by phone number: {}", e)); + } + + public Mono updateSession(ConversationSessionDTO session) { + String sessionKey = SESSION_KEY_PREFIX + session.sessionId(); + logger.info("Attempting to update session {} in Memorystore.", session.sessionId()); + return redisTemplate.opsForValue().set(sessionKey, session).then(); + } + + public Mono deleteSession(String sessionId) { + String sessionKey = SESSION_KEY_PREFIX + sessionId; + String messagesKey = MESSAGES_KEY_PREFIX + sessionId; + logger.info("Deleting session {} from Memorystore.", sessionId); + + return redisTemplate.opsForValue().get(sessionKey) + .flatMap(session -> { + if (session != null && session.telefono() != null) { + String phoneToSessionKey = PHONE_TO_SESSION_KEY_PREFIX + session.telefono(); + return redisTemplate.opsForValue().delete(sessionKey) + .then(stringRedisTemplate.opsForValue().delete(phoneToSessionKey)) + .then(messageRedisTemplate.delete(messagesKey)); + } else { + return redisTemplate.opsForValue().delete(sessionKey) + .then(messageRedisTemplate.delete(messagesKey)); + } + }).then(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/service/llm/LlmResponseTunerService.java b/src/main/java/com/example/service/llm/LlmResponseTunerService.java new file mode 100644 index 0000000..de93a39 --- /dev/null +++ b/src/main/java/com/example/service/llm/LlmResponseTunerService.java @@ -0,0 +1,13 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.service.llm; + +import reactor.core.publisher.Mono; + +public interface LlmResponseTunerService { + Mono getValue(String key); + Mono setValue(String key, String value); +} \ No newline at end of file diff --git a/src/main/java/com/example/service/llm/LlmResponseTunerServiceImpl.java b/src/main/java/com/example/service/llm/LlmResponseTunerServiceImpl.java new file mode 100644 index 0000000..2ea0a19 --- /dev/null +++ b/src/main/java/com/example/service/llm/LlmResponseTunerServiceImpl.java @@ -0,0 +1,33 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.service.llm; + +import java.time.Duration; +import org.springframework.data.redis.core.ReactiveRedisTemplate; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; + +@Service +public class LlmResponseTunerServiceImpl implements LlmResponseTunerService { + + private final ReactiveRedisTemplate reactiveStringRedisTemplate; + private final String llmPreResponseCollectionName = "llm-pre-response:"; + private final Duration ttl = Duration.ofHours(1); + + public LlmResponseTunerServiceImpl(ReactiveRedisTemplate reactiveStringRedisTemplate) { + this.reactiveStringRedisTemplate = reactiveStringRedisTemplate; + } + + @Override + public Mono getValue(String key) { + return reactiveStringRedisTemplate.opsForValue().get(llmPreResponseCollectionName + key); + } + + @Override + public Mono setValue(String key, String value) { + return reactiveStringRedisTemplate.opsForValue().set(llmPreResponseCollectionName + key, value, ttl).then(); + } +} diff --git a/src/main/java/com/example/service/notification/FirestoreNotificationService.java b/src/main/java/com/example/service/notification/FirestoreNotificationService.java new file mode 100644 index 0000000..1fbb427 --- /dev/null +++ b/src/main/java/com/example/service/notification/FirestoreNotificationService.java @@ -0,0 +1,188 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.service.notification; + +import com.example.dto.dialogflow.notification.NotificationDTO; +import com.example.exception.FirestorePersistenceException; +import com.example.mapper.notification.FirestoreNotificationMapper; +import com.example.repository.FirestoreBaseRepository; +import com.google.cloud.Timestamp; +import com.google.cloud.firestore.DocumentReference; +import com.google.cloud.firestore.FieldValue; +import java.time.Instant; +import java.util.Collections; +import java.util.Map; +import java.util.List; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.concurrent.ExecutionException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +@Service +public class FirestoreNotificationService { + + private static final Logger logger = LoggerFactory.getLogger(FirestoreNotificationService.class); + private static final String NOTIFICATION_COLLECTION_PATH_FORMAT = "artifacts/%s/notifications"; + private static final String FIELD_MESSAGES = "notificaciones"; + private static final String FIELD_LAST_UPDATED = "ultimaActualizacion"; + private static final String FIELD_PHONE_NUMBER = "telefono"; + private static final String FIELD_NOTIFICATION_ID = "sessionId"; + + private final FirestoreBaseRepository firestoreBaseRepository; + private final FirestoreNotificationMapper firestoreNotificationMapper; + + public FirestoreNotificationService( + FirestoreBaseRepository firestoreBaseRepository, + FirestoreNotificationMapper firestoreNotificationMapper, + MemoryStoreNotificationService memoryStoreNotificationService) { + this.firestoreBaseRepository = firestoreBaseRepository; + this.firestoreNotificationMapper = firestoreNotificationMapper; + } + + public Mono saveOrAppendNotificationEntry(NotificationDTO newEntry) { + return Mono.fromRunnable( + () -> { + String phoneNumber = newEntry.telefono(); + if (phoneNumber == null || phoneNumber.isBlank()) { + throw new IllegalArgumentException( + "Phone number is required to manage notification entries."); + } + // Use the phone number as the document ID for the session. + String notificationSessionId = phoneNumber; + + // Synchronize on the notification session ID to prevent race conditions when + // creating a new session. + synchronized (notificationSessionId.intern()) { + DocumentReference notificationDocRef = getNotificationDocumentReference(notificationSessionId); + Map entryMap = firestoreNotificationMapper.mapNotificationDTOToMap(newEntry); + try { + // Check if the session document exists. + boolean docExists = firestoreBaseRepository.documentExists(notificationDocRef); + + if (docExists) { + // If the document exists, append the new entry to the 'notificaciones' array. + Map updates = Map.of( + FIELD_MESSAGES, FieldValue.arrayUnion(entryMap), + FIELD_LAST_UPDATED, Timestamp.of(java.util.Date.from(Instant.now()))); + firestoreBaseRepository.updateDocument(notificationDocRef, updates); + logger.info( + "Successfully appended new entry to notification session {} in Firestore.", + notificationSessionId); + } else { + // If the document does not exist, create a new session document. + Map newSessionData = Map.of( + FIELD_NOTIFICATION_ID, + notificationSessionId, + FIELD_PHONE_NUMBER, + phoneNumber, + "fechaCreacion", + Timestamp.of(java.util.Date.from(Instant.now())), + FIELD_LAST_UPDATED, + Timestamp.of(java.util.Date.from(Instant.now())), + FIELD_MESSAGES, + Collections.singletonList(entryMap)); + firestoreBaseRepository.setDocument(notificationDocRef, newSessionData); + logger.info( + "Successfully created a new notification session {} in Firestore.", + notificationSessionId); + } + } catch (ExecutionException e) { + logger.error( + "Error saving notification to Firestore for phone: {}", + e.getMessage(), + e); + throw new FirestorePersistenceException( + "Failed to save notification to Firestore for phone ", e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + logger.error( + "Thread interrupted while saving notification to Firestore for phone {}: {}", + phoneNumber, + e.getMessage(), + e); + throw new FirestorePersistenceException( + "Saving notification was interrupted for phone ", e); + } + } + }) + .subscribeOn(Schedulers.boundedElastic()) + .then(); + } + + private String getNotificationCollectionPath() { + return String.format(NOTIFICATION_COLLECTION_PATH_FORMAT, firestoreBaseRepository.getAppId()); + } + + private DocumentReference getNotificationDocumentReference(String notificationId) { + String collectionPath = getNotificationCollectionPath(); + return firestoreBaseRepository.getDocumentReference(collectionPath, notificationId); + } + + @SuppressWarnings("unchecked") + public Mono updateNotificationStatus(String sessionId, String status) { + return Mono.fromRunnable(() -> { + DocumentReference notificationDocRef = getNotificationDocumentReference(sessionId); + try { + Map sessionData = firestoreBaseRepository.getDocument(notificationDocRef, Map.class); + if (sessionData != null) { + List> notifications = (List>) sessionData + .get(FIELD_MESSAGES); + if (notifications != null) { + List> updatedNotifications = new ArrayList<>(); + for (Map notification : notifications) { + Map updatedNotification = new HashMap<>(notification); + updatedNotification.put("status", status); + updatedNotifications.add(updatedNotification); + } + Map updates = new HashMap<>(); + updates.put(FIELD_MESSAGES, updatedNotifications); + updates.put(FIELD_LAST_UPDATED, Timestamp.of(java.util.Date.from(Instant.now()))); + firestoreBaseRepository.updateDocument(notificationDocRef, updates); + logger.info("Successfully updated notification status to '{}' for session {} in Firestore.", + status, sessionId); + } + } else { + logger.warn("Notification session {} not found in Firestore. Cannot update status.", sessionId); + } + } catch (ExecutionException e) { + logger.error("Error updating notification status in Firestore for session {}: {}", sessionId, + e.getMessage(), e); + throw new FirestorePersistenceException( + "Failed to update notification status in Firestore for session " + sessionId, e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + logger.error("Thread interrupted while updating notification status in Firestore for session {}: {}", + sessionId, e.getMessage(), e); + throw new FirestorePersistenceException( + "Updating notification status was interrupted for session " + sessionId, e); + } + }) + .subscribeOn(Schedulers.boundedElastic()) + .then(); + } + + public Mono deleteNotification(String notificationId) { + logger.info("Attempting to delete notification session {} from Firestore.", notificationId); + return Mono.fromRunnable(() -> { + try { + DocumentReference notificationDocRef = getNotificationDocumentReference(notificationId); + firestoreBaseRepository.deleteDocument(notificationDocRef); + logger.info("Successfully deleted notification session {} from Firestore.", notificationId); + } catch (ExecutionException e) { + logger.error("Error deleting notification session {} from Firestore: {}", notificationId, e.getMessage(), e); + throw new FirestorePersistenceException("Failed to delete notification session " + notificationId, e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + logger.error("Thread interrupted while deleting notification session {} from Firestore: {}", notificationId, e.getMessage(), e); + throw new FirestorePersistenceException("Deleting notification session was interrupted for " + notificationId, e); + } + }).subscribeOn(Schedulers.boundedElastic()).then(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/service/notification/MemoryStoreNotificationService.java b/src/main/java/com/example/service/notification/MemoryStoreNotificationService.java new file mode 100644 index 0000000..2d2cd0b --- /dev/null +++ b/src/main/java/com/example/service/notification/MemoryStoreNotificationService.java @@ -0,0 +1,120 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.service.notification; + +import com.example.dto.dialogflow.notification.NotificationDTO; +import com.example.dto.dialogflow.notification.NotificationSessionDTO; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.redis.core.ReactiveRedisTemplate; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.time.Instant; + +@Service +public class MemoryStoreNotificationService { + + private static final Logger logger = LoggerFactory.getLogger(MemoryStoreNotificationService.class); + private final ReactiveRedisTemplate notificationRedisTemplate; + private final ReactiveRedisTemplate stringRedisTemplate; + private static final String NOTIFICATION_KEY_PREFIX = "notification:"; + private static final String PHONE_TO_NOTIFICATION_SESSION_KEY_PREFIX = "notification:phone_to_notification:"; + private final Duration notificationTtl = Duration.ofDays(30); + + public MemoryStoreNotificationService( + ReactiveRedisTemplate notificationRedisTemplate, + ReactiveRedisTemplate stringRedisTemplate, + ObjectMapper objectMapper) { + this.notificationRedisTemplate = notificationRedisTemplate; + this.stringRedisTemplate = stringRedisTemplate; + } + + public Mono saveOrAppendNotificationEntry(NotificationDTO newEntry) { + String phoneNumber = newEntry.telefono(); + if (phoneNumber == null || phoneNumber.isBlank()) { + return Mono.error(new IllegalArgumentException("Phone number is required to manage notification entries.")); + } + //noote: Use the phone number as the session ID for notifications + String notificationSessionId = phoneNumber; + + return getCachedNotificationSession(notificationSessionId) + .flatMap(existingSession -> { + // Session exists, append the new entry + List updatedEntries = new ArrayList<>(existingSession.notificaciones()); + updatedEntries.add(newEntry); + NotificationSessionDTO updatedSession = new NotificationSessionDTO( + notificationSessionId, + phoneNumber, + existingSession.fechaCreacion(), + Instant.now(), + updatedEntries + ); + return Mono.just(updatedSession); + }) + .switchIfEmpty(Mono.defer(() -> { + // No session found, create a new one + NotificationSessionDTO newSession = new NotificationSessionDTO( + notificationSessionId, + phoneNumber, + Instant.now(), + Instant.now(), + Collections.singletonList(newEntry) + ); + return Mono.just(newSession); + })) + .flatMap(this::cacheNotificationSession) + .then(); + } + + private Mono cacheNotificationSession(NotificationSessionDTO session) { + String key = NOTIFICATION_KEY_PREFIX + session.sessionId(); + String phoneToSessionKey = PHONE_TO_NOTIFICATION_SESSION_KEY_PREFIX + session.telefono(); + + return notificationRedisTemplate.opsForValue().set(key, session, notificationTtl) + .then(stringRedisTemplate.opsForValue().set(phoneToSessionKey, session.sessionId(), notificationTtl)); + } + + public Mono getCachedNotificationSession(String sessionId) { + String key = NOTIFICATION_KEY_PREFIX + sessionId; + return notificationRedisTemplate.opsForValue().get(key) + .doOnSuccess(notification -> { + if (notification != null) { + logger.info("Notification session with ID {} retrieved from MemoryStore.", sessionId); + } else { + logger.debug("Notification session with ID {} not found in MemoryStore.", sessionId); + } + }) + .doOnError(e -> logger.error("Error retrieving notification session with ID {} from MemoryStore: {}", sessionId, e.getMessage(), e)); + } + + public Mono getNotificationIdForPhone(String phone) { + String key = PHONE_TO_NOTIFICATION_SESSION_KEY_PREFIX + phone; + return stringRedisTemplate.opsForValue().get(key) + .doOnSuccess(sessionId -> { + if (sessionId != null) { + logger.info("Session ID {} found for phone.", sessionId); + } else { + logger.debug("Session ID not found for phone."); + } + }) + .doOnError(e -> logger.error("Error retrieving session ID for phone from MemoryStore: {}", + e.getMessage(), e)); + } + + public Mono deleteNotificationSession(String phoneNumber) { + String notificationKey = NOTIFICATION_KEY_PREFIX + phoneNumber; + String phoneToNotificationKey = PHONE_TO_NOTIFICATION_SESSION_KEY_PREFIX + phoneNumber; + logger.info("Deleting notification session for phone number {}.", phoneNumber); + return notificationRedisTemplate.opsForValue().delete(notificationKey) + .then(stringRedisTemplate.opsForValue().delete(phoneToNotificationKey)) + .then(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/service/notification/NotificationManagerService.java b/src/main/java/com/example/service/notification/NotificationManagerService.java new file mode 100644 index 0000000..49fd852 --- /dev/null +++ b/src/main/java/com/example/service/notification/NotificationManagerService.java @@ -0,0 +1,184 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.service.notification; + +import com.example.dto.dialogflow.notification.ExternalNotRequestDTO; +import com.example.dto.dialogflow.base.DetectIntentRequestDTO; +import com.example.dto.dialogflow.base.DetectIntentResponseDTO; +import com.example.dto.dialogflow.conversation.ConversationEntryDTO; +import com.example.dto.dialogflow.conversation.ConversationMessageDTO; +import com.example.dto.dialogflow.conversation.ConversationSessionDTO; +import com.example.dto.dialogflow.notification.NotificationDTO; +import com.example.mapper.conversation.ConversationEntryMapper; +import com.example.mapper.notification.ExternalNotRequestMapper; +import com.example.service.base.DialogflowClientService; +import com.example.service.conversation.DataLossPrevention; +import com.example.service.conversation.FirestoreConversationService; +import com.example.service.conversation.MemoryStoreConversationService; +import com.example.util.SessionIdGenerator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +@Service +public class NotificationManagerService { + + private static final Logger logger = LoggerFactory.getLogger(NotificationManagerService.class); + private static final String eventName = "notificacion"; + private static final String PREFIX_PO_PARAM = "notification_po_"; + + private final DialogflowClientService dialogflowClientService; + private final FirestoreNotificationService firestoreNotificationService; + private final MemoryStoreNotificationService memoryStoreNotificationService; + private final ExternalNotRequestMapper externalNotRequestMapper; + private final MemoryStoreConversationService memoryStoreConversationService; + private final FirestoreConversationService firestoreConversationService; + private final DataLossPrevention dataLossPrevention; + private final String dlpTemplateCompleteFlow; + private final ConversationEntryMapper conversationEntryMapper; + + @Value("${dialogflow.default-language-code:es}") + private String defaultLanguageCode; + + public NotificationManagerService( + DialogflowClientService dialogflowClientService, + FirestoreNotificationService firestoreNotificationService, + MemoryStoreNotificationService memoryStoreNotificationService, + MemoryStoreConversationService memoryStoreConversationService, + FirestoreConversationService firestoreConversationService, + + ExternalNotRequestMapper externalNotRequestMapper, + DataLossPrevention dataLossPrevention, + ConversationEntryMapper conversationEntryMapper, + @Value("${google.cloud.dlp.dlpTemplateCompleteFlow}") String dlpTemplateCompleteFlow) { + + this.dialogflowClientService = dialogflowClientService; + this.firestoreNotificationService = firestoreNotificationService; + this.memoryStoreNotificationService = memoryStoreNotificationService; + this.externalNotRequestMapper = externalNotRequestMapper; + this.dataLossPrevention = dataLossPrevention; + this.dlpTemplateCompleteFlow = dlpTemplateCompleteFlow; + this.memoryStoreConversationService = memoryStoreConversationService; + this.firestoreConversationService = firestoreConversationService; + this.conversationEntryMapper = conversationEntryMapper; + } + + public Mono processNotification(ExternalNotRequestDTO externalRequest) { + Objects.requireNonNull(externalRequest, "ExternalNotRequestDTO cannot be null."); + + String telefono = externalRequest.phoneNumber(); + if (telefono == null || telefono.isBlank()) { + logger.warn("No phone number provided in ExternalNotRequestDTO. Cannot process notification."); + return Mono.error(new IllegalArgumentException("Phone number is required.")); + } + + return dataLossPrevention.getObfuscatedString(externalRequest.text(), dlpTemplateCompleteFlow) + .flatMap(obfuscatedMessage -> { + ExternalNotRequestDTO obfuscatedRequest = new ExternalNotRequestDTO( + obfuscatedMessage, + externalRequest.phoneNumber(), + externalRequest.hiddenParameters() + ); + + String newNotificationId = SessionIdGenerator.generateStandardSessionId(); + Map parameters = new HashMap<>(); + if (obfuscatedRequest.hiddenParameters() != null) { + obfuscatedRequest.hiddenParameters().forEach((key, value) -> parameters.put(PREFIX_PO_PARAM + key, value)); + } + + NotificationDTO newNotificationEntry = new NotificationDTO(newNotificationId, telefono, Instant.now(), + obfuscatedRequest.text(), eventName, defaultLanguageCode, parameters, "active"); + Mono persistenceMono = memoryStoreNotificationService.saveOrAppendNotificationEntry(newNotificationEntry) + .doOnSuccess(v -> { + logger.info("Notification for phone {} cached. Kicking off async Firestore write-back.", telefono); + firestoreNotificationService.saveOrAppendNotificationEntry(newNotificationEntry) + .subscribe( + ignored -> logger.debug( + "Background: Notification entry persistence initiated for phone {} in Firestore.", telefono), + e -> logger.error( + "Background: Error during notification entry persistence for phone {} in Firestore: {}", + telefono, e.getMessage(), e)); + }); + + Mono sessionMono = memoryStoreConversationService.getSessionByTelefono(telefono) + .doOnNext(session -> logger.info("Found existing conversation session {} for phone number {}", + session.sessionId(), telefono)) + .flatMap(session -> { + Map prefixedParameters = new HashMap<>(); + if (obfuscatedRequest.hiddenParameters() != null) { + obfuscatedRequest.hiddenParameters() + .forEach((key, value) -> prefixedParameters.put(PREFIX_PO_PARAM + key, value)); + } + ConversationEntryDTO systemEntry = ConversationEntryDTO.forSystem(obfuscatedRequest.text(), + prefixedParameters); + return persistConversationTurn(session, systemEntry) + .thenReturn(session); + }) + .switchIfEmpty(Mono.defer(() -> { + String newSessionId = SessionIdGenerator.generateStandardSessionId(); + logger.info("No existing conversation session found for phone number {}. Creating new session: {}", + telefono, newSessionId); + String userId = "user_by_phone_" + telefono; + Map prefixedParameters = new HashMap<>(); + if (obfuscatedRequest.hiddenParameters() != null) { + obfuscatedRequest.hiddenParameters() + .forEach((key, value) -> prefixedParameters.put(PREFIX_PO_PARAM + key, value)); + } + ConversationEntryDTO systemEntry = ConversationEntryDTO.forSystem(obfuscatedRequest.text(), + prefixedParameters); + ConversationSessionDTO newSession = ConversationSessionDTO.create(newSessionId, userId, telefono); + return persistConversationTurn(newSession, systemEntry) + .then(Mono.just(newSession)); + })); + + return persistenceMono.then(sessionMono) + .flatMap(session -> { + final String sessionId = session.sessionId(); + logger.info("Sending notification text to Dialogflow using conversation session: {}", sessionId); + + DetectIntentRequestDTO detectIntentRequest = externalNotRequestMapper.map(obfuscatedRequest); + + return dialogflowClientService.detectIntent(sessionId, detectIntentRequest); + }) + .doOnSuccess(response -> logger + .info("Finished processing notification. Dialogflow response received for phone {}.", telefono)) + .doOnError(e -> logger.error("Overall error in NotificationManagerService: {}", e.getMessage(), e)); + }); + } + + private Mono persistConversationTurn(ConversationSessionDTO session, ConversationEntryDTO entry) { + logger.debug("Starting Write-Back persistence for session {}. Type: {}. Writing to Redis first.", session.sessionId(), + entry.type().name()); + ConversationMessageDTO message = conversationEntryMapper.toConversationMessageDTO(entry); + ConversationSessionDTO updatedSession = session.withLastMessage(message.text()); + + return memoryStoreConversationService.saveSession(updatedSession) + .then(memoryStoreConversationService.saveMessage(session.sessionId(), message)) + .doOnSuccess(v -> { + logger.info( + "Entry saved to Redis for session {}. Type: {}. Kicking off async Firestore write-back.", + session.sessionId(), entry.type().name()); + + firestoreConversationService.saveSession(updatedSession) + .then(firestoreConversationService.saveMessage(session.sessionId(), message)) + .subscribe( + fsVoid -> logger.debug( + "Asynchronously (Write-Back): Entry successfully saved to Firestore for session {}. Type: {}.", + session.sessionId(), entry.type().name()), + fsError -> logger.error( + "Asynchronously (Write-Back): Failed to save entry to Firestore for session {}. Type: {}: {}", + session.sessionId(), entry.type().name(), fsError.getMessage(), fsError)); + }) + .doOnError(e -> logger.error("Error during primary Redis write for session {}. Type: {}: {}", session.sessionId(), + entry.type().name(), e.getMessage(), e)); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/service/quickreplies/MemoryStoreQRService.java b/src/main/java/com/example/service/quickreplies/MemoryStoreQRService.java new file mode 100644 index 0000000..0d02ef8 --- /dev/null +++ b/src/main/java/com/example/service/quickreplies/MemoryStoreQRService.java @@ -0,0 +1,99 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.service.quickreplies; + +import com.example.dto.dialogflow.conversation.ConversationEntryDTO; +import com.example.dto.dialogflow.conversation.ConversationMessageDTO; +import com.example.dto.dialogflow.conversation.ConversationSessionDTO; +import com.example.mapper.conversation.ConversationEntryMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.ReactiveRedisTemplate; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; + +import java.time.Duration; + +@Service +public class MemoryStoreQRService { + private static final Logger logger = LoggerFactory.getLogger(MemoryStoreQRService.class); + private static final String SESSION_KEY_PREFIX = "qr:session:"; + private static final String PHONE_TO_SESSION_KEY_PREFIX = "qr:phone_to_session:"; + private static final String MESSAGES_KEY_PREFIX = "qr:messages:"; + private static final Duration SESSION_TTL = Duration.ofHours(24); + private final ReactiveRedisTemplate redisTemplate; + private final ReactiveRedisTemplate stringRedisTemplate; + private final ReactiveRedisTemplate messageRedisTemplate; + private final ConversationEntryMapper conversationEntryMapper; + + @Autowired + public MemoryStoreQRService( + ReactiveRedisTemplate redisTemplate, + ReactiveRedisTemplate stringRedisTemplate, + ReactiveRedisTemplate messageRedisTemplate, + ConversationEntryMapper conversationEntryMapper) { + this.redisTemplate = redisTemplate; + this.stringRedisTemplate = stringRedisTemplate; + this.messageRedisTemplate = messageRedisTemplate; + this.conversationEntryMapper = conversationEntryMapper; + } + + 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; + String messagesKey = MESSAGES_KEY_PREFIX + sessionId; + + logger.info("Attempting to save entry to Redis for quick reply session {}. Entity: {}", sessionId, + newEntry.entity().name()); + + return redisTemplate.opsForValue().get(sessionKey) + .defaultIfEmpty(ConversationSessionDTO.create(sessionId, userId, userPhoneNumber)) + .flatMap(session -> { + ConversationSessionDTO sessionWithUpdatedTelefono = session.withTelefono(userPhoneNumber); + ConversationSessionDTO updatedSession = sessionWithUpdatedTelefono.withLastMessage(newEntry.text()); + ConversationMessageDTO message = conversationEntryMapper.toConversationMessageDTO(newEntry); + + logger.info("Attempting to set updated quick reply session {} with new entry entity {} in Redis.", + sessionId, newEntry.entity().name()); + + return redisTemplate.opsForValue().set(sessionKey, updatedSession, SESSION_TTL) + .then(stringRedisTemplate.opsForValue().set(phoneToSessionKey, sessionId, SESSION_TTL)) + .then(messageRedisTemplate.opsForList().rightPush(messagesKey, message)) + .then(); + }) + .doOnSuccess(success -> { + logger.info( + "Successfully saved updated quick reply session and phone mapping to Redis for session {}. Entity Type: {}", + sessionId, newEntry.entity().name()); + }) + .doOnError(e -> logger.error("Error appending entry to Redis for quick reply session {}: {}", sessionId, + e.getMessage(), e)); + } + + public Mono getSessionByTelefono(String telefono) { + if (telefono == null || telefono.isBlank()) { + return Mono.empty(); + } + String phoneToSessionKey = PHONE_TO_SESSION_KEY_PREFIX + telefono; + return stringRedisTemplate.opsForValue().get(phoneToSessionKey) + .flatMap(sessionId -> { + logger.debug("Found quick reply session ID {} for phone number {}. Retrieving session data.", + sessionId, telefono); + return redisTemplate.opsForValue().get(SESSION_KEY_PREFIX + sessionId); + }) + .doOnSuccess(session -> { + if (session != null) { + logger.info("Successfully retrieved quick reply session {}", + session.sessionId()); + } else { + logger.info("No quick reply session found in Redis for phone number"); + } + }) + .doOnError(e -> logger.error("Error retrieving quick reply session by phone numbe: {}",e.getMessage(), e)); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/service/quickreplies/QuickRepliesManagerService.java b/src/main/java/com/example/service/quickreplies/QuickRepliesManagerService.java new file mode 100644 index 0000000..91fab1f --- /dev/null +++ b/src/main/java/com/example/service/quickreplies/QuickRepliesManagerService.java @@ -0,0 +1,182 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.service.quickreplies; + +import com.example.dto.dialogflow.base.DetectIntentResponseDTO; +import com.example.dto.dialogflow.conversation.*; +import com.example.dto.quickreplies.QuickReplyScreenRequestDTO; +import com.example.dto.quickreplies.QuestionDTO; +import com.example.dto.quickreplies.QuickReplyDTO; +import com.example.mapper.conversation.ConversationEntryMapper; +import com.example.service.conversation.FirestoreConversationService; +import com.example.service.conversation.MemoryStoreConversationService; +import com.example.util.SessionIdGenerator; +import java.time.Instant; +import java.util.Collections; +import java.util.List; +import java.util.stream.IntStream; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import com.example.service.conversation.ConversationManagerService; +import org.springframework.context.annotation.Lazy; +import reactor.core.publisher.Mono; + +@Service +public class QuickRepliesManagerService { + private static final Logger logger = LoggerFactory.getLogger(QuickRepliesManagerService.class); + private final MemoryStoreConversationService memoryStoreConversationService; + private final FirestoreConversationService firestoreConversationService; + private final QuickReplyContentService quickReplyContentService; + private final ConversationManagerService conversationManagerService; + private final ConversationEntryMapper conversationEntryMapper; + + public QuickRepliesManagerService( + @Lazy ConversationManagerService conversationManagerService, + MemoryStoreConversationService memoryStoreConversationService, + FirestoreConversationService firestoreConversationService, + QuickReplyContentService quickReplyContentService, + ConversationEntryMapper conversationEntryMapper) { + this.conversationManagerService = conversationManagerService; + this.memoryStoreConversationService = memoryStoreConversationService; + this.firestoreConversationService = firestoreConversationService; + this.quickReplyContentService = quickReplyContentService; + this.conversationEntryMapper = conversationEntryMapper; + } + + public Mono startQuickReplySession(QuickReplyScreenRequestDTO externalRequest) { + String userPhoneNumber = externalRequest.user().telefono(); + if (userPhoneNumber == null || userPhoneNumber.isBlank()) { + logger.warn("No phone number provided in request. Cannot manage conversation session without it."); + return Mono + .error(new IllegalArgumentException("Phone number is required to manage conversation sessions.")); + } + return memoryStoreConversationService.getSessionByTelefono(userPhoneNumber) + .flatMap(session -> Mono.just(session.sessionId())) + .switchIfEmpty(Mono.fromCallable(SessionIdGenerator::generateStandardSessionId)) + .flatMap(sessionId -> { + String userId = "user_by_phone_" + userPhoneNumber.replaceAll("[^0-9]", ""); + ConversationEntryDTO systemEntry = new ConversationEntryDTO( + ConversationEntryEntity.SISTEMA, + ConversationEntryType.INICIO, + Instant.now(), + "Pantalla :" + externalRequest.pantallaContexto() + " Agregada a la conversacion :", + null, + null); + ConversationSessionDTO newSession = ConversationSessionDTO.create(sessionId, userId, userPhoneNumber).withPantallaContexto(externalRequest.pantallaContexto()); + return persistConversationTurn(newSession, systemEntry) + .then(quickReplyContentService.getQuickReplies(externalRequest.pantallaContexto())) + .map(quickReplyDTO -> new DetectIntentResponseDTO(sessionId, null, quickReplyDTO)); + }); + } + + public Mono manageConversation(ExternalConvRequestDTO externalRequest) { + String userPhoneNumber = externalRequest.user().telefono(); + if (userPhoneNumber == null || userPhoneNumber.isBlank()) { + logger.warn("No phone number provided in request. Cannot manage conversation session without it."); + return Mono + .error(new IllegalArgumentException("Phone number is required to manage conversation sessions.")); + } + + return memoryStoreConversationService.getSessionByTelefono(userPhoneNumber) + .switchIfEmpty(Mono.error( + new IllegalStateException("No quick reply session found for phone number"))) + .flatMap(session -> { + + return memoryStoreConversationService.getMessages(session.sessionId()).collectList().flatMap(messages -> { + ConversationEntryDTO userEntry = ConversationEntryDTO.forUser(externalRequest.message()); + + int lastInitIndex = IntStream.range(0, messages.size()) + .map(i -> messages.size() - 1 - i) + .filter(i -> { + ConversationMessageDTO message = messages.get(i); + return message.type() == MessageType.SYSTEM; + }) + .findFirst() + .orElse(-1); + + long userMessagesCount; + if (lastInitIndex != -1) { + userMessagesCount = messages.subList(lastInitIndex + 1, messages.size()).stream() + .filter(e -> e.type() == MessageType.USER) + .count(); + } else { + userMessagesCount = 0; + } + + if (userMessagesCount == 0) { // Is the first user message in the Quick-Replies flow + // This is the second message of the flow. Return the full list. + return persistConversationTurn(session, userEntry) + .then(quickReplyContentService.getQuickReplies(session.pantallaContexto())) + .flatMap(quickReplyDTO -> { + ConversationEntryDTO agentEntry = ConversationEntryDTO + .forAgentWithMessage(quickReplyDTO.toString()); + return persistConversationTurn(session, agentEntry) + .thenReturn(new DetectIntentResponseDTO(session.sessionId(), null, quickReplyDTO)); + }); + } else if (userMessagesCount == 1) { // Is the second user message in the QR flow + // This is the third message of the flow. Filter and end. + return persistConversationTurn(session, userEntry) + .then(quickReplyContentService.getQuickReplies(session.pantallaContexto())) + .flatMap(quickReplyDTO -> { + List matchedPreguntas = quickReplyDTO.preguntas().stream() + .filter(p -> p.titulo().equalsIgnoreCase(externalRequest.message().trim())) + .toList(); + + if (!matchedPreguntas.isEmpty()) { + // Matched question, return the answer + String respuesta = matchedPreguntas.get(0).respuesta(); + QueryResultDTO queryResult = new QueryResultDTO(respuesta, null); + DetectIntentResponseDTO response = new DetectIntentResponseDTO(session.sessionId(), + queryResult, null); + + return memoryStoreConversationService + .updateSession(session.withPantallaContexto(null)) + .then(persistConversationTurn(session, + ConversationEntryDTO.forAgentWithMessage(respuesta))) + .thenReturn(response); + } else { + // No match, delegate to Dialogflow + return memoryStoreConversationService + .updateSession(session.withPantallaContexto(null)) + .then(conversationManagerService.manageConversation(externalRequest)); + } + }); + } else { + // Should not happen. End the flow. + return memoryStoreConversationService.updateSession(session.withPantallaContexto(null)) + .then(Mono.just(new DetectIntentResponseDTO(session.sessionId(), null, + new QuickReplyDTO("Flow Error", null, null, null, Collections.emptyList())))); + } + }); + }); + } + + private Mono persistConversationTurn(ConversationSessionDTO session, ConversationEntryDTO entry) { + logger.debug("Starting Write-Back persistence for quick reply session {}. Type: {}. Writing to Redis first.", + session.sessionId(), entry.type().name()); + ConversationMessageDTO message = conversationEntryMapper.toConversationMessageDTO(entry); + ConversationSessionDTO updatedSession = session.withLastMessage(message.text()); + return memoryStoreConversationService.saveSession(updatedSession) + .then(memoryStoreConversationService.saveMessage(session.sessionId(), message)) + .doOnSuccess(v -> logger.info( + "Entry saved to Redis for quick reply session {}. Type: {}. Kicking off async Firestore write-back.", + session.sessionId(), entry.type().name())) + .then(firestoreConversationService.saveSession(updatedSession) + .then(firestoreConversationService.saveMessage(session.sessionId(), message)) + .doOnSuccess(fsVoid -> logger.debug( + "Asynchronously (Write-Back): Entry successfully saved to Firestore for quick reply session {}. Type: {}.", + session.sessionId(), entry.type().name())) + .doOnError(fsError -> logger.error( + "Asynchronously (Write-Back): Failed to save entry to Firestore for quick reply session {}. Type: {}: {}", + session.sessionId(), entry.type().name(), fsError.getMessage(), fsError))) + .doOnError( + e -> logger.error("Error during primary Redis write for quick reply session {}. Type: {}: {}", + session.sessionId(), entry.type().name(), e.getMessage(), e)); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/service/quickreplies/QuickReplyContentService.java b/src/main/java/com/example/service/quickreplies/QuickReplyContentService.java new file mode 100644 index 0000000..b785587 --- /dev/null +++ b/src/main/java/com/example/service/quickreplies/QuickReplyContentService.java @@ -0,0 +1,70 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.service.quickreplies; + +import com.example.dto.quickreplies.QuestionDTO; +import com.example.dto.quickreplies.QuickReplyDTO; +import com.google.cloud.firestore.DocumentSnapshot; +import com.google.cloud.firestore.Firestore; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; +import java.util.Collections; +import java.util.List; + +@Service +public class QuickReplyContentService { + private static final Logger logger = LoggerFactory.getLogger(QuickReplyContentService.class); + private final Firestore firestore; + public QuickReplyContentService(Firestore firestore) { + this.firestore = firestore; + } + public Mono getQuickReplies(String collectionId) { + logger.info("Fetching quick replies from Firestore for document: {}", collectionId); + if (collectionId == null || collectionId.isBlank()) { + logger.warn("collectionId is null or empty. Returning empty quick replies."); + return Mono.just(new QuickReplyDTO("empty", null, null, null, Collections.emptyList())); + } + return Mono.fromCallable(() -> { + try { + return firestore.collection("artifacts") + .document("default-app-id") + .collection("quick-replies") + .document(collectionId) + .get() + .get(); + } catch (Exception e) { + throw new RuntimeException(e); + } + }) + .filter(DocumentSnapshot::exists) + .map(document -> { + String header = document.getString("header"); + String body = document.getString("body"); + String button = document.getString("button"); + String headerSection = document.getString("header_section"); + List> preguntasData = (List>) document.get("preguntas"); + List preguntas = preguntasData.stream() + .map(p -> new QuestionDTO((String) p.get("titulo"), (String) p.get("descripcion"), (String) p.get("respuesta"))) + .toList(); + return new QuickReplyDTO(header, body, button, headerSection, preguntas); + }) + .doOnSuccess(quickReplyDTO -> { + if (quickReplyDTO != null) { + logger.info("Successfully fetched {} quick replies for document: {}", quickReplyDTO.preguntas().size(), collectionId); + } else { + logger.info("No quick reply document found for id: {}", collectionId); + } + }) + .doOnError(error -> logger.error("Error fetching quick replies from Firestore for document: {}", collectionId, error)) + .switchIfEmpty(Mono.defer(() -> { + logger.info("No quick reply document found for id: {}", collectionId); + return Mono.empty(); + })); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/util/FirestoreDataImporter.java b/src/main/java/com/example/util/FirestoreDataImporter.java new file mode 100644 index 0000000..6c437dd --- /dev/null +++ b/src/main/java/com/example/util/FirestoreDataImporter.java @@ -0,0 +1,91 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.util; + +import com.example.repository.FirestoreBaseRepository; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.cloud.firestore.DocumentReference; +import com.google.cloud.firestore.DocumentSnapshot; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ExecutionException; + +public class FirestoreDataImporter { + + private static final Logger logger = LoggerFactory.getLogger(FirestoreDataImporter.class); + private static final String QUICK_REPLIES_COLLECTION_PATH_FORMAT = "artifacts/%s/quick-replies"; + + private final FirestoreBaseRepository firestoreBaseRepository; + private final ObjectMapper objectMapper; + + private final boolean isImporterEnabled; + + public FirestoreDataImporter(FirestoreBaseRepository firestoreBaseRepository, ObjectMapper objectMapper) { + this.firestoreBaseRepository = firestoreBaseRepository; + this.objectMapper = objectMapper; + this.isImporterEnabled = Boolean.parseBoolean(System.getProperty("firestore.data.importer.enabled")); + + } + + public void runImport() { + if (isImporterEnabled) { + try { + importQuickReplies(); + } catch (Exception e) { + logger.error("Failed to import data to Firestore on startup", e); + } + } + } + + private void importQuickReplies() throws IOException, ExecutionException, InterruptedException { + String collectionPath = String.format(QUICK_REPLIES_COLLECTION_PATH_FORMAT, firestoreBaseRepository.getAppId()); + + importJson(collectionPath, "home"); + importJson(collectionPath, "pagos"); + importJson(collectionPath, "finanzas"); + importJson(collectionPath, "lealtad"); + importJson(collectionPath, "descubre"); + importJson(collectionPath, "detalle-tdc"); + importJson(collectionPath, "detalle-tdd"); + importJson(collectionPath, "transferencia"); + importJson(collectionPath, "retiro-sin-tarjeta"); + importJson(collectionPath, "capsulas"); + importJson(collectionPath, "inversiones"); + importJson(collectionPath, "prestamos"); + + logger.info("All JSON files were imported successfully."); + } + + private void importJson(String collectionPath, String documentId) throws IOException, ExecutionException, InterruptedException { + String resourcePath = "/quick-replies/" + documentId + ".json"; + try (InputStream inputStream = getClass().getResourceAsStream(resourcePath)) { + if (inputStream == null) { + logger.warn("Resource not found: {}", resourcePath); + return; + } + Map localData = objectMapper.readValue(inputStream, new TypeReference>() {}); + DocumentReference docRef = firestoreBaseRepository.getDocumentReference(collectionPath, documentId); + + if (firestoreBaseRepository.documentExists(docRef)) { + DocumentSnapshot documentSnapshot = firestoreBaseRepository.getDocumentSnapshot(docRef); + Map firestoreData = documentSnapshot.getData(); + if (!Objects.equals(localData, firestoreData)) { + firestoreBaseRepository.setDocument(docRef, localData); + logger.info("Successfully updated {} in Firestore.", documentId); + } + } else { + firestoreBaseRepository.setDocument(docRef, localData); + logger.info("Successfully imported {} to Firestore.", documentId); + } + } + } +} \ No newline at end of file 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..c85bee2 --- /dev/null +++ b/src/main/java/com/example/util/FirestoreTimestampDeserializer.java @@ -0,0 +1,75 @@ +/* +* Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. +* Your use of it is subject to your agreement with Google. +*/ + +package com.example.util; + +import com.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..b9f3ccf --- /dev/null +++ b/src/main/java/com/example/util/FirestoreTimestampSerializer.java @@ -0,0 +1,35 @@ +/* +* Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. +* Your use of it is subject to your agreement with Google. +*/ + +package com.example.util; + +import com.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/PerformanceTimer.java b/src/main/java/com/example/util/PerformanceTimer.java new file mode 100644 index 0000000..6b82a9a --- /dev/null +++ b/src/main/java/com/example/util/PerformanceTimer.java @@ -0,0 +1,37 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ +package com.example.util; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.function.Supplier; + +/** + * A utility class to measure and log the execution time of a given operation. + * It uses the Supplier functional interface to wrap the code block to be timed. + */ +public class PerformanceTimer { + private static final Logger logger = LoggerFactory.getLogger(PerformanceTimer.class); + + + public static T timeExecution(String operationName, Supplier operation) { + long startTime = System.nanoTime(); + try { + T result = operation.get(); + long endTime = System.nanoTime(); + long durationNanos = endTime - startTime; + double durationMillis = durationNanos / 1_000_000.0; + logger.info("Operation '{}' completed in {} ms.", operationName, String.format("%.2f", durationMillis)); + return result; + } catch (Exception e) { + long endTime = System.nanoTime(); + long durationNanos = endTime - startTime; + double durationMillis = durationNanos / 1_000_000.0; + logger.error("Operation '{}' failed in {} ms: {}", operationName, String.format("%.2f", durationMillis), e.getMessage(), e); + throw new RuntimeException("Error during timed operation: " + operationName, e); + } + } +} \ 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..047744e --- /dev/null +++ b/src/main/java/com/example/util/ProtobufUtil.java @@ -0,0 +1,89 @@ +/* +* Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. +* Your use of it is subject to your agreement with Google. +*/ + +package com.example.util; + +import com.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. + */ + @SuppressWarnings("rawtypes") + 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 Enum) { + return Value.newBuilder().setStringValue(((Enum) obj).name()).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/java/com/example/util/SessionIdGenerator.java b/src/main/java/com/example/util/SessionIdGenerator.java new file mode 100644 index 0000000..0dc8aa4 --- /dev/null +++ b/src/main/java/com/example/util/SessionIdGenerator.java @@ -0,0 +1,68 @@ +/* +* Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. +* Your use of it is subject to your agreement with Google. +*/ + +package com.example.util; + +import java.util.UUID; +import java.util.Base64; + +/** + * A utility class for generating consistent and formatted session IDs. + * Centralizing ID generation ensures all parts of the application use the same + * logic and format. + */ +public final class SessionIdGenerator { + + // Private constructor to prevent instantiation of the utility class. + private SessionIdGenerator() {} + + /** + * Generates a standard, version 4 (random) UUID as a string. + * This is the most common and robust approach for general-purpose unique IDs. + * The UUID is a 36-character string with hyphens (e.g., "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"). + * + * @return a new, randomly generated UUID string. + */ + public static String generateStandardSessionId() { + return UUID.randomUUID().toString(); + } + + /** + * Generates a more compact session ID by removing the hyphens from a standard UUID. + * This is useful for contexts where a shorter or URL-friendly ID is needed. + * + * @return a 32-character UUID string without hyphens. + */ + public static String generateCompactSessionId() { + return UUID.randomUUID().toString().replace("-", ""); + } + + /** + * Generates a base64-encoded, URL-safe session ID from a UUID. + * This provides a very compact, yet robust, representation of the UUID. + * It's ideal for use in URLs, cookies, or other contexts where size matters. + * + * @return a new, base64-encoded UUID string. + */ + public static String generateUrlSafeSessionId() { + UUID uuid = UUID.randomUUID(); + byte[] uuidBytes = toBytes(uuid); + return Base64.getUrlEncoder().withoutPadding().encodeToString(uuidBytes); + } + + // Helper method to convert UUID to a byte array + private static byte[] toBytes(UUID uuid) { + long mostSignificantBits = uuid.getMostSignificantBits(); + long leastSignificantBits = uuid.getLeastSignificantBits(); + byte[] bytes = new byte[16]; + for (int i = 0; i < 8; i++) { + bytes[i] = (byte) (mostSignificantBits >>> (8 * (7 - i))); + } + for (int i = 0; i < 8; i++) { + bytes[8 + i] = (byte) (leastSignificantBits >>> (8 * (7 - i))); + } + return bytes; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/util/TextObfuscator.java b/src/main/java/com/example/util/TextObfuscator.java new file mode 100644 index 0000000..77321e8 --- /dev/null +++ b/src/main/java/com/example/util/TextObfuscator.java @@ -0,0 +1,98 @@ +/* +* Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. +* Your use of it is subject to your agreement with Google. +*/ + +package com.example.util; + +import java.util.Comparator; +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.privacy.dlp.v2.Finding; +import com.google.privacy.dlp.v2.InspectContentResponse; + +public class TextObfuscator { + private static final Logger logger = LoggerFactory.getLogger(TextObfuscator.class); + + public static String obfuscate(InspectContentResponse response, String textToInspect) { + + List findings = response.getResult().getFindingsList().stream() + .filter(finding -> finding.getLikelihoodValue() > 3) + .sorted(Comparator.comparing(Finding::getLikelihoodValue).reversed()) + .peek(finding -> logger.info("InfoType: {} | Likelihood: {}", finding.getInfoType().getName(), + finding.getLikelihoodValue())) + .toList(); + + for (Finding finding : findings) { + String quote = finding.getQuote(); + + switch (finding.getInfoType().getName()) { + case "CREDIT_CARD_NUMBER": + textToInspect = textToInspect.replace(quote, "**** **** **** " + getLast4(quote)); + break; + case "CREDIT_CARD_EXPIRATION_DATE": + case "FECHA_VENCIMIENTO": + textToInspect = textToInspect.replace(quote, "[FECHA_VENCIMIENTO_TARJETA]"); + break; + case "CVV_NUMBER": + case "CVV": + textToInspect = textToInspect.replace(quote, "[CVV]"); + break; + case "EMAIL_ADDRESS": + textToInspect = textToInspect.replace(quote, "[CORREO]"); + break; + case "PERSON_NAME": + textToInspect = textToInspect.replace(quote, "[NOMBRE]"); + break; + case "PHONE_NUMBER": + textToInspect = textToInspect.replace(quote, "[TELEFONO]"); + break; + case "DIRECCION": + case "DIR_COLONIA": + case "DIR_DEL_MUN": + case "DIR_INTERIOR": + case "DIR_ESQUINA": + case "DIR_CIUDAD_EDO": + case "DIR_CP": + textToInspect = textToInspect.replace(quote, "[DIRECCION]"); + break; + case "CLABE_INTERBANCARIA": + textToInspect = textToInspect.replace(quote, "[CLABE]"); + break; + case "CLAVE_RASTREO_SPEI": + textToInspect = textToInspect.replace(quote, "[CLAVE_RASTREO]"); + break; + case "NIP": + textToInspect = textToInspect.replace(quote, "[NIP]"); + break; + case "SALDO": + textToInspect = textToInspect.replace(quote, "[SALDO]"); + break; + case "CUENTA": + textToInspect = textToInspect.replace(quote, "**************" + getLast4(quote)); + break; + case "NUM_ACLARACION": + textToInspect = textToInspect.replace(quote, "[NUM_ACLARACION]"); + break; + } + } + textToInspect = cleanDireccion(textToInspect); + return textToInspect; + } + + private static String getLast4(String quote) { + char[] last4 = new char[4]; + String cleanQuote = quote.trim(); + cleanQuote = cleanQuote.replace(" ", ""); + cleanQuote.getChars(cleanQuote.length() - 4, cleanQuote.length(), last4, 0); + return new String(last4); + } + + private static String cleanDireccion(String quote) { + String output = quote.replaceAll("\\[DIRECCION\\](?:(?:,\\s*|\\s+)\\[DIRECCION\\])*", "[DIRECCION]"); + return output.trim(); + } +} diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties new file mode 100644 index 0000000..513f673 --- /dev/null +++ b/src/main/resources/application-dev.properties @@ -0,0 +1,78 @@ +# Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. +# Your use of it is subject to your agreement with Google. + +# ========================================= +# Spring Boot Configuration Template +# ========================================= +# This file serves as a reference template for all application configuration properties. + +# Best Practices: +# - Use Spring Profiles (e.g., application-dev.properties, application-prod.properties) +# to manage environment-specific settings. +# - Do not store in PROD sensitive information directly here. +# Use environment variables or a configuration server for production environments. +# - This template can be adapted for logging configuration, database connections, +# and other external service settings. + +# ========================================================= +# Orchestrator general Configuration +# ========================================================= +spring.cloud.gcp.project-id=${GCP_PROJECT_ID} +# ========================================================= +# Google Firestore Configuration +# ========================================================= +spring.cloud.gcp.firestore.project-id=${GCP_PROJECT_ID} +spring.cloud.gcp.firestore.database-id=${GCP_FIRESTORE_DATABASE_ID} +spring.cloud.gcp.firestore.host=${GCP_FIRESTORE_HOST} +spring.cloud.gcp.firestore.port=${GCP_FIRESTORE_PORT} +# ========================================================= +# Google Memorystore(Redis) Configuration +# ========================================================= +spring.data.redis.host=${REDIS_HOST} +spring.data.redis.port=${REDIS_PORT} +#spring.data.redis.password=${REDIS_PWD} +#spring.data.redis.username=default + +# SSL Configuration (if using SSL) +# spring.data.redis.ssl=true +# spring.data.redis.ssl.key-store=classpath:keystore.p12 +# spring.data.redis.ssl.key-store-password=${REDIS_KEY_PWD} +# ========================================================= +# Google Conversational Agents Configuration +# ========================================================= +dialogflow.cx.project-id=${DIALOGFLOW_CX_PROJECT_ID} +dialogflow.cx.location=${DIALOGFLOW_CX_LOCATION} +dialogflow.cx.agent-id=${DIALOGFLOW_CX_AGENT_ID} +dialogflow.default-language-code=${DIALOGFLOW_DEFAULT_LANGUAGE_CODE} +# ========================================================= +# Google Generative AI (Gemini) Configuration +# ========================================================= +google.cloud.project=${GCP_PROJECT_ID} +google.cloud.location=${GCP_LOCATION} +gemini.model.name=${GEMINI_MODEL_NAME} +# ========================================================= +# (Gemini) MessageFilter Configuration +# ========================================================= +messagefilter.geminimodel=${MESSAGE_FILTER_GEMINI_MODEL} +messagefilter.temperature=${MESSAGE_FILTER_TEMPERATURE} +messagefilter.maxOutputTokens=${MESSAGE_FILTER_MAX_OUTPUT_TOKENS} +messagefilter.topP=${MESSAGE_FILTER_TOP_P} +messagefilter.prompt=prompts/message_filter_prompt.txt +# ========================================================= +# (DLP) Configuration +# ========================================================= +google.cloud.dlp.dlpTemplateCompleteFlow=${DLP_TEMPLATE_COMPLETE_FLOW} +# ========================================================= +# Quick-replies Preset-data +# ========================================================= +firestore.data.importer.enabled=${GCP_FIRESTORE_IMPORTER_ENABLE} +# ========================================================= +# LOGGING Configuration +# ========================================================= +logging.level.root=${LOGGING_LEVEL_ROOT:INFO} +logging.level.com.example=${LOGGING_LEVEL_COM_EXAMPLE:INFO} +# ========================================================= +# ConversationContext Configuration +# ========================================================= +conversation.context.message.limit=${CONVERSATION_CONTEXT_MESSAGE_LIMIT} +conversation.context.days.limit=${CONVERSATION_CONTEXT_DAYS_LIMIT} \ No newline at end of file diff --git a/src/main/resources/application-prod.properties b/src/main/resources/application-prod.properties new file mode 100644 index 0000000..dea84f3 --- /dev/null +++ b/src/main/resources/application-prod.properties @@ -0,0 +1,79 @@ +# Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. +# Your use of it is subject to your agreement with Google. + +# ========================================= +# Spring Boot Configuration Template +# ========================================= +# This file serves as a reference template for all application configuration properties. + +# Best Practices: +# - Use Spring Profiles (e.g., application-dev.properties, application-prod.properties) +# to manage environment-specific settings. +# - Do not store in PROD sensitive information directly here. +# Use environment variables or a configuration server for production environments. +# - This template can be adapted for logging configuration, database connections, +# and other external service settings. + +# ========================================================= +# Orchestrator general Configuration +# ========================================================= +spring.cloud.gcp.project-id=${GCP_PROJECT_ID} +# ========================================================= +# Google Firestore Configuration +# ========================================================= +spring.cloud.gcp.firestore.project-id=${GCP_PROJECT_ID} +spring.cloud.gcp.firestore.database-id=${GCP_FIRESTORE_DATABASE_ID} +spring.cloud.gcp.firestore.host=${GCP_FIRESTORE_HOST} +spring.cloud.gcp.firestore.port=${GCP_FIRESTORE_PORT} +# ========================================================= +# Google Memorystore(Redis) Configuration +# ========================================================= +spring.data.redis.host=${REDIS_HOST} +spring.data.redis.port=${REDIS_PORT} +#spring.data.redis.password=${REDIS_PWD} +#spring.data.redis.username=default + +# SSL Configuration (if using SSL) +# spring.data.redis.ssl=true +# spring.data.redis.ssl.key-store=classpath:keystore.p12 +# spring.data.redis.ssl.key-store-password=${REDIS_KEY_PWD} +# ========================================================= +# Google Conversational Agents Configuration +# ========================================================= +dialogflow.cx.project-id=${DIALOGFLOW_CX_PROJECT_ID} +dialogflow.cx.location=${DIALOGFLOW_CX_LOCATION} +dialogflow.cx.agent-id=${DIALOGFLOW_CX_AGENT_ID} +dialogflow.default-language-code=${DIALOGFLOW_DEFAULT_LANGUAGE_CODE} +# ========================================================= +# Google Generative AI (Gemini) Configuration +# ========================================================= +google.cloud.project=${GCP_PROJECT_ID} +google.cloud.location=${GCP_LOCATION} +gemini.model.name=${GEMINI_MODEL_NAME} +# ========================================================= +# (Gemini) MessageFilter Configuration +# ========================================================= +messagefilter.geminimodel=${MESSAGE_FILTER_GEMINI_MODEL} +messagefilter.temperature=${MESSAGE_FILTER_TEMPERATURE} +messagefilter.maxOutputTokens=${MESSAGE_FILTER_MAX_OUTPUT_TOKENS} +messagefilter.topP=${MESSAGE_FILTER_TOP_P} +messagefilter.prompt=prompts/message_filter_prompt.txt +# ========================================================= +# (DLP) Configuration +# ========================================================= +google.cloud.dlp.dlpTemplateCompleteFlow=${DLP_TEMPLATE_COMPLETE_FLOW} +google.cloud.dlp.dlpTemplatePersistFlow=${DLP_TEMPLATE_PERSIST_FLOW} +# ========================================================= +# Quick-replies Preset-data +# ========================================================= +firestore.data.importer.enabled=${GCP_FIRESTORE_IMPORTER_ENABLE} +# ========================================================= +# LOGGING Configuration +# ========================================================= +logging.level.root=${LOGGING_LEVEL_ROOT:INFO} +logging.level.com.example=${LOGGING_LEVEL_COM_EXAMPLE:INFO} +# ========================================================= +# ConversationContext Configuration +# ========================================================= +conversation.context.message.limit=${CONVERSATION_CONTEXT_MESSAGE_LIMIT} +conversation.context.days.limit=${CONVERSATION_CONTEXT_DAYS_LIMIT} \ No newline at end of file diff --git a/src/main/resources/application-qa.properties b/src/main/resources/application-qa.properties new file mode 100644 index 0000000..dea84f3 --- /dev/null +++ b/src/main/resources/application-qa.properties @@ -0,0 +1,79 @@ +# Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. +# Your use of it is subject to your agreement with Google. + +# ========================================= +# Spring Boot Configuration Template +# ========================================= +# This file serves as a reference template for all application configuration properties. + +# Best Practices: +# - Use Spring Profiles (e.g., application-dev.properties, application-prod.properties) +# to manage environment-specific settings. +# - Do not store in PROD sensitive information directly here. +# Use environment variables or a configuration server for production environments. +# - This template can be adapted for logging configuration, database connections, +# and other external service settings. + +# ========================================================= +# Orchestrator general Configuration +# ========================================================= +spring.cloud.gcp.project-id=${GCP_PROJECT_ID} +# ========================================================= +# Google Firestore Configuration +# ========================================================= +spring.cloud.gcp.firestore.project-id=${GCP_PROJECT_ID} +spring.cloud.gcp.firestore.database-id=${GCP_FIRESTORE_DATABASE_ID} +spring.cloud.gcp.firestore.host=${GCP_FIRESTORE_HOST} +spring.cloud.gcp.firestore.port=${GCP_FIRESTORE_PORT} +# ========================================================= +# Google Memorystore(Redis) Configuration +# ========================================================= +spring.data.redis.host=${REDIS_HOST} +spring.data.redis.port=${REDIS_PORT} +#spring.data.redis.password=${REDIS_PWD} +#spring.data.redis.username=default + +# SSL Configuration (if using SSL) +# spring.data.redis.ssl=true +# spring.data.redis.ssl.key-store=classpath:keystore.p12 +# spring.data.redis.ssl.key-store-password=${REDIS_KEY_PWD} +# ========================================================= +# Google Conversational Agents Configuration +# ========================================================= +dialogflow.cx.project-id=${DIALOGFLOW_CX_PROJECT_ID} +dialogflow.cx.location=${DIALOGFLOW_CX_LOCATION} +dialogflow.cx.agent-id=${DIALOGFLOW_CX_AGENT_ID} +dialogflow.default-language-code=${DIALOGFLOW_DEFAULT_LANGUAGE_CODE} +# ========================================================= +# Google Generative AI (Gemini) Configuration +# ========================================================= +google.cloud.project=${GCP_PROJECT_ID} +google.cloud.location=${GCP_LOCATION} +gemini.model.name=${GEMINI_MODEL_NAME} +# ========================================================= +# (Gemini) MessageFilter Configuration +# ========================================================= +messagefilter.geminimodel=${MESSAGE_FILTER_GEMINI_MODEL} +messagefilter.temperature=${MESSAGE_FILTER_TEMPERATURE} +messagefilter.maxOutputTokens=${MESSAGE_FILTER_MAX_OUTPUT_TOKENS} +messagefilter.topP=${MESSAGE_FILTER_TOP_P} +messagefilter.prompt=prompts/message_filter_prompt.txt +# ========================================================= +# (DLP) Configuration +# ========================================================= +google.cloud.dlp.dlpTemplateCompleteFlow=${DLP_TEMPLATE_COMPLETE_FLOW} +google.cloud.dlp.dlpTemplatePersistFlow=${DLP_TEMPLATE_PERSIST_FLOW} +# ========================================================= +# Quick-replies Preset-data +# ========================================================= +firestore.data.importer.enabled=${GCP_FIRESTORE_IMPORTER_ENABLE} +# ========================================================= +# LOGGING Configuration +# ========================================================= +logging.level.root=${LOGGING_LEVEL_ROOT:INFO} +logging.level.com.example=${LOGGING_LEVEL_COM_EXAMPLE:INFO} +# ========================================================= +# ConversationContext Configuration +# ========================================================= +conversation.context.message.limit=${CONVERSATION_CONTEXT_MESSAGE_LIMIT} +conversation.context.days.limit=${CONVERSATION_CONTEXT_DAYS_LIMIT} \ 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..404a33d --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1 @@ +spring.profiles.active=${SPRING_PROFILE} \ No newline at end of file diff --git a/src/main/resources/prompts/message_filter_prompt.txt b/src/main/resources/prompts/message_filter_prompt.txt new file mode 100644 index 0000000..c9bc8cf --- /dev/null +++ b/src/main/resources/prompts/message_filter_prompt.txt @@ -0,0 +1,93 @@ +Hay un sistema de conversaciones entre un agente y un usuario. Durante +la conversación, una notificación puede entrar a la conversación de forma +abrupta, de tal forma que la siguiente interacción del usuario después +de la notificación puede corresponder a la conversación que estaba +sucediendo o puede ser un seguimiento a la notificación. + +Tu tarea es identificar si la siguiente interacción del usuario es un +seguimiento a la notificación o una continuación de la conversación. + +Recibirás esta información: + +- HISTORIAL_CONVERSACION: El diálogo entre el agente y el usuario antes + de la notificación. +- INTERRUPCION_NOTIFICACION: La notificación. Esta puede o no traer parámetros + los cuales refieren a detalles específicos de la notificación. Por ejemplo: + { "vigencia": “12 de septiembre de 2025”, "credito_tipo" : "platinum" } +- INTERACCION_USUARIO: La siguiente interacción del usuario después de + la notificación. + +Reglas: +- Solo debes responder una palabra: NOTIFICATION o CONVERSATION. No agregues + o inventes otra palabra. +- Clasifica como NOTIFICATION si la siguiente interacción del usuario + es una clara respuesta o seguimiento a la notificación. +- Clasifica como CONVERSATION si la siguiente interacción del usuario + es un claro seguimiento al histórico de la conversación. +- Si la siguiente interacción del usuario es ambigua, clasifica + como CONVERSATION. + +Ejemplos: + +Ejemplo 1: +HISTORIAL_CONVERSACION: + Agente: Claro, para un crédito de vehículo, las tasas actuales inician en el 1.2%% mensual. + Usuario: Entiendo, ¿y el plazo máximo de cuánto sería? +INTERRUPCION_NOTIFICACION: + Tu pago de la tarjeta de crédito por $1,500.00 ha sido procesado. +INTERACCION_USUARIO: + perfecto, cuando es la fecha de corte? +Clasificación: NOTIFICACION + +Ejemplo 2: +HISTORIAL_CONVERSACION: + Agente: No es necesario, puedes completar todo el proceso para abrir tu cuenta desde nuestra app. + Usuario: Ok + Agente: ¿Necesitas algo más? +INTERRUPCION_NOTIFICACION: + Tu estado de cuenta de Julio ya está disponible. + Parametros: {"fecha_corte": "30 de Agosto del 2025", "tipo_cuenta": "credito"} +INTERACCION_USUARIO: + que documentos necesito? +Clasificación: CONVERSACION + +Ejemplo 3: +HISTORIAL_CONVERSACION: + Agente: Ese fondo de inversión tiene un perfil de alto riesgo, pero históricamente ha dado un rendimiento superior al 15%% anual. + Usuario: ok, entiendo +INTERRUPCION_NOTIFICACION: + Alerta: Tu cuenta de ahorros tiene un saldo bajo de $50.00. + Parametros: {"fecha_retiro": "5 de septiembre del 2025", "tipo_cuenta": "ahorros"} +INTERACCION_USUARIO: + cuando fue el ultimo retiro? +Clasificación: NOTIFICACION + +Ejemplo 4: +HISTORIAL_CONVERSACION: + Usuario: Que es el CAT? + Agente: El CAT (Costo Anual Total) es un indicador financiero, expresado en un porcentaje anual, que refleja el costo total de un crédito, incluyendo no solo la tasa de interés, sino también todas las comisiones, gastos y otros cobros que genera. +INTERRUPCION_NOTIFICACION: + Alerta: Se realizó un retiro en efectivo por $100. +INTERACCION_USUARIO: + y este se aplica solo si dejo de pagar? +Clasificación: CONVERSACION + +Ejemplo 5: +HISTORIAL_CONVERSACION: + Usuario: Cual es la tasa de hipoteca que manejan? + Agente: La tasa de una hipoteca depende tanto de factores económicos generales (inflación, tasas de referencia del banco central) como de factores individuales del solicitante (historial crediticio, monto del pago inicial, ingresos, endeudamiento, etc.) +INTERRUPCION_NOTIFICACION: + Hola, [Alias]: Pasó algo con la captura de tu INE y no se completó tu solicitud de tarjeta de crédito con folio 3421. + Parametros: {“solicitud_tarjeta_credito_vigencia”: “12 de septiembre de 2025”, “solicitud_tarjeta_credito_error”: “Error con el formato de la captura”, “solicitud_tarjeta_credito_tipo” : “platinum” } +INTERACCION_USUARIO: + cual fue el error? +Clasificación: NOTIFICACION + +Tarea: +HISTORIAL_CONVERSACION: + %s +INTERRUPCION_NOTIFICACION: + %s +INTERACCION_USUARIO: + %s +Clasificación: diff --git a/src/main/resources/prompts/notification_context_resolver.txt b/src/main/resources/prompts/notification_context_resolver.txt new file mode 100644 index 0000000..ba83e22 --- /dev/null +++ b/src/main/resources/prompts/notification_context_resolver.txt @@ -0,0 +1,84 @@ +Eres un agente conversacional de soporte al usuario, amable, servicial y conciso. + +Recibirás cuatro piezas de información: +1. HISTORIAL_CONVERSACION: El diálogo previo con el usuario. Úsalo para entender el contexto y evitar repetir información. +2. NOTIFICACION: El texto del mensaje que el usuario acaba de recibir. +3. METADATOS_NOTIFICACION: Un objeto JSON con datos estructurados relacionados con la notificación. Esta es tu fuente de verdad principal. +4. PREGUNTA_USUARIO: La pregunta específica del usuario que debes responder. + +Tu objetivo es sintetizar la información de estas fuentes para dar la respuesta más directa y útil posible. + +**Reglas de Comportamiento:** + +**Proceso Lógico:** Debes seguir este orden de prioridad para encontrar la respuesta: + 1. Autoridad Principal: Busca la respuesta primero en el objeto METADATOS_NOTIFICACION. Los datos aquí tienen la máxima autoridad. + 2. Fuente Alternativa: Si la respuesta no está en el objeto METADATOS_NOTIFICACION, busca como alternativa en el texto de HISTORIAL_CONVERSACION los datos que empiecen con el prefijo notification_po_. + 3. Contexto: Utiliza el HISTORIAL_CONVERSACION únicamente para dar contexto y asegurarte de no repetir algo que ya se dijo + +**Manejo de Datos Faltantes:** Si la respuesta a la PREGUNTA_USUARIO no se encuentra METADATOS_NOTIFICACION ni en el HISTORIAL_CONVERSACION (con el prefijo notification_po_) entonces debes responder exactamente con la palabra DIALOGFLOW.No intentes adivinar ni disculparte +**Concisión y Tono:** Tu respuesta debe ser directa, clara y resolver la pregunta. Mantén un tono profesional, amable y servicial. +**Idioma:** Responde siempre en el mismo idioma de la PREGUNTA_USUARIO. + +Manejo de Datos Faltantes: Si la respuesta a la PREGUNTA_USUARIO no se encuentra ni en METADATOS_NOTIFICACION ni en el HISTORIAL_CONVERSACION (con el prefijo notification_po_), +entonces debes responder exactamente con la palabra DIALOGFLOW. +No intentes adivinar ni disculparte. + +Estrategia de Respuesta: +Siempre sintetiza la información encontrada en una respuesta completa y conversacional. No devuelvas solo el dato. Utiliza el dato para construir una frase que sea útil y siga el tono. Por ejemplo, si encuentras el dato "30/09/2025", tu respuesta debe ser una frase como "La vigencia de tu solicitud es hasta el 30 de septiembre de 2025." o similar. + +**Ejemplos (Few-Shot Learning):** + +**Ejemplo 1: La respuesta está en los Metadatos** +HISTORIAL_CONVERSACION: +Usuario: Hola, necesito ayuda con una documentación. +Agente: Claro, ¿en qué puedo ayudarte? +NOTIFICACION: Hola :Pasó algo con la captura de tu INE y no se completó tu solicitud de tarjeta de crédito con folio ###.¡Reinténtalo cuando quieras! Solo toma en cuenta estos consejos: +Presenta tu INE original (no copias ni escaneos).📅Revisa que esté vigente y sin tachaduras.📷 Confirma que la fotografía sea clara.🏠 Asegúrate de que la dirección sea legible. +Estamos listos para recibirte. +METADATOS_NOTIFICACION: { + "parametrosOcultos": { + "vigencia": "30/09/2025" + } +} +PREGUNTA_USUARIO: ¿Hasta cuando esta disponible esta solicitud? +Respuesta: Tienes hasta el 30 de septiembre de 2025 para revisarlos. + +**Ejemplo 2: Poca Información encontrada en texto de Notificacion * +HISTORIAL_CONVERSACION: +Usuario: Hola. +Agente: ¡Qué onda! Soy Beto, tu asistente virtual de Sigma. ¿Como te puedo ayudar hoy? 🧐 +NOTIFICACION: Hola :Pasó algo con la captura de tu INE y no se completó tu *solicitud de tarjeta de crédito con folio ###*. +¡Reinténtalo cuando quieras! Solo toma en cuenta estos consejos: Presenta tu INE original (no copias ni escaneos)... +Estamos listos para recibirte. +METADATOS_NOTIFICACION: { + "parametrosOcultos": { + "vigencia": "30/09/2025" + } +} +PREGUNTA_USUARIO: Mi INE tiene algunas tachaduras y en general esta en mal estado +Respuesta: DIALOGFLOW + +**Ejemplo 3: Información no encontrada en ninguna fuente** +HISTORIAL_CONVERSACION: +Usuario: ¿Cómo van mis trámites? +Agente: Veo que tienes una cita de mantenimiento programada. +NOTIFICACION: Tu cita para el servicio de mantenimiento ha sido confirmada. Por favor, llega 15 minutos antes. +METADATOS_NOTIFICACION: { + "tipo_servicio": "mantenimiento rutinario", + "ubicacion": "Sucursal Centro", + "id_cita": "C-182736" +} +PREGUNTA_USUARIO: Perfecto, ¿cuál será el costo del mantenimiento? +Respuesta: DIALOGFLOW + +Historial de Conversación: +%s + +Notificación: +%s + +Metadatos de la Notificación: +%s + +Pregunta del Usuario: +%s \ No newline at end of file diff --git a/src/main/resources/quick-replies/capsulas.json b/src/main/resources/quick-replies/capsulas.json new file mode 100644 index 0000000..be8a192 --- /dev/null +++ b/src/main/resources/quick-replies/capsulas.json @@ -0,0 +1 @@ +{"titulo": "Capsulas"} \ No newline at end of file diff --git a/src/main/resources/quick-replies/descubre.json b/src/main/resources/quick-replies/descubre.json new file mode 100644 index 0000000..cf46565 --- /dev/null +++ b/src/main/resources/quick-replies/descubre.json @@ -0,0 +1 @@ +{"titulo": "Descubre"} \ No newline at end of file diff --git a/src/main/resources/quick-replies/detalle-tdc.json b/src/main/resources/quick-replies/detalle-tdc.json new file mode 100644 index 0000000..42689ea --- /dev/null +++ b/src/main/resources/quick-replies/detalle-tdc.json @@ -0,0 +1 @@ +{"titulo": "Detalle TDC"} \ No newline at end of file diff --git a/src/main/resources/quick-replies/detalle-tdd.json b/src/main/resources/quick-replies/detalle-tdd.json new file mode 100644 index 0000000..dc2490c --- /dev/null +++ b/src/main/resources/quick-replies/detalle-tdd.json @@ -0,0 +1 @@ +{"titulo": "Detalle TDD"} \ No newline at end of file diff --git a/src/main/resources/quick-replies/finanzas.json b/src/main/resources/quick-replies/finanzas.json new file mode 100644 index 0000000..920a72f --- /dev/null +++ b/src/main/resources/quick-replies/finanzas.json @@ -0,0 +1 @@ +{"titulo": "Finanzas"} \ No newline at end of file diff --git a/src/main/resources/quick-replies/home.json b/src/main/resources/quick-replies/home.json new file mode 100644 index 0000000..ac41a8c --- /dev/null +++ b/src/main/resources/quick-replies/home.json @@ -0,0 +1 @@ +{"titulo": "Home"} \ No newline at end of file diff --git a/src/main/resources/quick-replies/inversiones.json b/src/main/resources/quick-replies/inversiones.json new file mode 100644 index 0000000..c5b5b36 --- /dev/null +++ b/src/main/resources/quick-replies/inversiones.json @@ -0,0 +1 @@ +{"titulo": "Inversiones"} \ No newline at end of file diff --git a/src/main/resources/quick-replies/lealtad.json b/src/main/resources/quick-replies/lealtad.json new file mode 100644 index 0000000..1dfcb56 --- /dev/null +++ b/src/main/resources/quick-replies/lealtad.json @@ -0,0 +1 @@ +{"titulo": "Lealtad"} \ No newline at end of file diff --git a/src/main/resources/quick-replies/pagos.json b/src/main/resources/quick-replies/pagos.json new file mode 100644 index 0000000..0db7034 --- /dev/null +++ b/src/main/resources/quick-replies/pagos.json @@ -0,0 +1,18 @@ +{ + "header": "preguntas frecuentes", + "body": "Aquí tienes las preguntas frecuentes que suelen hacernos algunos de nuestros clientes", + "button": "Ver", + "header_section": "preguntas sobre pagos", + "preguntas": [ + { + "titulo": "Donde veo mi historial de pagos?", + "descripcion": "View your recent payments", + "respuesta": "puedes visualizar esto en la opcion X de tu app" + }, + { + "titulo": "Pregunta servicio A", + "descripcion": "descripcion servicio A", + "respuesta": "puedes ver info de servicio A en tu app" + } + ] +} \ No newline at end of file diff --git a/src/main/resources/quick-replies/prestamos.json b/src/main/resources/quick-replies/prestamos.json new file mode 100644 index 0000000..9d1989d --- /dev/null +++ b/src/main/resources/quick-replies/prestamos.json @@ -0,0 +1 @@ +{"titulo": "Prestamos"} \ No newline at end of file diff --git a/src/main/resources/quick-replies/retiro-sin-tarjeta.json b/src/main/resources/quick-replies/retiro-sin-tarjeta.json new file mode 100644 index 0000000..6794c55 --- /dev/null +++ b/src/main/resources/quick-replies/retiro-sin-tarjeta.json @@ -0,0 +1 @@ +{"titulo": "Retiro sin tarjeta"} \ No newline at end of file diff --git a/src/main/resources/quick-replies/transferencia.json b/src/main/resources/quick-replies/transferencia.json new file mode 100644 index 0000000..0d10be4 --- /dev/null +++ b/src/main/resources/quick-replies/transferencia.json @@ -0,0 +1 @@ +{"titulo": "Transferencia"} \ No newline at end of file diff --git a/src/test/java/com/example/mapper/conversation/DialogflowRequestMapperTest.java b/src/test/java/com/example/mapper/conversation/DialogflowRequestMapperTest.java new file mode 100644 index 0000000..b7685e8 --- /dev/null +++ b/src/test/java/com/example/mapper/conversation/DialogflowRequestMapperTest.java @@ -0,0 +1,133 @@ + +package com.example.mapper.conversation; + +import com.example.dto.dialogflow.base.DetectIntentRequestDTO; +import com.example.dto.dialogflow.conversation.QueryInputDTO; +import com.example.dto.dialogflow.conversation.QueryParamsDTO; +import com.example.dto.dialogflow.conversation.TextInputDTO; +import com.example.dto.dialogflow.notification.EventInputDTO; +import com.google.cloud.dialogflow.cx.v3.DetectIntentRequest; +import com.google.cloud.dialogflow.cx.v3.QueryInput; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Collections; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +@ExtendWith(MockitoExtension.class) +class DialogflowRequestMapperTest { + + @InjectMocks + private DialogflowRequestMapper dialogflowRequestMapper; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(dialogflowRequestMapper, "defaultLanguageCode", "es"); + } + + @Test + void mapToDetectIntentRequestBuilder_withTextInput_shouldMapCorrectly() { + // Given + TextInputDTO textInputDTO = new TextInputDTO("Hola"); + QueryInputDTO queryInputDTO = new QueryInputDTO(textInputDTO, null, "es"); + DetectIntentRequestDTO requestDTO = new DetectIntentRequestDTO(queryInputDTO, null); + + // When + DetectIntentRequest.Builder builder = dialogflowRequestMapper.mapToDetectIntentRequestBuilder(requestDTO); + DetectIntentRequest request = builder.build(); + + // Then + assertNotNull(request); + assertTrue(request.hasQueryInput()); + QueryInput queryInput = request.getQueryInput(); + assertEquals("es", queryInput.getLanguageCode()); + assertTrue(queryInput.hasText()); + assertEquals("Hola", queryInput.getText().getText()); + assertFalse(queryInput.hasEvent()); + } + + @Test + void mapToDetectIntentRequestBuilder_withEventInput_shouldMapCorrectly() { + // Given + EventInputDTO eventInputDTO = new EventInputDTO("welcome_event"); + QueryInputDTO queryInputDTO = new QueryInputDTO(null, eventInputDTO, "es"); + DetectIntentRequestDTO requestDTO = new DetectIntentRequestDTO(queryInputDTO, null); + + // When + DetectIntentRequest.Builder builder = dialogflowRequestMapper.mapToDetectIntentRequestBuilder(requestDTO); + DetectIntentRequest request = builder.build(); + + // Then + assertNotNull(request); + assertTrue(request.hasQueryInput()); + QueryInput queryInput = request.getQueryInput(); + assertEquals("es", queryInput.getLanguageCode()); + assertTrue(queryInput.hasEvent()); + assertEquals("welcome_event", queryInput.getEvent().getEvent()); + assertFalse(queryInput.hasText()); + } + + @Test + void mapToDetectIntentRequestBuilder_withNoInput_shouldThrowException() { + // Given + QueryInputDTO queryInputDTO = new QueryInputDTO(null, null, "es"); + DetectIntentRequestDTO requestDTO = new DetectIntentRequestDTO(queryInputDTO, null); + + // When & Then + assertThrows(IllegalArgumentException.class, () -> { + dialogflowRequestMapper.mapToDetectIntentRequestBuilder(requestDTO); + }); + } + + @Test + void mapToDetectIntentRequestBuilder_withParameters_shouldMapCorrectly() { + // Given + TextInputDTO textInputDTO = new TextInputDTO("Hola"); + QueryInputDTO queryInputDTO = new QueryInputDTO(textInputDTO, null, "es"); + Map parameters = Collections.singletonMap("param1", "value1"); + QueryParamsDTO queryParamsDTO = new QueryParamsDTO(parameters); + DetectIntentRequestDTO requestDTO = new DetectIntentRequestDTO(queryInputDTO, queryParamsDTO); + + // When + DetectIntentRequest.Builder builder = dialogflowRequestMapper.mapToDetectIntentRequestBuilder(requestDTO); + DetectIntentRequest request = builder.build(); + + // Then + assertNotNull(request); + assertTrue(request.hasQueryParams()); + assertTrue(request.getQueryParams().hasParameters()); + assertEquals("value1", request.getQueryParams().getParameters().getFieldsMap().get("param1").getStringValue()); + } + + @Test + void mapToDetectIntentRequestBuilder_withNullRequestDTO_shouldThrowException() { + // When & Then + assertThrows(NullPointerException.class, () -> { + dialogflowRequestMapper.mapToDetectIntentRequestBuilder(null); + }); + } + + @Test + void mapToDetectIntentRequestBuilder_withDefaultLanguageCode_shouldMapCorrectly() { + // Given + TextInputDTO textInputDTO = new TextInputDTO("Hola"); + QueryInputDTO queryInputDTO = new QueryInputDTO(textInputDTO, null, null); + DetectIntentRequestDTO requestDTO = new DetectIntentRequestDTO(queryInputDTO, null); + + // When + DetectIntentRequest.Builder builder = dialogflowRequestMapper.mapToDetectIntentRequestBuilder(requestDTO); + DetectIntentRequest request = builder.build(); + + // Then + assertNotNull(request); + assertTrue(request.hasQueryInput()); + assertEquals("es", request.getQueryInput().getLanguageCode()); + } +} diff --git a/src/test/java/com/example/mapper/conversation/DialogflowResponseMapperTest.java b/src/test/java/com/example/mapper/conversation/DialogflowResponseMapperTest.java new file mode 100644 index 0000000..049c9a7 --- /dev/null +++ b/src/test/java/com/example/mapper/conversation/DialogflowResponseMapperTest.java @@ -0,0 +1,129 @@ + +package com.example.mapper.conversation; + +import com.example.dto.dialogflow.base.DetectIntentResponseDTO; +import com.example.dto.dialogflow.conversation.QueryResultDTO; +import com.google.cloud.dialogflow.cx.v3.DetectIntentResponse; +import com.google.cloud.dialogflow.cx.v3.QueryResult; +import com.google.cloud.dialogflow.cx.v3.ResponseMessage; +import com.google.protobuf.Struct; +import com.google.protobuf.Value; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Arrays; +import java.util.Collections; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +@ExtendWith(MockitoExtension.class) +class DialogflowResponseMapperTest { + + @InjectMocks + private DialogflowResponseMapper dialogflowResponseMapper; + + @Test + void mapFromDialogflowResponse_shouldMapCorrectly() { + // Given + ResponseMessage.Text text1 = ResponseMessage.Text.newBuilder() + .addAllText(Collections.singletonList("Hello")).build(); + ResponseMessage message1 = ResponseMessage.newBuilder().setText(text1).build(); + ResponseMessage.Text text2 = ResponseMessage.Text.newBuilder() + .addAllText(Collections.singletonList("World")).build(); + ResponseMessage message2 = ResponseMessage.newBuilder().setText(text2).build(); + + Struct params = Struct.newBuilder() + .putFields("param1", Value.newBuilder().setStringValue("value1").build()) + .putFields("param2", Value.newBuilder().setNumberValue(123).build()) + .build(); + + QueryResult queryResult = QueryResult.newBuilder() + .addAllResponseMessages(Arrays.asList(message1, message2)) + .setParameters(params) + .build(); + + DetectIntentResponse detectIntentResponse = DetectIntentResponse.newBuilder() + .setResponseId("test-response-id") + .setQueryResult(queryResult) + .build(); + + // When + DetectIntentResponseDTO responseDTO = dialogflowResponseMapper + .mapFromDialogflowResponse(detectIntentResponse, "test-session-id"); + + // Then + assertNotNull(responseDTO); + assertEquals("test-response-id", responseDTO.responseId()); + + QueryResultDTO queryResultDTO = responseDTO.queryResult(); + assertNotNull(queryResultDTO); + assertEquals("Hello World", queryResultDTO.responseText()); + + Map parameters = queryResultDTO.parameters(); + assertNotNull(parameters); + assertEquals(2, parameters.size()); + assertEquals("value1", parameters.get("param1")); + assertEquals(123.0, parameters.get("param2")); + } + + @Test + void mapFromDialogflowResponse_withNoMessages_shouldReturnEmptyFulfillmentText() { + // Given + QueryResult queryResult = QueryResult.newBuilder() + .build(); + + DetectIntentResponse detectIntentResponse = DetectIntentResponse.newBuilder() + .setResponseId("test-response-id") + .setQueryResult(queryResult) + .build(); + + // When + DetectIntentResponseDTO responseDTO = dialogflowResponseMapper + .mapFromDialogflowResponse(detectIntentResponse, "test-session-id"); + + // Then + assertNotNull(responseDTO); + assertEquals("test-response-id", responseDTO.responseId()); + + QueryResultDTO queryResultDTO = responseDTO.queryResult(); + assertNotNull(queryResultDTO); + assertEquals("", queryResultDTO.responseText()); + } + + @Test + void mapFromDialogflowResponse_withNoParameters_shouldReturnEmptyMap() { + // Given + ResponseMessage.Text text = ResponseMessage.Text.newBuilder() + .addAllText(Collections.singletonList("Hello")).build(); + ResponseMessage message = ResponseMessage.newBuilder().setText(text).build(); + + QueryResult queryResult = QueryResult.newBuilder() + .addResponseMessages(message) + .build(); + + DetectIntentResponse detectIntentResponse = DetectIntentResponse.newBuilder() + .setResponseId("test-response-id") + .setQueryResult(queryResult) + .build(); + + // When + DetectIntentResponseDTO responseDTO = dialogflowResponseMapper + .mapFromDialogflowResponse(detectIntentResponse, "test-session-id"); + + // Then + assertNotNull(responseDTO); + assertEquals("test-response-id", responseDTO.responseId()); + + QueryResultDTO queryResultDTO = responseDTO.queryResult(); + assertNotNull(queryResultDTO); + assertEquals("Hello", queryResultDTO.responseText()); + + Map parameters = queryResultDTO.parameters(); + assertNotNull(parameters); + assertEquals(0, parameters.size()); + } +} diff --git a/src/test/java/com/example/mapper/messagefilter/ConversationContextMapperTest.java b/src/test/java/com/example/mapper/messagefilter/ConversationContextMapperTest.java new file mode 100644 index 0000000..4cce3c8 --- /dev/null +++ b/src/test/java/com/example/mapper/messagefilter/ConversationContextMapperTest.java @@ -0,0 +1,148 @@ +package com.example.mapper.messagefilter; + +import com.example.dto.dialogflow.conversation.ConversationMessageDTO; +import com.example.dto.dialogflow.conversation.MessageType; +import org.junit.jupiter.api.Test; +import java.lang.reflect.Method; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class ConversationContextMapperTest { + + @Test + public void testCleanAgentMessage() throws Exception { + ConversationContextMapper mapper = new ConversationContextMapper(); + Method method = ConversationContextMapper.class.getDeclaredMethod("cleanAgentMessage", String.class); + method.setAccessible(true); + + String input = "Agent: ¡Seguro, déjame buscarlo para ti! 😉 El 'mejor' banco es " + + "subjetivo y depende de sus necesidades financieras personales. Para determinar " + + "cuál es el más adecuado para usted, considere los siguientes factores:\n" + + "* **Comisiones y cargos**: Evalúe las tarifas por mantenimiento de cuenta, " + + "transferencias, retiros en cajeros automáticos de otras redes, y otros servicios.\n" + + "* **Tasas de interés**: Compare las tasas de interés ofrecidas en cuentas de " + + "ahorro, depósitos a plazo fijo y préstamos.\n" + + "* **Servicios y productos**: Verifique si el banco ofrece los productos que " + + "necesita, como cuentas corrientes, cuentas de ahorro, tarjetas de crédito, " + + "hipotecas, inversiones, etc.\n" + + "* **Accesibilidad y conveniencia**: Considere la ubicación de sucursales y " + + "cajeros automáticos, la calidad de la banca en línea y móvil, y el servicio al " + + "cliente.\n" + + "* **Tecnología**: Evalúe la facilidad de uso de sus plataformas digitales, la " + + "seguridad y las herramientas de gestión financiera que ofrecen.\n" + + "**Ejemplo**: Si usted realiza muchas transacciones en línea y rara vez visita una " + + "sucursal, un banco con una excelente aplicación móvil y bajas comisiones por " + + "transacciones digitales podría ser ideal. Si, por el contrario, prefiere la " + + "atención personalizada, un banco con una red de sucursales amplia y un buen " + + "servicio al cliente presencial sería más adecuado.\n" + + "**Siguientes pasos**: Le recomendamos investigar y comparar al menos tres bancos " + + "diferentes basándose en sus prioridades financieras. Revise sus sitios web, lea " + + "las condiciones de sus productos y, si es posible, consulte opiniones de otros " + + "usuarios para tomar una decisión informada. \n" + + "{response=El 'mejor' banco es subjetivo y depende de sus necesidades financieras " + + "personales. Para determinar cuál es el más adecuado para usted, considere los " + + "siguientes factores:* **Comisiones y cargos**: Evalúe las tarifas por " + + "mantenimiento de cuenta, transferencias, retiros en cajeros automáticos de " + + "otras redes, y otros servicios.* **Tasas de interés**: Compare las tasas de " + + "interés ofrecidas en cuentas de ahorro, depósitos a plazo fijo y préstamos." + + "* **Servicios y productos**: Verifique si el banco ofrece los productos que " + + "necesita, como cuentas corrientes, cuentas de ahorro, tarjetas de crédito, " + + "hipotecas, inversiones, etc.* **Accesibilidad y conveniencia**: Considere la " + + "ubicación de sucursales y cajeros automáticos, la calidad de la banca en línea y " + + "móvil, y el servicio al cliente.* **Tecnología**: Evalúe la facilidad de uso " + + "de sus plataformas digitales, la seguridad y las herramientas de gestión " + + "financiera que ofrecen.**Ejemplo**: Si usted realiza muchas transacciones en línea " + + "y rara vez visita una sucursal, un banco con una excelente aplicación móvil y " + + "bajas comisiones por transacciones digitales podría ser ideal. Si, por el " + + "contrario, prefiere la atención personalizada, un banco con una red de " + + "sucursales amplia y un buen servicio al cliente presencial sería más adecuado." + + "**Siguientes pasos**: Le recomendamos investigar y comparar al menos tres bancos " + + "diferentes basándose en sus prioridades financieras. Revise sus sitios web, lea " + + "las condiciones de sus productos y, si es posible, consulte opiniones de otros " + + "Gente, tomen una decisión informada., telefono=123456789, pregunta_nueva=NO, " + + "usuario_id=user_by_phone_123456789, historial=que son las capsulas?cual es la mejor " + + "para mi?, query_inicial=Cual es el mejor banco?, canal=sigma, " + + "$request.generative.confirmacion_ayuda=¡Seguro, déjame buscarlo para ti! 😉, " + + "query=Cual es el mejor banco?, webhook_success=true, " + + "$request.generative.respuesta_algo_mas=¿Te puedo echar la mano con otra cosa? ¡Tú dime! 😎, " + + "conversacion_notificacion=false, nickname=John Doe, notificacion= }"; + + String expected = "Agent: ¡Seguro, déjame buscarlo para ti! 😉 El 'mejor' banco es " + + "subjetivo y depende de sus necesidades financieras personales. Para determinar " + + "cuál es el más adecuado para usted, considere los siguientes factores:\n" + + "* **Comisiones y cargos**: Evalúe las tarifas por mantenimiento de cuenta, " + + "transferencias, retiros en cajeros automáticos de otras redes, y otros servicios.\n" + + "* **Tasas de interés**: Compare las tasas de interés ofrecidas en cuentas de " + + "ahorro, depósitos a plazo fijo y préstamos.\n" + + "* **Servicios y productos**: Verifique si el banco ofrece los productos que " + + "necesita, como cuentas corrientes, cuentas de ahorro, tarjetas de crédito, " + + "hipotecas, inversiones, etc.\n" + + "* **Accesibilidad y conveniencia**: Considere la ubicación de sucursales y " + + "cajeros automáticos, la calidad de la banca en línea y móvil, y el servicio al " + + "cliente.\n" + + "* **Tecnología**: Evalúe la facilidad de uso de sus plataformas digitales, la " + + "seguridad y las herramientas de gestión financiera que ofrecen.\n" + + "**Ejemplo**: Si usted realiza muchas transacciones en línea y rara vez visita una " + + "sucursal, un banco con una excelente aplicación móvil y bajas comisiones por " + + "transacciones digitales podría ser ideal. Si, por el contrario, prefiere la " + + "atención personalizada, un banco con una red de sucursales amplia y un buen " + + "servicio al cliente presencial sería más adecuado.\n" + + "**Siguientes pasos**: Le recomendamos investigar y comparar al menos tres bancos " + + "diferentes basándose en sus prioridades financieras. Revise sus sitios web, lea " + + "las condiciones de sus productos y, si es posible, consulte opiniones de otros " + + "usuarios para tomar una decisión informada."; + String result = (String) method.invoke(mapper, input); + assertEquals(expected, result); + } + + @Test + public void testToTextWithTruncation() { + ConversationContextMapper mapper = new ConversationContextMapper(); + List messages = new ArrayList<>(); + for (int i = 0; i < 1000; i++) { + messages.add(createMessage("This is message " + i, MessageType.USER)); + } + for (int i = 1000; i < 2000; i++) { + messages.add(createMessage("This is message " + i, MessageType.AGENT)); + } + + String result = mapper.toTextWithTruncation(messages); + assertTrue(result.length() > 0); + assertTrue(result.getBytes(java.nio.charset.StandardCharsets.UTF_8).length <= 50 * 1024); + } + + private ConversationMessageDTO createMessage(String text, MessageType type) { + return new ConversationMessageDTO(type, Instant.now(), text, null, null); + } + + + @Test + public void testToTextFromMessages_SystemNotification_ShouldUseParamText() { + ConversationContextMapper mapper = new ConversationContextMapper(); + + Map params = new java.util.HashMap<>(); + params.put("notification_text", "Tu estado de cuenta está listo"); + + ConversationMessageDTO systemMessage = new ConversationMessageDTO( + MessageType.SYSTEM, + Instant.now(), + "NOTIFICATION", + params, + "whatsapp" + ); + + List messages = new java.util.ArrayList<>(); + messages.add(systemMessage); + + // WHEN + String result = mapper.toTextFromMessages(messages); + System.out.println(result); + // THEN + assertEquals("System: Tu estado de cuenta está listo", result); + } +} diff --git a/src/test/java/com/example/service/conversation/ConversationManagerServiceTest.java b/src/test/java/com/example/service/conversation/ConversationManagerServiceTest.java new file mode 100644 index 0000000..a411275 --- /dev/null +++ b/src/test/java/com/example/service/conversation/ConversationManagerServiceTest.java @@ -0,0 +1,107 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ + +package com.example.service.conversation; + +import com.example.dto.dialogflow.base.DetectIntentRequestDTO; +import com.example.dto.dialogflow.base.DetectIntentResponseDTO; +import com.example.dto.dialogflow.conversation.*; +import com.example.dto.dialogflow.notification.NotificationDTO; +import com.example.mapper.conversation.ConversationEntryMapper; +import com.example.mapper.conversation.ExternalConvRequestMapper; +import com.example.mapper.messagefilter.ConversationContextMapper; +import com.example.mapper.messagefilter.NotificationContextMapper; +import com.example.service.base.DialogflowClientService; +import com.example.service.base.MessageEntryFilter; +import com.example.service.base.NotificationContextResolver; +import com.example.service.llm.LlmResponseTunerService; +import com.example.service.notification.MemoryStoreNotificationService; +import com.example.service.quickreplies.QuickRepliesManagerService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.time.Instant; +import java.util.Collections; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class ConversationManagerServiceTest { + + @Mock + private ExternalConvRequestMapper externalRequestToDialogflowMapper; + @Mock + private DialogflowClientService dialogflowServiceClient; + @Mock + private FirestoreConversationService firestoreConversationService; + @Mock + private MemoryStoreConversationService memoryStoreConversationService; + @Mock + private QuickRepliesManagerService quickRepliesManagerService; + @Mock + private MessageEntryFilter messageEntryFilter; + @Mock + private MemoryStoreNotificationService memoryStoreNotificationService; + @Mock + private NotificationContextMapper notificationContextMapper; + @Mock + private ConversationContextMapper conversationContextMapper; + @Mock + private DataLossPrevention dataLossPrevention; + @Mock + private NotificationContextResolver notificationContextResolver; + @Mock + private LlmResponseTunerService llmResponseTunerService; + @Mock + private ConversationEntryMapper conversationEntryMapper; + + @InjectMocks + private ConversationManagerService conversationManagerService; + + @Test + void startNotificationConversation_shouldSaveResolvedContextAndReturnIt() { + // Given + String userId = "test-user"; + String userPhoneNumber = "1234567890"; + String userMessageText = "test message"; + String sessionId = "test-session"; + String resolvedContext = "resolved context"; + + ConversationContext context = new ConversationContext(userId, null, userMessageText, userPhoneNumber); + DetectIntentRequestDTO request = new DetectIntentRequestDTO(null, null); + NotificationDTO notification = new NotificationDTO("1", "1234567890", Instant.now(), "test text", "test_event", "es", Collections.emptyMap(), "active"); + ConversationSessionDTO session = ConversationSessionDTO.create(sessionId, userId, userPhoneNumber); + + when(memoryStoreConversationService.getSessionByTelefono(userPhoneNumber)).thenReturn(Mono.just(session)); + when(memoryStoreConversationService.getMessages(anyString())).thenReturn(Flux.empty()); + when(conversationContextMapper.toTextFromMessages(any())).thenReturn("history"); + when(notificationContextMapper.toText(notification)).thenReturn("notification text"); + when(notificationContextResolver.resolveContext(anyString(), anyString(), anyString(), anyString(), anyString(), anyString(), anyString())) + .thenReturn(resolvedContext); + when(llmResponseTunerService.setValue(anyString(), anyString())).thenReturn(Mono.empty()); + when(memoryStoreConversationService.saveSession(any(ConversationSessionDTO.class))).thenReturn(Mono.empty()); + when(memoryStoreConversationService.saveMessage(anyString(), any(ConversationMessageDTO.class))).thenReturn(Mono.empty()); + when(firestoreConversationService.saveSession(any(ConversationSessionDTO.class))).thenReturn(Mono.empty()); + when(firestoreConversationService.saveMessage(anyString(), any(ConversationMessageDTO.class))).thenReturn(Mono.empty()); + when(conversationEntryMapper.toConversationMessageDTO(any(ConversationEntryDTO.class))).thenReturn(new ConversationMessageDTO(MessageType.USER, Instant.now(), "text", null, null)); + when(dialogflowServiceClient.detectIntent(anyString(), any(DetectIntentRequestDTO.class))).thenReturn(Mono.just(new DetectIntentResponseDTO(sessionId, new QueryResultDTO(resolvedContext, null), null))); + + // When + Mono result = conversationManagerService.startNotificationConversation(context, request, notification); + + // Then + StepVerifier.create(result) + .expectNextMatches(response -> response.queryResult().responseText().equals(resolvedContext)) + .verifyComplete(); + } +} \ No newline at end of file diff --git a/src/test/java/com/example/service/integration_testing/MessageEntryFilterIntegrationTest.java b/src/test/java/com/example/service/integration_testing/MessageEntryFilterIntegrationTest.java new file mode 100644 index 0000000..890f03b --- /dev/null +++ b/src/test/java/com/example/service/integration_testing/MessageEntryFilterIntegrationTest.java @@ -0,0 +1,121 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ +package com.example.service.integration_testing; + +import com.example.service.base.MessageEntryFilter; +import com.example.util.PerformanceTimer; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@SpringBootTest +@ActiveProfiles("dev") +@DisplayName("MessageEntryFilter Integration Tests") +public class MessageEntryFilterIntegrationTest { + + @Autowired + private MessageEntryFilter messageEntryFilter; + + private static final String NOTIFICATION_JSON_EXAMPLE = + "[{\"texto\": \"Tu estado de cuenta de Agosto esta listo\"}," + + "{\"texto\": \"Tu pago ha sido procesado\"}]"; + + private static final String CONVERSATION_JSON_EXAMPLE = + "{\"sessionId\":\"ec9f3731-59ac-4bd0-849e-f45fcc18436d\"," + + "\"userId\":\"user_by_phone_0102030405060708\"," + + "\"telefono\":\"0102030405060708\"," + + "\"createdAt\":\"2025-08-06T20:35:05.123699404Z\"," + + "\"lastModified\":\"2025-08-06T20:35:05.984574281Z\"," + + "\"entries\":[{" + + "\"type\":\"USUARIO\"," + + "\"timestamp\":\"2025-08-06T20:35:05.123516916Z\"," + + "\"text\":\"Hola que tal\"" + + "},{" + + "\"type\":\"SISTEMA\"," + + "\"timestamp\":\"2025-08-06T20:35:05.967828173Z\"," + + "\"text\":\"\\Hola! Bienvenido a Banorte, te saluda Beto. \\En que te puedo ayudar? \"," + + "\"parameters\":{" + + "\"canal\":\"banortec\"," + + "\"telefono\":\"0102030405060708\"," + + "\"pantalla_contexto\":\"transferencias\"," + + "\"usuario_id\":\"user_by_phone_0102030405060708\"," + + "\"nickname\":\"John Doe\"" + + "}" + + "}]" + + "}"; + + private static final List CONVERSATION_QUERIES = Arrays.asList( + "Hola, ¿cómo estás?", + "Qué tal, ¿qué hay de nuevo?", + "¿Cuál es el pronóstico del tiempo para hoy?", + "Me gustaría saber más sobre otro servicio", + "Tengo una pregunta general" + ); + + private static final List NOTIFICATION_QUERIES = Arrays.asList( + "¿Dónde puedo ver mi estado de cuenta?", + //"Quiero saber mas", + "Muéstrame mi estado de cuenta de este mes", + "¿Qué dice la notificación del 1 de agosto?" + ); + + @Test + @DisplayName("Gemini should classify various conversational queries as CONVERSATION") + void classifyMessage_integrationTest_shouldClassifyVariousQueriesAsConversation() { + for (int i = 0; i < CONVERSATION_QUERIES.size(); i++) { + String query = CONVERSATION_QUERIES.get(i); + String testName = String.format("Gemini (CONVERSATION) - Query %d", i + 1); + + String result = PerformanceTimer.timeExecution( + testName, + () -> messageEntryFilter.classifyMessage(query, null,null) + ); + + assertEquals(MessageEntryFilter.CATEGORY_CONVERSATION, result, + String.format("Assertion failed for query: '%s'", query)); + } + } + + @Test + @DisplayName("Gemini should classify various notification queries as NOTIFICATION with context") + void classifyMessage_integrationTest_shouldClassifyVariousQueriesAsNotificationWithContext() { + for (int i = 0; i < NOTIFICATION_QUERIES.size(); i++) { + String query = NOTIFICATION_QUERIES.get(i); + String testName = String.format("Gemini (NOTIFICATION with context) - Query %d", i + 1); + + String result = PerformanceTimer.timeExecution( + testName, + () -> messageEntryFilter.classifyMessage(query, NOTIFICATION_JSON_EXAMPLE,CONVERSATION_JSON_EXAMPLE) + ); + + assertEquals(MessageEntryFilter.CATEGORY_NOTIFICATION, result, + String.format("Assertion failed for query: '%s'", query)); + } + } + + @Test + @DisplayName("Gemini should classify various conversational queries as CONVERSATION even with context") + void classifyMessage_integrationTest_shouldClassifyVariousConversationalQueriesWithContext() { + for (int i = 0; i < CONVERSATION_QUERIES.size(); i++) { + String query = CONVERSATION_QUERIES.get(i); + String testName = String.format("Gemini (CONVERSATION with context) - Query %d", i + 1); + + String result = PerformanceTimer.timeExecution( + testName, + () -> messageEntryFilter.classifyMessage(query, NOTIFICATION_JSON_EXAMPLE,CONVERSATION_JSON_EXAMPLE) + ); + + assertEquals(MessageEntryFilter.CATEGORY_CONVERSATION, result, + String.format("Assertion failed for query: '%s'", query)); + } + } +} \ No newline at end of file diff --git a/src/test/java/com/example/service/integration_testing/NotificationContextResolverLiveTest.java b/src/test/java/com/example/service/integration_testing/NotificationContextResolverLiveTest.java new file mode 100644 index 0000000..0beb414 --- /dev/null +++ b/src/test/java/com/example/service/integration_testing/NotificationContextResolverLiveTest.java @@ -0,0 +1,52 @@ +package com.example.service.integration_testing; + +import com.example.service.base.NotificationContextResolver; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import static org.junit.jupiter.api.Assertions.assertNotNull; + +@SpringBootTest +@ActiveProfiles("dev") +@DisplayName("NotificationContextResolver Live Tests") +public class NotificationContextResolverLiveTest { + + private String notificationsJson; + private String conversationJson; + private String queryInputText; + private String metadataJson; + + @Autowired + private NotificationContextResolver notificationContextResolver; + + @BeforeEach + void setUp() { + notificationsJson = "Hola :\n" + + "Pasó algo con la captura de tu INE y no se completó tu *solicitud de tarjeta de crédito con folio *.\n" + + + "¡Reinténtalo cuando quieras! Solo toma en cuenta estos consejos:\n" + + "🪪 Presenta tu INE original (no copias ni escaneos).\n" + + "📅Revisa que esté vigente y sin tachaduras.\n" + + "📷 Confirma que la fotografía sea clara.\n" + + "🏠 Asegúrate de que la dirección sea legible.\n" + + "Estamos listos para recibirte.\n"; + + conversationJson = "System: Hola :Pasó algo con la captura de tu INE y no se completó tu *solicitud de tarjeta de crédito con folio *.¡Reinténtalo cuando quieras! Solo toma en cuenta estos consejos:🪪 Presenta tu INE original (no copias ni escaneos).📅Revisa que esté vigente y sin tachaduras.📷 Confirma que la fotografía sea clara.🏠 Asegúrate de que la dirección sea legible.Estamos listos para recibirte.notification_po_contexto=campañaprueba, notification_po_id_campaña=campaña01, notification_po_id_aplicacion=TestSigma, notification_po_id_notificacion=Prueba2"; + queryInputText = "cual es el id de la notificaion?"; + metadataJson = "{\"contexto\":\"campañaprueba\",\"id_aplicacion\":\"TestSigma\",\"id_campaña\":\"campaña01\",\"id_notificacion\":\"Prueba2\",\"vigencia\":\"30/09/2025\"}"; + //metadataJson = "{}"; + } + + @Test + @DisplayName("Should get live response from LLM and print it") + public void shouldGetLiveResponseFromLlmAndPrintIt() { + String result = notificationContextResolver.resolveContext(queryInputText, notificationsJson, conversationJson, + metadataJson, "test_user", "test_session", "1234567890"); + System.out.println("Live LLM Response: " + result); + assertNotNull(result); + } +} diff --git a/src/test/java/com/example/service/llm/LlmResponseTunerServiceImplTest.java b/src/test/java/com/example/service/llm/LlmResponseTunerServiceImplTest.java new file mode 100644 index 0000000..82f6727 --- /dev/null +++ b/src/test/java/com/example/service/llm/LlmResponseTunerServiceImplTest.java @@ -0,0 +1,57 @@ +package com.example.service.llm; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.ReactiveRedisTemplate; +import org.springframework.data.redis.core.ReactiveValueOperations; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class LlmResponseTunerServiceImplTest { + + @Mock + private ReactiveRedisTemplate reactiveStringRedisTemplate; + + @Mock + private ReactiveValueOperations reactiveValueOperations; + + @InjectMocks + private LlmResponseTunerServiceImpl llmResponseTunerService; + + private final String llmPreResponseCollectionName = "llm-pre-response:"; + + @BeforeEach + void setUp() { + when(reactiveStringRedisTemplate.opsForValue()).thenReturn(reactiveValueOperations); + } + + @Test + void getValue_shouldReturnValueFromRedis() { + String key = "test_key"; + String expectedValue = "test_value"; + + when(reactiveValueOperations.get(llmPreResponseCollectionName + key)).thenReturn(Mono.just(expectedValue)); + + StepVerifier.create(llmResponseTunerService.getValue(key)) + .expectNext(expectedValue) + .verifyComplete(); + } + + @Test + void setValue_shouldSetValueInRedis() { + String key = "test_key"; + String value = "test_value"; + + when(reactiveValueOperations.set(llmPreResponseCollectionName + key, value)).thenReturn(Mono.just(true)); + + StepVerifier.create(llmResponseTunerService.setValue(key, value)) + .verifyComplete(); + } +} diff --git a/src/test/java/com/example/service/unit_testing/DialogflowClientServiceTest.java b/src/test/java/com/example/service/unit_testing/DialogflowClientServiceTest.java new file mode 100644 index 0000000..f5517a2 --- /dev/null +++ b/src/test/java/com/example/service/unit_testing/DialogflowClientServiceTest.java @@ -0,0 +1,167 @@ +package com.example.service.unit_testing; + +import com.example.dto.dialogflow.base.DetectIntentRequestDTO; +import com.example.dto.dialogflow.base.DetectIntentResponseDTO; +import com.example.exception.DialogflowClientException; +import com.example.mapper.conversation.DialogflowRequestMapper; +import com.example.mapper.conversation.DialogflowResponseMapper; +import com.example.service.base.DialogflowClientService; +import com.google.api.gax.rpc.ApiException; +import com.google.api.gax.rpc.StatusCode; +import com.google.cloud.dialogflow.cx.v3.DetectIntentRequest; +import com.google.cloud.dialogflow.cx.v3.DetectIntentResponse; +import com.google.cloud.dialogflow.cx.v3.SessionsClient; +import com.google.cloud.dialogflow.cx.v3.SessionsSettings; + +import io.grpc.Status; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import reactor.test.StepVerifier; + +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class DialogflowClientServiceTest { + + private static final String PROJECT_ID = "test-project"; + private static final String LOCATION = "us-central1"; + private static final String AGENT_ID = "test-agent"; + private static final String SESSION_ID = "test-session-123"; + + @Mock + private DialogflowRequestMapper mockRequestMapper; + @Mock + private DialogflowResponseMapper mockResponseMapper; + @Mock + private SessionsClient mockSessionsClient; + + private MockedStatic mockedStaticSessionsClient; + + private DialogflowClientService dialogflowClientService; + + @BeforeEach + void setUp() throws IOException { + mockedStaticSessionsClient = Mockito.mockStatic(SessionsClient.class); + mockedStaticSessionsClient.when(() -> SessionsClient.create(any(SessionsSettings.class))) + .thenReturn(mockSessionsClient); + + dialogflowClientService = new DialogflowClientService( + PROJECT_ID, + LOCATION, + AGENT_ID, + mockRequestMapper, + mockResponseMapper + ); + } + + @AfterEach + void tearDown() { + mockedStaticSessionsClient.close(); + } + + @Test + void constructor_shouldInitializeClientSuccessfully() { + assertNotNull(dialogflowClientService); + mockedStaticSessionsClient.verify(() -> SessionsClient.create(any(SessionsSettings.class))); + } + + @Test + void closeSessionsClient_shouldCloseClient() { + dialogflowClientService.closeSessionsClient(); + verify(mockSessionsClient, times(1)).close(); + } + + @Test + void detectIntent_whenSuccess_shouldReturnMappedResponse() { + // Arrange + DetectIntentRequestDTO requestDTO = mock(DetectIntentRequestDTO.class); + DetectIntentRequest.Builder requestBuilder = DetectIntentRequest.newBuilder(); + DetectIntentRequest finalRequest = DetectIntentRequest.newBuilder() + .setSession(String.format("projects/%s/locations/%s/agents/%s/sessions/%s", PROJECT_ID, LOCATION, AGENT_ID, SESSION_ID)) + .build(); + DetectIntentResponse dfResponse = DetectIntentResponse.newBuilder().build(); + DetectIntentResponseDTO expectedResponseDTO = mock(DetectIntentResponseDTO.class); + + when(mockRequestMapper.mapToDetectIntentRequestBuilder(requestDTO)).thenReturn(requestBuilder); + when(mockSessionsClient.detectIntent(any(DetectIntentRequest.class))).thenReturn(dfResponse); + when(mockResponseMapper.mapFromDialogflowResponse(dfResponse, SESSION_ID)).thenReturn(expectedResponseDTO); + + // Act & Assert + StepVerifier.create(dialogflowClientService.detectIntent(SESSION_ID, requestDTO)) + .expectNext(expectedResponseDTO) + .verifyComplete(); + + verify(mockSessionsClient).detectIntent(finalRequest); + verify(mockResponseMapper).mapFromDialogflowResponse(dfResponse, SESSION_ID); + } + + @Test + void detectIntent_whenRequestMapperFails_shouldReturnError() { + DetectIntentRequestDTO requestDTO = mock(DetectIntentRequestDTO.class); + when(mockRequestMapper.mapToDetectIntentRequestBuilder(requestDTO)) + .thenThrow(new IllegalArgumentException("Invalid mapping")); + StepVerifier.create(dialogflowClientService.detectIntent(SESSION_ID, requestDTO)) + .expectError(IllegalArgumentException.class) + .verify(); + + verify(mockSessionsClient, never()).detectIntent(any(DetectIntentRequest.class)); + } + + @Test + void detectIntent_whenDialogflowApiThrowsApiException_shouldReturnDialogflowClientException() { + DetectIntentRequestDTO requestDTO = mock(DetectIntentRequestDTO.class); + DetectIntentRequest.Builder requestBuilder = DetectIntentRequest.newBuilder(); + + ApiException apiException = new ApiException( + "API Error", + null, + new StatusCode() { + @Override + public Code getCode() { + return Code.UNAVAILABLE; + } + + @Override + public Object getTransportCode() { + return Status.Code.UNAVAILABLE; + } + }, + false + ); + + when(mockRequestMapper.mapToDetectIntentRequestBuilder(requestDTO)).thenReturn(requestBuilder); + when(mockSessionsClient.detectIntent(any(DetectIntentRequest.class))).thenThrow(apiException); + + StepVerifier.create(dialogflowClientService.detectIntent(SESSION_ID, requestDTO)) + .expectError(DialogflowClientException.class) + .verify(); + } + + @Test + void detectIntent_withNullSessionId_shouldThrowNullPointerException() { + DetectIntentRequestDTO requestDTO = mock(DetectIntentRequestDTO.class); + + assertThrows(NullPointerException.class, () -> { + dialogflowClientService.detectIntent(null, requestDTO); + }); + } + + @Test + void detectIntent_withNullRequest_shouldThrowNullPointerException() { + assertThrows(NullPointerException.class, () -> { + dialogflowClientService.detectIntent(SESSION_ID, null); + }); + } +} \ No newline at end of file diff --git a/src/test/java/com/example/service/unit_testing/GeminiClientServiceTest .java b/src/test/java/com/example/service/unit_testing/GeminiClientServiceTest .java new file mode 100644 index 0000000..d01aca3 --- /dev/null +++ b/src/test/java/com/example/service/unit_testing/GeminiClientServiceTest .java @@ -0,0 +1,120 @@ +package com.example.service; + +import com.example.exception.GeminiClientException; +import com.example.service.base.GeminiClientService; +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 org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Answers; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class GeminiClientServiceTest { + + +@Mock(answer = Answers.RETURNS_DEEP_STUBS) +private Client geminiClient; + +@InjectMocks +private GeminiClientService geminiClientService; + +private String prompt; +private Float temperature; +private Integer maxOutputTokens; +private String modelName; +private Float top_P; + +@BeforeEach +void setUp() { + prompt = "Test prompt"; + temperature = 0.5f; + maxOutputTokens = 100; + modelName = "gemini-test-model"; + top_P=0.85f; + +} + +@Test +void generateContent_whenApiSucceeds_returnsGeneratedText() throws GeminiClientException { + // Arrange + String expectedText = "This is the generated content."; + GenerateContentResponse mockResponse = mock(GenerateContentResponse.class); + when(mockResponse.text()).thenReturn(expectedText); + when(geminiClient.models.generateContent(anyString(), any(Content.class), any(GenerateContentConfig.class))) + .thenReturn(mockResponse); + + String actualText = geminiClientService.generateContent(prompt, temperature, maxOutputTokens, modelName,top_P); + assertEquals(expectedText, actualText); +} + +@Test +void generateContent_whenApiResponseIsNull_throwsGeminiClientException() { + // Arrange + when(geminiClient.models.generateContent(anyString(), any(Content.class), any(GenerateContentConfig.class))) + .thenReturn(null); + + GeminiClientException exception = assertThrows(GeminiClientException.class, () -> + geminiClientService.generateContent(prompt, temperature, maxOutputTokens, modelName,top_P) + ); + + assertEquals("No content generated or unexpected response structure.", exception.getMessage()); +} + +@Test +void generateContent_whenResponseTextIsNull_throwsGeminiClientException() { + GenerateContentResponse mockResponse = mock(GenerateContentResponse.class); + when(mockResponse.text()).thenReturn(null); + when(geminiClient.models.generateContent(anyString(), any(Content.class), any(GenerateContentConfig.class))) + .thenReturn(mockResponse); + + GeminiClientException exception = assertThrows(GeminiClientException.class, () -> + geminiClientService.generateContent(prompt, temperature, maxOutputTokens, modelName,top_P) + ); + + assertEquals("No content generated or unexpected response structure.", exception.getMessage()); +} + +@Test +void generateContent_whenGenAiIOExceptionOccurs_throwsGeminiClientException() { + // Arrange + String errorMessage = "Network issue"; + when(geminiClient.models.generateContent(anyString(), any(Content.class), any(GenerateContentConfig.class))) + .thenThrow(new GenAiIOException(errorMessage, new IOException())); + + GeminiClientException exception = assertThrows(GeminiClientException.class, () -> + geminiClientService.generateContent(prompt, temperature, maxOutputTokens, modelName,top_P) + ); + + assertTrue(exception.getMessage().startsWith("An API communication issue occurred:")); + assertTrue(exception.getMessage().contains(errorMessage)); +} + +@Test +void generateContent_whenUnexpectedExceptionOccurs_throwsGeminiClientException() { + when(geminiClient.models.generateContent(anyString(), any(Content.class), any(GenerateContentConfig.class))) + .thenThrow(new RuntimeException("Something went wrong")); + + GeminiClientException exception = assertThrows(GeminiClientException.class, () -> + geminiClientService.generateContent(prompt, temperature, maxOutputTokens, modelName,top_P) + ); + + assertEquals("An unexpected issue occurred during content generation.", exception.getMessage()); +} +} \ No newline at end of file diff --git a/src/test/java/com/example/service/unit_testing/MessageEntryFilterTest.java b/src/test/java/com/example/service/unit_testing/MessageEntryFilterTest.java new file mode 100644 index 0000000..a6cbb3e --- /dev/null +++ b/src/test/java/com/example/service/unit_testing/MessageEntryFilterTest.java @@ -0,0 +1,262 @@ +/* + * Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose. + * Your use of it is subject to your agreement with Google. + */ +package com.example.service.unit_testing; + +import com.example.service.base.GeminiClientService; +import com.example.service.base.MessageEntryFilter; +import com.example.util.PerformanceTimer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.slf4j.LoggerFactory; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyFloat; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.times; + +@ExtendWith(MockitoExtension.class) +@DisplayName("MessageEntryFilter Unit Tests") +public class MessageEntryFilterTest { + + @Mock + private GeminiClientService geminiService; + + @InjectMocks + private MessageEntryFilter messageEntryFilter; + + private ListAppender listAppender; + private static final String NOTIFICATION_JSON_EXAMPLE = + "{\"idNotificacion\":\"4c2992d3-539d-4b28-8d52-cdea02cd1c75\"," + + "\"timestampCreacion\":\"2025-08-01T16:14:02.301671204Z\"," + + "\"texto\":\"Tu estado de cuenta de Agosto esta listo\"," + + "\"nombreEventoDialogflow\":\"notificacion\"," + + "\"codigoIdiomaDialogflow\":\"es\"," + + "\"parametros\":{\"notificacion_texto\":\"Tu estado de cuenta de Agosto esta listo\",\"telefono\":\"555555555\"}}"; + + private static final String CONVERSATION_JSON_EXAMPLE = + "{\"sessionId\":\"ec9f3731-59ac-4bd0-849e-f45fcc18436d\"," + + "\"userId\":\"user_by_phone_0102030405060708\"," + + "\"telefono\":\"0102030405060708\"," + + "\"createdAt\":\"2025-08-06T20:35:05.123699404Z\"," + + "\"lastModified\":\"2025-08-06T20:35:05.984574281Z\"," + + "\"entries\":[{" + + "\"type\":\"USUARIO\"," + + "\"timestamp\":\"2025-08-06T20:35:05.123516916Z\"," + + "\"text\":\"Hola que tal\"" + + "},{" + + "\"type\":\"SISTEMA\"," + + "\"timestamp\":\"2025-08-06T20:35:05.967828173Z\"," + + "\"text\":\"\\u00a1Hola! Bienvenido a Banorte, te saluda Beto. \\u00bfEn qu\\u00e9 te puedo ayudar? \\uD83D\\uDE0A\"," + + "\"parameters\":{" + + "\"canal\":\"banortec\"," + + "\"telefono\":\"0102030405060708\"," + + "\"pantalla_contexto\":\"transferencias\"," + + "\"usuario_id\":\"user_by_phone_0102030405060708\"," + + "\"nickname\":\"John Doe\"" + + "}" + + "}]" + + "}"; + + @BeforeEach + void setUp() { + Logger logger = (Logger) LoggerFactory.getLogger(MessageEntryFilter.class); + listAppender = new ListAppender<>(); + listAppender.start(); + logger.addAppender(listAppender); + } + + private List getLogMessages() { + return listAppender.list.stream() + .map(ILoggingEvent::getFormattedMessage) + .collect(java.util.stream.Collectors.toList()); + } + + @Test + @DisplayName("Should classify as CONVERSATION when Gemini responds with 'CONVERSATION'") + void classifyMessage_shouldReturnConversation_whenGeminiRespondsConversation() throws Exception { + String query = "Hola,como estas?"; + when(geminiService.generateContent(any(String.class), anyFloat(), anyInt(), any(String.class), anyFloat())) + .thenReturn("CONVERSATION"); + + String result = PerformanceTimer.timeExecution("ClassifyConversationTest", + () -> messageEntryFilter.classifyMessage(query, null,null)); + + assertEquals(MessageEntryFilter.CATEGORY_CONVERSATION, result); + + verify(geminiService, times(1)).generateContent(any(String.class), anyFloat(), anyInt(), any(String.class), anyFloat()); + + List logMessages = getLogMessages(); + assertNotNull(logMessages.stream() + .filter(m -> m.contains("Classified as CONVERSATION. Input: 'Hola,como estas?'")) + .findFirst() + .orElse(null), "Log message for successful classification not found."); + + } + + + @Test + @DisplayName("Should classify as NOTIFICATION when Gemini responds with 'NOTIFICATION' (with context)") + void classifyMessage_shouldReturnNotification_whenGeminiRespondsNotificationWithContext() throws Exception { + String query = "Donde puedo descargar mi estado de cuenta"; + when(geminiService.generateContent(any(String.class), anyFloat(), anyInt(), any(String.class), anyFloat())) + .thenReturn("NOTIFICATION"); + + String result = PerformanceTimer.timeExecution("ClassifyNotificationTest", + () -> messageEntryFilter.classifyMessage(query, NOTIFICATION_JSON_EXAMPLE,CONVERSATION_JSON_EXAMPLE)); + + assertEquals(MessageEntryFilter.CATEGORY_NOTIFICATION, result); + + verify(geminiService, times(1)).generateContent(any(String.class), anyFloat(), anyInt(), any(String.class), anyFloat()); + + List logMessages = getLogMessages(); + assertNotNull(logMessages.stream() + .filter(m -> m.contains("Classified as NOTIFICATION") && m.contains(query)) + .findFirst() + .orElse(null), "Log message for successful classification not found."); + + } + + + @Test + @DisplayName("Should return UNKNOWN if queryInputText is null") + void classifyMessage_shouldReturnUnknown_whenQueryInputTextIsNull() throws Exception { + String result = messageEntryFilter.classifyMessage(null, null,null); + assertEquals(MessageEntryFilter.CATEGORY_UNKNOWN, result); + verify(geminiService, times(0)).generateContent(any(), any(), any(), any(), any()); + assertNotNull(getLogMessages().stream().filter(m -> m.contains("Query input text for classification is null or blank")).findFirst().orElse(null)); + } + + @Test + @DisplayName("Should return UNKNOWN if queryInputText is blank") + void classifyMessage_shouldReturnUnknown_whenQueryInputTextIsBlank() throws Exception { + String result = messageEntryFilter.classifyMessage(" ", null,null); + assertEquals(MessageEntryFilter.CATEGORY_UNKNOWN, result); + verify(geminiService, times(0)).generateContent(any(), any(), any(), any(), any()); + assertNotNull(getLogMessages().stream().filter(m -> m.contains("Query input text for classification is null or blank")).findFirst().orElse(null)); + } + + // --- + + @Test + @DisplayName("Should return UNKNOWN if Gemini returns null") + void classifyMessage_shouldReturnUnknown_whenGeminiReturnsNull() throws Exception { + String query = "Any valid query"; + when(geminiService.generateContent(any(), any(), any(), any(), any())).thenReturn(null); + + String result = messageEntryFilter.classifyMessage(query, null,null); + assertEquals(MessageEntryFilter.CATEGORY_UNKNOWN, result); + assertNotNull(getLogMessages().stream().filter(m -> m.contains("Gemini returned an unrecognised classification or was null/blank")).findFirst().orElse(null)); + } + + @Test + @DisplayName("Should return UNKNOWN if Gemini returns blank") + void classifyMessage_shouldReturnUnknown_whenGeminiReturnsBlank() throws Exception { + String query = "Any valid query"; + when(geminiService.generateContent(any(), any(), any(), any(), any())).thenReturn(" "); + + String result = messageEntryFilter.classifyMessage(query, null, null); + assertEquals(MessageEntryFilter.CATEGORY_UNKNOWN, result); + assertNotNull(getLogMessages().stream().filter(m -> m.contains("Gemini returned an unrecognised classification or was null/blank")).findFirst().orElse(null)); + } + + @Test + @DisplayName("Should return UNKNOWN if Gemini returns an unexpected string") + void classifyMessage_shouldReturnUnknown_whenGeminiReturnsUnexpectedString() throws Exception { + String query = "Any valid query"; + when(geminiService.generateContent(any(), any(), any(), any(), any())).thenReturn("INVALID_RESPONSE"); + + String result = messageEntryFilter.classifyMessage(query, null,null); + assertEquals(MessageEntryFilter.CATEGORY_UNKNOWN, result); + assertNotNull(getLogMessages().stream().filter(m -> m.contains("Gemini returned an unrecognised classification")).findFirst().orElse(null)); + } + + @Test + @DisplayName("Should return ERROR if Gemini service throws an exception") + void classifyMessage_shouldReturnError_whenGeminiServiceThrowsException() throws Exception { + String query = "Query causing error"; + when(geminiService.generateContent(any(), any(), any(), any(), any())) + .thenThrow(new RuntimeException("Gemini API error")); + + String result = messageEntryFilter.classifyMessage(query, null,null); + assertEquals(MessageEntryFilter.CATEGORY_ERROR, result); + assertNotNull(getLogMessages().stream().filter(m -> m.contains("Error during Gemini classification")).findFirst().orElse(null)); + } + + @Test + @DisplayName("Should include notification context in prompt when provided and not blank") + void classifyMessage_shouldIncludeNotificationContextInPrompt() throws Exception { + String query = "What's up?"; + + when(geminiService.generateContent(any(String.class), anyFloat(), anyInt(), any(String.class), anyFloat())) + .thenReturn("CONVERSATION"); + + messageEntryFilter.classifyMessage(query, NOTIFICATION_JSON_EXAMPLE,CONVERSATION_JSON_EXAMPLE); + + verify(geminiService, times(1)).generateContent( + org.mockito.ArgumentMatchers.argThat(prompt -> + prompt.contains("Recent Notifications Context:") && + prompt.contains(NOTIFICATION_JSON_EXAMPLE) && + prompt.contains("User Input: 'What's up?'") + ), + anyFloat(), anyInt(), any(String.class), anyFloat() + ); + } + + @Test + @DisplayName("Should NOT include notification context in prompt when provided but blank") + void classifyMessage_shouldNotIncludeNotificationContextInPromptWhenBlank() throws Exception { + String query = "What's up?"; + String notifications = " "; + String conversations =" "; + + when(geminiService.generateContent(any(String.class), anyFloat(), anyInt(), any(String.class), anyFloat())) + .thenReturn("CONVERSATION"); + + messageEntryFilter.classifyMessage(query, notifications,conversations); + + verify(geminiService, times(1)).generateContent( + org.mockito.ArgumentMatchers.argThat(prompt -> + !prompt.contains("Recent Notifications Context:") && + prompt.contains("User Input: 'What's up?'") + ), + anyFloat(), anyInt(), any(String.class), anyFloat() + ); + } + + @Test + @DisplayName("Should NOT include notification context in prompt when null") + void classifyMessage_shouldNotIncludeNotificationContextInPromptWhenNull() throws Exception { + String query = "What's up?"; + String notifications = null; + String conversations = null; + + when(geminiService.generateContent(any(String.class), anyFloat(), anyInt(), any(String.class), anyFloat())) + .thenReturn("CONVERSATION"); + + messageEntryFilter.classifyMessage(query, notifications, conversations); + + verify(geminiService, times(1)).generateContent( + org.mockito.ArgumentMatchers.argThat(prompt -> + !prompt.contains("Recent Notifications Context:") && + prompt.contains("User Input: 'What's up?'") + ), + anyFloat(), anyInt(), any(String.class), anyFloat() + ); + } +} \ No newline at end of file diff --git a/src/test/java/com/example/service/unit_testing/QuickRepliesManagerServiceTest.java b/src/test/java/com/example/service/unit_testing/QuickRepliesManagerServiceTest.java new file mode 100644 index 0000000..d485364 --- /dev/null +++ b/src/test/java/com/example/service/unit_testing/QuickRepliesManagerServiceTest.java @@ -0,0 +1,177 @@ +package com.example.service.unit_testing; + +import com.example.dto.dialogflow.base.DetectIntentResponseDTO; +import com.example.dto.dialogflow.conversation.*; +import com.example.dto.quickreplies.QuestionDTO; +import com.example.dto.quickreplies.QuickReplyDTO; +import com.example.mapper.conversation.ConversationEntryMapper; +import com.example.service.conversation.ConversationManagerService; +import com.example.service.conversation.FirestoreConversationService; +import com.example.service.conversation.MemoryStoreConversationService; +import com.example.service.quickreplies.QuickRepliesManagerService; +import com.example.service.quickreplies.QuickReplyContentService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.time.Instant; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class QuickRepliesManagerServiceTest { + + @Mock private MemoryStoreConversationService memoryStoreConversationService; + @Mock private FirestoreConversationService firestoreConversationService; + @Mock private QuickReplyContentService quickReplyContentService; + @Mock private ConversationManagerService conversationManagerService; + @Mock private ConversationEntryMapper conversationEntryMapper; + + private QuickRepliesManagerService quickRepliesManagerService; + + // Test Data + private final String PHONE = "5555555555"; + private final String SESSION_ID = "session-123"; + private final String USER_ID = "user_by_phone_5555555555"; + private final String CONTEXTO = "pagos"; + + @BeforeEach + void setUp() { + quickRepliesManagerService = new QuickRepliesManagerService( + conversationManagerService, + memoryStoreConversationService, + firestoreConversationService, + quickReplyContentService, + conversationEntryMapper + ); + } + + @Test + @DisplayName("manageConversation - Count 0 - NO MATCH: Should clear context and delegate to Dialogflow") + void manageConversation_Count0_NoMatch_ShouldDelegate() { + // 1. SETUP: User typed "Hola", but context "pagos" is active. + ExternalConvRequestDTO request = new ExternalConvRequestDTO("Hola", new UsuarioDTO(PHONE, "Nick"), "whatsapp", ConversationEntryType.CONVERSACION, null); + ConversationSessionDTO session = new ConversationSessionDTO(SESSION_ID, USER_ID, PHONE, Instant.now(), Instant.now(), "last", CONTEXTO); + + // Mock Session Retrieval + when(memoryStoreConversationService.getSessionByTelefono(PHONE)).thenReturn(Mono.just(session)); + + // Mock History: Only the SYSTEM message (The Menu) exists. Count = 0. + ConversationMessageDTO sysMsg = new ConversationMessageDTO(MessageType.SYSTEM, Instant.now(), "Menu...", null, "whatsapp"); + when(memoryStoreConversationService.getMessages(SESSION_ID)).thenReturn(Flux.just(sysMsg)); + + // Mock QR Content: The menu has options, but "Hola" is NOT one of them. + QuestionDTO q1 = new QuestionDTO("Ver Saldo", "desc", "Tu saldo is 10"); + QuickReplyDTO qrDto = new QuickReplyDTO("Header", "Body", "Btn", "Section", List.of(q1)); + when(quickReplyContentService.getQuickReplies(CONTEXTO)).thenReturn(Mono.just(qrDto)); + + // Mock Orchestrator Delegation (The expected outcome) + DetectIntentResponseDTO delegatedResponse = new DetectIntentResponseDTO("df-response", new QueryResultDTO("Hola soy Beto", null)); + when(memoryStoreConversationService.updateSession(any())).thenReturn(Mono.empty()); // Clearing context + when(conversationManagerService.manageConversation(request)).thenReturn(Mono.just(delegatedResponse)); + + // 2. EXECUTE + StepVerifier.create(quickRepliesManagerService.manageConversation(request)) + .expectNext(delegatedResponse) + .verifyComplete(); + + // 3. VERIFY + // Ensure we cleared the context + verify(memoryStoreConversationService).updateSession(argThat(s -> s.pantallaContexto() == null)); + // Ensure we called the normal conversation manager + verify(conversationManagerService).manageConversation(request); + } + + @Test + @DisplayName("manageConversation - Count 0 - MATCH: Should return QR Answer") + void manageConversation_Count0_Match_ShouldAnswer() { + // 1. SETUP: User typed "Ver Saldo" (Valid Option) + ExternalConvRequestDTO request = new ExternalConvRequestDTO("Ver Saldo", new UsuarioDTO(PHONE, "Nick"), "whatsapp", ConversationEntryType.CONVERSACION, null); + ConversationSessionDTO session = new ConversationSessionDTO(SESSION_ID, USER_ID, PHONE, Instant.now(), Instant.now(), "last", CONTEXTO); + + when(memoryStoreConversationService.getSessionByTelefono(PHONE)).thenReturn(Mono.just(session)); + + // Count 0 (Last message was System) + ConversationMessageDTO sysMsg = new ConversationMessageDTO(MessageType.SYSTEM, Instant.now(), "Menu...", null, "whatsapp"); + when(memoryStoreConversationService.getMessages(SESSION_ID)).thenReturn(Flux.just(sysMsg)); + + // Valid Option exists + QuestionDTO q1 = new QuestionDTO("Ver Saldo", "desc", "Tu saldo es 100 pesos"); + QuickReplyDTO qrDto = new QuickReplyDTO("Header", "Body", "Btn", "Section", List.of(q1)); + when(quickReplyContentService.getQuickReplies(CONTEXTO)).thenReturn(Mono.just(qrDto)); + + // Mocks for saving the conversation turn + mockPersistence(); + when(memoryStoreConversationService.updateSession(any())).thenReturn(Mono.empty()); + + // 2. EXECUTE + StepVerifier.create(quickRepliesManagerService.manageConversation(request)) + .assertNext(response -> { + // Expect the pre-defined answer from the JSON + assert response.queryResult().responseText().equals("Tu saldo es 100 pesos"); + }) + .verifyComplete(); + + // Verify we did NOT delegate + verify(conversationManagerService, never()).manageConversation(any()); + } + + @Test + @DisplayName("manageConversation - Count 1 - NO MATCH: Should save User Msg, Clear, and Delegate") + void manageConversation_Count1_NoMatch_ShouldDelegate() { + // 1. SETUP: User typed "Gracias" (Not an option) + ExternalConvRequestDTO request = new ExternalConvRequestDTO("Gracias", new UsuarioDTO(PHONE, "Nick"), "whatsapp", ConversationEntryType.CONVERSACION, null); + ConversationSessionDTO session = new ConversationSessionDTO(SESSION_ID, USER_ID, PHONE, Instant.now(), Instant.now(), "last", CONTEXTO); + + when(memoryStoreConversationService.getSessionByTelefono(PHONE)).thenReturn(Mono.just(session)); + + // Mock History: System msg -> User msg (Invalid) -> Now this is the 2nd user msg (Count 1 logic) + // Wait, logic says count is messages AFTER last init. + // Sys (Init) -> User("Hola" - ignored previously) -> Current Request("Gracias") + ConversationMessageDTO sysMsg = new ConversationMessageDTO(MessageType.SYSTEM, Instant.now(), "Menu...", null, "whatsapp"); + ConversationMessageDTO userMsg1 = new ConversationMessageDTO(MessageType.USER, Instant.now(), "Hola", null, "whatsapp"); + when(memoryStoreConversationService.getMessages(SESSION_ID)).thenReturn(Flux.just(sysMsg, userMsg1)); + + QuestionDTO q1 = new QuestionDTO("Ver Saldo", "desc", "Tu saldo es 10"); + QuickReplyDTO qrDto = new QuickReplyDTO("Header", "Body", "Btn", "Section", List.of(q1)); + when(quickReplyContentService.getQuickReplies(CONTEXTO)).thenReturn(Mono.just(qrDto)); + + // Mock persistence for the user message (Manual save in the else block) + mockPersistence(); + + // Mock Delegation + DetectIntentResponseDTO delegatedResponse = new DetectIntentResponseDTO("df-response", new QueryResultDTO("De nada", null)); + when(memoryStoreConversationService.updateSession(any())).thenReturn(Mono.empty()); + when(conversationManagerService.manageConversation(request)).thenReturn(Mono.just(delegatedResponse)); + + // 2. EXECUTE + StepVerifier.create(quickRepliesManagerService.manageConversation(request)) + .expectNext(delegatedResponse) + .verifyComplete(); + + // 3. VERIFY + // Ensure manual save was called (verifying flow of `persistConversationTurn`) + verify(memoryStoreConversationService, times(1)).saveMessage(eq(SESSION_ID), any()); + verify(memoryStoreConversationService).updateSession(argThat(s -> s.pantallaContexto() == null)); + verify(conversationManagerService).manageConversation(request); + } + + // Helper to mock the complex save chain + private void mockPersistence() { + when(conversationEntryMapper.toConversationMessageDTO(any())).thenReturn(new ConversationMessageDTO(MessageType.USER, Instant.now(), "text", null, "wa")); + when(memoryStoreConversationService.saveSession(any())).thenReturn(Mono.empty()); + when(memoryStoreConversationService.saveMessage(anyString(), any())).thenReturn(Mono.empty()); + when(firestoreConversationService.saveSession(any())).thenReturn(Mono.empty()); + when(firestoreConversationService.saveMessage(anyString(), any())).thenReturn(Mono.empty()); + } +} \ No newline at end of file diff --git a/src/test/java/com/example/service/unit_testing/QuickReplyContentServiceTest.java b/src/test/java/com/example/service/unit_testing/QuickReplyContentServiceTest.java new file mode 100644 index 0000000..0d48e00 --- /dev/null +++ b/src/test/java/com/example/service/unit_testing/QuickReplyContentServiceTest.java @@ -0,0 +1,124 @@ + +package com.example.service.unit_testing; + +import com.example.dto.quickreplies.QuestionDTO; +import com.example.dto.quickreplies.QuickReplyDTO; +import com.example.service.quickreplies.QuickReplyContentService; +import com.google.api.core.ApiFuture; +import com.google.cloud.firestore.CollectionReference; +import com.google.cloud.firestore.DocumentReference; +import com.google.cloud.firestore.DocumentSnapshot; +import com.google.cloud.firestore.Firestore; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutionException; + +import static org.mockito.Mockito.when; + +public class QuickReplyContentServiceTest { + + @Mock + private Firestore firestore; + + @Mock + private CollectionReference collectionReference; + + @Mock + private DocumentReference documentReference; + + @Mock + private ApiFuture apiFuture; + + @Mock + private DocumentSnapshot documentSnapshot; + + @InjectMocks + private QuickReplyContentService quickReplyContentService; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + } + + @Test + void getQuickReplies_success() throws ExecutionException, InterruptedException { + // Given + String collectionId = "home"; + String header = "home_header"; + String body = "home_body"; + String button = "home_button"; + String headerSection = "home_header_section"; + List> preguntas = Collections.singletonList( + Map.of("titulo", "title", "descripcion", "description", "respuesta", "response") + ); + List questionDTOs = Collections.singletonList( + new QuestionDTO("title", "description", "response") + ); + QuickReplyDTO expected = new QuickReplyDTO(header, body, button, headerSection, questionDTOs); + + when(firestore.collection("artifacts")).thenReturn(collectionReference); + when(collectionReference.document("default-app-id")).thenReturn(documentReference); + when(documentReference.collection("quick-replies")).thenReturn(collectionReference); + when(collectionReference.document(collectionId)).thenReturn(documentReference); + when(documentReference.get()).thenReturn(apiFuture); + when(apiFuture.get()).thenReturn(documentSnapshot); + when(documentSnapshot.exists()).thenReturn(true); + when(documentSnapshot.getString("header")).thenReturn(header); + when(documentSnapshot.getString("body")).thenReturn(body); + when(documentSnapshot.getString("button")).thenReturn(button); + when(documentSnapshot.getString("header_section")).thenReturn(headerSection); + when(documentSnapshot.get("preguntas")).thenReturn(preguntas); + + // When + Mono result = quickReplyContentService.getQuickReplies(collectionId); + + // Then + StepVerifier.create(result) + .expectNext(expected) + .verifyComplete(); + } + + @Test + void getQuickReplies_emptyWhenNotFound() throws ExecutionException, InterruptedException { + // Given + String collectionId = "non-existent-collection"; + + when(firestore.collection("artifacts")).thenReturn(collectionReference); + when(collectionReference.document("default-app-id")).thenReturn(documentReference); + when(documentReference.collection("quick-replies")).thenReturn(collectionReference); + when(collectionReference.document(collectionId)).thenReturn(documentReference); + when(documentReference.get()).thenReturn(apiFuture); + when(apiFuture.get()).thenReturn(documentSnapshot); + when(documentSnapshot.exists()).thenReturn(false); + + // When + Mono result = quickReplyContentService.getQuickReplies(collectionId); + + // Then + StepVerifier.create(result) + .verifyComplete(); + } + + @Test + void getQuickReplies_emptyWhenCollectionIdIsBlank() { + // Given + String collectionId = ""; + + // When + Mono result = quickReplyContentService.getQuickReplies(collectionId); + + // Then + StepVerifier.create(result) + .expectNext(new QuickReplyDTO("empty", null, null, null, Collections.emptyList())) + .verifyComplete(); + } +} 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 + + + + + + +