fix(soporte): el filtro se estaba comiendo correos nuevos, y nadie se enteraba
Una respuesta a un hilo existente saltea el clasificador; un correo nuevo no. Por eso el sistema parecía funcionar solo con las respuestas: los correos nuevos que el modelo consideraba "no soporte" desaparecían sin ticket, sin acuse al cliente y sin más rastro que un renglón de log que nadie mira. Dos cambios: - El filtro solo opina sobre desconocidos. Si el remitente es un cliente o un usuario del portal, siempre se abre el ticket. Para eso la resolución del remitente pasa a correr antes del filtro. A un cliente registrado no se le descarta el correo por lo que diga un modelo. - Lo descartado se ve. "Revisar buzón ahora" informa cuántos dejó afuera el filtro, con asunto, remitente y motivo. Guardado en memoria: es diagnóstico de hace un rato, no algo que valga una tabla. Además, si la respuesta automática está apagada, el log lo dice al crear el ticket — era la otra explicación posible de "no me contestó" y no se distinguía. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1b599606f7
commit
4674413257
@@ -139,11 +139,15 @@ func revisarBuzon(cfg *models.SoporteWebhookConfig) (int, int, error) {
|
|||||||
return 0, 0, nil
|
return 0, 0, nil
|
||||||
}
|
}
|
||||||
fallosAlMarcar := 0
|
fallosAlMarcar := 0
|
||||||
|
var descartes []string
|
||||||
|
|
||||||
procesados := 0
|
procesados := 0
|
||||||
for _, correo := range correos {
|
for _, correo := range correos {
|
||||||
if IngestarCorreoSoporte(cfg, correo.Correo) {
|
ok, descarte := IngestarCorreoSoporteConDetalle(cfg, correo.Correo)
|
||||||
|
if ok {
|
||||||
procesados++
|
procesados++
|
||||||
|
} else if descarte != "" {
|
||||||
|
descartes = append(descartes, descarte)
|
||||||
}
|
}
|
||||||
// Se marca leído aunque se haya ignorado por duplicado: si no, se
|
// Se marca leído aunque se haya ignorado por duplicado: si no, se
|
||||||
// vuelve a bajar en cada corrida para siempre.
|
// vuelve a bajar en cada corrida para siempre.
|
||||||
@@ -152,6 +156,7 @@ func revisarBuzon(cfg *models.SoporteWebhookConfig) (int, int, error) {
|
|||||||
fallosAlMarcar++
|
fallosAlMarcar++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ultimosDescartes.guardar(descartes)
|
||||||
if fallosAlMarcar > 0 {
|
if fallosAlMarcar > 0 {
|
||||||
return len(correos), procesados, fmt.Errorf(
|
return len(correos), procesados, fmt.Errorf(
|
||||||
"se procesaron %d correo(s), pero %d no se pudieron marcar como leídos en el servidor: van a volver a leerse",
|
"se procesaron %d correo(s), pero %d no se pudieron marcar como leídos en el servidor: van a volver a leerse",
|
||||||
@@ -160,6 +165,32 @@ func revisarBuzon(cfg *models.SoporteWebhookConfig) (int, int, error) {
|
|||||||
return len(correos), procesados, nil
|
return len(correos), procesados, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ultimosDescartes guarda lo que el filtro dejó afuera en la última corrida,
|
||||||
|
// para poder mostrarlo en la pantalla de configuración. En memoria a propósito:
|
||||||
|
// es información de diagnóstico de hace un rato, no algo que valga una tabla.
|
||||||
|
var ultimosDescartes = &descartesRecientes{}
|
||||||
|
|
||||||
|
type descartesRecientes struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
items []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *descartesRecientes) guardar(items []string) {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
if len(items) > 0 {
|
||||||
|
d.items = items
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UltimosCorreosDescartados devuelve los descartes de la última corrida que
|
||||||
|
// tuvo alguno.
|
||||||
|
func UltimosCorreosDescartados() []string {
|
||||||
|
ultimosDescartes.mu.Lock()
|
||||||
|
defer ultimosDescartes.mu.Unlock()
|
||||||
|
return append([]string(nil), ultimosDescartes.items...)
|
||||||
|
}
|
||||||
|
|
||||||
// criterioNoLeidos arma la búsqueda: no leídos y, si hay ventana configurada,
|
// criterioNoLeidos arma la búsqueda: no leídos y, si hay ventana configurada,
|
||||||
// recibidos dentro de ella.
|
// recibidos dentro de ella.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package services
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -31,9 +32,17 @@ var ticketRefRe = regexp.MustCompile(`(?i)\[Ticket #(\d+)\]`)
|
|||||||
// ticket existente. Devuelve true si se procesó algo (false = ignorado por
|
// ticket existente. Devuelve true si se procesó algo (false = ignorado por
|
||||||
// duplicado o por venir vacío).
|
// duplicado o por venir vacío).
|
||||||
func IngestarCorreoSoporte(cfg *models.SoporteWebhookConfig, e CorreoSoporte) bool {
|
func IngestarCorreoSoporte(cfg *models.SoporteWebhookConfig, e CorreoSoporte) bool {
|
||||||
|
ok, _ := IngestarCorreoSoporteConDetalle(cfg, e)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// IngestarCorreoSoporteConDetalle es la misma ingesta, pero además dice por qué
|
||||||
|
// se descartó un correo. Un descarte que solo va al log es invisible: si el
|
||||||
|
// filtro se come el correo de un cliente, nadie se entera.
|
||||||
|
func IngestarCorreoSoporteConDetalle(cfg *models.SoporteWebhookConfig, e CorreoSoporte) (procesado bool, descarte string) {
|
||||||
if strings.TrimSpace(e.From) == "" {
|
if strings.TrimSpace(e.From) == "" {
|
||||||
log.Printf("[Soporte] Correo sin remitente, ignorado (asunto=%q)", e.Subject)
|
log.Printf("[Soporte] Correo sin remitente, ignorado (asunto=%q)", e.Subject)
|
||||||
return false
|
return false, ""
|
||||||
}
|
}
|
||||||
// Un correo sin asunto es raro pero existe, y perderlo en silencio es peor
|
// Un correo sin asunto es raro pero existe, y perderlo en silencio es peor
|
||||||
// que abrir un ticket con un título feo.
|
// que abrir un ticket con un título feo.
|
||||||
@@ -45,7 +54,7 @@ func IngestarCorreoSoporte(cfg *models.SoporteWebhookConfig, e CorreoSoporte) bo
|
|||||||
// puede releer un correo si falló el marcado como leído.
|
// puede releer un correo si falló el marcado como leído.
|
||||||
if models.EmailMessageIDYaProcesado(e.MessageID) {
|
if models.EmailMessageIDYaProcesado(e.MessageID) {
|
||||||
log.Printf("[Soporte] Correo duplicado ignorado (message_id=%s)", e.MessageID)
|
log.Printf("[Soporte] Correo duplicado ignorado (message_id=%s)", e.MessageID)
|
||||||
return false
|
return false, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
fromEmail := ExtraerEmail(e.From)
|
fromEmail := ExtraerEmail(e.From)
|
||||||
@@ -76,17 +85,17 @@ func IngestarCorreoSoporte(cfg *models.SoporteWebhookConfig, e CorreoSoporte) bo
|
|||||||
if err := models.CreateTicketMensaje(msg); err != nil {
|
if err := models.CreateTicketMensaje(msg); err != nil {
|
||||||
if esCorreoDuplicado(err) {
|
if esCorreoDuplicado(err) {
|
||||||
log.Printf("[Soporte] El correo %s ya estaba en el ticket #%d", e.MessageID, hilo.ID)
|
log.Printf("[Soporte] El correo %s ya estaba en el ticket #%d", e.MessageID, hilo.ID)
|
||||||
return false
|
return false, ""
|
||||||
}
|
}
|
||||||
log.Printf("[Soporte] Error agregando mensaje al ticket #%d: %v", hilo.ID, err)
|
log.Printf("[Soporte] Error agregando mensaje al ticket #%d: %v", hilo.ID, err)
|
||||||
return false
|
return false, ""
|
||||||
}
|
}
|
||||||
if hilo.Estado == "resuelto" || hilo.Estado == "cerrado" {
|
if hilo.Estado == "resuelto" || hilo.Estado == "cerrado" {
|
||||||
_ = models.UpdateTicketEstado(hilo.ID, "abierto")
|
_ = models.UpdateTicketEstado(hilo.ID, "abierto")
|
||||||
}
|
}
|
||||||
log.Printf("[Soporte] Respuesta agregada al ticket #%d (%s)", hilo.ID, fromEmail)
|
log.Printf("[Soporte] Respuesta agregada al ticket #%d (%s)", hilo.ID, fromEmail)
|
||||||
notificarTicketDeCorreo(hilo, contenido, true)
|
notificarTicketDeCorreo(hilo, contenido, true)
|
||||||
return true
|
return true, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
ticket := &models.ProyectoTicket{
|
ticket := &models.ProyectoTicket{
|
||||||
@@ -99,23 +108,26 @@ func IngestarCorreoSoporte(cfg *models.SoporteWebhookConfig, e CorreoSoporte) bo
|
|||||||
MessageID: e.MessageID,
|
MessageID: e.MessageID,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filtro y triage con IA: solo para tickets nuevos. Una respuesta a un hilo
|
// De quién es: sin esto un ticket de correo es un texto suelto que no se
|
||||||
// ya existente es soporte por definición, no hace falta preguntárselo a nadie.
|
// puede cruzar con nada. Si no matchea nadie, queda como contacto externo.
|
||||||
if cfg != nil && cfg.ClasificarConIA {
|
// Va antes del filtro a propósito: saber quién escribe cambia si se filtra.
|
||||||
|
resolverRemitente(ticket, fromEmail)
|
||||||
|
|
||||||
|
// Filtro y triage con IA: solo para tickets nuevos y solo para desconocidos.
|
||||||
|
// Una respuesta a un hilo es soporte por definición, y a un cliente
|
||||||
|
// registrado no se le descarta el correo por lo que opine un modelo.
|
||||||
|
conocido := ticket.ClienteID != nil || ticket.PortalUserID != nil
|
||||||
|
if cfg != nil && cfg.ClasificarConIA && !conocido {
|
||||||
cl := ClasificarCorreoSoporte(e, cfg.ContextoNegocio)
|
cl := ClasificarCorreoSoporte(e, cfg.ContextoNegocio)
|
||||||
if !cl.EsSoporte {
|
if !cl.EsSoporte {
|
||||||
log.Printf("[Soporte] Descartado por el filtro (%s): %q de %s — %s",
|
motivo := fmt.Sprintf("%q de %s — %s (%s)", e.Subject, fromEmail, cl.Motivo, cl.Categoria)
|
||||||
cl.Categoria, e.Subject, fromEmail, cl.Motivo)
|
log.Printf("[Soporte] Descartado por el filtro: %s", motivo)
|
||||||
return false
|
return false, motivo
|
||||||
}
|
}
|
||||||
ticket.Categoria = cl.Categoria
|
ticket.Categoria = cl.Categoria
|
||||||
ticket.Prioridad = cl.Prioridad
|
ticket.Prioridad = cl.Prioridad
|
||||||
}
|
}
|
||||||
|
|
||||||
// De quién es: sin esto un ticket de correo es un texto suelto que no se
|
|
||||||
// puede cruzar con nada. Si no matchea nadie, queda como contacto externo.
|
|
||||||
resolverRemitente(ticket, fromEmail)
|
|
||||||
|
|
||||||
if cfg != nil && cfg.AsignarA != nil {
|
if cfg != nil && cfg.AsignarA != nil {
|
||||||
ticket.AsignadoA = cfg.AsignarA
|
ticket.AsignadoA = cfg.AsignarA
|
||||||
}
|
}
|
||||||
@@ -124,18 +136,20 @@ func IngestarCorreoSoporte(cfg *models.SoporteWebhookConfig, e CorreoSoporte) bo
|
|||||||
// correo entrando dos veces. Si salta, no es un error: es que ya estaba.
|
// correo entrando dos veces. Si salta, no es un error: es que ya estaba.
|
||||||
if esCorreoDuplicado(err) {
|
if esCorreoDuplicado(err) {
|
||||||
log.Printf("[Soporte] Correo duplicado rechazado por la base (message_id=%s)", e.MessageID)
|
log.Printf("[Soporte] Correo duplicado rechazado por la base (message_id=%s)", e.MessageID)
|
||||||
return false
|
return false, ""
|
||||||
}
|
}
|
||||||
log.Printf("[Soporte] Error creando ticket: %v", err)
|
log.Printf("[Soporte] Error creando ticket: %v", err)
|
||||||
return false
|
return false, ""
|
||||||
}
|
}
|
||||||
log.Printf("[Soporte] Ticket #%d creado desde email (%s): %s", ticket.ID, fromEmail, e.Subject)
|
log.Printf("[Soporte] Ticket #%d creado desde email (%s): %s", ticket.ID, fromEmail, e.Subject)
|
||||||
|
|
||||||
notificarTicketDeCorreo(ticket, contenido, false)
|
notificarTicketDeCorreo(ticket, contenido, false)
|
||||||
if cfg != nil && cfg.ResponderAuto {
|
if cfg != nil && cfg.ResponderAuto {
|
||||||
SendSoporteAutoRespuesta(ticket)
|
SendSoporteAutoRespuesta(ticket)
|
||||||
|
} else {
|
||||||
|
log.Printf("[Soporte] Ticket #%d sin acuse al cliente: la respuesta automática está desactivada", ticket.ID)
|
||||||
}
|
}
|
||||||
return true
|
return true, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// buscarTicketDeHilo intenta encontrar el ticket al que pertenece una respuesta:
|
// buscarTicketDeHilo intenta encontrar el ticket al que pertenece una respuesta:
|
||||||
|
|||||||
@@ -205,10 +205,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-slate-400 mb-3">
|
<p class="text-xs text-slate-400 mb-3">
|
||||||
Antes de abrir un ticket, la IA lee el correo y decide si es una solicitud de soporte o ruido
|
Antes de abrir un ticket, la IA lee el correo y decide si es una solicitud de soporte o ruido
|
||||||
(newsletters, notificaciones automáticas, facturas de proveedores, spam). Lo que descarta no abre
|
(newsletters, notificaciones automáticas, facturas de proveedores, spam).
|
||||||
ticket, pero el correo sigue en el buzón y queda registrado en el log.
|
<strong>Solo se filtra a los desconocidos</strong>: si el remitente es un cliente o un usuario del
|
||||||
Ante la duda abre el ticket: es peor ignorar a un cliente que tener uno de más.
|
portal, siempre abre ticket. Ante la duda también abre: es peor ignorar a alguien que tener un
|
||||||
Si la IA no responde, el ticket se abre igual.
|
ticket de más, y si la IA no responde el ticket se abre igual.
|
||||||
|
Lo descartado no abre ticket, pero el correo sigue en el buzón y "Revisar buzón ahora" te dice qué
|
||||||
|
dejó afuera y por qué.
|
||||||
</p>
|
</p>
|
||||||
<label class="block text-xs font-medium text-slate-600 mb-1">Contexto del negocio (opcional)</label>
|
<label class="block text-xs font-medium text-slate-600 mb-1">Contexto del negocio (opcional)</label>
|
||||||
<textarea x-model="cfg.contexto_negocio" rows="3"
|
<textarea x-model="cfg.contexto_negocio" rows="3"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||||
@@ -336,8 +337,13 @@ func RevisarBuzonAhora(c *fiber.Ctx) error {
|
|||||||
if encontrados == 0 {
|
if encontrados == 0 {
|
||||||
return c.JSON(fiber.Map{"ok": true, "message": "Conectó bien, pero no hay correos sin leer en el buzón"})
|
return c.JSON(fiber.Map{"ok": true, "message": "Conectó bien, pero no hay correos sin leer en el buzón"})
|
||||||
}
|
}
|
||||||
return c.JSON(fiber.Map{"ok": true, "message": fmt.Sprintf(
|
msg := fmt.Sprintf("%d correo(s) sin leer, %d convertido(s) en ticket. El resto ya estaba registrado.", encontrados, procesados)
|
||||||
"%d correo(s) sin leer, %d convertido(s) en ticket. El resto ya estaba registrado.", encontrados, procesados)})
|
// Si el filtro descartó algo hay que decirlo acá: un correo de un cliente
|
||||||
|
// que se comió la IA es exactamente lo que nadie va a ir a buscar al log.
|
||||||
|
if descartes := services.UltimosCorreosDescartados(); len(descartes) > 0 {
|
||||||
|
msg += fmt.Sprintf(" El filtro descartó %d: %s", len(descartes), strings.Join(descartes, " | "))
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"ok": true, "message": msg})
|
||||||
}
|
}
|
||||||
|
|
||||||
// BorradorRespuestaTicket propone una respuesta usando la base de conocimiento.
|
// BorradorRespuestaTicket propone una respuesta usando la base de conocimiento.
|
||||||
|
|||||||
Reference in New Issue
Block a user