Initial commit

This commit is contained in:
Anibal Angulo
2026-02-18 19:29:54 +00:00
commit da95a64fb7
125 changed files with 8796 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
.env
.ipynb_checkpoints

15
Dockerfile Normal file
View File

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

15
Dockerfile 2 Normal file
View File

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

236
README 2.md Normal file
View File

@@ -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"
}
}
}
```

269
README.md Normal file
View File

@@ -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
```

View File

@@ -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)
```

View File

@@ -0,0 +1,163 @@
<instruccion_maestra>
- Analiza cada entrada del usuario y sigue las instrucciones detalladas en <reglas> 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 <logica_de_conversacion>
- En cualquier momento de la conversacion que el usuario pida hablar con un agente, un humano o un asistente, procede con
- <manejo_de_solicitud_de_agente_humano> sin importar los parametros anteriores.
</instruccion_maestra>
<restricciones>
- 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]
</restricciones>
<reglas>
- <reglas_de_prioridad_alta>
- <prioridad_1_abuso>
- 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}
- </prioridad_1_abuso>
- <prioridad_2_manejo_agente>
- SI el usuario solicita hablar con un agente humano, sigue la lógica de los 3 intentos definida en <manejo_de_solicitud_de_agente_humano> y detén el resto del análisis.
- </prioridad_2_manejo_agente>
- <prioridad_3_manejo_notificacion>
- 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.
- </prioridad_3_manejo_notificacion>
- </reglas_de_prioridad_alta>
- <logica_de_conversacion>
- En cualquier momento de la conversacion que el usuario pida hablar con un agente, un humano o un asistente, procede con <manejo_de_solicitud_de_agente_humano> sin importar los parametros anteriores
- <finalizacion>
- 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}
- </finalizacion>
- <paso_2_extraccion_de_intencion>
- <paso_1_extraer_intencion>
- 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?"
- </paso_1_extraer_intencion>
- <paso_2_extraer_intencion> procede al <paso_3_enrutamiento_final> con el `$query_inicial` que has formulado. </paso_2_extraer_intencion>
- </paso_2_extraccion_de_intencion>
- <paso_3_enrutamiento_final>
- # === 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 <saludo> una <conversacion_en_curso> , si es una <conversacion_nueva> o un <query_invalido>.
- <query_invalido>
- Si el parámetro `$query_inicial` no tiene contenido o es vacío, rutea a ${FLOW:query_vacio_inadecuado}.
- </query_invalido>
- <saludo> 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? 🧐" </saludo>
- <conversacion_en_curso>
- 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}.
- </conversacion_en_curso>
- <conversacion_nueva>
- Si el parámetro `$contexto` está vacío, establece el valor del parámetro `$conversacion_anterior` = "false", rutea a ${PLAYBOOK:playbook_nueva_conversacion}.
- </conversacion_nueva>
- # === FIN CHEQUEO CRÍTICO DE DETENCIÓN ===
- </paso_3_enrutamiento_final>
- </logica_de_conversacion>
</reglas>
<manejo_de_solicitud_de_agente_humano>
- <primer_intento>
- 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"
- </primer_intento>
- <segundo_intento>
- 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"
- </segundo_intento>
- <tercer_intento>
- 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}.
- </tercer_intento>
</manejo_de_solicitud_de_agente_humano>
- **Recursos Disponibles:** ${FLOW:manejador_webhook_notificacion}

View File

@@ -0,0 +1,80 @@
- <instruccion_maestra>
- 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.
- <revision_rag_respondio>
- **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**
- </revision_rag_respondio>
- </instruccion_maestra>
- <reglas_de_prioridad_alta>
- <prioridad_1_abuso>
- 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.
- </prioridad_1_abuso>
- <prioridad_2_condicion_de_guarda>
- 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}.
- </prioridad_2_condicion_de_guarda>
- </reglas_de_prioridad_alta>
- <logica_de_analisis_contextual_y_enrutamiento>
- <paso_1_definicion_del_contexto>
- 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`.
- </paso_1_definicion_del_contexto>
- <paso_2_extraccion_de_intencion_contextual>
- ANALIZA cuidadosamente la expresión del usuario `$utterance` **tomando en cuenta el contexto definido en el paso <paso_1_definicion_del_contexto>**.
- 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 <paso_1_definicion_del_contexto>`.
- </paso_2_extraccion_de_intencion_contextual>
- <paso_3_clasificacion_y_redireccion>
- 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.
- <ejemplo_de_reparacion>
- `$historial` es: "¿Cuales capsulas hay?" y el `$utterance` es: "¿Cual es mejor?"
- ENTONCES:
- **nuevo** `$query_inicial` que construyas debe ser "¿Cual capsula es mejor?".
- </ejemplo_de_reparacion>
- **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}.
- </paso_3_clasificacion_y_redireccion>
- </logica_de_analisis_contextual_y_enrutamiento>
- <manejo_de_no_coincidencia_fallback>
- 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.
- <no-match-1>
- **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. 😉"
- </no-match-1>
- <no-match-2>
- **RESPUESTA ESTÁTICA:** "Parece que sigo sin entender. ¿Tu duda es sobre **(1) Productos y Servicios** o **(2) Educación Financiera**?"
- </no-match-2>
- <no-match-3>
- **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-match-3>
- </manejo_de_no_coincidencia_fallback>

View File

@@ -0,0 +1,64 @@
- <instruccion_maestra>
- 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.
- </instruccion_maestra>
- <reglas_de_prioridad_alta>
- <prioridad_1_abuso>
- 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}
- </prioridad_1_abuso>
- <prioridad_2_condicion_de_guarda>
- 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.
- </prioridad_2_condicion_de_guarda>
- </reglas_de_prioridad_alta>
- <logica_de_analisis_y_enrutamiento>
- <paso_1_extraccion_de_intencion>
- 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`.
- </paso_1_extraccion_de_intencion>
- <paso_2_clasificacion_y_redireccion>
- 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}.
- </paso_2_clasificacion_y_redireccion>
- </logica_de_analisis_y_enrutamiento>
- <manejo_de_no_coincidencia_fallback>
- 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.
- <no-match-1>
- 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. 😉"
- </no-match-1>
- <no-match-2>
- 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."
- </no-match-2>
- <no-match-3>
- 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-match-3>
- </manejo_de_no_coincidencia_fallback>

28
docs/notification.md Normal file
View File

@@ -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
```

30
docs/quick_replies.md Normal file
View File

@@ -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
```

28
notification.md Normal file
View File

@@ -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
```

171
orquestador_cognitivo.md Normal file
View File

@@ -0,0 +1,171 @@
<instruccion_maestra>
- Analiza cada entrada del usuario y sigue las instrucciones detalladas en <reglas> 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 <logica_de_conversacion>
- En cualquier momento de la conversacion que el usuario pida hablar con un agente, un humano o un asistente, procede con
- <manejo_de_solicitud_de_agente_humano> sin importar los parametros anteriores.
</instruccion_maestra>
<restricciones>
- 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]
</restricciones>
<reglas>
- <reglas_de_prioridad_alta>
- <prioridad_1_abuso>
- 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}
- </prioridad_1_abuso>
- <prioridad_2_manejo_agente>
- SI el usuario solicita hablar con un agente humano, sigue la lógica de los 3 intentos definida en <manejo_de_solicitud_de_agente_humano> y detén el resto del análisis.
- </prioridad_2_manejo_agente>
- <prioridad_3_manejo_notificacion>
- 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.
- </prioridad_3_manejo_notificacion>
- </reglas_de_prioridad_alta>
- <logica_de_conversacion>
- En cualquier momento de la conversacion que el usuario pida hablar con un agente, un humano o un asistente, procede con <manejo_de_solicitud_de_agente_humano> sin importar los parametros anteriores
- <finalizacion>
- 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}
- </finalizacion>
- <paso_2_extraccion_de_intencion>
- <paso_1_extraer_intencion>
- 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?"
- </paso_1_extraer_intencion>
- <paso_2_extraer_intencion> procede al <paso_3_enrutamiento_final> con el `$query_inicial` que has formulado. </paso_2_extraer_intencion>
- </paso_2_extraccion_de_intencion>
- <paso_3_enrutamiento_final>
- # === 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 <saludo> una <conversacion_en_curso> , si es una <conversacion_nueva> o un <query_invalido>.
- <query_invalido>
- Si el parámetro `$query_inicial` no tiene contenido o es vacío, rutea a ${FLOW:query_vacio_inadecuado}.
- </query_invalido>
- <saludo> 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? 🧐" </saludo>
- <conversacion_en_curso>
- 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}.
- </conversacion_en_curso>
- <conversacion_nueva>
- Si el parámetro `$contexto` está vacío, establece el valor del parámetro `$conversacion_anterior` = "false", rutea a ${PLAYBOOK:playbook_nueva_conversacion}.
- </conversacion_nueva>
- # === FIN CHEQUEO CRÍTICO DE DETENCIÓN ===
- </paso_3_enrutamiento_final>
- </logica_de_conversacion>
</reglas>
<manejo_de_solicitud_de_agente_humano>
- <primer_intento>
- 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"
- </primer_intento>
- <segundo_intento>
- 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"
- </segundo_intento>
- <tercer_intento>
- 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}.
- </tercer_intento>
</manejo_de_solicitud_de_agente_humano>
- **Recursos Disponibles:** ${FLOW:manejador_webhook_notificacion}

View File

@@ -0,0 +1,80 @@
- <instruccion_maestra>
- 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.
- <revision_rag_respondio>
- **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**
- </revision_rag_respondio>
- </instruccion_maestra>
- <reglas_de_prioridad_alta>
- <prioridad_1_abuso>
- 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.
- </prioridad_1_abuso>
- <prioridad_2_condicion_de_guarda>
- 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}.
- </prioridad_2_condicion_de_guarda>
- </reglas_de_prioridad_alta>
- <logica_de_analisis_contextual_y_enrutamiento>
- <paso_1_definicion_del_contexto>
- 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`.
- </paso_1_definicion_del_contexto>
- <paso_2_extraccion_de_intencion_contextual>
- ANALIZA cuidadosamente la expresión del usuario `$utterance` **tomando en cuenta el contexto definido en el paso <paso_1_definicion_del_contexto>**.
- 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 <paso_1_definicion_del_contexto>`.
- </paso_2_extraccion_de_intencion_contextual>
- <paso_3_clasificacion_y_redireccion>
- 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.
- <ejemplo_de_reparacion>
- `$historial` es: "¿Cuales capsulas hay?" y el `$utterance` es: "¿Cual es mejor?"
- ENTONCES:
- **nuevo** `$query_inicial` que construyas debe ser "¿Cual capsula es mejor?".
- </ejemplo_de_reparacion>
- **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}.
- </paso_3_clasificacion_y_redireccion>
- </logica_de_analisis_contextual_y_enrutamiento>
- <manejo_de_no_coincidencia_fallback>
- 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.
- <no-match-1>
- **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. 😉"
- </no-match-1>
- <no-match-2>
- **RESPUESTA ESTÁTICA:** "Parece que sigo sin entender. ¿Tu duda es sobre **(1) Productos y Servicios** o **(2) Educación Financiera**?"
- </no-match-2>
- <no-match-3>
- **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-match-3>
- </manejo_de_no_coincidencia_fallback>

View File

@@ -0,0 +1,66 @@
- <instruccion_maestra>
- 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.
- <revision_rag_respondio>
- **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**
- </revision_rag_respondio>
- </instruccion_maestra>
- <reglas_de_prioridad_alta>
- <prioridad_1_abuso>
- SI el mensaje del usuario `$utterance` contiene lenguaje abusivo, emojis ofensivos o es spam
- llama al ${FLOW:concluir_conversacion}
- </prioridad_1_abuso>
- <prioridad_2_condicion_de_guarda>
- 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.
- </prioridad_2_condicion_de_guarda>
- </reglas_de_prioridad_alta>
- <logica_de_analisis_y_enrutamiento>
- <paso_1_extraccion_de_intencion>
- 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`.
- </paso_1_extraccion_de_intencion>
- <paso_2_clasificacion_y_redireccion>
- 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}.
- </paso_2_clasificacion_y_redireccion>
- </logica_de_analisis_y_enrutamiento>
- <manejo_de_no_coincidencia_fallback>
- 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.
- <no-match-1>
- 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. 😉"
- </no-match-1>
- <no-match-2>
- 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."
- </no-match-2>
- <no-match-3>
- 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-match-3>
- </manejo_de_no_coincidencia_fallback>

241
pom 2.xml Normal file
View File

@@ -0,0 +1,241 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.11</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>app-jovenes-service-orchestrator</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>app-jovenes-service-orchestrator</name>
<description>This serivce handle conversations over Dialogflow and multiple Storage GCP services</description>
<properties>
<java.version>21</java.version>
<spring-cloud-gcp.version>5.4.0</spring-cloud-gcp.version>
<spring-cloud.version>2023.0.0</spring-cloud.version>
<lettuce.version>6.4.0.RELEASE</lettuce.version>
<spring-framework.version>6.1.21</spring-framework.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>com.google.cloud</groupId>
<artifactId>spring-cloud-gcp-dependencies</artifactId>
<version>${spring-cloud-gcp.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>com.google.cloud</groupId>
<artifactId>libraries-bom</artifactId>
<version>26.40.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-bom</artifactId>
<version>2024.0.8</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webflux-ui</artifactId>
<version>2.5.0</version>
<exclusions>
<exclusion>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.google.cloud</groupId>
<artifactId>spring-cloud-gcp-starter-data-firestore</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.google.cloud</groupId>
<artifactId>spring-cloud-gcp-data-firestore</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.google.cloud</groupId>
<artifactId>spring-cloud-gcp-starter-storage</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis-reactive</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.google.cloud</groupId>
<artifactId>google-cloud-dialogflow-cx</artifactId>
</dependency>
<dependency>
<groupId>com.google.genai</groupId>
<artifactId>google-genai</artifactId>
<version>1.14.0</version>
</dependency>
<dependency>
<groupId>com.google.protobuf</groupId>
<artifactId>protobuf-java-util</artifactId>
</dependency>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.module</groupId>
<artifactId>jackson-module-parameter-names</artifactId>
<version>2.19.0</version>
</dependency>
<dependency>
<groupId>com.google.api</groupId>
<artifactId>gax</artifactId>
</dependency>
<dependency>
<groupId>com.google.cloud</groupId>
<artifactId>google-cloud-dlp</artifactId>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-codec-http2</artifactId>
<version>4.1.125.Final</version>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-handler</artifactId>
<version>4.1.125.Final</version>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-common</artifactId>
<version>4.1.125.Final</version>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-codec-http</artifactId>
<version>4.1.125.Final</version>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-codec</artifactId>
<version>4.1.125.Final</version>
</dependency>
<dependency>
<groupId>com.google.protobuf</groupId>
<artifactId>protobuf-java</artifactId>
<version>3.25.5</version>
</dependency>
<dependency>
<groupId>net.minidev</groupId>
<artifactId>json-smart</artifactId>
<version>2.5.2</version>
</dependency>
<dependency>
<groupId>org.xmlunit</groupId>
<artifactId>xmlunit-core</artifactId>
<version>2.10.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.18.0</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

241
pom.xml Normal file
View File

@@ -0,0 +1,241 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.11</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>app-jovenes-service-orchestrator</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>app-jovenes-service-orchestrator</name>
<description>This serivce handle conversations over Dialogflow and multiple Storage GCP services</description>
<properties>
<java.version>21</java.version>
<spring-cloud-gcp.version>5.4.0</spring-cloud-gcp.version>
<spring-cloud.version>2023.0.0</spring-cloud.version>
<lettuce.version>6.4.0.RELEASE</lettuce.version>
<spring-framework.version>6.1.21</spring-framework.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>com.google.cloud</groupId>
<artifactId>spring-cloud-gcp-dependencies</artifactId>
<version>${spring-cloud-gcp.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>com.google.cloud</groupId>
<artifactId>libraries-bom</artifactId>
<version>26.40.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-bom</artifactId>
<version>2024.0.8</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webflux-ui</artifactId>
<version>2.5.0</version>
<exclusions>
<exclusion>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.google.cloud</groupId>
<artifactId>spring-cloud-gcp-starter-data-firestore</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.google.cloud</groupId>
<artifactId>spring-cloud-gcp-data-firestore</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.google.cloud</groupId>
<artifactId>spring-cloud-gcp-starter-storage</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis-reactive</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.google.cloud</groupId>
<artifactId>google-cloud-dialogflow-cx</artifactId>
</dependency>
<dependency>
<groupId>com.google.genai</groupId>
<artifactId>google-genai</artifactId>
<version>1.14.0</version>
</dependency>
<dependency>
<groupId>com.google.protobuf</groupId>
<artifactId>protobuf-java-util</artifactId>
</dependency>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.module</groupId>
<artifactId>jackson-module-parameter-names</artifactId>
<version>2.19.0</version>
</dependency>
<dependency>
<groupId>com.google.api</groupId>
<artifactId>gax</artifactId>
</dependency>
<dependency>
<groupId>com.google.cloud</groupId>
<artifactId>google-cloud-dlp</artifactId>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-codec-http2</artifactId>
<version>4.1.130.Final</version>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-handler</artifactId>
<version>4.1.130.Final</version>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-common</artifactId>
<version>4.1.130.Final</version>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-codec-http</artifactId>
<version>4.1.130.Final</version>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-codec</artifactId>
<version>4.1.130.Final</version>
</dependency>
<dependency>
<groupId>com.google.protobuf</groupId>
<artifactId>protobuf-java</artifactId>
<version>3.25.5</version>
</dependency>
<dependency>
<groupId>net.minidev</groupId>
<artifactId>json-smart</artifactId>
<version>2.5.2</version>
</dependency>
<dependency>
<groupId>org.xmlunit</groupId>
<artifactId>xmlunit-core</artifactId>
<version>2.10.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.18.0</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,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);
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -0,0 +1,33 @@
/*
* Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose.
* Your use of it is subject to your agreement with Google.
*/
package com.example.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")));
}
}

View File

@@ -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<String, ConversationSessionDTO> reactiveRedisTemplate(ReactiveRedisConnectionFactory factory, ObjectMapper objectMapper) {
Jackson2JsonRedisSerializer<ConversationSessionDTO> serializer = new Jackson2JsonRedisSerializer<>(objectMapper, ConversationSessionDTO.class);
RedisSerializationContext.RedisSerializationContextBuilder<String, ConversationSessionDTO> builder = RedisSerializationContext.newSerializationContext(new StringRedisSerializer());
RedisSerializationContext<String, ConversationSessionDTO> context = builder.value(serializer).build();
return new ReactiveRedisTemplate<>(factory, context);
}
@Bean
public ReactiveRedisTemplate<String, NotificationSessionDTO> reactiveNotificationRedisTemplate(ReactiveRedisConnectionFactory factory, ObjectMapper objectMapper) {
Jackson2JsonRedisSerializer<NotificationSessionDTO> serializer = new Jackson2JsonRedisSerializer<>(objectMapper, NotificationSessionDTO.class);
RedisSerializationContext.RedisSerializationContextBuilder<String, NotificationSessionDTO> builder = RedisSerializationContext.newSerializationContext(new StringRedisSerializer());
RedisSerializationContext<String, NotificationSessionDTO> context = builder.value(serializer).build();
return new ReactiveRedisTemplate<>(factory, context);
}
@Bean
public ReactiveRedisTemplate<String, ConversationMessageDTO> reactiveMessageRedisTemplate(ReactiveRedisConnectionFactory factory, ObjectMapper objectMapper) {
Jackson2JsonRedisSerializer<ConversationMessageDTO> serializer = new Jackson2JsonRedisSerializer<>(objectMapper, ConversationMessageDTO.class);
RedisSerializationContext.RedisSerializationContextBuilder<String, ConversationMessageDTO> builder = RedisSerializationContext.newSerializationContext(new StringRedisSerializer());
RedisSerializationContext<String, ConversationMessageDTO> context = builder.value(serializer).build();
return new ReactiveRedisTemplate<>(factory, context);
}
@Bean
public ReactiveRedisTemplate<String, String> reactiveStringRedisTemplate(ReactiveRedisConnectionFactory factory) {
return new ReactiveRedisTemplate<>(factory, RedisSerializationContext.string());
}
}

View File

@@ -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<DetectIntentResponseDTO> 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));
}
}

View File

@@ -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<Void> 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));
}
}

View File

@@ -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<WebhookResponseDTO> tuneResponse(@RequestBody WebhookRequestDTO request) {
String uuid = (String) request.getSessionInfo().getParameters().get("uuid");
return llmResponseTunerService
.getValue(uuid)
.map(
value -> {
Map<String, Object> 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<String, Object> 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<Map<String, String>> handleException(Exception e) {
logger.error("An unexpected error occurred: {}", e.getMessage());
Map<String, String> 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<Map<String, String>> handleIllegalArgumentException(
IllegalArgumentException e) {
logger.error("Bad request: {}", e.getMessage());
Map<String, String> response = new HashMap<>();
response.put("error", "Bad Request");
response.put("message", e.getMessage());
return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
}
}

View File

@@ -0,0 +1,38 @@
/*
* Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose.
* Your use of it is subject to your agreement with Google.
*/
package com.example.controller;
import com.example.dto.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<Void> 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();
}
}

View File

@@ -0,0 +1,38 @@
/*
* Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose.
* Your use of it is subject to your agreement with Google.
*/
package com.example.controller;
import com.example.dto.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<DetectIntentResponseDTO> 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));
}
}

View File

@@ -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<String, Object> 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
);
}
}

View File

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

View File

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

View File

@@ -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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> parameters) {
return new ConversationEntryDTO(
ConversationEntryEntity.LLM,
ConversationEntryType.CONVERSACION,
Instant.now(),
text,
parameters,
null
);
}
}

View File

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

View File

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

View File

@@ -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<String, Object> parameters,
String canal
) {
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<String, Object> parameters) {
public QueryParamsDTO {
parameters = Objects.requireNonNullElseGet(parameters, HashMap::new);
parameters = new HashMap<>(parameters);
}
public QueryParamsDTO withSessionParameter(String key, Object value) {
Map<String, Object> updatedParams = new HashMap<>(this.parameters());
updatedParams.put(key, value);
return new QueryParamsDTO(updatedParams);
}
public QueryParamsDTO withSessionParameters(Map<String, Object> parameters) {
Map<String, Object> updatedParams = new HashMap<>(this.parameters());
updatedParams.putAll(parameters);
return new QueryParamsDTO(updatedParams);
}
}

View File

@@ -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<String, Object> parameters
) {}

View File

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

View File

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

View File

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

View File

@@ -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<String, String> hiddenParameters
) {
public ExternalNotRequestDTO {
}
}

View File

@@ -0,0 +1,33 @@
/*
* Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose.
* Your use of it is subject to your agreement with Google.
*/
package com.example.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<String, Object> parametros, // Parámetros de sesión finales después del procesamiento de// Dialogflow
String status
) {
public NotificationDTO {
Objects.requireNonNull(idNotificacion, "Notification ID cannot be null.");
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.");
}
}

View File

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

View File

@@ -0,0 +1,23 @@
package com.example.dto.llm.webhook;
import java.util.Map;
public class SessionInfoDTO {
private Map<String, Object> parameters;
public SessionInfoDTO() {
}
public SessionInfoDTO(Map<String, Object> parameters) {
this.parameters = parameters;
}
public Map<String, Object> getParameters() {
return parameters;
}
public void setParameters(Map<String, Object> parameters) {
this.parameters = parameters;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

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

View File

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

View File

@@ -0,0 +1,14 @@
package com.example.dto.quickreplies;
import com.example.dto.dialogflow.conversation.ConversationEntryType;
import com.example.dto.dialogflow.conversation.UsuarioDTO;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
@JsonIgnoreProperties(ignoreUnknown = true)
public record QuickReplyScreenRequestDTO(
@JsonProperty("usuario") UsuarioDTO user,
@JsonProperty("canal") String channel,
@JsonProperty("tipo") ConversationEntryType tipo,
@JsonProperty("pantallaContexto") String pantallaContexto
) {}

View File

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

View File

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

View File

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

View File

@@ -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<Map<String, String>> handleDialogflowClientException(
DialogflowClientException ex) {
Map<String, String> 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<Map<String, String>> handleGeminiClientException(GeminiClientException ex) {
Map<String, String> 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<Map<String, String>> handleAllExceptions(Exception ex) {
Map<String, String> 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);
}
}

View File

@@ -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()
);
}
}

View File

@@ -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<String, Object> toMap(ConversationMessageDTO message) {
Map<String, Object> 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<String, Object> 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<String, Object>) map.get("parametros"),
(String) map.get("canal")
);
}
}

View File

@@ -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<String, Object> 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;
}
}

View File

@@ -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<String, Object> 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;
}
}

View File

@@ -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<String, Object> 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);
}
}

View File

@@ -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<String, Object> createSessionMap(ConversationSessionDTO session) {
Map<String, Object> 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;
}
}

View File

@@ -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<ConversationMessageDTO> messages) {
if (messages == null || messages.isEmpty()) {
return "";
}
return toTextFromMessages(messages);
}
public String toTextWithLimits(ConversationSessionDTO session, List<ConversationMessageDTO> messages) {
if (messages == null || messages.isEmpty()) {
return "";
}
Instant thirtyDaysAgo = Instant.now().minus(daysLimit, ChronoUnit.DAYS);
List<ConversationMessageDTO> 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<ConversationMessageDTO> messages) {
return messages.stream()
.map(this::formatEntry)
.collect(Collectors.joining("\n"));
}
public String toTextWithTruncation(List<ConversationMessageDTO> messages) {
if (messages == null || messages.isEmpty()) {
return "";
}
StringBuilder textBlock = new StringBuilder();
List<String> 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();
}
}

View File

@@ -0,0 +1,33 @@
/*
* Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose.
* Your use of it is subject to your agreement with Google.
*/
package com.example.mapper.messagefilter;
import com.example.dto.dialogflow.notification.NotificationDTO;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.stream.Collectors;
@Component
public class NotificationContextMapper {
public String toText(NotificationDTO notification) {
if (notification == null || notification.texto() == null) {
return "";
}
return notification.texto();
}
public String toText(List<NotificationDTO> notifications) {
if (notifications == null || notifications.isEmpty()) {
return "";
}
return notifications.stream()
.map(NotificationDTO::texto)
.filter(texto -> texto != null && !texto.isBlank())
.collect(Collectors.joining("\n"));
}
}

View File

@@ -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<String, Object> 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);
}
}

View File

@@ -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<String, Object> 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<String, Object> 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<Map<String, Object>>() {});
}
}

View File

@@ -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> T getDocument(DocumentReference docRef, Class<T> clazz)
throws InterruptedException, ExecutionException {
Objects.requireNonNull(docRef, "DocumentReference cannot be null.");
Objects.requireNonNull(clazz, "Class for mapping cannot be null.");
ApiFuture<DocumentSnapshot> future = docRef.get();
DocumentSnapshot document = future.get();
if (document.exists()) {
try {
logger.debug(
"FirestoreBaseRepository: Raw document data for {}: {}",
docRef.getPath(),
document.getData());
T result = objectMapper.convertValue(document.getData(), clazz);
return result;
} catch (IllegalArgumentException e) {
logger.error(
"Failed to convert Firestore document data to {}: {}", clazz.getName(), e.getMessage(), e);
throw new RuntimeException(
"Failed to convert Firestore document data to " + clazz.getName(), e);
}
}
return null;
}
public DocumentSnapshot getDocumentSnapshot(DocumentReference docRef)
throws ExecutionException, InterruptedException {
Objects.requireNonNull(docRef, "DocumentReference cannot be null.");
ApiFuture<DocumentSnapshot> future = docRef.get();
return future.get();
}
public Flux<DocumentSnapshot> getDocuments(String collectionPath) {
return Flux.create(sink -> {
ApiFuture<QuerySnapshot> 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<DocumentSnapshot> getDocumentsByField(
String collectionPath, String fieldName, String value) {
return Mono.fromCallable(
() -> {
Query query = firestore.collection(collectionPath).whereEqualTo(fieldName, value);
ApiFuture<QuerySnapshot> future = query.get();
QuerySnapshot querySnapshot = future.get();
if (!querySnapshot.isEmpty()) {
return querySnapshot.getDocuments().get(0);
}
return null;
});
}
public boolean documentExists(DocumentReference docRef)
throws InterruptedException, ExecutionException {
Objects.requireNonNull(docRef, "DocumentReference cannot be null.");
ApiFuture<DocumentSnapshot> future = docRef.get();
return future.get().exists();
}
public void setDocument(DocumentReference docRef, Object data)
throws InterruptedException, ExecutionException {
Objects.requireNonNull(docRef, "DocumentReference cannot be null.");
Objects.requireNonNull(data, "Data for setting document cannot be null.");
ApiFuture<WriteResult> future = docRef.set(data);
WriteResult writeResult = future.get();
logger.debug(
"Document set: {} with update time: {}", docRef.getPath(), writeResult.getUpdateTime());
}
public void updateDocument(DocumentReference docRef, Map<String, Object> updates)
throws InterruptedException, ExecutionException {
Objects.requireNonNull(docRef, "DocumentReference cannot be null.");
Objects.requireNonNull(updates, "Updates map cannot be null.");
ApiFuture<WriteResult> future = docRef.update(updates);
WriteResult writeResult = future.get();
logger.debug(
"Document updated: {} with update time: {}", docRef.getPath(), writeResult.getUpdateTime());
}
public void deleteDocument(DocumentReference docRef)
throws InterruptedException, ExecutionException {
Objects.requireNonNull(docRef, "DocumentReference cannot be null.");
ApiFuture<WriteResult> 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<QuerySnapshot> future = collection.limit(batchSize).get();
int deleted = 0;
// future.get() blocks on document retrieval
List<QueryDocumentSnapshot> 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);
}
}

View File

@@ -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<String, ?> redisTemplate;
private final FirestoreBaseRepository firestoreBaseRepository;
private final Firestore firestore;
@Autowired
public DataPurgeService(
@Qualifier("reactiveRedisTemplate") ReactiveRedisTemplate<String, ?> redisTemplate,
FirestoreBaseRepository firestoreBaseRepository, Firestore firestore) {
this.redisTemplate = redisTemplate;
this.firestoreBaseRepository = firestoreBaseRepository;
this.firestore = firestore;
}
public Mono<Void> purgeAllData() {
return purgeRedis()
.then(purgeFirestore());
}
private Mono<Void> 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<Void> 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<QueryDocumentSnapshot> 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();
}
}

View File

@@ -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<DetectIntentResponseDTO> detectIntent(
String sessionId,
DetectIntentRequestDTO request) {
Objects.requireNonNull(sessionId, "Dialogflow session ID cannot be null.");
Objects.requireNonNull(request, "Dialogflow request DTO cannot be null.");
logger.info("Initiating detectIntent for session: {}", sessionId);
DetectIntentRequest.Builder detectIntentRequestBuilder;
try {
detectIntentRequestBuilder = dialogflowRequestMapper.mapToDetectIntentRequestBuilder(request);
logger.debug("Obtained partial DetectIntentRequest.Builder from mapper for session: {}", sessionId);
} catch (IllegalArgumentException e) {
logger.error(" Failed to map DTO to partial Protobuf request for session {}: {}", sessionId, e.getMessage());
return Mono.error(new IllegalArgumentException("Invalid Dialogflow request input: " + e.getMessage()));
}
SessionName sessionName = SessionName.newBuilder()
.setProject(dialogflowCxProjectId)
.setLocation(dialogflowCxLocation)
.setAgent(dialogflowCxAgentId)
.setSession(sessionId)
.build();
detectIntentRequestBuilder.setSession(sessionName.toString());
logger.debug("Set session path {} on the request builder for session: {}", sessionName.toString(), sessionId);
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));
}
}

View File

@@ -0,0 +1,60 @@
/*
* Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose.
* Your use of it is subject to your agreement with Google.
*/
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);
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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<String, ConversationMessageDTO> messageRedisTemplate;
@Value("${conversation.context.message.limit:60}")
private int messageLimit;
@Value("${conversation.context.days.limit:30}")
private int daysLimit;
@Autowired
public ConversationHistoryService(ReactiveRedisTemplate<String, ConversationMessageDTO> messageRedisTemplate) {
this.messageRedisTemplate = messageRedisTemplate;
}
public Mono<Void> 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<Double> scoreRange = Range.of(Range.Bound.inclusive(0d), Range.Bound.inclusive((double) cutoff.toEpochMilli()));
logger.info("Removing messages older than {} for sessionId: {}", cutoff, sessionId);
Mono<Long> removeByScore = messageRedisTemplate.opsForZSet().removeRangeByScore(messagesKey, scoreRange)
.doOnSuccess(count -> logger.info("Removed {} old messages for sessionId: {}", count, sessionId));
Mono<Long> trimToSize = messageRedisTemplate.opsForZSet().size(messagesKey)
.flatMap(size -> {
if (size > messageLimit) {
logger.info("Current message count {} exceeds limit {} for sessionId: {}. Trimming...", size, messageLimit, sessionId);
Range<Long> 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));
}
}

View File

@@ -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<DetectIntentResponseDTO> 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<DetectIntentResponseDTO> 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<String, Object> 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<DetectIntentResponseDTO> 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<DetectIntentResponseDTO> 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<DetectIntentResponseDTO> 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<DetectIntentResponseDTO> 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<DetectIntentResponseDTO> processDialogflowRequest(ConversationSessionDTO session,
DetectIntentRequestDTO request, String userId, String userMessageText, String userPhoneNumber,
boolean newSession) {
final String finalSessionId = session.sessionId();
ConversationEntryDTO userEntry = ConversationEntryDTO.forUser(userMessageText);
return this.persistConversationTurn(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<DetectIntentResponseDTO> 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<String, Object> 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<Void> 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));
}
}

View File

@@ -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<String> getObfuscatedString(String textToInspect, String templateId);
}

View File

@@ -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<String> 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<InspectContentResponse> futureResponse = dlpServiceClient.inspectContentCallable()
.futureCall(request);
return Mono.<InspectContentResponse>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);
});
}
}

View File

@@ -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<Void> 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<Void> 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<ConversationMessageDTO> 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<ConversationSessionDTO> 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<ConversationSessionDTO> 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<Void> 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);
}
}

View File

@@ -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<String, ConversationSessionDTO> redisTemplate;
private final ReactiveRedisTemplate<String, String> stringRedisTemplate;
private final ReactiveRedisTemplate<String, ConversationMessageDTO> messageRedisTemplate;
private final ConversationHistoryService conversationHistoryService;
@Autowired
public MemoryStoreConversationService(
ReactiveRedisTemplate<String, ConversationSessionDTO> redisTemplate,
ReactiveRedisTemplate<String, String> stringRedisTemplate,
ReactiveRedisTemplate<String, ConversationMessageDTO> messageRedisTemplate,
ConversationHistoryService conversationHistoryService) {
this.redisTemplate = redisTemplate;
this.stringRedisTemplate = stringRedisTemplate;
this.messageRedisTemplate = messageRedisTemplate;
this.conversationHistoryService = conversationHistoryService;
}
public Mono<Void> 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<Void> 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<ConversationMessageDTO> 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<ConversationSessionDTO> 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<Void> 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<Void> 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();
}
}

View File

@@ -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<String> getValue(String key);
Mono<Void> setValue(String key, String value);
}

View File

@@ -0,0 +1,33 @@
/*
* Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose.
* Your use of it is subject to your agreement with Google.
*/
package com.example.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<String, String> reactiveStringRedisTemplate;
private final String llmPreResponseCollectionName = "llm-pre-response:";
private final Duration ttl = Duration.ofHours(1);
public LlmResponseTunerServiceImpl(ReactiveRedisTemplate<String, String> reactiveStringRedisTemplate) {
this.reactiveStringRedisTemplate = reactiveStringRedisTemplate;
}
@Override
public Mono<String> getValue(String key) {
return reactiveStringRedisTemplate.opsForValue().get(llmPreResponseCollectionName + key);
}
@Override
public Mono<Void> setValue(String key, String value) {
return reactiveStringRedisTemplate.opsForValue().set(llmPreResponseCollectionName + key, value, ttl).then();
}
}

View File

@@ -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<Void> 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<String, Object> 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<String, Object> 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<String, Object> newSessionData = Map.of(
FIELD_NOTIFICATION_ID,
notificationSessionId,
FIELD_PHONE_NUMBER,
phoneNumber,
"fechaCreacion",
Timestamp.of(java.util.Date.from(Instant.now())),
FIELD_LAST_UPDATED,
Timestamp.of(java.util.Date.from(Instant.now())),
FIELD_MESSAGES,
Collections.singletonList(entryMap));
firestoreBaseRepository.setDocument(notificationDocRef, newSessionData);
logger.info(
"Successfully created a new notification session {} in Firestore.",
notificationSessionId);
}
} catch (ExecutionException e) {
logger.error(
"Error saving notification to Firestore for phone: {}",
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<Void> updateNotificationStatus(String sessionId, String status) {
return Mono.fromRunnable(() -> {
DocumentReference notificationDocRef = getNotificationDocumentReference(sessionId);
try {
Map<String, Object> sessionData = firestoreBaseRepository.getDocument(notificationDocRef, Map.class);
if (sessionData != null) {
List<Map<String, Object>> notifications = (List<Map<String, Object>>) sessionData
.get(FIELD_MESSAGES);
if (notifications != null) {
List<Map<String, Object>> updatedNotifications = new ArrayList<>();
for (Map<String, Object> notification : notifications) {
Map<String, Object> updatedNotification = new HashMap<>(notification);
updatedNotification.put("status", status);
updatedNotifications.add(updatedNotification);
}
Map<String, Object> updates = new HashMap<>();
updates.put(FIELD_MESSAGES, updatedNotifications);
updates.put(FIELD_LAST_UPDATED, Timestamp.of(java.util.Date.from(Instant.now())));
firestoreBaseRepository.updateDocument(notificationDocRef, updates);
logger.info("Successfully updated notification status to '{}' for session {} in Firestore.",
status, sessionId);
}
} else {
logger.warn("Notification session {} not found in Firestore. Cannot update status.", sessionId);
}
} catch (ExecutionException e) {
logger.error("Error updating notification status in Firestore for session {}: {}", sessionId,
e.getMessage(), e);
throw new FirestorePersistenceException(
"Failed to update notification status in Firestore for session " + sessionId, e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
logger.error("Thread interrupted while updating notification status in Firestore for session {}: {}",
sessionId, e.getMessage(), e);
throw new FirestorePersistenceException(
"Updating notification status was interrupted for session " + sessionId, e);
}
})
.subscribeOn(Schedulers.boundedElastic())
.then();
}
public Mono<Void> 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();
}
}

View File

@@ -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<String, NotificationSessionDTO> notificationRedisTemplate;
private final ReactiveRedisTemplate<String, String> 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<String, NotificationSessionDTO> notificationRedisTemplate,
ReactiveRedisTemplate<String, String> stringRedisTemplate,
ObjectMapper objectMapper) {
this.notificationRedisTemplate = notificationRedisTemplate;
this.stringRedisTemplate = stringRedisTemplate;
}
public Mono<Void> 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<NotificationDTO> 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<Boolean> 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<NotificationSessionDTO> 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<String> 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<Void> 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();
}
}

View File

@@ -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<DetectIntentResponseDTO> 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<String, Object> 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<Void> persistenceMono = memoryStoreNotificationService.saveOrAppendNotificationEntry(newNotificationEntry)
.doOnSuccess(v -> {
logger.info("Notification for phone {} cached. Kicking off async Firestore write-back.", telefono);
firestoreNotificationService.saveOrAppendNotificationEntry(newNotificationEntry)
.subscribe(
ignored -> logger.debug(
"Background: Notification entry persistence initiated for phone {} in Firestore.", telefono),
e -> logger.error(
"Background: Error during notification entry persistence for phone {} in Firestore: {}",
telefono, e.getMessage(), e));
});
Mono<ConversationSessionDTO> sessionMono = memoryStoreConversationService.getSessionByTelefono(telefono)
.doOnNext(session -> logger.info("Found existing conversation session {} for phone number {}",
session.sessionId(), telefono))
.flatMap(session -> {
Map<String, Object> 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<String, Object> 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<Void> 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));
}
}

View File

@@ -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<String, ConversationSessionDTO> redisTemplate;
private final ReactiveRedisTemplate<String, String> stringRedisTemplate;
private final ReactiveRedisTemplate<String, ConversationMessageDTO> messageRedisTemplate;
private final ConversationEntryMapper conversationEntryMapper;
@Autowired
public MemoryStoreQRService(
ReactiveRedisTemplate<String, ConversationSessionDTO> redisTemplate,
ReactiveRedisTemplate<String, String> stringRedisTemplate,
ReactiveRedisTemplate<String, ConversationMessageDTO> messageRedisTemplate,
ConversationEntryMapper conversationEntryMapper) {
this.redisTemplate = redisTemplate;
this.stringRedisTemplate = stringRedisTemplate;
this.messageRedisTemplate = messageRedisTemplate;
this.conversationEntryMapper = conversationEntryMapper;
}
public Mono<Void> saveEntry(String userId, String sessionId, ConversationEntryDTO newEntry,
String userPhoneNumber) {
String sessionKey = SESSION_KEY_PREFIX + sessionId;
String phoneToSessionKey = PHONE_TO_SESSION_KEY_PREFIX + userPhoneNumber;
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<ConversationSessionDTO> 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));
}
}

View File

@@ -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<DetectIntentResponseDTO> startQuickReplySession(QuickReplyScreenRequestDTO externalRequest) {
String userPhoneNumber = externalRequest.user().telefono();
if (userPhoneNumber == null || userPhoneNumber.isBlank()) {
logger.warn("No phone number provided in request. Cannot manage conversation session without it.");
return Mono
.error(new IllegalArgumentException("Phone number is required to manage conversation sessions."));
}
return memoryStoreConversationService.getSessionByTelefono(userPhoneNumber)
.flatMap(session -> Mono.just(session.sessionId()))
.switchIfEmpty(Mono.fromCallable(SessionIdGenerator::generateStandardSessionId))
.flatMap(sessionId -> {
String userId = "user_by_phone_" + userPhoneNumber.replaceAll("[^0-9]", "");
ConversationEntryDTO systemEntry = new ConversationEntryDTO(
ConversationEntryEntity.SISTEMA,
ConversationEntryType.INICIO,
Instant.now(),
"Pantalla :" + externalRequest.pantallaContexto() + " Agregada a la conversacion :",
null,
null);
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<DetectIntentResponseDTO> manageConversation(ExternalConvRequestDTO externalRequest) {
String userPhoneNumber = externalRequest.user().telefono();
if (userPhoneNumber == null || userPhoneNumber.isBlank()) {
logger.warn("No phone number provided in request. Cannot manage conversation session without it.");
return Mono
.error(new IllegalArgumentException("Phone number is required to manage conversation sessions."));
}
return memoryStoreConversationService.getSessionByTelefono(userPhoneNumber)
.switchIfEmpty(Mono.error(
new IllegalStateException("No quick reply session found for phone number")))
.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<QuestionDTO> 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<Void> 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));
}
}

View File

@@ -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<QuickReplyDTO> getQuickReplies(String collectionId) {
logger.info("Fetching quick replies from Firestore for document: {}", collectionId);
if (collectionId == null || collectionId.isBlank()) {
logger.warn("collectionId is null or empty. Returning empty quick replies.");
return Mono.just(new QuickReplyDTO("empty", 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<Map<String, Object>> preguntasData = (List<Map<String, Object>>) document.get("preguntas");
List<QuestionDTO> 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();
}));
}
}

View File

@@ -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<String, Object> localData = objectMapper.readValue(inputStream, new TypeReference<Map<String, Object>>() {});
DocumentReference docRef = firestoreBaseRepository.getDocumentReference(collectionPath, documentId);
if (firestoreBaseRepository.documentExists(docRef)) {
DocumentSnapshot documentSnapshot = firestoreBaseRepository.getDocumentSnapshot(docRef);
Map<String, Object> 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);
}
}
}
}

View File

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

View File

@@ -0,0 +1,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<String, Object> and need to be internally
* serialized before deserialization into a DTO. It converts Timestamp into a
* simple JSON object with "seconds" and "nanos" fields.
*/
public class FirestoreTimestampSerializer extends JsonSerializer<Timestamp> {
@Override
public void serialize(Timestamp value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
if (value == null) {
gen.writeNull();
} else {
// Write Timestamp as a JSON object with seconds and nanos
gen.writeStartObject();
gen.writeNumberField("seconds", value.getSeconds());
gen.writeNumberField("nanos", value.getNanos());
gen.writeEndObject();
}
}
}

View File

@@ -0,0 +1,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> T timeExecution(String operationName, Supplier<T> 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);
}
}
}

View File

@@ -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;
}
};
}
}

View File

@@ -0,0 +1,68 @@
/*
* Copyright 2025 Google. This software is provided as-is, without warranty or representation for any use or purpose.
* Your use of it is subject to your agreement with Google.
*/
package com.example.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;
}
}

View File

@@ -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<Finding> 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();
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
spring.profiles.active=${SPRING_PROFILE}

View File

@@ -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:

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More