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>
97 lines
3.7 KiB
Go
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]
|
|
}
|