# Bot conversacional (STT / TTS / LLM intercambiables)

Aplicación de control de llamadas con Asterisk ARI mediate un Bot entrenado. Con acceso a APIS externas en tiempo real. todo backend.

## ❤️ Support the Project

Si este proyecto puede ayudarte, considera colaborar en su desarrollo.

- PayPal: https://paypal.me/WPisacco

<a href='https://ko-fi.com/I7T320N9U1' target='_blank'><img height='36' style='border:0px;height:36px;' src='https://storage.ko-fi.com/cdn/kofi6.png?v=6' border='0' alt='Buy Me a Coffee at ko-fi.com' /></a>


## Arquitectura

```
Llamada Entrante → API Externa → BOT → TTS → STT → FastAPI  →  ARI  →  Asterisk
```


## Requisitos

- Python 3.11+
- Asterisk con ARI habilitado y aplicación Stasis configurada
- TTS/STT Google Cloud
- Ollama local (LLM_PROVIDER=ollama)
- OpenAI cloud (LLM_PROVIDER=openai)

## Configuración

Copiá las variables de entorno:

```bash
cp .env.example .env
# Editá ARI_BASE_URL, ARI_USER, ARI_PASSWORD, etc.
```

### Variables principales

| Variable | Descripción |
|----------|-------------|
| `ARI_BASE_URL` | URL HTTP de ARI (ej. `http://127.0.0.1:8088`) |
| `ARI_USER` / `ARI_PASSWORD` | Credenciales ARI |
| `STASIS_APP` | Nombre de la app Stasis (default: `StasisApp`) |
| `OUTBOUND_ENDPOINT_TEMPLATE` | Plantilla del endpoint (default: `PJSIP/{number}`) |
| `WEBRTC_ENABLED` | Audio del operador en el navegador vía externalMedia para pruebas (default: `true`) |
| `EXTERNAL_MEDIA_ADVERTISE_HOST` | IP del backend que Asterisk usa para RTP (crítica para el bot) |
| `CORS_ORIGINS` | Orígenes permitidos para el frontend |
| `BOT_ENABLED` | Activa el bot conversacional |
| `LLM_PROVIDER` | `openai` \| `ollama` |
| `STT_PROVIDER` | `google` \| `local` |
| `TTS_PROVIDER` | `google` \| `elevenlabs` \| `local` |
| `CRM_API_BASE_ID` / `CRM_ENDPOINT_POLIZAS` | API externa de pólizas |

Más bajo BOT_SILENCE_MS → corta antes (más ágil, puede cortar frases).
Más alto BOT_SILENCE_MS → espera más (mejor si hablan pausado).

Nota: además hay un cooldown fijo de 600 ms (POST_SPEAK_COOLDOWN_MS en código) después del TTS del bot, para no capturar eco; eso no está en el .env.

## Asterisk

### ARI (`ari.conf`)

```ini
[general]
enabled = yes

[admin]
type = user
read_only = no
password = tu_password
```

### Llamadas entrantes a tu extensión (dialplan)

```ini
[Bot-Ari]
exten => _.,1,NoOp(====== Ingresando a Bot-Ari ======)
 same => n,set(DNI=21601605)
 same => n,Stasis(StasisApp,${EXTEN},${DNI})
 same => n,Hangup()

```

### Bot conversacional con documento / póliza (dialplan)

Para el flujo de bot IA (readout de DNI → consulta CRM → OpenAI/Ollama), Stasis debe recibir la extensión y el documento:

Args típicos: `Stasis(StasisApp,${EXTEN},21601605)` → el backend guarda `call.document_id=21601605`, contesta, reproduce dígitos del documento y arranca el bot.


## Bot IA (STT / TTS / LLM / CRM)

Al contestar una llamada con `document_id`, el backend:

1. Reproduce el documento (dígitos ARI).
2. Adjunta `externalMedia` RTP (bot ↔ Asterisk).
3. Consulta CRM: `GET /polizas/{document_id}`.
4. Precarga esos datos en el LLM y genera el saludo (OpenAI Responses / Ollama chat).
5. Loop conversacional: RTP → VAD → STT → LLM → TTS → RTP.

Si OpenAI responde `rate_limit_exceeded` / cuota agotada, se abre un **circuito** y el proceso deja de llamar a OpenAI (no insiste en loop).

### Audio RTP (`EXTERNAL_MEDIA_ADVERTISE_HOST`)

Debe ser la **IP de esta máquina** alcanzable desde Asterisk (no `127.0.0.1` si Asterisk es remoto). Si no llega RTP verás `timeout esperando RTP` y el bot no escucha.

```env
EXTERNAL_MEDIA_ADVERTISE_HOST=
```

### CRM / pólizas

Paquete `backend/crm/`: cliente HTTP configurable por base id y endpoints.

```env
CRM_API_ENABLED=true
CRM_API_BASE_ID=6a57b0b0914a025dcff35cfd
CRM_API_BASE_TEMPLATE=https://{base_id}.mockapi.io/api/v1
# O URL absoluta:
# CRM_API_BASE_URL=https://6a57b0b0914a025dcff35cfd.mockapi.io/api/v1
CRM_ENDPOINT_POLIZAS=polizas
```

Al iniciar el bot se llama:

`GET {base}/polizas/{call.document_id}`

Ejemplo de respuesta:

```json
{
  "id": "21601605",
  "nombre": "Walter",
  "apellido": "Pisacco",
  "poliza": "H123456",
  "producto": "Hogar",
  "plan_actual": "Plan 1",
  "precio_actual": 3900,
  "moneda":"Pesos",
  "creado": "2026-02-01",
  "plan_ofrecido": "Plan 2",
  "precio_ofrecido": 3100,
  "plan_retencion": 3000,
  "monto_asegurado":1350000
}
```

Esos datos se guardan en `call.poliza_data` y se inyectan al LLM: en Responses como `input` (JSON al inicio de la llamada); en Ollama dentro del system message. Las reglas van en `BOT_SYSTEM_PROMPT_FILE` (`instructions`).

Rutas de prueba del backend:

| Método | Ruta | Descripción |
|--------|------|-------------|
| GET | `/api/crm/status` | Base URL y endpoints configurados |
| GET | `/api/crm/polizas` | Lista pólizas |
| GET | `/api/crm/polizas/{id}` | Una póliza (mismo id que el documento) |

### Acciones por frase del bot

Si el LLM dice una frase configurada, el backend ejecuta una acción (p. ej. transferir).

Archivo: `backend/config/bot_action_triggers.json`

```json
[
  {
    "phrase": "TE TRANSFIERO",
    "action": "transfer",
    "target": "111565309188",
    "speak_phrase": true
  }
]
```

- `phrase`: texto a detectar en la respuesta del bot (sin distinguir mayúsculas/acentos)
- `action`: por ahora `transfer`
- `target`: número/endpoint vía `OUTBOUND_ENDPOINT_TEMPLATE` (ej. `PJSIP/111565309188`)
- `speak_phrase`: si `false`, la frase no se sintetiza por TTS (sí se detecta)

Al transferir: se detiene el bot, se saca `externalMedia` del puente y se origina el destino a Stasis (`agent`) para mezclarlo con el cliente.

### Ambiente de oficina (fondo)

Con `BOT_AMBIENCE_ENABLED=true`, se reproduce en loop `sonido_oficina.mp3` por el RTP del bot (mezclado a bajo volumen con el TTS). Requiere `ffmpeg` la primera vez (genera cache `.ulaw`).

```env
BOT_AMBIENCE_ENABLED=true
BOT_AMBIENCE_FILE=sonido_oficina.mp3
BOT_AMBIENCE_GAIN=0.18
```

### LLM: OpenAI o Ollama (solo `.env`)



Misma orquestación del bot; cambiás el provider y reiniciás `python main.py`.

#### OpenAI cloud (Responses API)

```env
LLM_PROVIDER=openai
OPENAI_BASE_URL=https://api.openai.com/v1
OPENAI_API_KEY=sk-...
OPENAI_MODEL=gpt-4.1-mini
OPENAI_TIMEOUT=60
# Personalidad: BOT_SYSTEM_PROMPT_FILE (o BOT_SYSTEM_PROMPT)
# Multi-turno: previous_response_id por call_id (Responses API)
```

`OPENAI_ASSISTANT_ID` (Assistants API) está deprecado y se ignora si aparece en el `.env`.

Al colgar, el backend hace una llamada extra a Responses con `text.format.type=json_schema` (`resultado_llamada`) y guarda el JSON en `call.call_result` (también llega por WebSocket en `call_update`). Campos: `id`, `poliza`, `motivo`, `resultado` (`baja`|`retencion`), `plan_anterior`, `plan_nuevo`, `descuento_aceptado`, `descuento_aplicado`. Requiere un modelo con Structured Outputs (p. ej. `gpt-4.1-mini` / `gpt-4o`); `gpt-3.5-turbo` no alcanza.

#### Ollama local (sin costo de API)

Ollama usa **chat completions** compatible OpenAI (`/v1/chat/completions`). La personalidad va en el system prompt.

**Archivo de prompt (recomendado):** `backend/prompts/bot_system_prompt.txt`

El promt se carga solo 1 vez al inicar el backend y ese texto queda en la instancia del LLM.

```env
BOT_SYSTEM_PROMPT_FILE=prompts/bot_system_prompt.txt
# BOT_SYSTEM_PROMPT=   # opcional: pisa el archivo si no está vacío
```

```bash
curl -fsSL https://ollama.com/install.sh | sh
ollama pull qwen2.5:7b
ollama serve
```

```env
LLM_PROVIDER=ollama
OLLAMA_BASE_URL=http://127.0.0.1:11434/v1
OLLAMA_MODEL=qwen2.5:7b
OLLAMA_API_KEY=ollama
OLLAMA_TIMEOUT=120
BOT_SYSTEM_PROMPT_FILE=prompts/bot_system_prompt.txt
# Saludo TTS inmediato (nombre CRM) + warmup del modelo en paralelo.
# Usá llm para esperar el saludo generado por el modelo (más lento).
BOT_OPENING_MODE=immediate
```

# Precargar y dejar en memoria indefinidamente
curl http://localhost:11434/api/generate \
  -d '{"model":"qwen2.5:7b","keep_alive":-1}'

| Concepto OpenAI Responses | Equivalente con Ollama |
|---------------------------|-------------------------|
| `instructions` + JSON de póliza en `input` | `BOT_SYSTEM_PROMPT_FILE` + póliza en system |
| Modelo | `OLLAMA_MODEL` |
| `previous_response_id` | Historial en `bot_session` |
| Tools / File Search | No soportado |

### STT / TTS

```env
STT_PROVIDER=google   # o local (placeholder)
TTS_PROVIDER=google   # o elevenlabs | local
GOOGLE_CREDENTIAL=ruta/al/service-account.json
GOOGLE_STT_LANGUAGE=es-AR
GOOGLE_TTS_VOICE=es-US-Neural2-A

# Si TTS_PROVIDER=elevenlabs:
# ELEVENLABS_API_KEY=...
# ELEVENLABS_VOICE_ID=...
```

## Arranque

```bash
cp .env.example .env
```

### Backend

```bash
cd backend
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
# Opción A: recarga automática vía .env (DEV_RELOAD=true) y:
python main.py

# Opción B: uvicorn directo
uvicorn main:app --reload --host 0.0.0.0 --port 8000
```


## Estructura del proyecto

```
bot-asterisk-ari/
├── backend/
│   ├── api/          # REST (calls, crm, debug)
│   ├── ari/          # Cliente ARI + listener WebSocket
│   ├── calls/        # Modelos, registry, state machine
│   ├── crm/          # Cliente API externa (pólizas, etc.)
│   ├── llm/          # OpenAI Responses + Ollama chat.completions
│   ├── media/        # RTP / externalMedia / WebRTC
│   ├── services/     # CallService, BotSession
│   ├── voice/        # STT / TTS (Google | local)
│   ├── websocket/    # WebSocket hacia el frontend
│   └── main.py
├── .env.example
└── README.md
```

## Roadmap

- Grabaciones y transcripción offline
- Tools / function-calling del LLM hacia más endpoints CRM

##  Cómo arrancar

```bash
cd llamadas/
cp .env.example .env

cd backend
source .venv/bin/activate
# Opción A: recarga automática vía .env (DEV_RELOAD=true) y:
python main.py

# Opción B: uvicorn directo
uvicorn main:app --reload --host 0.0.0.0 --port 8000

```

## ❤️ Support the Project

Si este proyecto puede ayudarte, considera colaborar en su desarrollo.


- PayPal: https://paypal.me/WPisacco

<a href='https://ko-fi.com/I7T320N9U1' target='_blank'><img height='36' style='border:0px;height:36px;' src='https://storage.ko-fi.com/cdn/kofi6.png?v=6' border='0' alt='Buy Me a Coffee at ko-fi.com' /></a>
