From d7c266f1116a135f67d80d8b19404746430520a3 Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Mon, 17 Aug 2026 19:36:01 -0500 Subject: [PATCH] feat(soporte): filtro con IA para los correos entrantes, y que un correo no pueda abrir dos tickets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filtro (opcional, apagado por defecto): antes de abrir ticket, la IA lee el correo y decide si es soporte o ruido —newsletters, notificaciones de plataformas, facturas de proveedores, spam—. Falla abierto: si la IA no está configurada o se cae, el ticket se abre igual. Ignorar a un cliente es peor que tener un ticket de más. Hay un campo de contexto del negocio para los casos raros de cada uno. Los correos masivos se descartan por sus propios encabezados (List-Unsubscribe, Precedence, Auto-Submitted) sin gastar una llamada de IA. Y el motivo por el que el mismo correo se leía y se contestaba de nuevo: la deduplicación era solo un SELECT previo, que no sirve si el flag de leído no llegó a guardarse o si dos instancias leen el buzón a la vez. Ahora hay un índice único parcial sobre message_id: la base rechaza el segundo, y ese rechazo se trata como "ya estaba", no como error — sin ticket repetido y sin segunda auto-respuesta. Si algún correo no se pudo marcar como leído, "Revisar buzón ahora" lo dice en vez de dejarlo solo en el log: es la señal de que van a volver a leerse. Co-Authored-By: Claude Opus 5 --- main.go | 1 + migrations/migrate.go | 34 ++++++++ pkg/models/soporte_webhook.go | 6 ++ pkg/services/soporte_clasificador_service.go | 83 ++++++++++++++++++++ pkg/services/soporte_clasificador_test.go | 59 ++++++++++++++ pkg/services/soporte_imap_service.go | 35 +++++++-- pkg/services/soporte_ingesta_service.go | 36 +++++++++ resources/views/soporte_webhook.html | 26 ++++++ rest/controllers/soporte_controller.go | 53 +++++++------ 9 files changed, 306 insertions(+), 27 deletions(-) create mode 100644 pkg/services/soporte_clasificador_service.go create mode 100644 pkg/services/soporte_clasificador_test.go 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.

+
+