# Informe técnico — Análisis y correcciones (agosto 2026)

Análisis realizado cruzando el código (`whatsapp_v2.zip`) contra los datos reales de
producción (`interlink_crm__50_.sql`, tabla `wa2_ai_decisions`, 1337 interacciones).
Todo lo que sigue está verificado contra mensajes reales, no adivinado.

## 1. Diagnóstico

De 1337 decisiones registradas, **26–28% cayeron en intención `unknown`** (la
categoría más grande, antes y después del refactor del 30/07). Ese `unknown` es la
causa principal de las respuestas repetitivas/confusas: sin una intención clara, el
sistema cae en una respuesta genérica redactada por la IA a partir de la base de
conocimiento completa, desconectada de lo que el cliente preguntó.

Se identificaron dos causas raíz distintas:

### 1.1 Bug de robustez UTF-8 (el más importante)

`Wa2ServiceRepository::normalizeText()` usaba `preg_replace(..., '/u')` sin
validar antes que el texto fuera UTF-8 válido. En PHP, si el texto trae bytes
inválidos (algo frecuente en payloads reales de WhatsApp: emojis corruptos,
caracteres mal codificados, etc.), `preg_replace` con el modificador `/u`
**devuelve `NULL` en silencio** — sin excepción, sin warning visible en producción.
El código tenía `?? $text` como respaldo, así que el texto quedaba **sin normalizar**
(con mayúsculas, tildes y puntuación intactas). Con ese texto "sucio" ningún patrón
`\b(...)\b` del detector calza, y el mensaje cae a `unknown`.

Esto explica un patrón muy visible en los logs: la frase más común de un cliente de
ISP — *"no me anda el internet"* — aparece **varias veces clasificada como
`unknown`** en distintas conversaciones, pese a que el propio archivo de pruebas del
proyecto (`tests/run_pure_tests.php`, línea 76) ya afirmaba que esa frase exacta
debía detectarse como `support_no_service`. La regla es correcta; lo que fallaba era
la limpieza previa del texto.

**Corrección:** `normalizeText()` ahora valida la codificación con
`mb_check_encoding()` y limpia el texto con `mb_convert_encoding()` antes de
normalizar, con una segunda red de seguridad si `preg_replace` igual fallara.
Ver `app/OperationalRepositories.php`.

*Nota de honestidad: no tuve forma de ejecutar PHP en este entorno para reproducir
el fallo paso a paso contra el intérprete real, así que esto es la explicación más
sólida que la evidencia respalda — no una certeza confirmada en vivo. Recomiendo
desplegar este fix y monitorear la tasa de `unknown` una semana para confirmarlo.*

### 1.2 Reglas de detección incompletas (frases coloquiales)

Se compararon los ~285 mensajes únicos que cayeron en `unknown` contra los patrones
regex de `IntentDetector.php`. Se ampliaron únicamente los patrones con evidencia
directa de mensajes reales de clientes que ningún patrón cubría:

| Intención | Frases reales que antes no se reconocían |
|---|---|
| `support_no_service` | "no tengo señal (d/de) internet", "anda mal el internet", "está andando mal el internet", "se conecta pero no tiene internet" |
| `balance` | "cuánto estoy debiendo", "última**s** factura**s**" (plural), "¿está suspendido/activo mi servicio?" |
| `new_installation` | "quiero la instalación", "quería hacer una instalación", "instalar wifi/internet" |

También se corrigió un caso de **enrutamiento cruzado**: mensajes como *"Instalar
wifi"* o *"¿Cuánto sale instalar wifi?"* se clasificaban como `support_wifi`
(soporte técnico) en lugar de `new_installation` (instalación nueva), porque la
regla de soporte solo mira la palabra suelta "wifi". Ahora una frase explícita de
instalación gana por sobre esa palabra suelta.

Cada cambio se validó de dos formas antes de aplicarse:
1. Simulación en Python del regex exacto contra los 868 mensajes únicos históricos,
   comparando clasificación antes/después para confirmar que solo mejora casos que
   antes eran `unknown` y no reclasifica mensajes que ya funcionaban bien.
2. Se agregaron casos nuevos a `tests/run_pure_tests.php` (su propio framework de
   pruebas) con las frases reales corregidas, incluyendo un caso de control para
   confirmar que una consulta de banda 2.4G/5G no migra por error a
   `support_no_service` solo por mencionar "sin señal".

**Importante:** no toqué el manejo de continuidad de flujo (cuando el cliente
contesta con su nombre, dirección o un "sí" suelto en medio de una instalación o un
pago). Ese es un problema real y también explica una porción del 26% de `unknown`,
pero quedó fuera de esta entrega porque priorizaste ampliar las reglas de frases
coloquiales. Queda como recomendación para una siguiente iteración.

## 2. Seguridad — no relacionado al pedido original, pero crítico

`wa2_settings.api_key_enc` guarda la clave de OpenAI como si estuviera cifrada, pero
`Wa2AiService::normalizeApiKey()` solo hace `base64_decode()`. Base64 no es
cifrado: cualquiera con acceso al dump de la base (como el que compartiste acá)
tiene la clave real en segundos. **Recomendación: rotar la clave en OpenAI y, si se
quiere guardarla en la base, cifrarla de verdad** (por ejemplo con
`sodium_crypto_secretbox` y una clave de cifrado que viva fuera de la base, en una
variable de entorno del VPS). No apliqué ningún cambio de código para esto porque
requiere que decidas dónde y cómo va a vivir esa clave de cifrado — es una decisión
de infraestructura, no algo que deba asumir por vos.

## 3. Archivos modificados (primera iteración)

- `app/OperationalRepositories.php` — blindaje UTF-8 en `normalizeText()`.
- `app/IntentDetector.php` — reglas ampliadas (ver tabla arriba).
- `tests/run_pure_tests.php` — 13 casos de regresión nuevos con frases reales.

No se modificó ningún otro archivo en esta iteración. No se tocó la lógica de
continuidad de flujo, el guard de la IA (`Wa2ReplyGuard`), ni el manejo de
tickets/pagos/incidencias.

## 4. Segunda iteración — evidencia en vivo (captura de pantalla)

El usuario compartió una captura real del panel de administración mostrando el bug
en acción: cliente "PRUEBA CASA" reporta "No esta funcionando el internet", el bot
responde correctamente pidiendo reiniciar el equipo, pero al responder "sigue sin
funcionar" y después "Soporte", el bot repite dos veces seguidas el mismo mensaje
genérico ("No quiero adivinar tu consulta...") — perdiendo el hilo de una consulta
que tenía toda la información necesaria.

**Causa raíz #1 (confirmada, no adivinada):** el menú que el propio bot ofrece
("¿soporte técnico, pagos, factura, instalación o planes?") solo tenía reglas de
reconocimiento para "pagos", "instalación" y "factura" (vía catálogo entrenado).
La palabra **"soporte"**, que el bot literalmente sugiere y el cliente literalmente
escribió, no tenía ningún patrón asociado. Se verificó contra los datos reales: las
4 veces que un cliente escribió "soporte" o "soporte técnico" a solas, las 4
terminaron en `unknown` (100% de fallo). **Corregido** con un nuevo disparador en
`IntentDetector.php`.

**Causa raíz #2 (confirmada):** la función que detecta si un mensaje continúa un
flujo de soporte ya activo (`mentionsNoService()`) usaba la misma lista de frases
que el detector de intención original — que no cubría "sigue sin funcionar" (la
respuesta textual del cliente en la captura). **Corregido** en
`app/ConversationEngine.php`, en conjunto con las mismas variantes coloquiales ya
agregadas en la iteración anterior.

**Hallazgo adicional:** la tabla `wa2_settings` ya tenía columnas
`support_human_phone` y `support_handoff_alert_template` — pensadas para avisarle a
una persona cuando el bot no puede resolver algo — pero **ningún archivo del
proyecto las leía ni las usaba**. Cuando el sistema marcaba una conversación para
revisión humana (`markHumanRequired()`), nadie se enteraba salvo que alguien
revisara manualmente la pestaña "Derivaciones" del panel.

### Cambios de esta iteración

- **Freno al loop de respuestas repetidas** (`app/ConversationEngine.php`): si la
  última respuesta enviada ya fue la aclaración genérica y el sistema vuelve a no
  entender el mensaje siguiente, en vez de repetirla una tercera vez, el sistema
  pausa la IA, deriva la conversación a una persona y responde con el mensaje de
  derivación ya configurado en `wa2_settings.support_handoff_ack_message`.
- **Notificación real al humano** (`webhook.php`): cuando ocurre esa derivación, se
  envía automáticamente un WhatsApp al número configurado en
  `support_human_phone`, usando la plantilla `support_handoff_alert_template` que
  ya existía en la base. *Nota: la plantilla tiene un placeholder `{crm_url}`; como
  `admin/inbox.php` no lee parámetros por GET para abrir una conversación puntual,
  dejé un link a la bandeja general — no inventé un deep-link que no existe. Si
  quieren ese enlace directo a la conversación, es una mejora aparte.*
- Nuevo disparador para "soporte" / "soporte técnico" sueltos en
  `app/IntentDetector.php`.
- 3 casos nuevos en `tests/run_pure_tests.php` (incluye el caso exacto de la
  captura: "Soporte").

### Sobre las dos ideas que planteaste

**Menú de opciones:** tiene sentido, pero no reemplaza arreglar el reconocimiento
de texto libre — en la propia captura el bot ofreció las categorías como texto
plano (no como botones de WhatsApp) y el cliente respondió escribiendo, no
tocando nada. Un menú con botones interactivos de la API de Meta (`interactive
list message`) es una mejora real y complementaria, pero es un cambio más grande:
toca `MetaSender.php`, `webhook.php` y cómo se procesan las respuestas a botones.
Quedó fuera de esta entrega — si querés que lo arme, es un buen siguiente paso.

**Ticket como último recurso:** de acuerdo, y ya está parcialmente resuelto arriba
— agregué la derivación a humano después de 2 fallos seguidos, reutilizando el
mecanismo de aviso que ya existía pero no se disparaba. No implementé la creación
automática de un *ticket* del CRM en este punto (a diferencia de derivar a un
humano) porque en el momento del segundo `unknown` todavía no sabemos cuál es el
problema real del cliente — crear un ticket sin esa información generaría un
reclamo vacío o mal categorizado en el CRM. Derivar a una persona que lea el
historial me parece más seguro que ese ticket "a ciegas"; si preferís que además
se cree un ticket genérico automáticamente, decime y lo agrego.

### Archivos modificados en esta iteración

- `app/ConversationEngine.php` — freno al loop + continuidad de flujo ampliada.
- `app/IntentDetector.php` — disparador para "soporte"/"soporte técnico".
- `webhook.php` — notificación real al humano vía WhatsApp.
- `tests/run_pure_tests.php` — 3 casos nuevos.

## 5. Cómo verificar antes de publicar

En el VPS, con PHP disponible:

```
php tests/run_pure_tests.php /ruta/a/whatsapp_v2
```

Debería mostrar `[OK]` en los 16 casos nuevos (13 de la primera iteración + 3 de
esta) y no romper ninguno de los existentes. No pude correr esto yo mismo porque
este entorno no tiene PHP instalado — validé la lógica con una simulación
equivalente en Python contra los patrones exactos del archivo y contra las 1337
interacciones reales, pero la verificación final con el intérprete real queda
pendiente de tu lado antes de desplegar.
