diff --git a/main.go b/main.go index 5e0bfc2..59cf7ad 100755 --- a/main.go +++ b/main.go @@ -203,6 +203,7 @@ func main() { // dejó el refactor multi-agente. Sin esto no se puede insertar nada en // uMind (ver el comentario de la función). migrations.LiberarColumnasHuerfanasUmind() + migrations.IndicesUnicosMessageID() migrations.MigrarUmindAgentes() migrations.SeedApiKeys() if n, err := models.RepararEstadosTareaInvalidos(); err != nil { diff --git a/migrations/migrate.go b/migrations/migrate.go index ec91af1..65c1543 100755 --- a/migrations/migrate.go +++ b/migrations/migrate.go @@ -1622,3 +1622,37 @@ func SeedDocumentacion() { } } } + +// IndicesUnicosMessageID crea índices únicos parciales sobre el Message-Id de +// los correos ya procesados. Es lo que hace que un mismo correo no pueda abrir +// dos tickets pase lo que pase: si el buzón vuelve a entregarlo, si el flag de +// leído no se guardó, o si dos instancias de la app lo leen a la vez, la base +// rechaza el segundo. +// +// Parcial (WHERE message_id <> ”) porque los tickets del portal no tienen +// Message-Id y todos comparten la cadena vacía. +func IndicesUnicosMessageID() { + db := app.Http.Database.DB + indices := []struct{ nombre, tabla string }{ + {"idx_tickets_message_id_unico", "proyecto_tickets"}, + {"idx_ticket_mensajes_message_id_unico", "ticket_mensajes"}, + } + for _, ix := range indices { + var existe bool + if err := db.Raw( + `SELECT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = ?)`, ix.nombre, + ).Scan(&existe).Error; err != nil || existe { + continue + } + // Si ya hay duplicados de antes, el índice no se puede crear. Se avisa y + // se sigue: el chequeo previo en código igual filtra la mayoría. + sql := fmt.Sprintf( + `CREATE UNIQUE INDEX %s ON %s (message_id) WHERE message_id <> '' AND deleted_at IS NULL`, + ix.nombre, ix.tabla) + if err := db.Exec(sql).Error; err != nil { + log.Printf("[MIGRATE] No se pudo crear %s (¿hay correos duplicados de antes?): %v", ix.nombre, err) + continue + } + log.Printf("[MIGRATE] Índice %s creado.", ix.nombre) + } +} diff --git a/pkg/models/soporte_webhook.go b/pkg/models/soporte_webhook.go index cc3ac06..ba7b0b9 100644 --- a/pkg/models/soporte_webhook.go +++ b/pkg/models/soporte_webhook.go @@ -36,6 +36,12 @@ type SoporteWebhookConfig struct { ImapEncryption string `json:"imap_encryption" gorm:"column:imap_encryption;size:20;default:'ssl'"` // ssl|starttls ImapCarpeta string `json:"imap_carpeta" gorm:"column:imap_carpeta;size:100;default:'INBOX'"` + // Filtro con IA: no todo lo que llega al buzón es soporte (newsletters, + // notificaciones de bancos, facturas de proveedores). Si está prendido, se + // clasifica cada correo nuevo antes de abrir ticket. + ClasificarConIA bool `json:"clasificar_con_ia" gorm:"column:clasificar_con_ia;default:false"` + ContextoNegocio string `json:"contexto_negocio" gorm:"column:contexto_negocio;type:text"` + // Solo para la vista: dice si ya hay contraseña guardada sin exponerla, para // que el formulario sepa que puede mandar el campo vacío sin borrarla. TieneImapPassword bool `json:"tiene_imap_password" gorm:"-"` diff --git a/pkg/services/soporte_clasificador_service.go b/pkg/services/soporte_clasificador_service.go new file mode 100644 index 0000000..7f1e307 --- /dev/null +++ b/pkg/services/soporte_clasificador_service.go @@ -0,0 +1,83 @@ +package services + +import ( + "encoding/json" + "fmt" + "log" + "strings" +) + +// ClasificacionCorreo es el veredicto sobre un correo entrante. +type ClasificacionCorreo struct { + EsSoporte bool `json:"es_soporte"` + Categoria string `json:"categoria"` // soporte | comercial | factura | newsletter | notificacion | spam | otro + Motivo string `json:"motivo"` +} + +// ClasificarCorreoSoporte decide si un correo entrante es una solicitud de +// soporte o ruido (newsletters, notificaciones automáticas, facturas de +// proveedores, spam). +// +// Falla abierto a propósito: si la IA no está configurada, se cae o devuelve +// cualquier cosa, el correo se trata como soporte. Perder el mensaje de un +// cliente porque el modelo estaba caído es mucho peor que abrir un ticket de +// más. +func ClasificarCorreoSoporte(correo CorreoSoporte, contextoNegocio string) ClasificacionCorreo { + // Los correos masivos se reconocen por sus propios encabezados; no hace + // falta gastar una llamada de IA para saber que un newsletter no es soporte. + if correo.Automatico { + return ClasificacionCorreo{ + EsSoporte: false, + Categoria: "newsletter", + Motivo: "el correo viene marcado como masivo o automático en sus encabezados", + } + } + + cuerpo := strings.TrimSpace(correo.Texto) + if len([]rune(cuerpo)) > 3000 { + cuerpo = string([]rune(cuerpo)[:3000]) + } + + sistema := `Clasificás correos que llegan a la casilla de soporte de una empresa. +Respondé SOLO con este JSON, sin explicaciones ni bloques de código: +{"es_soporte": true|false, "categoria": "soporte|comercial|factura|newsletter|notificacion|spam|otro", "motivo": "una frase corta"} + +es_soporte = true cuando una persona pide ayuda, reporta un problema, hace una +consulta sobre un servicio contratado o responde una conversación de soporte. +es_soporte = false para newsletters, promociones, notificaciones automáticas de +plataformas, facturas de proveedores y spam. +Ante la duda, es_soporte = true: es peor ignorar a un cliente que abrir un ticket de más.` + + if c := strings.TrimSpace(contextoNegocio); c != "" { + sistema += "\n\nContexto del negocio:\n" + c + } + + usuario := fmt.Sprintf("De: %s\nAsunto: %s\n\n%s", correo.From, correo.Subject, cuerpo) + + salida, err := CompletarTextoIA("ia", sistema, usuario) + if err != nil { + log.Printf("[Soporte] No se pudo clasificar el correo, se trata como soporte: %v", err) + return ClasificacionCorreo{EsSoporte: true, Categoria: "soporte", Motivo: "la clasificación falló"} + } + + var out ClasificacionCorreo + if err := json.Unmarshal([]byte(soloJSON(salida)), &out); err != nil { + log.Printf("[Soporte] Clasificación ilegible (%q), se trata como soporte", recortar(salida, 120)) + return ClasificacionCorreo{EsSoporte: true, Categoria: "soporte", Motivo: "respuesta ilegible del modelo"} + } + if out.Categoria == "" { + out.Categoria = "otro" + } + return out +} + +// soloJSON recorta lo que rodea al objeto JSON: los modelos agregan cercas de +// código o una frase antes aunque se les pida que no. +func soloJSON(s string) string { + i := strings.Index(s, "{") + j := strings.LastIndex(s, "}") + if i < 0 || j <= i { + return s + } + return s[i : j+1] +} diff --git a/pkg/services/soporte_clasificador_test.go b/pkg/services/soporte_clasificador_test.go new file mode 100644 index 0000000..46a4357 --- /dev/null +++ b/pkg/services/soporte_clasificador_test.go @@ -0,0 +1,59 @@ +package services + +import ( + "net/mail" + "strings" + "testing" +) + +// Los correos masivos se descartan por sus propios encabezados: este test pasa +// sin base de datos ni IA, que es justamente la prueba de que no las usa. +func TestClasificarCorreoAutomaticoNoGastaIA(t *testing.T) { + cl := ClasificarCorreoSoporte(CorreoSoporte{ + From: "news@marketing.com", Subject: "20% off", Texto: "Oferta", Automatico: true, + }, "") + if cl.EsSoporte { + t.Error("un correo masivo no debería contar como soporte") + } + if cl.Categoria != "newsletter" { + t.Errorf("Categoria = %q", cl.Categoria) + } +} + +func TestEsCorreoAutomatico(t *testing.T) { + casos := []struct { + nombre string + cabeceras string + want bool + }{ + {"newsletter", "List-Unsubscribe: \r\n", true}, + {"lista", "List-Id: \r\n", true}, + {"bulk", "Precedence: bulk\r\n", true}, + {"auto-generado", "Auto-Submitted: auto-generated\r\n", true}, + {"auto-submitted no", "Auto-Submitted: no\r\n", false}, + {"persona", "", false}, + } + for _, c := range casos { + msg, err := mail.ReadMessage(strings.NewReader( + "From: a@b.com\r\nSubject: x\r\n" + c.cabeceras + "\r\ncuerpo\r\n")) + if err != nil { + t.Fatalf("%s: %v", c.nombre, err) + } + if got := esCorreoAutomatico(msg.Header); got != c.want { + t.Errorf("%s: esCorreoAutomatico = %v, want %v", c.nombre, got, c.want) + } + } +} + +func TestSoloJSON(t *testing.T) { + casos := map[string]string{ + "```json\n{\"es_soporte\":true}\n```": `{"es_soporte":true}`, + "Claro:\n{\"es_soporte\":false}\nEspero...": `{"es_soporte":false}`, + `{"es_soporte":true}`: `{"es_soporte":true}`, + } + for in, want := range casos { + if got := soloJSON(in); got != want { + t.Errorf("soloJSON(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/pkg/services/soporte_imap_service.go b/pkg/services/soporte_imap_service.go index 91c2226..092c28d 100644 --- a/pkg/services/soporte_imap_service.go +++ b/pkg/services/soporte_imap_service.go @@ -133,6 +133,7 @@ func revisarBuzon(cfg *models.SoporteWebhookConfig) (int, int, error) { if len(correos) == 0 { return 0, 0, nil } + fallosAlMarcar := 0 procesados := 0 for _, correo := range correos { @@ -143,8 +144,14 @@ func revisarBuzon(cfg *models.SoporteWebhookConfig) (int, int, error) { // vuelve a bajar en cada corrida para siempre. if err := MarcarLeido(c, correo.UID); err != nil { log.Printf("[SoporteIMAP] no se pudo marcar leído el uid=%v: %v", correo.UID, err) + fallosAlMarcar++ } } + if fallosAlMarcar > 0 { + 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", + procesados, fallosAlMarcar) + } return len(correos), procesados, nil } @@ -247,11 +254,12 @@ func parsearCorreo(crudo []byte) (CorreoSoporte, error) { } return CorreoSoporte{ - From: from, - FromName: nombre, - Subject: decodificar(msg.Header.Get("Subject")), - Texto: limpiarCitas(cuerpo), - MessageID: strings.TrimSpace(msg.Header.Get("Message-Id")), + From: from, + FromName: nombre, + Subject: decodificar(msg.Header.Get("Subject")), + Texto: limpiarCitas(cuerpo), + MessageID: strings.TrimSpace(msg.Header.Get("Message-Id")), + Automatico: esCorreoAutomatico(msg.Header), }, nil } @@ -334,3 +342,20 @@ func limpiarCitas(texto string) string { } return strings.TrimSpace(strings.Join(out, "\n")) } + +// esCorreoAutomatico reconoce newsletters y notificaciones de máquina por sus +// encabezados estándar. Es gratis y no se equivoca, así que va antes que +// cualquier modelo. +func esCorreoAutomatico(h mail.Header) bool { + if h.Get("List-Unsubscribe") != "" || h.Get("List-Id") != "" { + return true + } + switch strings.ToLower(strings.TrimSpace(h.Get("Precedence"))) { + case "bulk", "list", "junk": + return true + } + if a := strings.ToLower(strings.TrimSpace(h.Get("Auto-Submitted"))); a != "" && a != "no" { + return true + } + return false +} diff --git a/pkg/services/soporte_ingesta_service.go b/pkg/services/soporte_ingesta_service.go index 9e23df1..8723a05 100644 --- a/pkg/services/soporte_ingesta_service.go +++ b/pkg/services/soporte_ingesta_service.go @@ -1,12 +1,14 @@ package services import ( + "errors" "log" "regexp" "strconv" "strings" "github.com/sujit-baniya/fiber-boilerplate/pkg/models" + "gorm.io/gorm" ) // CorreoSoporte es un correo entrante ya normalizado, sin importar por dónde @@ -18,6 +20,9 @@ type CorreoSoporte struct { Subject string Texto string MessageID string + // Automatico marca los correos masivos o generados por una máquina, según + // sus propios encabezados (List-Unsubscribe, Precedence, Auto-Submitted). + Automatico bool } var ticketRefRe = regexp.MustCompile(`(?i)\[Ticket #(\d+)\]`) @@ -69,6 +74,10 @@ func IngestarCorreoSoporte(cfg *models.SoporteWebhookConfig, e CorreoSoporte) bo MessageID: e.MessageID, } if err := models.CreateTicketMensaje(msg); err != nil { + if esCorreoDuplicado(err) { + log.Printf("[Soporte] El correo %s ya estaba en el ticket #%d", e.MessageID, hilo.ID) + return false + } log.Printf("[Soporte] Error agregando mensaje al ticket #%d: %v", hilo.ID, err) return false } @@ -80,6 +89,16 @@ func IngestarCorreoSoporte(cfg *models.SoporteWebhookConfig, e CorreoSoporte) bo return true } + // Filtro con IA: solo para tickets nuevos. Una respuesta a un hilo ya + // existente es soporte por definición, no hace falta preguntárselo a nadie. + if cfg != nil && cfg.ClasificarConIA { + if cl := ClasificarCorreoSoporte(e, cfg.ContextoNegocio); !cl.EsSoporte { + log.Printf("[Soporte] Descartado por el filtro (%s): %q de %s — %s", + cl.Categoria, e.Subject, fromEmail, cl.Motivo) + return false + } + } + ticket := &models.ProyectoTicket{ AutorNombre: fromName, EmailFrom: fromEmail, @@ -93,6 +112,12 @@ func IngestarCorreoSoporte(cfg *models.SoporteWebhookConfig, e CorreoSoporte) bo ticket.AsignadoA = cfg.AsignarA } if err := models.CreateProyectoTicket(ticket); err != nil { + // El índice único sobre message_id es la última defensa contra el mismo + // correo entrando dos veces. Si salta, no es un error: es que ya estaba. + if esCorreoDuplicado(err) { + log.Printf("[Soporte] Correo duplicado rechazado por la base (message_id=%s)", e.MessageID) + return false + } log.Printf("[Soporte] Error creando ticket: %v", err) return false } @@ -206,3 +231,14 @@ func ResumirTextoSoporte(texto string) string { } return string([]rune(t)[:700]) + "…" } + +// esCorreoDuplicado reconoce el rechazo del índice único de message_id. +func esCorreoDuplicado(err error) bool { + if err == nil { + return false + } + if errors.Is(err, gorm.ErrDuplicatedKey) { + return true + } + return strings.Contains(strings.ToLower(err.Error()), "duplicate key value") +} diff --git a/resources/views/soporte_webhook.html b/resources/views/soporte_webhook.html index b2eecfe..cee7399 100644 --- a/resources/views/soporte_webhook.html +++ b/resources/views/soporte_webhook.html @@ -180,6 +180,28 @@

Probá la conexión después de guardar: la contraseña se cifra al guardarse.

+ +
+
+

Filtrar con IA

+ +
+

+ 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 + ticket, pero el correo sigue en el buzón y queda registrado en el log. + Ante la duda abre el ticket: es peor ignorar a un cliente que tener uno de más. + Si la IA no responde, el ticket se abre igual. +

+ + +

Ayuda a la IA a distinguir tus casos raros. Los correos masivos se descartan por sus encabezados, sin gastar una llamada.

+
+