Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e77ca0fa4 |
@@ -1,6 +0,0 @@
|
||||
.venv
|
||||
__pycache__
|
||||
*.pyc
|
||||
.git
|
||||
.env
|
||||
src.bak
|
||||
39
.env.example
39
.env.example
@@ -1,39 +0,0 @@
|
||||
# GCP Configuration
|
||||
GCP_PROJECT_ID=your-project-id
|
||||
GCP_LOCATION=us-central1
|
||||
|
||||
# Firestore Configuration
|
||||
GCP_FIRESTORE_DATABASE_ID=your-database-id
|
||||
GCP_FIRESTORE_HOST=firestore.googleapis.com
|
||||
GCP_FIRESTORE_PORT=443
|
||||
GCP_FIRESTORE_IMPORTER_ENABLE=false
|
||||
|
||||
# Redis/Memorystore Configuration
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_PWD=
|
||||
|
||||
# Dialogflow CX Configuration
|
||||
DIALOGFLOW_CX_PROJECT_ID=your-dialogflow-project
|
||||
DIALOGFLOW_CX_LOCATION=us-central1
|
||||
DIALOGFLOW_CX_AGENT_ID=your-agent-id
|
||||
DIALOGFLOW_DEFAULT_LANGUAGE_CODE=es
|
||||
|
||||
# Gemini Configuration
|
||||
GEMINI_MODEL_NAME=gemini-2.0-flash-exp
|
||||
|
||||
# Message Filter Configuration
|
||||
MESSAGE_FILTER_GEMINI_MODEL=gemini-2.0-flash-exp
|
||||
MESSAGE_FILTER_TEMPERATURE=0.2
|
||||
MESSAGE_FILTER_MAX_OUTPUT_TOKENS=8192
|
||||
MESSAGE_FILTER_TOP_P=0.95
|
||||
|
||||
# DLP Configuration
|
||||
DLP_TEMPLATE_COMPLETE_FLOW=your-dlp-template
|
||||
|
||||
# Conversation Context Configuration
|
||||
CONVERSATION_CONTEXT_MESSAGE_LIMIT=10
|
||||
CONVERSATION_CONTEXT_DAYS_LIMIT=30
|
||||
|
||||
# Logging Configuration
|
||||
LOGGING_LEVEL_ROOT=INFO
|
||||
218
.gitignore
vendored
218
.gitignore
vendored
@@ -1,218 +0,0 @@
|
||||
.env
|
||||
.ipynb_checkpoints
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[codz]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py.cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
# Pipfile.lock
|
||||
|
||||
# UV
|
||||
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# uv.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
# poetry.lock
|
||||
# poetry.toml
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
||||
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
||||
# pdm.lock
|
||||
# pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
|
||||
# pixi
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
||||
# pixi.lock
|
||||
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
||||
# in the .venv directory. It is recommended not to include this directory in version control.
|
||||
.pixi
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# Redis
|
||||
*.rdb
|
||||
*.aof
|
||||
*.pid
|
||||
|
||||
# RabbitMQ
|
||||
mnesia/
|
||||
rabbitmq/
|
||||
rabbitmq-data/
|
||||
|
||||
# ActiveMQ
|
||||
activemq-data/
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.envrc
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
# .idea/
|
||||
|
||||
# Abstra
|
||||
# Abstra is an AI-powered process automation framework.
|
||||
# Ignore directories containing user credentials, local state, and settings.
|
||||
# Learn more at https://abstra.io/docs
|
||||
.abstra/
|
||||
|
||||
# Visual Studio Code
|
||||
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
||||
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
||||
# you could uncomment the following to ignore the entire vscode folder
|
||||
# .vscode/
|
||||
|
||||
# Ruff stuff:
|
||||
.ruff_cache/
|
||||
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
|
||||
# Marimo
|
||||
marimo/_static/
|
||||
marimo/_lsp/
|
||||
__marimo__/
|
||||
|
||||
# Streamlit
|
||||
.streamlit/secrets.toml
|
||||
@@ -1 +0,0 @@
|
||||
3.12
|
||||
51
Dockerfile
51
Dockerfile
@@ -1,36 +1,15 @@
|
||||
# --- Build stage ---
|
||||
FROM python:3.12-slim-bookworm AS builder
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.7.12 /uv /uvx /bin/
|
||||
|
||||
ENV UV_COMPILE_BYTECODE=1 \
|
||||
UV_LINK_MODE=copy \
|
||||
UV_NO_DEV=1 \
|
||||
UV_PYTHON_DOWNLOADS=0
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies (cached separately from app code)
|
||||
COPY pyproject.toml uv.lock ./
|
||||
RUN uv sync --locked --no-install-project --no-cache
|
||||
|
||||
# Copy application code and install project
|
||||
COPY . /app
|
||||
RUN uv sync --locked --no-editable --no-cache
|
||||
|
||||
# --- Runtime stage ---
|
||||
FROM python:3.12-slim-bookworm
|
||||
|
||||
RUN groupadd --system --gid 999 nonroot \
|
||||
&& useradd --system --gid 999 --uid 999 --create-home nonroot
|
||||
|
||||
COPY --from=builder --chown=nonroot:nonroot /app /app
|
||||
|
||||
ENV PATH="/app/.venv/bin:$PATH" \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
USER nonroot
|
||||
WORKDIR /app
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["uvicorn", "capa_de_integracion.main:app", "--host", "0.0.0.0", "--port", "8080", "--limit-concurrency", "1000", "--backlog", "2048"]
|
||||
# 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
15
Dockerfile 2
Normal 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
236
README 2.md
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
163
docs/dialogflow/orquestador_cognitivo.md
Normal file
163
docs/dialogflow/orquestador_cognitivo.md
Normal 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}
|
||||
80
docs/dialogflow/playbook_desambiguacion.md
Normal file
80
docs/dialogflow/playbook_desambiguacion.md
Normal 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>
|
||||
64
docs/dialogflow/playbook_nueva_conversacion.md
Normal file
64
docs/dialogflow/playbook_nueva_conversacion.md
Normal 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>
|
||||
207
locustfile.py
207
locustfile.py
@@ -1,207 +0,0 @@
|
||||
"""Locust load testing for capa-de-integracion service.
|
||||
|
||||
Usage:
|
||||
# Run with web UI (default port 8089)
|
||||
locust --host http://localhost:8080
|
||||
|
||||
# Run headless with specific users and spawn rate
|
||||
locust --host http://localhost:8080 --headless -u 100 -r 10
|
||||
|
||||
# Run for specific duration
|
||||
locust --host http://localhost:8080 --headless -u 50 -r 5 --run-time 5m
|
||||
"""
|
||||
|
||||
import random
|
||||
|
||||
from locust import HttpUser, between, task
|
||||
|
||||
|
||||
class ConversationUser(HttpUser):
|
||||
"""Simulate users interacting with the conversation API."""
|
||||
|
||||
wait_time = between(1, 3)
|
||||
|
||||
phone_numbers = [
|
||||
f"555-{1000 + i:04d}" for i in range(100)
|
||||
]
|
||||
|
||||
conversation_messages = [
|
||||
"Hola",
|
||||
"¿Cuál es mi saldo?",
|
||||
"Necesito ayuda con mi tarjeta",
|
||||
"¿Dónde está mi sucursal más cercana?",
|
||||
"Quiero hacer una transferencia",
|
||||
"¿Cómo puedo activar mi tarjeta?",
|
||||
"Tengo un problema con mi cuenta",
|
||||
"¿Cuáles son los horarios de atención?",
|
||||
]
|
||||
|
||||
notification_messages = [
|
||||
"Tu tarjeta fue bloqueada por seguridad",
|
||||
"Se detectó un cargo de $1,500 en tu cuenta",
|
||||
"Tu préstamo fue aprobado",
|
||||
"Transferencia recibida: $5,000",
|
||||
"Recordatorio: Tu pago vence mañana",
|
||||
]
|
||||
|
||||
screen_contexts = [
|
||||
"home",
|
||||
"pagos",
|
||||
"transferencia",
|
||||
"prestamos",
|
||||
"inversiones",
|
||||
"lealtad",
|
||||
"finanzas",
|
||||
"capsulas",
|
||||
"descubre",
|
||||
"retiro-sin-tarjeta",
|
||||
"detalle-tdc",
|
||||
"detalle-tdd",
|
||||
]
|
||||
|
||||
def on_start(self):
|
||||
"""Called when a simulated user starts."""
|
||||
self.phone = random.choice(self.phone_numbers)
|
||||
self.nombre = f"Usuario_{self.phone.replace('-', '')}"
|
||||
|
||||
@task(5)
|
||||
def health_check(self):
|
||||
"""Health check endpoint - most frequent task."""
|
||||
with self.client.get("/health", catch_response=True) as response:
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if data.get("status") == "healthy":
|
||||
response.success()
|
||||
else:
|
||||
response.failure("Health check returned unhealthy status")
|
||||
else:
|
||||
response.failure(f"Got status code {response.status_code}")
|
||||
|
||||
@task(10)
|
||||
def detect_intent(self):
|
||||
"""Test the main conversation endpoint."""
|
||||
payload = {
|
||||
"mensaje": random.choice(self.conversation_messages),
|
||||
"usuario": {
|
||||
"telefono": self.phone,
|
||||
"nickname": self.nombre,
|
||||
},
|
||||
"canal": "web",
|
||||
"pantallaContexto": random.choice(self.screen_contexts),
|
||||
}
|
||||
|
||||
with self.client.post(
|
||||
"/api/v1/dialogflow/detect-intent",
|
||||
json=payload,
|
||||
catch_response=True,
|
||||
) as response:
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if "responseId" in data or "queryResult" in data:
|
||||
response.success()
|
||||
else:
|
||||
response.failure("Response missing expected fields")
|
||||
elif response.status_code == 400:
|
||||
response.failure(f"Validation error: {response.text}")
|
||||
elif response.status_code == 500:
|
||||
response.failure(f"Internal server error: {response.text}")
|
||||
else:
|
||||
response.failure(f"Unexpected status code: {response.status_code}")
|
||||
|
||||
@task(3)
|
||||
def send_notification(self):
|
||||
"""Test the notification endpoint."""
|
||||
payload = {
|
||||
"texto": random.choice(self.notification_messages),
|
||||
"telefono": self.phone,
|
||||
"parametrosOcultos": {
|
||||
"transaction_id": f"TXN{random.randint(10000, 99999)}",
|
||||
"amount": random.randint(100, 10000),
|
||||
},
|
||||
}
|
||||
|
||||
with self.client.post(
|
||||
"/api/v1/dialogflow/notification",
|
||||
json=payload,
|
||||
catch_response=True,
|
||||
) as response:
|
||||
if response.status_code == 200:
|
||||
response.success()
|
||||
elif response.status_code == 400:
|
||||
response.failure(f"Validation error: {response.text}")
|
||||
elif response.status_code == 500:
|
||||
response.failure(f"Internal server error: {response.text}")
|
||||
else:
|
||||
response.failure(f"Unexpected status code: {response.status_code}")
|
||||
|
||||
@task(4)
|
||||
def quick_reply_screen(self):
|
||||
"""Test the quick reply screen endpoint."""
|
||||
payload = {
|
||||
"usuario": {
|
||||
"telefono": self.phone,
|
||||
"nombre": self.nombre,
|
||||
},
|
||||
"pantallaContexto": random.choice(self.screen_contexts),
|
||||
}
|
||||
|
||||
with self.client.post(
|
||||
"/api/v1/quick-replies/screen",
|
||||
json=payload,
|
||||
catch_response=True,
|
||||
) as response:
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if "responseId" in data and "quick_replies" in data:
|
||||
response.success()
|
||||
else:
|
||||
response.failure("Response missing expected fields")
|
||||
elif response.status_code == 400:
|
||||
response.failure(f"Validation error: {response.text}")
|
||||
elif response.status_code == 500:
|
||||
response.failure(f"Internal server error: {response.text}")
|
||||
else:
|
||||
response.failure(f"Unexpected status code: {response.status_code}")
|
||||
|
||||
|
||||
class ConversationFlowUser(HttpUser):
|
||||
"""Simulate realistic conversation flows with multiple interactions."""
|
||||
|
||||
wait_time = between(2, 5)
|
||||
weight = 2
|
||||
|
||||
def on_start(self):
|
||||
"""Initialize user session."""
|
||||
self.phone = f"555-{random.randint(2000, 2999):04d}"
|
||||
self.nombre = f"Flow_User_{random.randint(1000, 9999)}"
|
||||
|
||||
@task
|
||||
def complete_conversation_flow(self):
|
||||
"""Simulate a complete conversation flow."""
|
||||
screen_payload = {
|
||||
"usuario": {
|
||||
"telefono": self.phone,
|
||||
"nombre": self.nombre,
|
||||
},
|
||||
"pantallaContexto": "home",
|
||||
}
|
||||
self.client.post("/api/v1/quick-replies/screen", json=screen_payload)
|
||||
|
||||
conversation_steps = [
|
||||
"Hola, necesito ayuda",
|
||||
"¿Cómo puedo verificar mi saldo?",
|
||||
"Gracias por la información",
|
||||
]
|
||||
|
||||
for mensaje in conversation_steps:
|
||||
payload = {
|
||||
"mensaje": mensaje,
|
||||
"usuario": {
|
||||
"telefono": self.phone,
|
||||
"nickname": self.nombre,
|
||||
},
|
||||
"canal": "mobile",
|
||||
"pantallaContexto": "home",
|
||||
}
|
||||
self.client.post("/api/v1/dialogflow/detect-intent", json=payload)
|
||||
self.wait()
|
||||
28
notification.md
Normal file
28
notification.md
Normal 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
171
orquestador_cognitivo.md
Normal 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}
|
||||
80
playbook_desambiguacion.md
Normal file
80
playbook_desambiguacion.md
Normal 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>
|
||||
66
playbook_nueva_conversacion.md
Normal file
66
playbook_nueva_conversacion.md
Normal 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
241
pom 2.xml
Normal 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>
|
||||
6
pom.xml
6
pom.xml
@@ -229,6 +229,12 @@
|
||||
<artifactId>commons-lang3</artifactId>
|
||||
<version>3.18.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.squareup.okhttp3</groupId>
|
||||
<artifactId>mockwebserver</artifactId>
|
||||
<version>4.12.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<build>
|
||||
<plugins>
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
[project]
|
||||
name = "capa-de-integracion"
|
||||
version = "0.1.0"
|
||||
description = "Orchestrator service for conversational AI - Python implementation"
|
||||
readme = "README.md"
|
||||
authors = [
|
||||
{ name = "A8065384", email = "anibal.angulo.cardoza@banorte.com" }
|
||||
]
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"fastapi>=0.115.0",
|
||||
"uvicorn[standard]>=0.32.0",
|
||||
"pydantic>=2.10.0",
|
||||
"pydantic-settings>=2.6.0",
|
||||
"google-cloud-dialogflow-cx>=1.45.0",
|
||||
"google-cloud-firestore>=2.20.0",
|
||||
"google-cloud-aiplatform>=1.75.0",
|
||||
"google-generativeai>=0.8.0",
|
||||
"google-cloud-dlp>=3.30.0",
|
||||
"redis[hiredis]>=5.2.0",
|
||||
"tenacity>=9.0.0",
|
||||
"python-multipart>=0.0.12",
|
||||
"httpx[http2]>=0.27.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
capa-de-integracion = "capa_de_integracion:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["uv_build>=0.9.22,<0.10.0"]
|
||||
build-backend = "uv_build"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"fakeredis>=2.34.0",
|
||||
"inline-snapshot>=0.32.1",
|
||||
"locust>=2.43.3",
|
||||
"pytest>=9.0.2",
|
||||
"pytest-asyncio>=1.3.0",
|
||||
"pytest-cov>=7.0.0",
|
||||
"pytest-env>=1.5.0",
|
||||
"pytest-recording>=0.13.4",
|
||||
"ruff>=0.15.1",
|
||||
"ty>=0.0.17",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
exclude = ["tests", "scripts"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ['ALL']
|
||||
ignore = ['D203', 'D213', 'COM812']
|
||||
|
||||
[tool.ty.src]
|
||||
include = ["src"]
|
||||
exclude = ["tests"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
testpaths = ["tests"]
|
||||
addopts = [
|
||||
"--cov=capa_de_integracion",
|
||||
"--cov-report=term-missing",
|
||||
"--cov-report=html",
|
||||
"--cov-branch",
|
||||
]
|
||||
|
||||
filterwarnings = [
|
||||
"ignore:Call to '__init__' function with deprecated usage:DeprecationWarning:fakeredis",
|
||||
"ignore:.*retry_on_timeout.*:DeprecationWarning",
|
||||
"ignore:.*lib_name.*:DeprecationWarning",
|
||||
"ignore:.*lib_version.*:DeprecationWarning",
|
||||
]
|
||||
|
||||
env = [
|
||||
"FIRESTORE_EMULATOR_HOST=[::1]:8469",
|
||||
"GCP_PROJECT_ID=test-project",
|
||||
"GCP_LOCATION=us-central1",
|
||||
"GCP_FIRESTORE_DATABASE_ID=(default)",
|
||||
"RAG_ENDPOINT_URL=http://localhost:8000/rag",
|
||||
"REDIS_HOST=localhost",
|
||||
"REDIS_PORT=6379",
|
||||
"DLP_TEMPLATE_COMPLETE_FLOW=projects/test/dlpJobTriggers/test",
|
||||
]
|
||||
@@ -1,167 +0,0 @@
|
||||
/*
|
||||
* 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));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
"""Capa de Integración - Conversational AI Orchestrator Service."""
|
||||
|
||||
from .main import app, main
|
||||
|
||||
__all__ = ["app", "main"]
|
||||
@@ -1,64 +0,0 @@
|
||||
"""Configuration settings for the application."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application configuration from environment variables."""
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||
|
||||
# GCP General
|
||||
gcp_project_id: str
|
||||
gcp_location: str
|
||||
|
||||
# RAG
|
||||
rag_endpoint_url: str
|
||||
rag_echo_enabled: bool = Field(
|
||||
default=False,
|
||||
alias="RAG_ECHO_ENABLED",
|
||||
)
|
||||
|
||||
# Firestore
|
||||
firestore_database_id: str = Field(..., alias="GCP_FIRESTORE_DATABASE_ID")
|
||||
firestore_host: str = Field(
|
||||
default="firestore.googleapis.com",
|
||||
alias="GCP_FIRESTORE_HOST",
|
||||
)
|
||||
firestore_port: int = Field(default=443, alias="GCP_FIRESTORE_PORT")
|
||||
firestore_importer_enabled: bool = Field(
|
||||
default=False,
|
||||
alias="GCP_FIRESTORE_IMPORTER_ENABLE",
|
||||
)
|
||||
|
||||
# Redis
|
||||
redis_host: str
|
||||
redis_port: int
|
||||
redis_pwd: str | None = None
|
||||
|
||||
# DLP
|
||||
dlp_template_complete_flow: str
|
||||
|
||||
# Conversation Context
|
||||
conversation_context_message_limit: int = Field(
|
||||
default=60,
|
||||
alias="CONVERSATION_CONTEXT_MESSAGE_LIMIT",
|
||||
)
|
||||
conversation_context_days_limit: int = Field(
|
||||
default=30,
|
||||
alias="CONVERSATION_CONTEXT_DAYS_LIMIT",
|
||||
)
|
||||
|
||||
# Logging
|
||||
log_level: str = Field(default="INFO", alias="LOGGING_LEVEL_ROOT")
|
||||
|
||||
@property
|
||||
def base_path(self) -> Path:
|
||||
"""Get base path for resources."""
|
||||
return Path(__file__).parent.parent / "resources"
|
||||
|
||||
|
||||
settings = Settings.model_validate({})
|
||||
@@ -1,123 +0,0 @@
|
||||
"""Dependency injection and service lifecycle management."""
|
||||
|
||||
from functools import lru_cache
|
||||
|
||||
from capa_de_integracion.services.rag import (
|
||||
EchoRAGService,
|
||||
HTTPRAGService,
|
||||
RAGServiceBase,
|
||||
)
|
||||
|
||||
from .config import Settings, settings
|
||||
from .services import (
|
||||
ConversationManagerService,
|
||||
DLPService,
|
||||
NotificationManagerService,
|
||||
QuickReplyContentService,
|
||||
QuickReplySessionService,
|
||||
)
|
||||
from .services.storage import FirestoreService, RedisService
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_redis_service() -> RedisService:
|
||||
"""Get Redis service instance."""
|
||||
return RedisService(settings)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_firestore_service() -> FirestoreService:
|
||||
"""Get Firestore service instance."""
|
||||
return FirestoreService(settings)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_dlp_service() -> DLPService:
|
||||
"""Get DLP service instance."""
|
||||
return DLPService(settings)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_quick_reply_content_service() -> QuickReplyContentService:
|
||||
"""Get quick reply content service instance."""
|
||||
return QuickReplyContentService(settings)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_quick_reply_session_service() -> QuickReplySessionService:
|
||||
"""Get quick reply session service instance."""
|
||||
return QuickReplySessionService(
|
||||
redis_service=get_redis_service(),
|
||||
firestore_service=get_firestore_service(),
|
||||
quick_reply_content_service=get_quick_reply_content_service(),
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_notification_manager() -> NotificationManagerService:
|
||||
"""Get notification manager instance."""
|
||||
return NotificationManagerService(
|
||||
settings,
|
||||
redis_service=get_redis_service(),
|
||||
firestore_service=get_firestore_service(),
|
||||
dlp_service=get_dlp_service(),
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_rag_service() -> RAGServiceBase:
|
||||
"""Get RAG service instance."""
|
||||
if settings.rag_echo_enabled:
|
||||
return EchoRAGService()
|
||||
return HTTPRAGService(
|
||||
endpoint_url=settings.rag_endpoint_url,
|
||||
max_connections=100,
|
||||
max_keepalive_connections=20,
|
||||
timeout=30.0,
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_conversation_manager() -> ConversationManagerService:
|
||||
"""Get conversation manager instance."""
|
||||
return ConversationManagerService(
|
||||
settings,
|
||||
redis_service=get_redis_service(),
|
||||
firestore_service=get_firestore_service(),
|
||||
dlp_service=get_dlp_service(),
|
||||
rag_service=get_rag_service(),
|
||||
)
|
||||
|
||||
|
||||
# Lifecycle management functions
|
||||
|
||||
|
||||
def init_services(settings: Settings) -> None:
|
||||
"""Initialize services (placeholder for compatibility)."""
|
||||
# Services are lazy-loaded via lru_cache, no explicit init needed
|
||||
|
||||
|
||||
async def startup_services() -> None:
|
||||
"""Connect to external services on startup."""
|
||||
# Connect to Redis
|
||||
redis = get_redis_service()
|
||||
await redis.connect()
|
||||
|
||||
|
||||
async def shutdown_services() -> None:
|
||||
"""Close all service connections on shutdown."""
|
||||
# Close Redis
|
||||
redis = get_redis_service()
|
||||
await redis.close()
|
||||
|
||||
# Close Firestore
|
||||
firestore = get_firestore_service()
|
||||
await firestore.close()
|
||||
|
||||
# Close DLP
|
||||
dlp = get_dlp_service()
|
||||
await dlp.close()
|
||||
|
||||
# Close RAG
|
||||
rag = get_rag_service()
|
||||
await rag.close()
|
||||
@@ -1,19 +0,0 @@
|
||||
"""Custom exceptions for the application."""
|
||||
|
||||
|
||||
class FirestorePersistenceError(Exception):
|
||||
"""Exception raised when Firestore operations fail.
|
||||
|
||||
This is typically caught and logged without failing the request.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, cause: Exception | None = None) -> None:
|
||||
"""Initialize Firestore persistence exception.
|
||||
|
||||
Args:
|
||||
message: Error message
|
||||
cause: Original exception that caused this error
|
||||
|
||||
"""
|
||||
super().__init__(message)
|
||||
self.cause = cause
|
||||
@@ -1,99 +0,0 @@
|
||||
"""Main application entry point and FastAPI app configuration."""
|
||||
|
||||
import logging
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from .config import settings
|
||||
from .dependencies import init_services, shutdown_services, startup_services
|
||||
from .routers import conversation_router, notification_router, quick_replies_router
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=settings.log_level,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||
"""Application lifespan manager."""
|
||||
# Startup
|
||||
logger.info("Initializing services...")
|
||||
init_services(settings)
|
||||
await startup_services()
|
||||
logger.info("Application started successfully")
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown
|
||||
logger.info("Shutting down services...")
|
||||
await shutdown_services()
|
||||
logger.info("Application shutdown complete")
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Capa de Integración - Orchestrator Service",
|
||||
description=(
|
||||
"Conversational AI orchestrator for Dialogflow CX, Gemini, and Vertex AI"
|
||||
),
|
||||
version="0.1.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# CORS middleware
|
||||
# Note: Type checker reports false positive for CORSMiddleware
|
||||
# This is the correct FastAPI pattern per official documentation
|
||||
app.add_middleware(
|
||||
CORSMiddleware, # ty: ignore
|
||||
allow_origins=["*"], # Configure appropriately for production
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Register routers
|
||||
app.include_router(conversation_router)
|
||||
app.include_router(notification_router)
|
||||
app.include_router(quick_replies_router)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check() -> dict[str, str]:
|
||||
"""Health check endpoint."""
|
||||
return {"status": "healthy", "service": "capa-de-integracion"}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Entry point for CLI."""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Capa de Integración server")
|
||||
parser.add_argument("--host", default="0.0.0.0", help="Bind host (default: 0.0.0.0)") # noqa: S104
|
||||
parser.add_argument("--port", type=int, default=8080, help="Bind port (default: 8080)")
|
||||
parser.add_argument("--workers", type=int, default=1, help="Number of worker processes (default: 1)")
|
||||
parser.add_argument("--limit-concurrency", type=int, default=None, help="Max concurrent connections per worker")
|
||||
parser.add_argument("--backlog", type=int, default=2048, help="TCP listen backlog (default: 2048)")
|
||||
parser.add_argument("--reload", action="store_true", help="Enable auto-reload (dev only)")
|
||||
parser.add_argument("--no-access-log", action="store_true", help="Disable uvicorn access logs")
|
||||
args = parser.parse_args()
|
||||
|
||||
uvicorn.run(
|
||||
"capa_de_integracion.main:app",
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
workers=args.workers,
|
||||
limit_concurrency=args.limit_concurrency,
|
||||
backlog=args.backlog,
|
||||
reload=args.reload,
|
||||
access_log=not args.no_access_log,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,29 +0,0 @@
|
||||
"""Data models module."""
|
||||
|
||||
from .conversation import (
|
||||
ConversationEntry,
|
||||
ConversationRequest,
|
||||
ConversationSession,
|
||||
DetectIntentResponse,
|
||||
QueryResult,
|
||||
User,
|
||||
)
|
||||
from .notification import (
|
||||
ExternalNotificationRequest,
|
||||
Notification,
|
||||
NotificationSession,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ConversationEntry",
|
||||
"ConversationRequest",
|
||||
"ConversationSession",
|
||||
"DetectIntentResponse",
|
||||
# Notification
|
||||
"ExternalNotificationRequest",
|
||||
"Notification",
|
||||
"NotificationSession",
|
||||
"QueryResult",
|
||||
# Conversation
|
||||
"User",
|
||||
]
|
||||
@@ -1,102 +0,0 @@
|
||||
"""Conversation models and data structures."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class User(BaseModel):
|
||||
"""User information."""
|
||||
|
||||
telefono: str = Field(..., min_length=1)
|
||||
nickname: str | None = None
|
||||
|
||||
model_config = {"extra": "ignore"}
|
||||
|
||||
|
||||
class QueryResult(BaseModel):
|
||||
"""Query result from Dialogflow."""
|
||||
|
||||
response_text: str | None = Field(None, alias="responseText")
|
||||
parameters: dict[str, Any] | None = Field(None, alias="parameters")
|
||||
|
||||
model_config = {"populate_by_name": True, "extra": "ignore"}
|
||||
|
||||
|
||||
class DetectIntentResponse(BaseModel):
|
||||
"""Dialogflow detect intent response."""
|
||||
|
||||
response_id: str | None = Field(None, alias="responseId")
|
||||
query_result: QueryResult | None = Field(None, alias="queryResult")
|
||||
quick_replies: Any | None = None # QuickReplyScreen from quick_replies module
|
||||
|
||||
model_config = {"populate_by_name": True, "extra": "ignore"}
|
||||
|
||||
|
||||
class ConversationRequest(BaseModel):
|
||||
"""External conversation request from client."""
|
||||
|
||||
mensaje: str = Field(..., alias="mensaje")
|
||||
usuario: User = Field(..., alias="usuario")
|
||||
canal: str = Field(..., alias="canal")
|
||||
pantalla_contexto: str | None = Field(None, alias="pantallaContexto")
|
||||
|
||||
model_config = {"populate_by_name": True, "extra": "ignore"}
|
||||
|
||||
|
||||
class ConversationEntry(BaseModel):
|
||||
"""Single conversation entry."""
|
||||
|
||||
entity: Literal["user", "assistant"]
|
||||
type: str = Field(..., alias="type") # "INICIO", "CONVERSACION", "LLM"
|
||||
timestamp: datetime = Field(
|
||||
default_factory=lambda: datetime.now(UTC),
|
||||
alias="timestamp",
|
||||
)
|
||||
text: str = Field(..., alias="text")
|
||||
parameters: dict[str, Any] | None = Field(None, alias="parameters")
|
||||
canal: str | None = Field(None, alias="canal")
|
||||
|
||||
model_config = {"populate_by_name": True, "extra": "ignore"}
|
||||
|
||||
|
||||
class ConversationSession(BaseModel):
|
||||
"""Conversation session metadata."""
|
||||
|
||||
session_id: str = Field(..., alias="sessionId")
|
||||
user_id: str = Field(..., alias="userId")
|
||||
telefono: str = Field(..., alias="telefono")
|
||||
created_at: datetime = Field(
|
||||
default_factory=lambda: datetime.now(UTC),
|
||||
alias="createdAt",
|
||||
)
|
||||
last_modified: datetime = Field(
|
||||
default_factory=lambda: datetime.now(UTC),
|
||||
alias="lastModified",
|
||||
)
|
||||
last_message: str | None = Field(None, alias="lastMessage")
|
||||
pantalla_contexto: str | None = Field(None, alias="pantallaContexto")
|
||||
|
||||
model_config = {"populate_by_name": True, "extra": "ignore"}
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
session_id: str,
|
||||
user_id: str,
|
||||
telefono: str,
|
||||
pantalla_contexto: str | None = None,
|
||||
last_message: str | None = None,
|
||||
) -> "ConversationSession":
|
||||
"""Create a new conversation session."""
|
||||
now = datetime.now(UTC)
|
||||
return cls(
|
||||
sessionId=session_id,
|
||||
userId=user_id,
|
||||
telefono=telefono,
|
||||
createdAt=now,
|
||||
lastModified=now,
|
||||
pantallaContexto=pantalla_contexto,
|
||||
lastMessage=last_message,
|
||||
)
|
||||
@@ -1,125 +0,0 @@
|
||||
"""Notification models and data structures."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class Notification(BaseModel):
|
||||
"""Individual notification event record.
|
||||
|
||||
Represents a notification to be stored in Firestore and cached in Redis.
|
||||
"""
|
||||
|
||||
id_notificacion: str = Field(
|
||||
...,
|
||||
alias="idNotificacion",
|
||||
description="Unique notification ID",
|
||||
)
|
||||
telefono: str = Field(..., alias="telefono", description="User phone number")
|
||||
timestamp_creacion: datetime = Field(
|
||||
default_factory=lambda: datetime.now(UTC),
|
||||
alias="timestampCreacion",
|
||||
description="Notification creation timestamp",
|
||||
)
|
||||
texto: str = Field(..., alias="texto", description="Notification text content")
|
||||
nombre_evento_dialogflow: str = Field(
|
||||
default="notificacion",
|
||||
alias="nombreEventoDialogflow",
|
||||
description="Dialogflow event name",
|
||||
)
|
||||
codigo_idioma_dialogflow: str = Field(
|
||||
default="es",
|
||||
alias="codigoIdiomaDialogflow",
|
||||
description="Dialogflow language code",
|
||||
)
|
||||
parametros: dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
alias="parametros",
|
||||
description="Session parameters for Dialogflow",
|
||||
)
|
||||
status: str = Field(
|
||||
default="active",
|
||||
alias="status",
|
||||
description="Notification status",
|
||||
)
|
||||
|
||||
model_config = {"populate_by_name": True}
|
||||
|
||||
@classmethod
|
||||
def create( # noqa: PLR0913
|
||||
cls,
|
||||
id_notificacion: str,
|
||||
telefono: str,
|
||||
texto: str,
|
||||
nombre_evento_dialogflow: str = "notificacion",
|
||||
codigo_idioma_dialogflow: str = "es",
|
||||
parametros: dict[str, Any] | None = None,
|
||||
status: str = "active",
|
||||
) -> "Notification":
|
||||
"""Create a new Notification with auto-filled timestamp.
|
||||
|
||||
Args:
|
||||
id_notificacion: Unique notification ID
|
||||
telefono: User phone number
|
||||
texto: Notification text content
|
||||
nombre_evento_dialogflow: Dialogflow event name
|
||||
codigo_idioma_dialogflow: Dialogflow language code
|
||||
parametros: Session parameters for Dialogflow
|
||||
status: Notification status
|
||||
|
||||
Returns:
|
||||
New Notification instance with current timestamp
|
||||
|
||||
"""
|
||||
return cls.model_validate(
|
||||
{
|
||||
"idNotificacion": id_notificacion,
|
||||
"telefono": telefono,
|
||||
"timestampCreacion": datetime.now(UTC),
|
||||
"texto": texto,
|
||||
"nombreEventoDialogflow": nombre_evento_dialogflow,
|
||||
"codigoIdiomaDialogflow": codigo_idioma_dialogflow,
|
||||
"parametros": parametros or {},
|
||||
"status": status,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class NotificationSession(BaseModel):
|
||||
"""Notification session containing multiple notifications for a phone number."""
|
||||
|
||||
session_id: str = Field(..., alias="sessionId", description="Session identifier")
|
||||
telefono: str = Field(..., alias="telefono", description="User phone number")
|
||||
fecha_creacion: datetime = Field(
|
||||
default_factory=lambda: datetime.now(UTC),
|
||||
alias="fechaCreacion",
|
||||
description="Session creation time",
|
||||
)
|
||||
ultima_actualizacion: datetime = Field(
|
||||
default_factory=lambda: datetime.now(UTC),
|
||||
alias="ultimaActualizacion",
|
||||
description="Last update time",
|
||||
)
|
||||
notificaciones: list[Notification] = Field(
|
||||
default_factory=list,
|
||||
alias="notificaciones",
|
||||
description="List of notification events",
|
||||
)
|
||||
|
||||
model_config = {"populate_by_name": True}
|
||||
|
||||
|
||||
class ExternalNotificationRequest(BaseModel):
|
||||
"""External notification push request from client."""
|
||||
|
||||
texto: str = Field(..., min_length=1)
|
||||
telefono: str = Field(..., alias="telefono", description="User phone number")
|
||||
parametros_ocultos: dict[str, Any] | None = Field(
|
||||
None,
|
||||
alias="parametrosOcultos",
|
||||
description="Hidden parameters (metadata)",
|
||||
)
|
||||
|
||||
model_config = {"populate_by_name": True}
|
||||
@@ -1,21 +0,0 @@
|
||||
"""Models for quick reply functionality."""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class QuickReplyQuestions(BaseModel):
|
||||
"""Individual FAQ question."""
|
||||
|
||||
titulo: str
|
||||
descripcion: str | None = None
|
||||
respuesta: str
|
||||
|
||||
|
||||
class QuickReplyScreen(BaseModel):
|
||||
"""Quick reply screen with questions."""
|
||||
|
||||
header: str | None = None
|
||||
body: str | None = None
|
||||
button: str | None = None
|
||||
header_section: str | None = None
|
||||
preguntas: list[QuickReplyQuestions] = Field(default_factory=list)
|
||||
@@ -1 +0,0 @@
|
||||
{"titulo": "Capsulas"}
|
||||
@@ -1 +0,0 @@
|
||||
{"titulo": "Descubre"}
|
||||
@@ -1 +0,0 @@
|
||||
{"titulo": "Detalle TDC"}
|
||||
@@ -1 +0,0 @@
|
||||
{"titulo": "Detalle TDD"}
|
||||
@@ -1 +0,0 @@
|
||||
{"titulo": "Finanzas"}
|
||||
@@ -1 +0,0 @@
|
||||
{"titulo": "Home"}
|
||||
@@ -1 +0,0 @@
|
||||
{"titulo": "Inversiones"}
|
||||
@@ -1 +0,0 @@
|
||||
{"titulo": "Lealtad"}
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"header": "preguntas frecuentes",
|
||||
"body": "Aquí tienes las preguntas frecuentes que suelen hacernos algunos de nuestros clientes",
|
||||
"button": "Ver",
|
||||
"header_section": "preguntas sobre pagos",
|
||||
"preguntas": [
|
||||
{
|
||||
"titulo": "Donde veo mi historial de pagos?",
|
||||
"descripcion": "View your recent payments",
|
||||
"respuesta": "puedes visualizar esto en la opcion X de tu app"
|
||||
},
|
||||
{
|
||||
"titulo": "Pregunta servicio A",
|
||||
"descripcion": "descripcion servicio A",
|
||||
"respuesta": "puedes ver info de servicio A en tu app"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{"titulo": "Prestamos"}
|
||||
@@ -1 +0,0 @@
|
||||
{"titulo": "Retiro sin tarjeta"}
|
||||
@@ -1 +0,0 @@
|
||||
{"titulo": "Transferencia"}
|
||||
@@ -1,11 +0,0 @@
|
||||
"""Routers module."""
|
||||
|
||||
from .conversation import router as conversation_router
|
||||
from .notification import router as notification_router
|
||||
from .quick_replies import router as quick_replies_router
|
||||
|
||||
__all__ = [
|
||||
"conversation_router",
|
||||
"notification_router",
|
||||
"quick_replies_router",
|
||||
]
|
||||
@@ -1,49 +0,0 @@
|
||||
"""Conversation router for detect-intent endpoints."""
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from capa_de_integracion.dependencies import get_conversation_manager
|
||||
from capa_de_integracion.models import ConversationRequest, DetectIntentResponse
|
||||
from capa_de_integracion.services import ConversationManagerService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/dialogflow", tags=["conversation"])
|
||||
|
||||
|
||||
@router.post("/detect-intent")
|
||||
async def detect_intent(
|
||||
request: ConversationRequest,
|
||||
conversation_manager: Annotated[
|
||||
ConversationManagerService,
|
||||
Depends(
|
||||
get_conversation_manager,
|
||||
),
|
||||
],
|
||||
) -> DetectIntentResponse:
|
||||
"""Detect user intent and manage conversation.
|
||||
|
||||
Args:
|
||||
request: External conversation request from client
|
||||
conversation_manager: Conversation manager service instance
|
||||
|
||||
Returns:
|
||||
Dialogflow detect intent response
|
||||
|
||||
"""
|
||||
try:
|
||||
logger.info("Received detect-intent request")
|
||||
response = await conversation_manager.manage_conversation(request)
|
||||
logger.info("Successfully processed detect-intent request")
|
||||
except ValueError as e:
|
||||
logger.exception("Validation error")
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Error processing detect-intent")
|
||||
raise HTTPException(status_code=500, detail="Internal server error") from e
|
||||
else:
|
||||
return response
|
||||
@@ -1,60 +0,0 @@
|
||||
"""Notification router for processing push notifications."""
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from capa_de_integracion.dependencies import get_notification_manager
|
||||
from capa_de_integracion.models.notification import ExternalNotificationRequest
|
||||
from capa_de_integracion.services import NotificationManagerService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/v1/dialogflow", tags=["notifications"])
|
||||
|
||||
|
||||
@router.post("/notification", status_code=200)
|
||||
async def process_notification(
|
||||
request: ExternalNotificationRequest,
|
||||
notification_manager: Annotated[
|
||||
NotificationManagerService,
|
||||
Depends(
|
||||
get_notification_manager,
|
||||
),
|
||||
],
|
||||
) -> None:
|
||||
"""Process push notification from external system.
|
||||
|
||||
This endpoint receives notifications (e.g., "Your card was blocked") and:
|
||||
1. Stores them in Redis/Firestore
|
||||
2. Associates them with the user's conversation session
|
||||
3. Triggers a Dialogflow event
|
||||
|
||||
When the user later sends a message asking about the notification
|
||||
("Why was it blocked?"), the message filter will classify it as
|
||||
NOTIFICATION and route to the appropriate handler.
|
||||
|
||||
Args:
|
||||
request: External notification request with text, phone, and parameters
|
||||
notification_manager: Notification manager service instance
|
||||
|
||||
Returns:
|
||||
None (200 OK with empty body)
|
||||
|
||||
Raises:
|
||||
HTTPException: 400 if validation fails, 500 for internal errors
|
||||
|
||||
"""
|
||||
try:
|
||||
logger.info("Received notification request")
|
||||
await notification_manager.process_notification(request)
|
||||
logger.info("Successfully processed notification request")
|
||||
# Match Java behavior: process but don't return response body
|
||||
|
||||
except ValueError as e:
|
||||
logger.exception("Validation error")
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Error processing notification")
|
||||
raise HTTPException(status_code=500, detail="Internal server error") from e
|
||||
@@ -1,81 +0,0 @@
|
||||
"""Quick replies router for FAQ session management."""
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from capa_de_integracion.dependencies import (
|
||||
get_quick_reply_session_service,
|
||||
)
|
||||
from capa_de_integracion.models.quick_replies import QuickReplyScreen
|
||||
from capa_de_integracion.services import QuickReplySessionService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/v1/quick-replies", tags=["quick-replies"])
|
||||
|
||||
|
||||
class QuickReplyUser(BaseModel):
|
||||
"""User information for quick reply requests."""
|
||||
|
||||
telefono: str
|
||||
nombre: str
|
||||
|
||||
|
||||
class QuickReplyScreenRequest(BaseModel):
|
||||
"""Request model for quick reply screen."""
|
||||
|
||||
usuario: QuickReplyUser
|
||||
pantalla_contexto: str = Field(alias="pantallaContexto")
|
||||
|
||||
model_config = {"populate_by_name": True}
|
||||
|
||||
|
||||
class QuickReplyScreenResponse(BaseModel):
|
||||
"""Response model for quick reply screen."""
|
||||
|
||||
response_id: str = Field(alias="responseId")
|
||||
quick_replies: QuickReplyScreen
|
||||
|
||||
|
||||
@router.post("/screen")
|
||||
async def start_quick_reply_session(
|
||||
request: QuickReplyScreenRequest,
|
||||
quick_reply_session_service: Annotated[
|
||||
QuickReplySessionService,
|
||||
Depends(get_quick_reply_session_service),
|
||||
],
|
||||
) -> QuickReplyScreenResponse:
|
||||
"""Start a quick reply FAQ session for a specific screen.
|
||||
|
||||
Creates a conversation session with pantalla_contexto set,
|
||||
loads the quick reply questions for the screen, and returns them.
|
||||
|
||||
Args:
|
||||
request: Quick reply screen request
|
||||
quick_reply_session_service: Quick reply session service instance
|
||||
|
||||
Returns:
|
||||
Quick reply screen response with session ID and questions
|
||||
|
||||
"""
|
||||
try:
|
||||
result = await quick_reply_session_service.start_quick_reply_session(
|
||||
telefono=request.usuario.telefono,
|
||||
_nombre=request.usuario.nombre,
|
||||
pantalla_contexto=request.pantalla_contexto,
|
||||
)
|
||||
|
||||
return QuickReplyScreenResponse(
|
||||
responseId=result.session_id,
|
||||
quick_replies=result.quick_replies,
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
logger.exception("Validation error")
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Error starting quick reply session")
|
||||
raise HTTPException(status_code=500, detail="Internal server error") from e
|
||||
@@ -1,15 +0,0 @@
|
||||
"""Services module."""
|
||||
|
||||
from capa_de_integracion.services.conversation import ConversationManagerService
|
||||
from capa_de_integracion.services.dlp import DLPService
|
||||
from capa_de_integracion.services.notifications import NotificationManagerService
|
||||
from capa_de_integracion.services.quick_reply.content import QuickReplyContentService
|
||||
from capa_de_integracion.services.quick_reply.session import QuickReplySessionService
|
||||
|
||||
__all__ = [
|
||||
"ConversationManagerService",
|
||||
"DLPService",
|
||||
"NotificationManagerService",
|
||||
"QuickReplyContentService",
|
||||
"QuickReplySessionService",
|
||||
]
|
||||
@@ -1,598 +0,0 @@
|
||||
"""Conversation manager service for orchestrating user conversations."""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
from capa_de_integracion.config import Settings
|
||||
from capa_de_integracion.models import (
|
||||
ConversationEntry,
|
||||
ConversationRequest,
|
||||
ConversationSession,
|
||||
DetectIntentResponse,
|
||||
QueryResult,
|
||||
)
|
||||
from capa_de_integracion.models.notification import NotificationSession
|
||||
from capa_de_integracion.services.dlp import DLPService
|
||||
from capa_de_integracion.services.quick_reply.content import QuickReplyContentService
|
||||
from capa_de_integracion.services.rag import RAGServiceBase
|
||||
from capa_de_integracion.services.storage.firestore import FirestoreService
|
||||
from capa_de_integracion.services.storage.redis import RedisService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MSG_EMPTY_MESSAGE = "Message cannot be empty"
|
||||
|
||||
|
||||
class ConversationManagerService:
|
||||
"""Central orchestrator for managing user conversations."""
|
||||
|
||||
SESSION_RESET_THRESHOLD_MINUTES = 30
|
||||
SCREEN_CONTEXT_TIMEOUT_MINUTES = 10
|
||||
CONV_HISTORY_PARAM = "conversation_history"
|
||||
HISTORY_PARAM = "historial"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
settings: Settings,
|
||||
rag_service: RAGServiceBase,
|
||||
redis_service: RedisService,
|
||||
firestore_service: FirestoreService,
|
||||
dlp_service: DLPService,
|
||||
) -> None:
|
||||
"""Initialize conversation manager."""
|
||||
self.settings = settings
|
||||
self.rag_service = rag_service
|
||||
self.redis_service = redis_service
|
||||
self.firestore_service = firestore_service
|
||||
self.dlp_service = dlp_service
|
||||
self.quick_reply_service = QuickReplyContentService(settings)
|
||||
|
||||
logger.info("ConversationManagerService initialized successfully")
|
||||
|
||||
def _validate_message(self, mensaje: str) -> None:
|
||||
"""Validate message is not empty.
|
||||
|
||||
Args:
|
||||
mensaje: Message text to validate
|
||||
|
||||
Raises:
|
||||
ValueError: If message is empty or whitespace
|
||||
|
||||
"""
|
||||
if not mensaje or not mensaje.strip():
|
||||
raise ValueError(MSG_EMPTY_MESSAGE)
|
||||
|
||||
async def manage_conversation(
|
||||
self,
|
||||
request: ConversationRequest,
|
||||
) -> DetectIntentResponse:
|
||||
"""Manage conversation flow and return response.
|
||||
|
||||
Orchestrates:
|
||||
1. Validation
|
||||
2. Security (DLP obfuscation)
|
||||
3. Session management
|
||||
4. Quick reply path (if applicable)
|
||||
5. Standard RAG path (fallback)
|
||||
|
||||
Args:
|
||||
request: External conversation request from client
|
||||
|
||||
Returns:
|
||||
Detect intent response from Dialogflow
|
||||
|
||||
"""
|
||||
try:
|
||||
# Step 1: Validate message is not empty
|
||||
self._validate_message(request.mensaje)
|
||||
|
||||
# Step 2: Apply DLP security
|
||||
obfuscated_message = await self.dlp_service.get_obfuscated_string(
|
||||
request.mensaje,
|
||||
self.settings.dlp_template_complete_flow,
|
||||
)
|
||||
request.mensaje = obfuscated_message
|
||||
telefono = request.usuario.telefono
|
||||
|
||||
# Step 3: Obtain or create session
|
||||
session = await self._obtain_or_create_session(telefono)
|
||||
|
||||
# Step 4: Try quick reply path first
|
||||
response = await self._handle_quick_reply_path(request, session)
|
||||
if response:
|
||||
return response
|
||||
|
||||
# Step 5: Fall through to standard conversation path
|
||||
return await self._handle_standard_conversation(request, session)
|
||||
|
||||
except Exception:
|
||||
logger.exception("Error managing conversation")
|
||||
raise
|
||||
|
||||
async def _obtain_or_create_session(self, telefono: str) -> ConversationSession:
|
||||
"""Get existing session or create new one.
|
||||
|
||||
Checks Redis → Firestore → Creates new session with auto-caching.
|
||||
|
||||
Args:
|
||||
telefono: User phone number
|
||||
|
||||
Returns:
|
||||
ConversationSession instance
|
||||
|
||||
"""
|
||||
# Try Redis first
|
||||
session = await self.redis_service.get_session(telefono)
|
||||
if session:
|
||||
return session
|
||||
|
||||
# Try Firestore if Redis miss
|
||||
session = await self.firestore_service.get_session_by_phone(telefono)
|
||||
if session:
|
||||
return session
|
||||
|
||||
# Create new session if both miss
|
||||
session_id = str(uuid4())
|
||||
user_id = f"user_by_phone_{telefono.replace(' ', '').replace('-', '')}"
|
||||
session = await self.firestore_service.create_session(
|
||||
session_id,
|
||||
user_id,
|
||||
telefono,
|
||||
)
|
||||
|
||||
# Auto-cache to Redis
|
||||
await self.redis_service.save_session(session)
|
||||
|
||||
return session
|
||||
|
||||
async def _save_conversation_turn(
|
||||
self,
|
||||
session_id: str,
|
||||
user_text: str,
|
||||
assistant_text: str,
|
||||
entry_type: str,
|
||||
canal: str | None = None,
|
||||
) -> None:
|
||||
"""Save user and assistant messages to Firestore.
|
||||
|
||||
Args:
|
||||
session_id: Session identifier
|
||||
user_text: User message text
|
||||
assistant_text: Assistant response text
|
||||
entry_type: Type of conversation entry ("CONVERSACION" or "LLM")
|
||||
canal: Communication channel
|
||||
|
||||
"""
|
||||
# Save user entry
|
||||
user_entry = ConversationEntry(
|
||||
entity="user",
|
||||
type=entry_type,
|
||||
timestamp=datetime.now(UTC),
|
||||
text=user_text,
|
||||
parameters=None,
|
||||
canal=canal,
|
||||
)
|
||||
await self.firestore_service.save_entry(session_id, user_entry)
|
||||
|
||||
# Save assistant entry
|
||||
assistant_entry = ConversationEntry(
|
||||
entity="assistant",
|
||||
type=entry_type,
|
||||
timestamp=datetime.now(UTC),
|
||||
text=assistant_text,
|
||||
parameters=None,
|
||||
canal=canal,
|
||||
)
|
||||
await self.firestore_service.save_entry(session_id, assistant_entry)
|
||||
|
||||
async def _update_session_after_turn(
|
||||
self,
|
||||
session: ConversationSession,
|
||||
last_message: str,
|
||||
) -> None:
|
||||
"""Update session metadata and sync to storage.
|
||||
|
||||
Updates last_message, last_modified timestamp, and saves to
|
||||
both Firestore and Redis for dual-storage consistency.
|
||||
|
||||
Args:
|
||||
session: Session to update (modified in place)
|
||||
last_message: Latest message text
|
||||
|
||||
"""
|
||||
session.last_message = last_message
|
||||
session.last_modified = datetime.now(UTC)
|
||||
await self.firestore_service.save_session(session)
|
||||
await self.redis_service.save_session(session)
|
||||
|
||||
async def _handle_quick_reply_path(
|
||||
self,
|
||||
request: ConversationRequest,
|
||||
session: ConversationSession,
|
||||
) -> DetectIntentResponse | None:
|
||||
"""Handle conversation when pantalla_contexto is active and valid.
|
||||
|
||||
Args:
|
||||
request: User conversation request
|
||||
session: Current conversation session
|
||||
|
||||
Returns:
|
||||
DetectIntentResponse if handled, None if fall through to standard path
|
||||
|
||||
"""
|
||||
# Check if pantalla_contexto exists
|
||||
if not session.pantalla_contexto:
|
||||
return None
|
||||
|
||||
# Check if pantalla_contexto is stale
|
||||
if not self._is_pantalla_context_valid(session.last_modified):
|
||||
logger.info(
|
||||
"Detected STALE 'pantallaContexto'. "
|
||||
"Ignoring and proceeding with normal flow.",
|
||||
)
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
"Detected 'pantallaContexto' in session: %s. "
|
||||
"Delegating to QuickReplies flow.",
|
||||
session.pantalla_contexto,
|
||||
)
|
||||
|
||||
response = await self._manage_quick_reply_conversation(
|
||||
request,
|
||||
session.pantalla_contexto,
|
||||
)
|
||||
|
||||
if not response:
|
||||
return None
|
||||
|
||||
# Extract response text
|
||||
response_text = (
|
||||
response.query_result.response_text if response.query_result else ""
|
||||
) or ""
|
||||
|
||||
# Save conversation turn
|
||||
await self._save_conversation_turn(
|
||||
session_id=session.session_id,
|
||||
user_text=request.mensaje,
|
||||
assistant_text=response_text,
|
||||
entry_type="CONVERSACION",
|
||||
canal=getattr(request, "canal", None),
|
||||
)
|
||||
|
||||
# Update session
|
||||
await self._update_session_after_turn(session, response_text)
|
||||
|
||||
return response
|
||||
|
||||
async def _handle_standard_conversation(
|
||||
self,
|
||||
request: ConversationRequest,
|
||||
session: ConversationSession,
|
||||
) -> DetectIntentResponse:
|
||||
"""Handle standard RAG-based conversation flow.
|
||||
|
||||
Loads history, notifications, queries RAG service, and persists results.
|
||||
|
||||
Args:
|
||||
request: User conversation request
|
||||
session: Current conversation session
|
||||
|
||||
Returns:
|
||||
DetectIntentResponse with RAG response
|
||||
|
||||
"""
|
||||
telefono = request.usuario.telefono
|
||||
nickname = request.usuario.nickname
|
||||
|
||||
logger.info(
|
||||
"Primary Check (Redis): Looking up session for phone: %s",
|
||||
telefono,
|
||||
)
|
||||
|
||||
# Load conversation history only if session is older than threshold
|
||||
# (optimization: new/recent sessions don't need history context)
|
||||
session_age = datetime.now(UTC) - session.created_at
|
||||
if session_age > timedelta(minutes=self.SESSION_RESET_THRESHOLD_MINUTES):
|
||||
entries = await self.firestore_service.get_entries(
|
||||
session.session_id,
|
||||
limit=self.settings.conversation_context_message_limit,
|
||||
)
|
||||
logger.info(
|
||||
"Session is %s minutes old. Loaded %s conversation entries.",
|
||||
session_age.total_seconds() / 60,
|
||||
len(entries),
|
||||
)
|
||||
else:
|
||||
entries = []
|
||||
logger.info(
|
||||
"Session is only %s minutes old. Skipping history load.",
|
||||
session_age.total_seconds() / 60,
|
||||
)
|
||||
|
||||
# Retrieve active notifications for this user
|
||||
notifications = await self._get_active_notifications(telefono)
|
||||
logger.info("Retrieved %s active notifications", len(notifications))
|
||||
|
||||
# Prepare current user message
|
||||
messages = await self._prepare_rag_messages(request.mensaje)
|
||||
|
||||
# Extract notification texts for RAG
|
||||
notification_texts = (
|
||||
[n.texto for n in notifications if n.texto and n.texto.strip()]
|
||||
if notifications
|
||||
else None
|
||||
)
|
||||
|
||||
# Format conversation history for RAG
|
||||
conversation_history = (
|
||||
self._format_conversation_history(session, entries) if entries else None
|
||||
)
|
||||
|
||||
# Query RAG service with separated fields
|
||||
logger.info("Sending query to RAG service")
|
||||
assistant_response = await self.rag_service.query(
|
||||
messages=messages,
|
||||
notifications=notification_texts,
|
||||
conversation_history=conversation_history,
|
||||
user_nickname=nickname or None,
|
||||
)
|
||||
logger.info(
|
||||
"Received response from RAG service: %s...",
|
||||
assistant_response[:100],
|
||||
)
|
||||
|
||||
# Save conversation turn
|
||||
await self._save_conversation_turn(
|
||||
session_id=session.session_id,
|
||||
user_text=request.mensaje,
|
||||
assistant_text=assistant_response,
|
||||
entry_type="LLM",
|
||||
canal=getattr(request, "canal", None),
|
||||
)
|
||||
logger.info("Saved user message and assistant response to Firestore")
|
||||
|
||||
# Update session
|
||||
await self._update_session_after_turn(session, assistant_response)
|
||||
logger.info("Updated session in Firestore and Redis")
|
||||
|
||||
# Mark notifications as processed if any were included
|
||||
if notifications:
|
||||
await self._mark_notifications_as_processed(telefono)
|
||||
logger.info("Marked %s notifications as processed", len(notifications))
|
||||
|
||||
# Return response object
|
||||
return DetectIntentResponse(
|
||||
responseId=str(uuid4()),
|
||||
queryResult=QueryResult(
|
||||
responseText=assistant_response,
|
||||
parameters=None,
|
||||
),
|
||||
quick_replies=None,
|
||||
)
|
||||
|
||||
def _is_pantalla_context_valid(self, last_modified: datetime) -> bool:
|
||||
"""Check if pantallaContexto is still valid (not stale)."""
|
||||
time_diff = datetime.now(UTC) - last_modified
|
||||
return time_diff < timedelta(minutes=self.SCREEN_CONTEXT_TIMEOUT_MINUTES)
|
||||
|
||||
async def _manage_quick_reply_conversation(
|
||||
self,
|
||||
request: ConversationRequest,
|
||||
screen_id: str,
|
||||
) -> DetectIntentResponse | None:
|
||||
"""Handle conversation within Quick Replies context."""
|
||||
quick_reply_screen = await self.quick_reply_service.get_quick_replies(screen_id)
|
||||
|
||||
# If no questions available, delegate to normal conversation flow
|
||||
if not quick_reply_screen.preguntas:
|
||||
logger.warning("No quick replies found for screen: %s.", screen_id)
|
||||
return None
|
||||
|
||||
# Match user message to a quick reply question
|
||||
user_message_lower = request.mensaje.lower().strip()
|
||||
matched_answer = None
|
||||
|
||||
for pregunta in quick_reply_screen.preguntas:
|
||||
# Simple matching: check if question title matches user message
|
||||
if pregunta.titulo.lower().strip() == user_message_lower:
|
||||
matched_answer = pregunta.respuesta
|
||||
logger.info("Matched quick reply: %s", pregunta.titulo)
|
||||
break
|
||||
|
||||
# If no match, delegate to normal flow
|
||||
if not matched_answer:
|
||||
logger.warning(
|
||||
"No matching quick reply found for message: '%s'. Falling back to RAG.",
|
||||
request.mensaje,
|
||||
)
|
||||
return None
|
||||
|
||||
# Create response with the matched quick reply answer
|
||||
return DetectIntentResponse(
|
||||
responseId=str(uuid4()),
|
||||
queryResult=QueryResult(responseText=matched_answer, parameters=None),
|
||||
quick_replies=quick_reply_screen,
|
||||
)
|
||||
|
||||
async def _get_active_notifications(self, telefono: str) -> list:
|
||||
"""Retrieve active notifications for a user from Redis or Firestore.
|
||||
|
||||
Args:
|
||||
telefono: User phone number
|
||||
|
||||
Returns:
|
||||
List of active Notification objects
|
||||
|
||||
"""
|
||||
try:
|
||||
# Try Redis first
|
||||
notification_session = await self.redis_service.get_notification_session(
|
||||
telefono,
|
||||
)
|
||||
|
||||
# If not in Redis, try Firestore
|
||||
if not notification_session:
|
||||
# Firestore uses phone as document ID for notifications
|
||||
doc_ref = self.firestore_service.db.collection(
|
||||
self.firestore_service.notifications_collection,
|
||||
).document(telefono)
|
||||
doc = await doc_ref.get()
|
||||
|
||||
if doc.exists:
|
||||
data = doc.to_dict()
|
||||
notification_session = NotificationSession.model_validate(data)
|
||||
|
||||
# Filter for active notifications only
|
||||
if notification_session and notification_session.notificaciones:
|
||||
active_notifications = [
|
||||
notif
|
||||
for notif in notification_session.notificaciones
|
||||
if notif.status == "active"
|
||||
]
|
||||
else:
|
||||
active_notifications = []
|
||||
|
||||
except Exception:
|
||||
logger.exception("Error retrieving notifications for %s", telefono)
|
||||
return []
|
||||
else:
|
||||
return active_notifications
|
||||
|
||||
async def _prepare_rag_messages(
|
||||
self,
|
||||
user_message: str,
|
||||
) -> list[dict[str, str]]:
|
||||
"""Prepare current user message for RAG service.
|
||||
|
||||
Args:
|
||||
user_message: Current user message
|
||||
|
||||
Returns:
|
||||
List with single user message
|
||||
|
||||
"""
|
||||
# Only include the current user message - no system messages
|
||||
return [{"role": "user", "content": user_message}]
|
||||
|
||||
async def _mark_notifications_as_processed(self, telefono: str) -> None:
|
||||
"""Mark all notifications for a user as processed.
|
||||
|
||||
Args:
|
||||
telefono: User phone number
|
||||
|
||||
"""
|
||||
try:
|
||||
# Update status in Firestore
|
||||
await self.firestore_service.update_notification_status(
|
||||
telefono,
|
||||
"processed",
|
||||
)
|
||||
|
||||
# Update or delete from Redis
|
||||
await self.redis_service.delete_notification_session(telefono)
|
||||
|
||||
logger.info("Marked notifications as processed for %s", telefono)
|
||||
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Error marking notifications as processed for %s",
|
||||
telefono,
|
||||
)
|
||||
|
||||
def _format_conversation_history(
|
||||
self,
|
||||
session: ConversationSession, # noqa: ARG002
|
||||
entries: list[ConversationEntry],
|
||||
) -> str:
|
||||
"""Format conversation history with business rule limits.
|
||||
|
||||
Applies limits:
|
||||
- Date: 30 days maximum
|
||||
- Count: 60 messages maximum
|
||||
- Size: 50KB maximum
|
||||
|
||||
Args:
|
||||
session: Conversation session
|
||||
entries: List of conversation entries
|
||||
|
||||
Returns:
|
||||
Formatted conversation text
|
||||
|
||||
"""
|
||||
if not entries:
|
||||
return ""
|
||||
|
||||
# Filter by date (30 days)
|
||||
cutoff_date = datetime.now(UTC) - timedelta(
|
||||
days=self.settings.conversation_context_days_limit,
|
||||
)
|
||||
recent_entries = [
|
||||
e for e in entries if e.timestamp and e.timestamp >= cutoff_date
|
||||
]
|
||||
|
||||
# Sort by timestamp (oldest first) and limit count
|
||||
recent_entries.sort(key=lambda e: e.timestamp)
|
||||
limited_entries = recent_entries[
|
||||
-self.settings.conversation_context_message_limit :
|
||||
]
|
||||
|
||||
# Format with size truncation (50KB)
|
||||
return self._format_entries_with_size_limit(limited_entries)
|
||||
|
||||
def _format_entries_with_size_limit(self, entries: list[ConversationEntry]) -> str:
|
||||
"""Format entries with 50KB size limit.
|
||||
|
||||
Builds from newest to oldest, stopping at size limit.
|
||||
|
||||
Args:
|
||||
entries: List of conversation entries
|
||||
|
||||
Returns:
|
||||
Formatted text, truncated if necessary
|
||||
|
||||
"""
|
||||
if not entries:
|
||||
return ""
|
||||
|
||||
max_bytes = 50 * 1024 # 50KB
|
||||
formatted_messages = [self._format_entry(entry) for entry in entries]
|
||||
|
||||
# Build from newest to oldest
|
||||
text_block = []
|
||||
current_size = 0
|
||||
|
||||
for message in reversed(formatted_messages):
|
||||
message_line = message + "\n"
|
||||
message_bytes = len(message_line.encode("utf-8"))
|
||||
|
||||
if current_size + message_bytes > max_bytes:
|
||||
break
|
||||
|
||||
text_block.insert(0, message_line)
|
||||
current_size += message_bytes
|
||||
|
||||
return "".join(text_block).strip()
|
||||
|
||||
def _format_entry(self, entry: ConversationEntry) -> str:
|
||||
"""Format a single conversation entry.
|
||||
|
||||
Args:
|
||||
entry: Conversation entry
|
||||
|
||||
Returns:
|
||||
Formatted string (e.g., "User: hello", "Assistant: hi there")
|
||||
|
||||
"""
|
||||
# Map entity to prefix (fixed bug from Java port!)
|
||||
prefix = "User: " if entry.entity == "user" else "Assistant: "
|
||||
|
||||
# Clean content if needed
|
||||
content = entry.text
|
||||
if entry.entity == "assistant":
|
||||
# Remove trailing JSON artifacts like {...}
|
||||
content = re.sub(r"\s*\{.*\}\s*$", "", content).strip()
|
||||
|
||||
return prefix + content
|
||||
@@ -1,205 +0,0 @@
|
||||
"""DLP service for detecting and obfuscating sensitive data."""
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
from google.cloud import dlp_v2
|
||||
from google.cloud.dlp_v2 import types
|
||||
|
||||
from capa_de_integracion.config import Settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# DLP likelihood threshold for filtering findings
|
||||
LIKELIHOOD_THRESHOLD = 3 # POSSIBLE (values: 0=VERY_UNLIKELY to 5=VERY_LIKELY)
|
||||
# Minimum length for last 4 characters extraction
|
||||
MIN_LENGTH_FOR_LAST_FOUR = 4
|
||||
|
||||
|
||||
class DLPService:
|
||||
"""Service for detecting and obfuscating sensitive data using Google Cloud DLP.
|
||||
|
||||
Integrates with the DLP API to scan text for PII and other sensitive information,
|
||||
then obfuscates findings based on their info type.
|
||||
"""
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
"""Initialize DLP service.
|
||||
|
||||
Args:
|
||||
settings: Application settings
|
||||
|
||||
"""
|
||||
self.settings = settings
|
||||
self.project_id = settings.gcp_project_id
|
||||
self.location = settings.gcp_location
|
||||
self._dlp_client: dlp_v2.DlpServiceAsyncClient | None = None
|
||||
|
||||
logger.info("DLP Service initialized")
|
||||
|
||||
@property
|
||||
def dlp_client(self) -> dlp_v2.DlpServiceAsyncClient:
|
||||
"""Lazily create the async DLP client (requires a running event loop)."""
|
||||
if self._dlp_client is None:
|
||||
self._dlp_client = dlp_v2.DlpServiceAsyncClient()
|
||||
return self._dlp_client
|
||||
|
||||
async def get_obfuscated_string(self, text: str, template_id: str) -> str:
|
||||
"""Inspect text for sensitive data and obfuscate findings.
|
||||
|
||||
Args:
|
||||
text: Text to inspect and obfuscate
|
||||
template_id: DLP inspect template ID
|
||||
|
||||
Returns:
|
||||
Obfuscated text with sensitive data replaced
|
||||
|
||||
Raises:
|
||||
Exception: If DLP API call fails (returns original text on error)
|
||||
|
||||
"""
|
||||
try:
|
||||
# Build content item
|
||||
byte_content_item = types.ByteContentItem(
|
||||
type_=types.ByteContentItem.BytesType.TEXT_UTF8,
|
||||
data=text.encode("utf-8"),
|
||||
)
|
||||
content_item = types.ContentItem(byte_item=byte_content_item)
|
||||
|
||||
# Build inspect config
|
||||
finding_limits = types.InspectConfig.FindingLimits(
|
||||
max_findings_per_item=0, # No limit
|
||||
)
|
||||
|
||||
inspect_config = types.InspectConfig(
|
||||
min_likelihood=types.Likelihood.VERY_UNLIKELY,
|
||||
limits=finding_limits,
|
||||
include_quote=True,
|
||||
)
|
||||
|
||||
# Build request
|
||||
inspect_template_name = (
|
||||
f"projects/{self.project_id}/locations/{self.location}/"
|
||||
f"inspectTemplates/{template_id}"
|
||||
)
|
||||
parent = f"projects/{self.project_id}/locations/{self.location}"
|
||||
|
||||
request = types.InspectContentRequest(
|
||||
parent=parent,
|
||||
inspect_template_name=inspect_template_name,
|
||||
inspect_config=inspect_config,
|
||||
item=content_item,
|
||||
)
|
||||
|
||||
# Call DLP API
|
||||
response = await self.dlp_client.inspect_content(request=request)
|
||||
|
||||
findings_count = len(response.result.findings)
|
||||
logger.info("DLP %s Findings: %s", template_id, findings_count)
|
||||
|
||||
if findings_count > 0:
|
||||
obfuscated_text = self._obfuscate_text(response, text)
|
||||
else:
|
||||
obfuscated_text = text
|
||||
|
||||
except Exception:
|
||||
logger.warning("DLP inspection failed. Returning original text.")
|
||||
return text
|
||||
else:
|
||||
return obfuscated_text
|
||||
|
||||
def _obfuscate_text(self, response: types.InspectContentResponse, text: str) -> str:
|
||||
"""Obfuscate sensitive findings in text.
|
||||
|
||||
Args:
|
||||
response: DLP inspect content response with findings
|
||||
text: Original text
|
||||
|
||||
Returns:
|
||||
Text with sensitive data obfuscated
|
||||
|
||||
"""
|
||||
# Filter findings by likelihood (> POSSIBLE)
|
||||
findings = [
|
||||
finding
|
||||
for finding in response.result.findings
|
||||
if finding.likelihood.value > LIKELIHOOD_THRESHOLD
|
||||
]
|
||||
|
||||
# Sort by likelihood (descending)
|
||||
findings.sort(key=lambda f: f.likelihood.value, reverse=True)
|
||||
|
||||
for finding in findings:
|
||||
quote = finding.quote
|
||||
info_type = finding.info_type.name
|
||||
|
||||
logger.info(
|
||||
"InfoType: %s | Likelihood: %s",
|
||||
info_type,
|
||||
finding.likelihood.value,
|
||||
)
|
||||
|
||||
# Obfuscate based on info type
|
||||
replacement = self._get_replacement(info_type, quote)
|
||||
if replacement:
|
||||
text = text.replace(quote, replacement)
|
||||
|
||||
# Clean up consecutive DIRECCION tags
|
||||
return self._clean_direccion(text)
|
||||
|
||||
def _get_replacement(self, info_type: str, quote: str) -> str | None:
|
||||
"""Get replacement text for a given info type.
|
||||
|
||||
Args:
|
||||
info_type: DLP info type name
|
||||
quote: Original sensitive text
|
||||
|
||||
Returns:
|
||||
Replacement text or None to skip
|
||||
|
||||
"""
|
||||
replacements = {
|
||||
"CREDIT_CARD_NUMBER": f"**** **** **** {self._get_last4(quote)}",
|
||||
"CREDIT_CARD_EXPIRATION_DATE": "[FECHA_VENCIMIENTO_TARJETA]",
|
||||
"FECHA_VENCIMIENTO": "[FECHA_VENCIMIENTO_TARJETA]",
|
||||
"CVV_NUMBER": "[CVV]",
|
||||
"CVV": "[CVV]",
|
||||
"EMAIL_ADDRESS": "[CORREO]",
|
||||
"PERSON_NAME": "[NOMBRE]",
|
||||
"PHONE_NUMBER": "[TELEFONO]",
|
||||
"DIRECCION": "[DIRECCION]",
|
||||
"DIR_COLONIA": "[DIRECCION]",
|
||||
"DIR_DEL_MUN": "[DIRECCION]",
|
||||
"DIR_INTERIOR": "[DIRECCION]",
|
||||
"DIR_ESQUINA": "[DIRECCION]",
|
||||
"DIR_CIUDAD_EDO": "[DIRECCION]",
|
||||
"DIR_CP": "[DIRECCION]",
|
||||
"CLABE_INTERBANCARIA": "[CLABE]",
|
||||
"CLAVE_RASTREO_SPEI": "[CLAVE_RASTREO]",
|
||||
"NIP": "[NIP]",
|
||||
"SALDO": "[SALDO]",
|
||||
"CUENTA": f"**************{self._get_last4(quote)}",
|
||||
"NUM_ACLARACION": "[NUM_ACLARACION]",
|
||||
}
|
||||
|
||||
return replacements.get(info_type)
|
||||
|
||||
def _get_last4(self, quote: str) -> str:
|
||||
"""Extract last 4 characters from quote (removing spaces)."""
|
||||
clean_quote = quote.strip().replace(" ", "")
|
||||
if len(clean_quote) >= MIN_LENGTH_FOR_LAST_FOUR:
|
||||
return clean_quote[-4:]
|
||||
return clean_quote
|
||||
|
||||
def _clean_direccion(self, text: str) -> str:
|
||||
"""Clean up consecutive [DIRECCION] tags.
|
||||
|
||||
Replace multiple [DIRECCION] tags separated by commas or spaces.
|
||||
"""
|
||||
pattern = r"\[DIRECCION\](?:(?:,\s*|\s+)\[DIRECCION\])*"
|
||||
return re.sub(pattern, "[DIRECCION]", text).strip()
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close DLP client."""
|
||||
await self.dlp_client.transport.close()
|
||||
logger.info("DLP client closed")
|
||||
@@ -1,137 +0,0 @@
|
||||
"""Notification manager service for processing push notifications."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from uuid import uuid4
|
||||
|
||||
from capa_de_integracion.config import Settings
|
||||
from capa_de_integracion.models.notification import (
|
||||
ExternalNotificationRequest,
|
||||
Notification,
|
||||
)
|
||||
from capa_de_integracion.services.dlp import DLPService
|
||||
from capa_de_integracion.services.storage.firestore import FirestoreService
|
||||
from capa_de_integracion.services.storage.redis import RedisService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PREFIX_PO_PARAM = "notification_po_"
|
||||
|
||||
# Keep references to background tasks to prevent garbage collection
|
||||
_background_tasks: set[asyncio.Task] = set()
|
||||
|
||||
|
||||
class NotificationManagerService:
|
||||
"""Manages notification processing and integration with conversations.
|
||||
|
||||
Handles push notifications from external systems, stores them in
|
||||
Redis/Firestore, and triggers Dialogflow event detection.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
settings: Settings,
|
||||
redis_service: RedisService,
|
||||
firestore_service: FirestoreService,
|
||||
dlp_service: DLPService,
|
||||
) -> None:
|
||||
"""Initialize notification manager.
|
||||
|
||||
Args:
|
||||
settings: Application settings
|
||||
dialogflow_client: Dialogflow CX client
|
||||
redis_service: Redis caching service
|
||||
firestore_service: Firestore persistence service
|
||||
dlp_service: Data Loss Prevention service
|
||||
|
||||
"""
|
||||
self.settings = settings
|
||||
self.redis_service = redis_service
|
||||
self.firestore_service = firestore_service
|
||||
self.dlp_service = dlp_service
|
||||
self.event_name = "notificacion"
|
||||
self.default_language_code = "es"
|
||||
|
||||
logger.info("NotificationManagerService initialized")
|
||||
|
||||
async def process_notification(
|
||||
self,
|
||||
external_request: ExternalNotificationRequest,
|
||||
) -> None:
|
||||
"""Process a push notification from external system.
|
||||
|
||||
Flow:
|
||||
1. Validate phone number
|
||||
2. Obfuscate sensitive data (DLP - TODO)
|
||||
3. Create notification entry
|
||||
4. Save to Redis and Firestore
|
||||
5. Get or create conversation session
|
||||
6. Add notification to conversation history
|
||||
7. Trigger Dialogflow event
|
||||
|
||||
Args:
|
||||
external_request: External notification request
|
||||
|
||||
Returns:
|
||||
Dialogflow detect intent response
|
||||
|
||||
Raises:
|
||||
ValueError: If phone number is missing
|
||||
|
||||
"""
|
||||
telefono = external_request.telefono
|
||||
|
||||
# Obfuscate sensitive data using DLP
|
||||
obfuscated_text = await self.dlp_service.get_obfuscated_string(
|
||||
external_request.texto,
|
||||
self.settings.dlp_template_complete_flow,
|
||||
)
|
||||
|
||||
# Prepare parameters with prefix
|
||||
parameters = {}
|
||||
if external_request.parametros_ocultos:
|
||||
for key, value in external_request.parametros_ocultos.items():
|
||||
parameters[f"{PREFIX_PO_PARAM}{key}"] = value
|
||||
|
||||
# Create notification entry
|
||||
new_notification_id = str(uuid4())
|
||||
new_notification_entry = Notification.create(
|
||||
id_notificacion=new_notification_id,
|
||||
telefono=telefono,
|
||||
texto=obfuscated_text,
|
||||
nombre_evento_dialogflow=self.event_name,
|
||||
codigo_idioma_dialogflow=self.default_language_code,
|
||||
parametros=parameters,
|
||||
status="active",
|
||||
)
|
||||
|
||||
# Save notification to Redis (with async Firestore write-back)
|
||||
await self.redis_service.save_or_append_notification(new_notification_entry)
|
||||
logger.info(
|
||||
"Notification for phone %s cached. Kicking off async Firestore write-back",
|
||||
telefono,
|
||||
)
|
||||
|
||||
# Fire-and-forget Firestore write (matching Java's .subscribe() behavior)
|
||||
async def save_notification_to_firestore() -> None:
|
||||
try:
|
||||
await self.firestore_service.save_or_append_notification(
|
||||
new_notification_entry,
|
||||
)
|
||||
logger.debug(
|
||||
"Notification entry persisted to Firestore for phone %s",
|
||||
telefono,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Background: Error during notification persistence "
|
||||
"to Firestore for phone %s",
|
||||
telefono,
|
||||
)
|
||||
|
||||
# Fire and forget - don't await
|
||||
task = asyncio.create_task(save_notification_to_firestore())
|
||||
# Store reference to prevent premature garbage collection
|
||||
_background_tasks.add(task)
|
||||
# Remove from set when done to prevent memory leak
|
||||
task.add_done_callback(_background_tasks.discard)
|
||||
@@ -1,9 +0,0 @@
|
||||
"""Quick reply services."""
|
||||
|
||||
from capa_de_integracion.services.quick_reply.content import QuickReplyContentService
|
||||
from capa_de_integracion.services.quick_reply.session import QuickReplySessionService
|
||||
|
||||
__all__ = [
|
||||
"QuickReplyContentService",
|
||||
"QuickReplySessionService",
|
||||
]
|
||||
@@ -1,166 +0,0 @@
|
||||
"""Quick reply content service for loading FAQ screens."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from capa_de_integracion.config import Settings
|
||||
from capa_de_integracion.models.quick_replies import (
|
||||
QuickReplyQuestions,
|
||||
QuickReplyScreen,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class QuickReplyContentService:
|
||||
"""Service for loading quick reply screen content from JSON files."""
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
"""Initialize quick reply content service.
|
||||
|
||||
Args:
|
||||
settings: Application settings
|
||||
|
||||
"""
|
||||
self.settings = settings
|
||||
self.quick_replies_path = settings.base_path / "quick_replies"
|
||||
self._cache: dict[str, QuickReplyScreen] = {}
|
||||
|
||||
logger.info(
|
||||
"QuickReplyContentService initialized with path: %s",
|
||||
self.quick_replies_path,
|
||||
)
|
||||
|
||||
# Preload all quick reply files into memory
|
||||
self._preload_cache()
|
||||
|
||||
def _validate_file(self, file_path: Path, screen_id: str) -> None:
|
||||
"""Validate that the quick reply file exists."""
|
||||
if not file_path.exists():
|
||||
logger.warning("Quick reply file not found: %s", file_path)
|
||||
msg = f"Quick reply file not found for screen_id: {screen_id}"
|
||||
raise ValueError(msg)
|
||||
|
||||
def _parse_quick_reply_data(self, data: dict) -> QuickReplyScreen:
|
||||
"""Parse JSON data into QuickReplyScreen model.
|
||||
|
||||
Args:
|
||||
data: JSON data dictionary
|
||||
|
||||
Returns:
|
||||
Parsed QuickReplyScreen object
|
||||
|
||||
"""
|
||||
preguntas_data = data.get("preguntas", [])
|
||||
preguntas = [
|
||||
QuickReplyQuestions(
|
||||
titulo=q.get("titulo", ""),
|
||||
descripcion=q.get("descripcion"),
|
||||
respuesta=q.get("respuesta", ""),
|
||||
)
|
||||
for q in preguntas_data
|
||||
]
|
||||
|
||||
return QuickReplyScreen(
|
||||
header=data.get("header"),
|
||||
body=data.get("body"),
|
||||
button=data.get("button"),
|
||||
header_section=data.get("header_section"),
|
||||
preguntas=preguntas,
|
||||
)
|
||||
|
||||
def _preload_cache(self) -> None:
|
||||
"""Preload all quick reply files into memory cache at startup.
|
||||
|
||||
This method runs synchronously at initialization to load all
|
||||
quick reply JSON files. Blocking here is acceptable since it
|
||||
only happens once at startup.
|
||||
|
||||
"""
|
||||
if not self.quick_replies_path.exists():
|
||||
logger.warning(
|
||||
"Quick replies directory not found: %s",
|
||||
self.quick_replies_path,
|
||||
)
|
||||
return
|
||||
|
||||
loaded_count = 0
|
||||
failed_count = 0
|
||||
|
||||
for file_path in self.quick_replies_path.glob("*.json"):
|
||||
screen_id = file_path.stem
|
||||
try:
|
||||
# Blocking I/O is OK at startup
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
data = json.loads(content)
|
||||
quick_reply = self._parse_quick_reply_data(data)
|
||||
|
||||
self._cache[screen_id] = quick_reply
|
||||
loaded_count += 1
|
||||
|
||||
logger.debug(
|
||||
"Cached %s quick replies for screen: %s",
|
||||
len(quick_reply.preguntas),
|
||||
screen_id,
|
||||
)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
logger.exception("Invalid JSON in file: %s", file_path)
|
||||
failed_count += 1
|
||||
except Exception:
|
||||
logger.exception("Failed to load quick reply file: %s", file_path)
|
||||
failed_count += 1
|
||||
|
||||
logger.info(
|
||||
"Quick reply cache initialized: %s screens loaded, %s failed",
|
||||
loaded_count,
|
||||
failed_count,
|
||||
)
|
||||
|
||||
async def get_quick_replies(self, screen_id: str) -> QuickReplyScreen:
|
||||
"""Get quick reply screen content by ID from in-memory cache.
|
||||
|
||||
This method is non-blocking as it retrieves data from the
|
||||
in-memory cache populated at startup.
|
||||
|
||||
Args:
|
||||
screen_id: Screen identifier (e.g., "pagos", "home")
|
||||
|
||||
Returns:
|
||||
Quick reply screen data
|
||||
|
||||
Raises:
|
||||
ValueError: If the quick reply is not found in cache
|
||||
|
||||
"""
|
||||
if not screen_id or not screen_id.strip():
|
||||
logger.warning("screen_id is null or empty. Returning empty quick replies")
|
||||
return QuickReplyScreen(
|
||||
header="empty",
|
||||
body=None,
|
||||
button=None,
|
||||
header_section=None,
|
||||
preguntas=[],
|
||||
)
|
||||
|
||||
# Non-blocking: just a dictionary lookup
|
||||
quick_reply = self._cache.get(screen_id)
|
||||
|
||||
if quick_reply is None:
|
||||
logger.warning("Quick reply not found in cache for screen: %s", screen_id)
|
||||
return QuickReplyScreen(
|
||||
header=None,
|
||||
body=None,
|
||||
button=None,
|
||||
header_section=None,
|
||||
preguntas=[],
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Retrieved %s quick replies for screen: %s from cache",
|
||||
len(quick_reply.preguntas),
|
||||
screen_id,
|
||||
)
|
||||
|
||||
return quick_reply
|
||||
@@ -1,128 +0,0 @@
|
||||
"""Quick reply session service for managing FAQ sessions."""
|
||||
|
||||
import logging
|
||||
from uuid import uuid4
|
||||
|
||||
from capa_de_integracion.models.quick_replies import QuickReplyScreen
|
||||
from capa_de_integracion.services.quick_reply.content import QuickReplyContentService
|
||||
from capa_de_integracion.services.storage.firestore import FirestoreService
|
||||
from capa_de_integracion.services.storage.redis import RedisService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class QuickReplySessionResponse:
|
||||
"""Response from quick reply session service."""
|
||||
|
||||
def __init__(self, session_id: str, quick_replies: QuickReplyScreen) -> None:
|
||||
"""Initialize response.
|
||||
|
||||
Args:
|
||||
session_id: The session ID
|
||||
quick_replies: The quick reply screen data
|
||||
|
||||
"""
|
||||
self.session_id = session_id
|
||||
self.quick_replies = quick_replies
|
||||
|
||||
|
||||
class QuickReplySessionService:
|
||||
"""Service for managing quick reply FAQ sessions."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
redis_service: RedisService,
|
||||
firestore_service: FirestoreService,
|
||||
quick_reply_content_service: QuickReplyContentService,
|
||||
) -> None:
|
||||
"""Initialize quick reply session service.
|
||||
|
||||
Args:
|
||||
redis_service: Redis service instance
|
||||
firestore_service: Firestore service instance
|
||||
quick_reply_content_service: Quick reply content service instance
|
||||
|
||||
"""
|
||||
self.redis_service = redis_service
|
||||
self.firestore_service = firestore_service
|
||||
self.quick_reply_content_service = quick_reply_content_service
|
||||
|
||||
def _validate_phone(self, phone: str) -> None:
|
||||
"""Validate phone number.
|
||||
|
||||
Args:
|
||||
phone: Phone number to validate
|
||||
|
||||
Raises:
|
||||
ValueError: If phone is empty or invalid
|
||||
|
||||
"""
|
||||
if not phone or not phone.strip():
|
||||
msg = "Phone number is required"
|
||||
raise ValueError(msg)
|
||||
|
||||
async def start_quick_reply_session(
|
||||
self,
|
||||
telefono: str,
|
||||
_nombre: str,
|
||||
pantalla_contexto: str,
|
||||
) -> QuickReplySessionResponse:
|
||||
"""Start a quick reply FAQ session for a specific screen.
|
||||
|
||||
Creates or updates a conversation session with pantalla_contexto set,
|
||||
loads the quick reply questions for the screen, and returns them.
|
||||
|
||||
Args:
|
||||
telefono: User's phone number
|
||||
_nombre: User's name (currently unused but part of API contract)
|
||||
pantalla_contexto: Screen context identifier
|
||||
|
||||
Returns:
|
||||
Quick reply session response with session ID and quick replies
|
||||
|
||||
Raises:
|
||||
ValueError: If validation fails or data is invalid
|
||||
Exception: If there's an error creating session or loading content
|
||||
|
||||
"""
|
||||
self._validate_phone(telefono)
|
||||
|
||||
# Get or create session (check Redis first for consistency)
|
||||
session = await self.redis_service.get_session(telefono)
|
||||
if not session:
|
||||
session = await self.firestore_service.get_session_by_phone(telefono)
|
||||
|
||||
if session:
|
||||
session_id = session.session_id
|
||||
await self.firestore_service.update_pantalla_contexto(
|
||||
session_id,
|
||||
pantalla_contexto,
|
||||
)
|
||||
session.pantalla_contexto = pantalla_contexto
|
||||
else:
|
||||
session_id = str(uuid4())
|
||||
user_id = f"user_by_phone_{telefono.replace(' ', '').replace('-', '')}"
|
||||
session = await self.firestore_service.create_session(
|
||||
session_id,
|
||||
user_id,
|
||||
telefono,
|
||||
pantalla_contexto,
|
||||
)
|
||||
|
||||
# Cache session in Redis
|
||||
await self.redis_service.save_session(session)
|
||||
logger.info(
|
||||
"Created quick reply session %s for screen: %s",
|
||||
session_id,
|
||||
pantalla_contexto,
|
||||
)
|
||||
|
||||
# Load quick replies for the screen
|
||||
quick_replies = await self.quick_reply_content_service.get_quick_replies(
|
||||
pantalla_contexto,
|
||||
)
|
||||
|
||||
return QuickReplySessionResponse(
|
||||
session_id=session_id,
|
||||
quick_replies=quick_replies,
|
||||
)
|
||||
@@ -1,19 +0,0 @@
|
||||
"""RAG service implementations."""
|
||||
|
||||
from capa_de_integracion.services.rag.base import (
|
||||
Message,
|
||||
RAGRequest,
|
||||
RAGResponse,
|
||||
RAGServiceBase,
|
||||
)
|
||||
from capa_de_integracion.services.rag.echo import EchoRAGService
|
||||
from capa_de_integracion.services.rag.http import HTTPRAGService
|
||||
|
||||
__all__ = [
|
||||
"EchoRAGService",
|
||||
"HTTPRAGService",
|
||||
"Message",
|
||||
"RAGRequest",
|
||||
"RAGResponse",
|
||||
"RAGServiceBase",
|
||||
]
|
||||
@@ -1,93 +0,0 @@
|
||||
"""Base RAG service interface."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from types import TracebackType
|
||||
from typing import Self
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class Message(BaseModel):
|
||||
"""OpenAI-style message format."""
|
||||
|
||||
role: str = Field(..., description="Role: system, user, or assistant")
|
||||
content: str = Field(..., description="Message content")
|
||||
|
||||
|
||||
class RAGRequest(BaseModel):
|
||||
"""Request model for RAG endpoint."""
|
||||
|
||||
messages: list[Message] = Field(
|
||||
...,
|
||||
description="Current conversation messages (user and assistant only)",
|
||||
)
|
||||
notifications: list[str] | None = Field(
|
||||
default=None,
|
||||
description="Active notifications for the user",
|
||||
)
|
||||
conversation_history: str | None = Field(
|
||||
default=None,
|
||||
description="Formatted conversation history",
|
||||
)
|
||||
user_nickname: str | None = Field(
|
||||
default=None,
|
||||
description="User's nickname or display name",
|
||||
)
|
||||
|
||||
|
||||
class RAGResponse(BaseModel):
|
||||
"""Response model from RAG endpoint."""
|
||||
|
||||
response: str = Field(..., description="Generated response from RAG")
|
||||
|
||||
|
||||
class RAGServiceBase(ABC):
|
||||
"""Abstract base class for RAG service implementations.
|
||||
|
||||
Provides a common interface for different RAG service backends
|
||||
(HTTP, mock, echo, etc.).
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def query(
|
||||
self,
|
||||
messages: list[dict[str, str]],
|
||||
notifications: list[str] | None = None,
|
||||
conversation_history: str | None = None,
|
||||
user_nickname: str | None = None,
|
||||
) -> str:
|
||||
"""Send conversation to RAG endpoint and get response.
|
||||
|
||||
Args:
|
||||
messages: Current conversation messages (user/assistant only)
|
||||
e.g., [{"role": "user", "content": "Hello"}, ...]
|
||||
notifications: Active notifications for the user (optional)
|
||||
conversation_history: Formatted conversation history (optional)
|
||||
user_nickname: User's nickname or display name (optional)
|
||||
|
||||
Returns:
|
||||
Response string from RAG endpoint
|
||||
|
||||
Raises:
|
||||
Exception: Implementation-specific exceptions
|
||||
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def close(self) -> None:
|
||||
"""Close the service and release resources."""
|
||||
...
|
||||
|
||||
async def __aenter__(self) -> Self:
|
||||
"""Async context manager entry."""
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc_val: BaseException | None,
|
||||
exc_tb: TracebackType | None,
|
||||
) -> None:
|
||||
"""Async context manager exit."""
|
||||
await self.close()
|
||||
@@ -1,73 +0,0 @@
|
||||
"""Echo RAG service implementation for testing."""
|
||||
|
||||
import logging
|
||||
|
||||
from capa_de_integracion.services.rag.base import RAGServiceBase
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Error messages
|
||||
_ERR_NO_MESSAGES = "No messages provided"
|
||||
_ERR_NO_USER_MESSAGE = "No user message found in conversation history"
|
||||
|
||||
|
||||
class EchoRAGService(RAGServiceBase):
|
||||
"""Echo RAG service that returns the last user message.
|
||||
|
||||
Useful for testing and development without needing a real RAG endpoint.
|
||||
Simply echoes back the content of the last user message with an optional prefix.
|
||||
"""
|
||||
|
||||
def __init__(self, prefix: str = "Echo: ") -> None:
|
||||
"""Initialize Echo RAG service.
|
||||
|
||||
Args:
|
||||
prefix: Prefix to add to echoed messages (default: "Echo: ")
|
||||
|
||||
"""
|
||||
self.prefix = prefix
|
||||
logger.info("EchoRAGService initialized with prefix: %r", prefix)
|
||||
|
||||
async def query(
|
||||
self,
|
||||
messages: list[dict[str, str]],
|
||||
notifications: list[str] | None = None, # noqa: ARG002
|
||||
conversation_history: str | None = None, # noqa: ARG002
|
||||
user_nickname: str | None = None, # noqa: ARG002
|
||||
) -> str:
|
||||
"""Echo back the last user message with a prefix.
|
||||
|
||||
Args:
|
||||
messages: Current conversation messages (user/assistant only)
|
||||
e.g., [{"role": "user", "content": "Hello"}, ...]
|
||||
notifications: Active notifications for the user (optional, ignored)
|
||||
conversation_history: Formatted conversation history (optional, ignored)
|
||||
user_nickname: User's nickname or display name (optional, ignored)
|
||||
|
||||
Returns:
|
||||
The last user message content with prefix
|
||||
|
||||
Raises:
|
||||
ValueError: If no messages or no user messages found
|
||||
|
||||
"""
|
||||
if not messages:
|
||||
raise ValueError(_ERR_NO_MESSAGES)
|
||||
|
||||
# Find the last user message
|
||||
last_user_message = None
|
||||
for msg in reversed(messages):
|
||||
if msg.get("role") == "user":
|
||||
last_user_message = msg.get("content", "")
|
||||
break
|
||||
|
||||
if last_user_message is None:
|
||||
raise ValueError(_ERR_NO_USER_MESSAGE)
|
||||
|
||||
response = f"{self.prefix}{last_user_message}"
|
||||
logger.debug("Echo response: %s", response)
|
||||
return response
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the service (no-op for echo service)."""
|
||||
logger.info("EchoRAGService closed")
|
||||
@@ -1,141 +0,0 @@
|
||||
"""HTTP-based RAG service implementation."""
|
||||
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
from capa_de_integracion.services.rag.base import (
|
||||
Message,
|
||||
RAGRequest,
|
||||
RAGResponse,
|
||||
RAGServiceBase,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HTTPRAGService(RAGServiceBase):
|
||||
"""HTTP-based RAG service with high concurrency support.
|
||||
|
||||
Uses httpx AsyncClient with connection pooling for optimal performance
|
||||
when handling multiple concurrent requests.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
endpoint_url: str,
|
||||
max_connections: int = 100,
|
||||
max_keepalive_connections: int = 20,
|
||||
timeout: float = 30.0,
|
||||
) -> None:
|
||||
"""Initialize HTTP RAG service with connection pooling.
|
||||
|
||||
Args:
|
||||
endpoint_url: URL of the RAG endpoint
|
||||
max_connections: Maximum number of concurrent connections
|
||||
max_keepalive_connections: Maximum number of idle connections to keep alive
|
||||
timeout: Request timeout in seconds
|
||||
|
||||
"""
|
||||
self.endpoint_url = endpoint_url
|
||||
self.timeout = timeout
|
||||
|
||||
# Configure connection limits for high concurrency
|
||||
limits = httpx.Limits(
|
||||
max_connections=max_connections,
|
||||
max_keepalive_connections=max_keepalive_connections,
|
||||
)
|
||||
|
||||
# Create async client with connection pooling
|
||||
self._client = httpx.AsyncClient(
|
||||
limits=limits,
|
||||
timeout=httpx.Timeout(timeout),
|
||||
http2=True, # Enable HTTP/2 for better performance
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"HTTPRAGService initialized with endpoint: %s, "
|
||||
"max_connections: %s, timeout: %ss",
|
||||
self.endpoint_url,
|
||||
max_connections,
|
||||
timeout,
|
||||
)
|
||||
|
||||
async def query(
|
||||
self,
|
||||
messages: list[dict[str, str]],
|
||||
notifications: list[str] | None = None,
|
||||
conversation_history: str | None = None,
|
||||
user_nickname: str | None = None,
|
||||
) -> str:
|
||||
"""Send conversation to RAG endpoint and get response.
|
||||
|
||||
Args:
|
||||
messages: Current conversation messages (user/assistant only)
|
||||
e.g., [{"role": "user", "content": "Hello"}, ...]
|
||||
notifications: Active notifications for the user (optional)
|
||||
conversation_history: Formatted conversation history (optional)
|
||||
user_nickname: User's nickname or display name (optional)
|
||||
|
||||
Returns:
|
||||
Response string from RAG endpoint
|
||||
|
||||
Raises:
|
||||
httpx.HTTPError: If HTTP request fails
|
||||
ValueError: If response format is invalid
|
||||
|
||||
"""
|
||||
try:
|
||||
# Validate and construct request
|
||||
message_objects = [Message(**msg) for msg in messages]
|
||||
request = RAGRequest(
|
||||
messages=message_objects,
|
||||
notifications=notifications,
|
||||
conversation_history=conversation_history,
|
||||
user_nickname=user_nickname,
|
||||
)
|
||||
|
||||
# Make async HTTP POST request
|
||||
logger.debug(
|
||||
"Sending RAG request with %s messages, %s notifications, "
|
||||
"history: %s, user: %s",
|
||||
len(messages),
|
||||
len(notifications) if notifications else 0,
|
||||
"yes" if conversation_history else "no",
|
||||
user_nickname or "anonymous",
|
||||
)
|
||||
|
||||
response = await self._client.post(
|
||||
self.endpoint_url,
|
||||
json=request.model_dump(),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
# Raise exception for HTTP errors
|
||||
response.raise_for_status()
|
||||
|
||||
# Parse response
|
||||
response_data = response.json()
|
||||
rag_response = RAGResponse(**response_data)
|
||||
|
||||
logger.debug("RAG response received: %s chars", len(rag_response.response))
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.exception(
|
||||
"HTTP error calling RAG endpoint: %s - %s",
|
||||
e.response.status_code,
|
||||
e.response.text,
|
||||
)
|
||||
raise
|
||||
except httpx.RequestError:
|
||||
logger.exception("Request error calling RAG endpoint:")
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("Unexpected error calling RAG endpoint")
|
||||
raise
|
||||
else:
|
||||
return rag_response.response
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the HTTP client and release connections."""
|
||||
await self._client.aclose()
|
||||
logger.info("HTTPRAGService client closed")
|
||||
@@ -1,9 +0,0 @@
|
||||
"""Storage services."""
|
||||
|
||||
from capa_de_integracion.services.storage.firestore import FirestoreService
|
||||
from capa_de_integracion.services.storage.redis import RedisService
|
||||
|
||||
__all__ = [
|
||||
"FirestoreService",
|
||||
"RedisService",
|
||||
]
|
||||
@@ -1,437 +0,0 @@
|
||||
"""Firestore service for conversation and notification persistence."""
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from google.cloud import firestore
|
||||
from google.cloud.firestore_v1.base_query import FieldFilter
|
||||
|
||||
from capa_de_integracion.config import Settings
|
||||
from capa_de_integracion.models import ConversationEntry, ConversationSession
|
||||
from capa_de_integracion.models.notification import Notification
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FirestoreService:
|
||||
"""Service for Firestore operations on conversations."""
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
"""Initialize Firestore client."""
|
||||
self.settings = settings
|
||||
self.db = firestore.AsyncClient(
|
||||
project=settings.gcp_project_id,
|
||||
database=settings.firestore_database_id,
|
||||
)
|
||||
self.conversations_collection = (
|
||||
f"artifacts/{settings.gcp_project_id}/conversations"
|
||||
)
|
||||
self.entries_subcollection = "mensajes"
|
||||
self.notifications_collection = (
|
||||
f"artifacts/{settings.gcp_project_id}/notifications"
|
||||
)
|
||||
logger.info(
|
||||
"Firestore client initialized for project: %s",
|
||||
settings.gcp_project_id,
|
||||
)
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close Firestore client."""
|
||||
self.db.close()
|
||||
logger.info("Firestore client closed")
|
||||
|
||||
def _session_ref(self, session_id: str) -> firestore.AsyncDocumentReference:
|
||||
"""Get Firestore document reference for session."""
|
||||
return self.db.collection(self.conversations_collection).document(session_id)
|
||||
|
||||
async def get_session(self, session_id: str) -> ConversationSession | None:
|
||||
"""Retrieve conversation session from Firestore by session ID."""
|
||||
try:
|
||||
doc_ref = self._session_ref(session_id)
|
||||
doc = await doc_ref.get()
|
||||
|
||||
if not doc.exists:
|
||||
logger.debug("Session not found in Firestore: %s", session_id)
|
||||
return None
|
||||
|
||||
data = doc.to_dict()
|
||||
session = ConversationSession.model_validate(data)
|
||||
logger.debug("Retrieved session from Firestore: %s", session_id)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Error retrieving session %s from Firestore:",
|
||||
session_id,
|
||||
)
|
||||
return None
|
||||
else:
|
||||
return session
|
||||
|
||||
async def get_session_by_phone(self, telefono: str) -> ConversationSession | None:
|
||||
"""Retrieve most recent conversation session from Firestore by phone number.
|
||||
|
||||
Args:
|
||||
telefono: User phone number
|
||||
|
||||
Returns:
|
||||
Most recent session for this phone, or None if not found
|
||||
|
||||
"""
|
||||
try:
|
||||
query = (
|
||||
self.db.collection(self.conversations_collection)
|
||||
.where(filter=FieldFilter("telefono", "==", telefono))
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
docs = query.stream()
|
||||
async for doc in docs:
|
||||
data = doc.to_dict()
|
||||
session = ConversationSession.model_validate(data)
|
||||
logger.debug(
|
||||
"Retrieved session from Firestore for phone %s: %s",
|
||||
telefono,
|
||||
session.session_id,
|
||||
)
|
||||
return session
|
||||
|
||||
logger.debug("No session found in Firestore for phone: %s", telefono)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Error querying session by phone %s from Firestore:",
|
||||
telefono,
|
||||
)
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
|
||||
async def save_session(self, session: ConversationSession) -> bool:
|
||||
"""Save conversation session to Firestore."""
|
||||
try:
|
||||
doc_ref = self._session_ref(session.session_id)
|
||||
data = session.model_dump()
|
||||
await doc_ref.set(data, merge=True)
|
||||
logger.debug("Saved session to Firestore: %s", session.session_id)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Error saving session %s to Firestore:",
|
||||
session.session_id,
|
||||
)
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
async def create_session(
|
||||
self,
|
||||
session_id: str,
|
||||
user_id: str,
|
||||
telefono: str,
|
||||
pantalla_contexto: str | None = None,
|
||||
last_message: str | None = None,
|
||||
) -> ConversationSession:
|
||||
"""Create and save a new conversation session to Firestore.
|
||||
|
||||
Args:
|
||||
session_id: Unique session identifier
|
||||
user_id: User identifier
|
||||
telefono: User phone number
|
||||
pantalla_contexto: Optional screen context for the conversation
|
||||
last_message: Optional last message in the conversation
|
||||
|
||||
Returns:
|
||||
The created session
|
||||
|
||||
Raises:
|
||||
Exception: If session creation or save fails
|
||||
|
||||
"""
|
||||
session = ConversationSession.create(
|
||||
session_id=session_id,
|
||||
user_id=user_id,
|
||||
telefono=telefono,
|
||||
pantalla_contexto=pantalla_contexto,
|
||||
last_message=last_message,
|
||||
)
|
||||
|
||||
doc_ref = self._session_ref(session.session_id)
|
||||
data = session.model_dump()
|
||||
await doc_ref.set(data, merge=True)
|
||||
|
||||
logger.info("Created new session in Firestore: %s", session_id)
|
||||
return session
|
||||
|
||||
async def save_entry(self, session_id: str, entry: ConversationEntry) -> bool:
|
||||
"""Save conversation entry to Firestore subcollection."""
|
||||
try:
|
||||
doc_ref = self._session_ref(session_id)
|
||||
entries_ref = doc_ref.collection(self.entries_subcollection)
|
||||
|
||||
# Use timestamp as document ID for chronological ordering
|
||||
entry_id = entry.timestamp.isoformat()
|
||||
entry_doc = entries_ref.document(entry_id)
|
||||
|
||||
data = entry.model_dump()
|
||||
await entry_doc.set(data)
|
||||
logger.debug("Saved entry to Firestore for session: %s", session_id)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Error saving entry for session %s to Firestore:",
|
||||
session_id,
|
||||
)
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
async def get_entries(
|
||||
self,
|
||||
session_id: str,
|
||||
limit: int = 10,
|
||||
) -> list[ConversationEntry]:
|
||||
"""Retrieve recent conversation entries from Firestore."""
|
||||
try:
|
||||
doc_ref = self._session_ref(session_id)
|
||||
entries_ref = doc_ref.collection(self.entries_subcollection)
|
||||
|
||||
# Get entries ordered by timestamp descending
|
||||
query = entries_ref.order_by(
|
||||
"timestamp",
|
||||
direction=firestore.Query.DESCENDING,
|
||||
).limit(limit)
|
||||
|
||||
docs = query.stream()
|
||||
entries = []
|
||||
|
||||
async for doc in docs:
|
||||
entry_data = doc.to_dict()
|
||||
entry = ConversationEntry.model_validate(entry_data)
|
||||
entries.append(entry)
|
||||
|
||||
# Reverse to get chronological order
|
||||
entries.reverse()
|
||||
logger.debug(
|
||||
"Retrieved %s entries for session: %s",
|
||||
len(entries),
|
||||
session_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Error retrieving entries for session %s from Firestore:",
|
||||
session_id,
|
||||
)
|
||||
return []
|
||||
else:
|
||||
return entries
|
||||
|
||||
async def delete_session(self, session_id: str) -> bool:
|
||||
"""Delete conversation session and all entries from Firestore."""
|
||||
try:
|
||||
doc_ref = self._session_ref(session_id)
|
||||
|
||||
# Delete all entries first
|
||||
entries_ref = doc_ref.collection(self.entries_subcollection)
|
||||
async for doc in entries_ref.stream():
|
||||
await doc.reference.delete()
|
||||
|
||||
# Delete session document
|
||||
await doc_ref.delete()
|
||||
logger.debug("Deleted session from Firestore: %s", session_id)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Error deleting session %s from Firestore:",
|
||||
session_id,
|
||||
)
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
async def update_pantalla_contexto(
|
||||
self,
|
||||
session_id: str,
|
||||
pantalla_contexto: str | None,
|
||||
) -> bool:
|
||||
"""Update the pantallaContexto field for a conversation session.
|
||||
|
||||
Args:
|
||||
session_id: Session ID to update
|
||||
pantalla_contexto: New pantalla contexto value
|
||||
|
||||
Returns:
|
||||
True if update was successful, False otherwise
|
||||
|
||||
"""
|
||||
try:
|
||||
doc_ref = self._session_ref(session_id)
|
||||
doc = await doc_ref.get()
|
||||
|
||||
if not doc.exists:
|
||||
logger.warning(
|
||||
"Session %s not found in Firestore. Cannot update pantallaContexto",
|
||||
session_id,
|
||||
)
|
||||
return False
|
||||
|
||||
await doc_ref.update(
|
||||
{
|
||||
"pantallaContexto": pantalla_contexto,
|
||||
"lastModified": datetime.now(UTC),
|
||||
},
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Updated pantallaContexto for session %s in Firestore",
|
||||
session_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Error updating pantallaContexto for session %s in Firestore:",
|
||||
session_id,
|
||||
)
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
# ====== Notification Methods ======
|
||||
|
||||
def _notification_ref(
|
||||
self,
|
||||
notification_id: str,
|
||||
) -> firestore.AsyncDocumentReference:
|
||||
"""Get Firestore document reference for notification."""
|
||||
return self.db.collection(self.notifications_collection).document(
|
||||
notification_id,
|
||||
)
|
||||
|
||||
async def save_or_append_notification(self, new_entry: Notification) -> None:
|
||||
"""Save or append notification entry to Firestore.
|
||||
|
||||
Args:
|
||||
new_entry: Notification entry to save
|
||||
|
||||
Raises:
|
||||
ValueError: If phone number is missing
|
||||
|
||||
"""
|
||||
phone_number = new_entry.telefono
|
||||
if not phone_number or not phone_number.strip():
|
||||
msg = "Phone number is required to manage notification entries"
|
||||
raise ValueError(msg)
|
||||
|
||||
# Use phone number as document ID
|
||||
notification_session_id = phone_number
|
||||
|
||||
try:
|
||||
doc_ref = self._notification_ref(notification_session_id)
|
||||
doc = await doc_ref.get()
|
||||
|
||||
entry_dict = new_entry.model_dump()
|
||||
|
||||
if doc.exists:
|
||||
# Append to existing session
|
||||
await doc_ref.update(
|
||||
{
|
||||
"notificaciones": firestore.ArrayUnion([entry_dict]),
|
||||
"ultima_actualizacion": datetime.now(UTC),
|
||||
},
|
||||
)
|
||||
logger.info(
|
||||
"Successfully appended notification entry "
|
||||
"to session %s in Firestore",
|
||||
notification_session_id,
|
||||
)
|
||||
else:
|
||||
# Create new notification session
|
||||
new_session_data = {
|
||||
"session_id": notification_session_id,
|
||||
"telefono": phone_number,
|
||||
"fecha_creacion": datetime.now(UTC),
|
||||
"ultima_actualizacion": datetime.now(UTC),
|
||||
"notificaciones": [entry_dict],
|
||||
}
|
||||
await doc_ref.set(new_session_data)
|
||||
logger.info(
|
||||
"Successfully created new notification session %s in Firestore",
|
||||
notification_session_id,
|
||||
)
|
||||
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Error saving notification to Firestore for phone %s",
|
||||
phone_number,
|
||||
)
|
||||
raise
|
||||
|
||||
async def update_notification_status(self, session_id: str, status: str) -> None:
|
||||
"""Update the status of all notifications in a session.
|
||||
|
||||
Args:
|
||||
session_id: Notification session ID (phone number)
|
||||
status: New status value
|
||||
|
||||
"""
|
||||
try:
|
||||
doc_ref = self._notification_ref(session_id)
|
||||
doc = await doc_ref.get()
|
||||
|
||||
if not doc.exists:
|
||||
logger.warning(
|
||||
"Notification session %s not found in Firestore. "
|
||||
"Cannot update status",
|
||||
session_id,
|
||||
)
|
||||
return
|
||||
|
||||
session_data = doc.to_dict()
|
||||
if not session_data:
|
||||
logger.warning(
|
||||
"Notification session %s has no data in Firestore",
|
||||
session_id,
|
||||
)
|
||||
return
|
||||
notifications = session_data.get("notificaciones", [])
|
||||
|
||||
# Update status for all notifications
|
||||
updated_notifications = [
|
||||
{**notif, "status": status} for notif in notifications
|
||||
]
|
||||
|
||||
await doc_ref.update(
|
||||
{
|
||||
"notificaciones": updated_notifications,
|
||||
"ultima_actualizacion": datetime.now(UTC),
|
||||
},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Successfully updated notification status to '%s' "
|
||||
"for session %s in Firestore",
|
||||
status,
|
||||
session_id,
|
||||
)
|
||||
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Error updating notification status in Firestore for session %s",
|
||||
session_id,
|
||||
)
|
||||
raise
|
||||
|
||||
async def delete_notification(self, notification_id: str) -> bool:
|
||||
"""Delete notification session from Firestore."""
|
||||
try:
|
||||
logger.info(
|
||||
"Deleting notification session %s from Firestore",
|
||||
notification_id,
|
||||
)
|
||||
doc_ref = self._notification_ref(notification_id)
|
||||
await doc_ref.delete()
|
||||
logger.info(
|
||||
"Successfully deleted notification session %s from Firestore",
|
||||
notification_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Error deleting notification session %s from Firestore",
|
||||
notification_id,
|
||||
)
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
@@ -1,396 +0,0 @@
|
||||
"""Redis service for caching conversation sessions and notifications."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from redis.asyncio import Redis
|
||||
|
||||
from capa_de_integracion.config import Settings
|
||||
from capa_de_integracion.models import ConversationEntry, ConversationSession
|
||||
from capa_de_integracion.models.notification import Notification, NotificationSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RedisService:
|
||||
"""Service for Redis operations on conversation sessions."""
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
"""Initialize Redis client."""
|
||||
self.settings = settings
|
||||
self.redis: Redis | None = None
|
||||
self.session_ttl = 2592000 # 30 days in seconds
|
||||
self.notification_ttl = 2592000 # 30 days in seconds
|
||||
self.qr_session_ttl = 86400 # 24 hours in seconds
|
||||
|
||||
async def connect(self) -> None:
|
||||
"""Connect to Redis."""
|
||||
self.redis = Redis(
|
||||
host=self.settings.redis_host,
|
||||
port=self.settings.redis_port,
|
||||
password=self.settings.redis_pwd,
|
||||
decode_responses=True,
|
||||
)
|
||||
logger.info(
|
||||
"Connected to Redis at %s:%s",
|
||||
self.settings.redis_host,
|
||||
self.settings.redis_port,
|
||||
)
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close Redis connection."""
|
||||
if self.redis:
|
||||
await self.redis.aclose()
|
||||
logger.info("Redis connection closed")
|
||||
|
||||
def _session_key(self, session_id: str) -> str:
|
||||
"""Generate Redis key for conversation session."""
|
||||
return f"conversation:session:{session_id}"
|
||||
|
||||
def _phone_to_session_key(self, phone: str) -> str:
|
||||
"""Generate Redis key for phone-to-session mapping."""
|
||||
return f"conversation:phone:{phone}"
|
||||
|
||||
async def get_session(self, session_id_or_phone: str) -> ConversationSession | None:
|
||||
"""Retrieve conversation session from Redis by session ID or phone number.
|
||||
|
||||
Args:
|
||||
session_id_or_phone: Either a session ID or phone number
|
||||
|
||||
Returns:
|
||||
Conversation session or None if not found
|
||||
|
||||
"""
|
||||
if not self.redis:
|
||||
msg = "Redis client not connected"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
# First try as phone number (lookup session ID)
|
||||
phone_key = self._phone_to_session_key(session_id_or_phone)
|
||||
mapped_session_id = await self.redis.get(phone_key)
|
||||
|
||||
# Use mapped session ID if found, otherwise use input directly
|
||||
session_id = mapped_session_id or session_id_or_phone
|
||||
|
||||
# Get session by ID
|
||||
key = self._session_key(session_id)
|
||||
data = await self.redis.get(key)
|
||||
|
||||
if not data:
|
||||
logger.debug("Session not found in Redis: %s", session_id_or_phone)
|
||||
return None
|
||||
|
||||
try:
|
||||
session_dict = json.loads(data)
|
||||
session = ConversationSession.model_validate(session_dict)
|
||||
logger.debug("Retrieved session from Redis: %s", session_id)
|
||||
except Exception:
|
||||
logger.exception("Error deserializing session %s:", session_id)
|
||||
return None
|
||||
else:
|
||||
return session
|
||||
|
||||
async def save_session(self, session: ConversationSession) -> bool:
|
||||
"""Save conversation session to Redis with TTL.
|
||||
|
||||
Also stores phone-to-session mapping for lookup by phone number.
|
||||
"""
|
||||
if not self.redis:
|
||||
msg = "Redis client not connected"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
key = self._session_key(session.session_id)
|
||||
phone_key = self._phone_to_session_key(session.telefono)
|
||||
|
||||
try:
|
||||
# Save session data
|
||||
data = session.model_dump_json(by_alias=False)
|
||||
await self.redis.setex(key, self.session_ttl, data)
|
||||
|
||||
# Save phone-to-session mapping
|
||||
await self.redis.setex(phone_key, self.session_ttl, session.session_id)
|
||||
|
||||
logger.debug(
|
||||
"Saved session to Redis: %s for phone: %s",
|
||||
session.session_id,
|
||||
session.telefono,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Error saving session %s to Redis:", session.session_id)
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
async def delete_session(self, session_id: str) -> bool:
|
||||
"""Delete conversation session from Redis."""
|
||||
if not self.redis:
|
||||
msg = "Redis client not connected"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
key = self._session_key(session_id)
|
||||
|
||||
try:
|
||||
result = await self.redis.delete(key)
|
||||
logger.debug("Deleted session from Redis: %s", session_id)
|
||||
except Exception:
|
||||
logger.exception("Error deleting session %s from Redis:", session_id)
|
||||
return False
|
||||
else:
|
||||
return result > 0
|
||||
|
||||
async def exists(self, session_id: str) -> bool:
|
||||
"""Check if session exists in Redis."""
|
||||
if not self.redis:
|
||||
msg = "Redis client not connected"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
key = self._session_key(session_id)
|
||||
return await self.redis.exists(key) > 0
|
||||
|
||||
# ====== Message Methods ======
|
||||
|
||||
def _messages_key(self, session_id: str) -> str:
|
||||
"""Generate Redis key for conversation messages."""
|
||||
return f"conversation:messages:{session_id}"
|
||||
|
||||
async def save_message(self, session_id: str, message: ConversationEntry) -> bool:
|
||||
"""Save a conversation message to Redis sorted set.
|
||||
|
||||
Messages are stored in a sorted set with timestamp as score.
|
||||
|
||||
Args:
|
||||
session_id: The session ID
|
||||
message: ConversationEntry
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
|
||||
"""
|
||||
if not self.redis:
|
||||
msg = "Redis client not connected"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
key = self._messages_key(session_id)
|
||||
|
||||
try:
|
||||
# Convert message to JSON
|
||||
message_data = message.model_dump_json(by_alias=False)
|
||||
# Use timestamp as score (in milliseconds)
|
||||
score = message.timestamp.timestamp() * 1000
|
||||
|
||||
# Add to sorted set
|
||||
await self.redis.zadd(key, {message_data: score})
|
||||
# Set TTL on the messages key to match session TTL
|
||||
await self.redis.expire(key, self.session_ttl)
|
||||
|
||||
logger.debug("Saved message to Redis: %s", session_id)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Error saving message to Redis for session %s:",
|
||||
session_id,
|
||||
)
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
async def get_messages(self, session_id: str) -> list:
|
||||
"""Retrieve all conversation messages for a session from Redis.
|
||||
|
||||
Returns messages ordered by timestamp (oldest first).
|
||||
|
||||
Args:
|
||||
session_id: The session ID
|
||||
|
||||
Returns:
|
||||
List of message dictionaries (parsed from JSON)
|
||||
|
||||
"""
|
||||
if not self.redis:
|
||||
msg = "Redis client not connected"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
key = self._messages_key(session_id)
|
||||
|
||||
try:
|
||||
# Get all messages from sorted set (ordered by score/timestamp)
|
||||
message_strings = await self.redis.zrange(key, 0, -1)
|
||||
|
||||
if not message_strings:
|
||||
logger.debug("No messages found in Redis for session: %s", session_id)
|
||||
return []
|
||||
|
||||
# Parse JSON strings to dictionaries
|
||||
messages = []
|
||||
for msg_str in message_strings:
|
||||
try:
|
||||
messages.append(json.loads(msg_str))
|
||||
except json.JSONDecodeError:
|
||||
logger.exception("Error parsing message JSON:")
|
||||
continue
|
||||
|
||||
logger.debug(
|
||||
"Retrieved %s messages from Redis for session: %s",
|
||||
len(messages),
|
||||
session_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Error retrieving messages from Redis for session %s:",
|
||||
session_id,
|
||||
)
|
||||
return []
|
||||
else:
|
||||
return messages
|
||||
|
||||
# ====== Notification Methods ======
|
||||
|
||||
def _notification_key(self, session_id: str) -> str:
|
||||
"""Generate Redis key for notification session."""
|
||||
return f"notification:{session_id}"
|
||||
|
||||
def _phone_to_notification_key(self, phone: str) -> str:
|
||||
"""Generate Redis key for phone-to-notification mapping."""
|
||||
return f"notification:phone_to_notification:{phone}"
|
||||
|
||||
async def save_or_append_notification(self, new_entry: Notification) -> None:
|
||||
"""Save or append notification entry to session.
|
||||
|
||||
Args:
|
||||
new_entry: Notification entry to save
|
||||
|
||||
Raises:
|
||||
ValueError: If phone number is missing
|
||||
|
||||
"""
|
||||
if not self.redis:
|
||||
msg = "Redis client not connected"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
phone_number = new_entry.telefono
|
||||
if not phone_number or not phone_number.strip():
|
||||
msg = "Phone number is required to manage notification entries"
|
||||
raise ValueError(msg)
|
||||
|
||||
# Use phone number as session ID for notifications
|
||||
notification_session_id = phone_number
|
||||
|
||||
# Get existing session or create new one
|
||||
existing_session = await self.get_notification_session(notification_session_id)
|
||||
|
||||
if existing_session:
|
||||
# Append to existing session
|
||||
updated_notifications = [*existing_session.notificaciones, new_entry]
|
||||
updated_session = NotificationSession(
|
||||
sessionId=notification_session_id,
|
||||
telefono=phone_number,
|
||||
fechaCreacion=existing_session.fecha_creacion,
|
||||
ultimaActualizacion=datetime.now(UTC),
|
||||
notificaciones=updated_notifications,
|
||||
)
|
||||
else:
|
||||
# Create new session
|
||||
updated_session = NotificationSession(
|
||||
sessionId=notification_session_id,
|
||||
telefono=phone_number,
|
||||
fechaCreacion=datetime.now(UTC),
|
||||
ultimaActualizacion=datetime.now(UTC),
|
||||
notificaciones=[new_entry],
|
||||
)
|
||||
|
||||
# Save to Redis
|
||||
await self._cache_notification_session(updated_session)
|
||||
|
||||
async def _cache_notification_session(self, session: NotificationSession) -> bool:
|
||||
"""Cache notification session in Redis."""
|
||||
if not self.redis:
|
||||
msg = "Redis client not connected"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
key = self._notification_key(session.session_id)
|
||||
phone_key = self._phone_to_notification_key(session.telefono)
|
||||
|
||||
try:
|
||||
# Save notification session
|
||||
data = session.model_dump_json(by_alias=False)
|
||||
await self.redis.setex(key, self.notification_ttl, data)
|
||||
|
||||
# Save phone-to-session mapping
|
||||
await self.redis.setex(phone_key, self.notification_ttl, session.session_id)
|
||||
|
||||
logger.debug("Cached notification session: %s", session.session_id)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Error caching notification session %s:",
|
||||
session.session_id,
|
||||
)
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
async def get_notification_session(
|
||||
self,
|
||||
session_id: str,
|
||||
) -> NotificationSession | None:
|
||||
"""Retrieve notification session from Redis."""
|
||||
if not self.redis:
|
||||
msg = "Redis client not connected"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
key = self._notification_key(session_id)
|
||||
data = await self.redis.get(key)
|
||||
|
||||
if not data:
|
||||
logger.debug("Notification session not found in Redis: %s", session_id)
|
||||
return None
|
||||
|
||||
try:
|
||||
session_dict = json.loads(data)
|
||||
session = NotificationSession.model_validate(session_dict)
|
||||
logger.info("Notification session %s retrieved from Redis", session_id)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Error deserializing notification session %s:",
|
||||
session_id,
|
||||
)
|
||||
return None
|
||||
else:
|
||||
return session
|
||||
|
||||
async def get_notification_id_for_phone(self, phone: str) -> str | None:
|
||||
"""Get notification session ID for a phone number."""
|
||||
if not self.redis:
|
||||
msg = "Redis client not connected"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
key = self._phone_to_notification_key(phone)
|
||||
session_id = await self.redis.get(key)
|
||||
|
||||
if session_id:
|
||||
logger.info("Session ID %s found for phone", session_id)
|
||||
else:
|
||||
logger.debug("Session ID not found for phone")
|
||||
|
||||
return session_id
|
||||
|
||||
async def delete_notification_session(self, phone_number: str) -> bool:
|
||||
"""Delete notification session from Redis."""
|
||||
if not self.redis:
|
||||
msg = "Redis client not connected"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
notification_key = self._notification_key(phone_number)
|
||||
phone_key = self._phone_to_notification_key(phone_number)
|
||||
|
||||
try:
|
||||
logger.info("Deleting notification session for phone %s", phone_number)
|
||||
await self.redis.delete(notification_key)
|
||||
await self.redis.delete(phone_key)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Error deleting notification session for phone %s:",
|
||||
phone_number,
|
||||
)
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
62
src/main/java/com/example/config/IntentDetectionConfig.java
Normal file
62
src/main/java/com/example/config/IntentDetectionConfig.java
Normal file
@@ -0,0 +1,62 @@
|
||||
package com.example.config;
|
||||
|
||||
import com.example.service.base.IntentDetectionService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
|
||||
/**
|
||||
* Configuration class for selecting the intent detection implementation.
|
||||
* Allows switching between Dialogflow and RAG based on configuration property.
|
||||
*
|
||||
* Usage:
|
||||
* - Set intent.detection.client=dialogflow to use Dialogflow CX
|
||||
* - Set intent.detection.client=rag to use RAG server
|
||||
*/
|
||||
@Configuration
|
||||
public class IntentDetectionConfig {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(
|
||||
IntentDetectionConfig.class
|
||||
);
|
||||
|
||||
@Value("${intent.detection.client:dialogflow}")
|
||||
private String clientType;
|
||||
|
||||
/**
|
||||
* Creates the primary IntentDetectionService bean based on configuration.
|
||||
* This bean will be injected into ConversationManagerService and NotificationManagerService.
|
||||
*
|
||||
* @param dialogflowService The Dialogflow implementation
|
||||
* @param ragService The RAG implementation
|
||||
* @return The selected IntentDetectionService implementation
|
||||
*/
|
||||
@Bean
|
||||
@Primary
|
||||
public IntentDetectionService intentDetectionService(
|
||||
@Qualifier(
|
||||
"dialogflowClientService"
|
||||
) IntentDetectionService dialogflowService,
|
||||
@Qualifier("ragClientService") IntentDetectionService ragService
|
||||
) {
|
||||
if ("rag".equalsIgnoreCase(clientType)) {
|
||||
logger.info("✓ Intent detection configured to use RAG client");
|
||||
return ragService;
|
||||
} else if ("dialogflow".equalsIgnoreCase(clientType)) {
|
||||
logger.info(
|
||||
"✓ Intent detection configured to use Dialogflow CX client"
|
||||
);
|
||||
return dialogflowService;
|
||||
} else {
|
||||
logger.warn(
|
||||
"Unknown intent.detection.client value: '{}'. Defaulting to Dialogflow.",
|
||||
clientType
|
||||
);
|
||||
return dialogflowService;
|
||||
}
|
||||
}
|
||||
}
|
||||
27
src/main/java/com/example/dto/rag/RagQueryRequest.java
Normal file
27
src/main/java/com/example/dto/rag/RagQueryRequest.java
Normal file
@@ -0,0 +1,27 @@
|
||||
package com.example.dto.rag;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Internal DTO representing a request to the RAG server.
|
||||
* This is used only within the RAG client adapter and is not exposed to other services.
|
||||
*/
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public record RagQueryRequest(
|
||||
@JsonProperty("phone_number") String phoneNumber,
|
||||
@JsonProperty("text") String text,
|
||||
@JsonProperty("type") String type,
|
||||
@JsonProperty("notification") NotificationContext notification,
|
||||
@JsonProperty("language_code") String languageCode
|
||||
) {
|
||||
/**
|
||||
* Nested record for notification context
|
||||
*/
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public record NotificationContext(
|
||||
@JsonProperty("text") String text,
|
||||
@JsonProperty("parameters") Map<String, Object> parameters
|
||||
) {}
|
||||
}
|
||||
17
src/main/java/com/example/dto/rag/RagQueryResponse.java
Normal file
17
src/main/java/com/example/dto/rag/RagQueryResponse.java
Normal file
@@ -0,0 +1,17 @@
|
||||
package com.example.dto.rag;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Internal DTO representing a response from the RAG server.
|
||||
* This is used only within the RAG client adapter and is not exposed to other services.
|
||||
*/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record RagQueryResponse(
|
||||
@JsonProperty("response_id") String responseId,
|
||||
@JsonProperty("response_text") String responseText,
|
||||
@JsonProperty("parameters") Map<String, Object> parameters,
|
||||
@JsonProperty("confidence") Double confidence
|
||||
) {}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user