Files
soft_usite/pkg/services/soporte_clasificador_service.go
T
Lizandro GuarnizoandClaude Opus 5 bfcc320b7c feat(soporte): el ticket de correo ya sabe de quién es, y llega con prioridad
Un ticket que entraba por correo guardaba la dirección como texto suelto: no se
podía cruzar con nada, el cliente no lo veía en su portal y no había forma de
pedir "todos los tickets de Acme". Ahora al crearlo se resuelve el remitente —
primero contra los usuarios del portal, si no contra el correo del cliente— y el
ticket queda atado a quien escribió. El que no matchea queda como contacto
externo, que también es información.

Solo coincidencia exacta de dirección, nunca por dominio: con gmail.com de por
medio, adivinar ata el ticket al cliente equivocado.

Y la llamada de IA que ya clasificaba el correo ahora devuelve también prioridad
y categoría en el mismo JSON: sin costo ni latencia extra. El campo prioridad
existía desde siempre y nadie lo llenaba. La urgencia la define el problema
descrito, no el tono del mensaje — está dicho en el prompt.

En la lista de tickets se ven los tres datos nuevos: cliente (o "externo"),
categoría y la prioridad que ya se mostraba.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 19:43:28 -05:00

97 lines
3.7 KiB
Go

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"` // error | facturacion | acceso | consulta | comercial | newsletter | spam | otro
Prioridad string `json:"prioridad"` // baja | media | alta | urgente
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",
Prioridad: "baja",
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": "...", "prioridad": "...", "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.
categoria: error | facturacion | acceso | consulta | comercial | newsletter | spam | otro
prioridad: urgente si algo está caído, si hay plata o datos en riesgo, o si el
cliente dice que está bloqueado; alta si no puede trabajar pero tiene cómo
seguir; media para el resto; baja para consultas sin apuro y para lo que no es
soporte. La urgencia la da el problema, no el tono del mensaje.`
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: "otro", Prioridad: "media", 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: "otro", Prioridad: "media", Motivo: "respuesta ilegible del modelo"}
}
if out.Categoria == "" {
out.Categoria = "otro"
}
switch out.Prioridad {
case "baja", "media", "alta", "urgente":
default:
out.Prioridad = "media"
}
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]
}