Compare commits
2
Commits
6afad025c2
...
1b599606f7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b599606f7 | ||
|
|
bfcc320b7c |
@@ -21,6 +21,13 @@ type ProyectoTicket struct {
|
|||||||
AsignadoA *uint `json:"asignado_a" gorm:"column:asignado_a;index"`
|
AsignadoA *uint `json:"asignado_a" gorm:"column:asignado_a;index"`
|
||||||
Asignado *Users `json:"asignado" gorm:"foreignKey:AsignadoA"`
|
Asignado *Users `json:"asignado" gorm:"foreignKey:AsignadoA"`
|
||||||
MessageID string `json:"message_id" gorm:"column:message_id;size:255;index"` // Message-Id del correo que originó el ticket (dedup)
|
MessageID string `json:"message_id" gorm:"column:message_id;size:255;index"` // Message-Id del correo que originó el ticket (dedup)
|
||||||
|
// ClienteID se resuelve al crear el ticket a partir del remitente. Si queda
|
||||||
|
// en nil el que escribió no está registrado: es un contacto externo, y eso
|
||||||
|
// también es información (no hay un campo aparte para "externo", es esto).
|
||||||
|
ClienteID *uint `json:"cliente_id" gorm:"column:cliente_id;index"`
|
||||||
|
Cliente *Cliente `json:"cliente" gorm:"foreignKey:ClienteID"`
|
||||||
|
// Categoria la pone el clasificador: error | facturacion | acceso | consulta | otro
|
||||||
|
Categoria string `json:"categoria" gorm:"column:categoria;size:40;index"`
|
||||||
Mensajes []TicketMensaje `json:"mensajes" gorm:"foreignKey:TicketID"`
|
Mensajes []TicketMensaje `json:"mensajes" gorm:"foreignKey:TicketID"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +112,7 @@ func GetTicketsByPortalUser(portalUserID uint) ([]ProyectoTicket, error) {
|
|||||||
|
|
||||||
func GetAllTickets(estado string) ([]ProyectoTicket, error) {
|
func GetAllTickets(estado string) ([]ProyectoTicket, error) {
|
||||||
var items []ProyectoTicket
|
var items []ProyectoTicket
|
||||||
db := app.Http.Database.DB.Preload("Mensajes").Preload("Asignado").Order("created_at DESC")
|
db := app.Http.Database.DB.Preload("Mensajes").Preload("Asignado").Preload("Cliente").Order("created_at DESC")
|
||||||
if estado != "" && estado != "todos" {
|
if estado != "" && estado != "todos" {
|
||||||
db = db.Where("estado = ?", estado)
|
db = db.Where("estado = ?", estado)
|
||||||
}
|
}
|
||||||
@@ -142,3 +149,18 @@ func MarkTicketMessagesReadByPortal(ticketID uint) error {
|
|||||||
Where("ticket_id = ? AND es_admin = true AND leido_portal = false", ticketID).
|
Where("ticket_id = ? AND es_admin = true AND leido_portal = false", ticketID).
|
||||||
Update("leido_portal", true).Error
|
Update("leido_portal", true).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetClientePorEmail busca un cliente por su dirección de correo, exacta y sin
|
||||||
|
// distinguir mayúsculas. A propósito no se busca por dominio: con gmail.com o
|
||||||
|
// hotmail.com de por medio, adivinar por dominio ata tickets al cliente
|
||||||
|
// equivocado.
|
||||||
|
func GetClientePorEmail(email string) (*Cliente, error) {
|
||||||
|
var c Cliente
|
||||||
|
err := app.Http.Database.DB.
|
||||||
|
Where("LOWER(email) = LOWER(?) OR LOWER(email_cc) = LOWER(?)", email, email).
|
||||||
|
First(&c).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &c, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ type SoporteWebhookConfig struct {
|
|||||||
// clasifica cada correo nuevo antes de abrir ticket.
|
// clasifica cada correo nuevo antes de abrir ticket.
|
||||||
ClasificarConIA bool `json:"clasificar_con_ia" gorm:"column:clasificar_con_ia;default:false"`
|
ClasificarConIA bool `json:"clasificar_con_ia" gorm:"column:clasificar_con_ia;default:false"`
|
||||||
ContextoNegocio string `json:"contexto_negocio" gorm:"column:contexto_negocio;type:text"`
|
ContextoNegocio string `json:"contexto_negocio" gorm:"column:contexto_negocio;type:text"`
|
||||||
|
// Agente de uMind cuya base de conocimiento se usa para redactar borradores
|
||||||
|
// de respuesta. Sin agente el borrador se arma solo con la conversación.
|
||||||
|
AgenteBorradorID *uint `json:"agente_borrador_id" gorm:"column:agente_borrador_id;index"`
|
||||||
|
|
||||||
// Solo para la vista: dice si ya hay contraseña guardada sin exponerla, para
|
// 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.
|
// que el formulario sepa que puede mandar el campo vacío sin borrarla.
|
||||||
|
|||||||
@@ -47,6 +47,15 @@ func GetUmindAgentesByTenant(tenantID uint) ([]UmindAgente, error) {
|
|||||||
return items, err
|
return items, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetTodosLosUmindAgentes lista todos los agentes de todos los tenants. Es solo
|
||||||
|
// para pantallas de staff que necesitan elegir uno (ej. de qué base de
|
||||||
|
// conocimiento salen los borradores de soporte).
|
||||||
|
func GetTodosLosUmindAgentes() ([]UmindAgente, error) {
|
||||||
|
var items []UmindAgente
|
||||||
|
err := app.Http.Database.DB.Order("nombre ASC").Find(&items).Error
|
||||||
|
return items, err
|
||||||
|
}
|
||||||
|
|
||||||
func GetUmindAgenteByID(id uint) (*UmindAgente, error) {
|
func GetUmindAgenteByID(id uint) (*UmindAgente, error) {
|
||||||
var a UmindAgente
|
var a UmindAgente
|
||||||
if err := app.Http.Database.DB.First(&a, id).Error; err != nil {
|
if err := app.Http.Database.DB.First(&a, id).Error; err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RedactarBorradorTicket propone una respuesta para un ticket, apoyándose en la
|
||||||
|
// base de conocimiento del agente de uMind que se haya elegido en la
|
||||||
|
// configuración de soporte.
|
||||||
|
//
|
||||||
|
// Devuelve un borrador, no una respuesta: no se manda a nadie. Contestarle solo
|
||||||
|
// a un cliente con lo que dijo un modelo es la forma más rápida de perderlo;
|
||||||
|
// acá lo lee una persona, lo corrige y aprieta enviar.
|
||||||
|
func RedactarBorradorTicket(ticket *models.ProyectoTicket) (string, error) {
|
||||||
|
if ticket == nil {
|
||||||
|
return "", fmt.Errorf("ticket no encontrado")
|
||||||
|
}
|
||||||
|
cfg, _ := models.GetSoporteWebhookActivo()
|
||||||
|
|
||||||
|
var conversacion strings.Builder
|
||||||
|
fmt.Fprintf(&conversacion, "Asunto: %s\n\n%s: %s\n", ticket.Titulo, ticket.AutorNombre, ticket.Descripcion)
|
||||||
|
for _, m := range ticket.Mensajes {
|
||||||
|
quien := ticket.AutorNombre
|
||||||
|
if m.EsAdmin {
|
||||||
|
quien = "Soporte"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&conversacion, "\n%s: %s\n", quien, m.Contenido)
|
||||||
|
}
|
||||||
|
|
||||||
|
sistema := `Redactás borradores de respuesta para un equipo de soporte. Los lee una
|
||||||
|
persona antes de enviarlos.
|
||||||
|
|
||||||
|
- Escribí en el mismo idioma que usó el cliente.
|
||||||
|
- Respondé lo que preguntó, directo, sin relleno ni frases de manual.
|
||||||
|
- Usá SOLO lo que esté en la documentación de referencia y en la conversación.
|
||||||
|
Si falta información para resolverlo, decilo y proponé qué preguntarle.
|
||||||
|
- Nunca inventes precios, plazos, funciones ni pasos que no estén escritos.
|
||||||
|
- Sin asunto, sin firma: solo el cuerpo del mensaje.`
|
||||||
|
|
||||||
|
if cfg != nil && strings.TrimSpace(cfg.ContextoNegocio) != "" {
|
||||||
|
sistema += "\n\nContexto del negocio:\n" + strings.TrimSpace(cfg.ContextoNegocio)
|
||||||
|
}
|
||||||
|
|
||||||
|
usuario := conversacion.String()
|
||||||
|
if doc := documentacionRelevante(cfg, ticket); doc != "" {
|
||||||
|
usuario = "Documentación de referencia:\n---\n" + doc + "\n---\n\nConversación:\n" + usuario
|
||||||
|
}
|
||||||
|
|
||||||
|
borrador, err := CompletarTextoIA("ia", sistema, usuario)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(borrador), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// documentacionRelevante busca en la base de conocimiento del agente elegido.
|
||||||
|
// Sin agente configurado devuelve vacío y el borrador se arma solo con la
|
||||||
|
// conversación — peor, pero mejor que un error.
|
||||||
|
func documentacionRelevante(cfg *models.SoporteWebhookConfig, ticket *models.ProyectoTicket) string {
|
||||||
|
if cfg == nil || cfg.AgenteBorradorID == nil || *cfg.AgenteBorradorID == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
consulta := ticket.Titulo + "\n" + ticket.Descripcion
|
||||||
|
chunks, err := BuscarConocimiento(*cfg.AgenteBorradorID, consulta, 4)
|
||||||
|
if err != nil || len(chunks) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
for _, ch := range chunks {
|
||||||
|
b.WriteString(strings.TrimSpace(ch.Contenido))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(b.String())
|
||||||
|
}
|
||||||
@@ -10,7 +10,8 @@ import (
|
|||||||
// ClasificacionCorreo es el veredicto sobre un correo entrante.
|
// ClasificacionCorreo es el veredicto sobre un correo entrante.
|
||||||
type ClasificacionCorreo struct {
|
type ClasificacionCorreo struct {
|
||||||
EsSoporte bool `json:"es_soporte"`
|
EsSoporte bool `json:"es_soporte"`
|
||||||
Categoria string `json:"categoria"` // soporte | comercial | factura | newsletter | notificacion | spam | otro
|
Categoria string `json:"categoria"` // error | facturacion | acceso | consulta | comercial | newsletter | spam | otro
|
||||||
|
Prioridad string `json:"prioridad"` // baja | media | alta | urgente
|
||||||
Motivo string `json:"motivo"`
|
Motivo string `json:"motivo"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ func ClasificarCorreoSoporte(correo CorreoSoporte, contextoNegocio string) Clasi
|
|||||||
return ClasificacionCorreo{
|
return ClasificacionCorreo{
|
||||||
EsSoporte: false,
|
EsSoporte: false,
|
||||||
Categoria: "newsletter",
|
Categoria: "newsletter",
|
||||||
|
Prioridad: "baja",
|
||||||
Motivo: "el correo viene marcado como masivo o automático en sus encabezados",
|
Motivo: "el correo viene marcado como masivo o automático en sus encabezados",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -40,13 +42,19 @@ func ClasificarCorreoSoporte(correo CorreoSoporte, contextoNegocio string) Clasi
|
|||||||
|
|
||||||
sistema := `Clasificás correos que llegan a la casilla de soporte de una empresa.
|
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:
|
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|false, "categoria": "...", "prioridad": "...", "motivo": "una frase corta"}
|
||||||
|
|
||||||
es_soporte = true cuando una persona pide ayuda, reporta un problema, hace una
|
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.
|
consulta sobre un servicio contratado o responde una conversación de soporte.
|
||||||
es_soporte = false para newsletters, promociones, notificaciones automáticas de
|
es_soporte = false para newsletters, promociones, notificaciones automáticas de
|
||||||
plataformas, facturas de proveedores y spam.
|
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.`
|
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 != "" {
|
if c := strings.TrimSpace(contextoNegocio); c != "" {
|
||||||
sistema += "\n\nContexto del negocio:\n" + c
|
sistema += "\n\nContexto del negocio:\n" + c
|
||||||
@@ -57,17 +65,22 @@ Ante la duda, es_soporte = true: es peor ignorar a un cliente que abrir un ticke
|
|||||||
salida, err := CompletarTextoIA("ia", sistema, usuario)
|
salida, err := CompletarTextoIA("ia", sistema, usuario)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[Soporte] No se pudo clasificar el correo, se trata como soporte: %v", err)
|
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ó"}
|
return ClasificacionCorreo{EsSoporte: true, Categoria: "otro", Prioridad: "media", Motivo: "la clasificación falló"}
|
||||||
}
|
}
|
||||||
|
|
||||||
var out ClasificacionCorreo
|
var out ClasificacionCorreo
|
||||||
if err := json.Unmarshal([]byte(soloJSON(salida)), &out); err != nil {
|
if err := json.Unmarshal([]byte(soloJSON(salida)), &out); err != nil {
|
||||||
log.Printf("[Soporte] Clasificación ilegible (%q), se trata como soporte", recortar(salida, 120))
|
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"}
|
return ClasificacionCorreo{EsSoporte: true, Categoria: "otro", Prioridad: "media", Motivo: "respuesta ilegible del modelo"}
|
||||||
}
|
}
|
||||||
if out.Categoria == "" {
|
if out.Categoria == "" {
|
||||||
out.Categoria = "otro"
|
out.Categoria = "otro"
|
||||||
}
|
}
|
||||||
|
switch out.Prioridad {
|
||||||
|
case "baja", "media", "alta", "urgente":
|
||||||
|
default:
|
||||||
|
out.Prioridad = "media"
|
||||||
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,3 +57,15 @@ func TestSoloJSON(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestClasificarNormalizaPrioridad(t *testing.T) {
|
||||||
|
// El correo automático corta antes de llamar a la IA, así que es el único
|
||||||
|
// camino que se puede probar sin base de datos — y ahí la prioridad tiene
|
||||||
|
// que salir puesta igual.
|
||||||
|
cl := ClasificarCorreoSoporte(CorreoSoporte{
|
||||||
|
From: "news@x.com", Subject: "promo", Automatico: true,
|
||||||
|
}, "")
|
||||||
|
if cl.Prioridad != "baja" {
|
||||||
|
t.Errorf("Prioridad = %q, want baja", cl.Prioridad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -89,16 +89,6 @@ func IngestarCorreoSoporte(cfg *models.SoporteWebhookConfig, e CorreoSoporte) bo
|
|||||||
return true
|
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{
|
ticket := &models.ProyectoTicket{
|
||||||
AutorNombre: fromName,
|
AutorNombre: fromName,
|
||||||
EmailFrom: fromEmail,
|
EmailFrom: fromEmail,
|
||||||
@@ -108,6 +98,24 @@ func IngestarCorreoSoporte(cfg *models.SoporteWebhookConfig, e CorreoSoporte) bo
|
|||||||
Origen: "email",
|
Origen: "email",
|
||||||
MessageID: e.MessageID,
|
MessageID: e.MessageID,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filtro y triage 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 {
|
||||||
|
cl := ClasificarCorreoSoporte(e, cfg.ContextoNegocio)
|
||||||
|
if !cl.EsSoporte {
|
||||||
|
log.Printf("[Soporte] Descartado por el filtro (%s): %q de %s — %s",
|
||||||
|
cl.Categoria, e.Subject, fromEmail, cl.Motivo)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
ticket.Categoria = cl.Categoria
|
||||||
|
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
|
||||||
}
|
}
|
||||||
@@ -242,3 +250,25 @@ func esCorreoDuplicado(err error) bool {
|
|||||||
}
|
}
|
||||||
return strings.Contains(strings.ToLower(err.Error()), "duplicate key value")
|
return strings.Contains(strings.ToLower(err.Error()), "duplicate key value")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolverRemitente ata el ticket a quien escribió: primero por usuario del
|
||||||
|
// portal (que además le deja ver el ticket desde su portal) y si no, por el
|
||||||
|
// correo del cliente. Solo coincidencia exacta de dirección — ver el comentario
|
||||||
|
// de models.GetClientePorEmail sobre por qué no se busca por dominio.
|
||||||
|
func resolverRemitente(ticket *models.ProyectoTicket, email string) {
|
||||||
|
if email == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if pu, err := models.GetPortalUserByEmail(email); err == nil && pu != nil {
|
||||||
|
id := pu.ID
|
||||||
|
ticket.PortalUserID = &id
|
||||||
|
if ids := models.GetClienteIDsForPortalUser(pu); len(ids) == 1 {
|
||||||
|
ticket.ClienteID = &ids[0]
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cli, err := models.GetClientePorEmail(email); err == nil && cli != nil {
|
||||||
|
id := cli.ID
|
||||||
|
ticket.ClienteID = &id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -217,6 +217,24 @@
|
|||||||
<p class="text-xs text-slate-400 mt-1">Ayuda a la IA a distinguir tus casos raros. Los correos masivos se descartan por sus encabezados, sin gastar una llamada.</p>
|
<p class="text-xs text-slate-400 mt-1">Ayuda a la IA a distinguir tus casos raros. Los correos masivos se descartan por sus encabezados, sin gastar una llamada.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ─── Borradores de respuesta ──────────────────────────────── -->
|
||||||
|
<div class="border-t border-slate-200 pt-5">
|
||||||
|
<p class="text-sm font-semibold text-slate-700 mb-1">Borradores de respuesta</p>
|
||||||
|
<p class="text-xs text-slate-400 mb-3">
|
||||||
|
En cada ticket aparece un botón <strong>✨ Borrador</strong> que propone una respuesta usando la base
|
||||||
|
de conocimiento del agente que elijas. <strong>Nunca la envía</strong>: cae en el cuadro de respuesta
|
||||||
|
para que la revises y la mandes vos. Sin agente elegido el borrador sale igual, pero solo con la
|
||||||
|
conversación del ticket.
|
||||||
|
</p>
|
||||||
|
<label class="block text-xs font-medium text-slate-600 mb-1">Agente de uMind</label>
|
||||||
|
<select x-model="cfg.agente_borrador_id" class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none">
|
||||||
|
<option value="">Sin base de conocimiento</option>
|
||||||
|
<template x-for="a in agentes" :key="a.id">
|
||||||
|
<option :value="a.id" x-text="a.nombre"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="pt-4">
|
<div class="pt-4">
|
||||||
<button @click="guardar()"
|
<button @click="guardar()"
|
||||||
class="px-6 py-2 rounded-xl text-white text-sm font-medium transition-colors"
|
class="px-6 py-2 rounded-xl text-white text-sm font-medium transition-colors"
|
||||||
@@ -263,8 +281,10 @@ function soporteWebhook() {
|
|||||||
imap_horas_atras: 12,
|
imap_horas_atras: 12,
|
||||||
clasificar_con_ia: false,
|
clasificar_con_ia: false,
|
||||||
contexto_negocio: '',
|
contexto_negocio: '',
|
||||||
|
agente_borrador_id: '',
|
||||||
},
|
},
|
||||||
admins: [],
|
admins: [],
|
||||||
|
agentes: [],
|
||||||
tieneImapPassword: false,
|
tieneImapPassword: false,
|
||||||
ocupado: false,
|
ocupado: false,
|
||||||
mensajeImap: '',
|
mensajeImap: '',
|
||||||
@@ -287,6 +307,10 @@ function soporteWebhook() {
|
|||||||
const r = await axios.get('/app/tickets/admins');
|
const r = await axios.get('/app/tickets/admins');
|
||||||
this.admins = r.data || [];
|
this.admins = r.data || [];
|
||||||
} catch {}
|
} catch {}
|
||||||
|
try {
|
||||||
|
const r = await axios.get('/app/soporte/agentes');
|
||||||
|
this.agentes = r.data || [];
|
||||||
|
} catch {}
|
||||||
if (!this.cfg.api_key) {
|
if (!this.cfg.api_key) {
|
||||||
this.cfg.api_key = this.generarClave();
|
this.cfg.api_key = this.generarClave();
|
||||||
}
|
}
|
||||||
@@ -329,6 +353,7 @@ function soporteWebhook() {
|
|||||||
imap_horas_atras: Number(this.cfg.imap_horas_atras) || 0,
|
imap_horas_atras: Number(this.cfg.imap_horas_atras) || 0,
|
||||||
clasificar_con_ia: !!this.cfg.clasificar_con_ia,
|
clasificar_con_ia: !!this.cfg.clasificar_con_ia,
|
||||||
contexto_negocio: this.cfg.contexto_negocio || '',
|
contexto_negocio: this.cfg.contexto_negocio || '',
|
||||||
|
agente_borrador_id: this.cfg.agente_borrador_id ? parseInt(this.cfg.agente_borrador_id) : null,
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
await axios.post('/app/soporte/webhook', payload);
|
await axios.post('/app/soporte/webhook', payload);
|
||||||
|
|||||||
@@ -38,6 +38,12 @@
|
|||||||
<span class="badge" :class="prioridadBadge(t.prioridad)" x-text="t.prioridad"></span>
|
<span class="badge" :class="prioridadBadge(t.prioridad)" x-text="t.prioridad"></span>
|
||||||
<span x-show="t.origen==='email'" class="badge badge-default text-xs">📧 Email</span>
|
<span x-show="t.origen==='email'" class="badge badge-default text-xs">📧 Email</span>
|
||||||
<span x-show="t.origen==='portal'" class="badge badge-default text-xs">🌐 Portal</span>
|
<span x-show="t.origen==='portal'" class="badge badge-default text-xs">🌐 Portal</span>
|
||||||
|
<span x-show="t.categoria" class="badge badge-default text-xs" x-text="t.categoria"></span>
|
||||||
|
<!-- De quién es. Sin cliente resuelto es alguien de afuera: no es
|
||||||
|
un error, pero conviene que se vea distinto. -->
|
||||||
|
<span x-show="t.cliente" class="badge text-xs bg-indigo-100 text-indigo-700"
|
||||||
|
x-text="t.cliente ? '🏢 ' + t.cliente.nombre : ''"></span>
|
||||||
|
<span x-show="!t.cliente && t.origen==='email'" class="badge text-xs bg-slate-100 text-slate-500">👤 Externo</span>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="font-semibold text-sm text-slate-800 truncate" x-text="t.titulo"></h3>
|
<h3 class="font-semibold text-sm text-slate-800 truncate" x-text="t.titulo"></h3>
|
||||||
<p class="text-xs text-slate-500 mt-0.5">
|
<p class="text-xs text-slate-500 mt-0.5">
|
||||||
@@ -97,10 +103,16 @@
|
|||||||
|
|
||||||
<!-- Responder -->
|
<!-- Responder -->
|
||||||
<div class="mt-3 flex gap-2">
|
<div class="mt-3 flex gap-2">
|
||||||
<input x-model="t._reply" type="text" placeholder="Escribir respuesta..."
|
<!-- Textarea y no input: el borrador de la IA viene en varios renglones. -->
|
||||||
class="flex-1 border border-slate-200 rounded-xl px-3 py-2 text-sm outline-none"
|
<textarea x-model="t._reply" rows="2" placeholder="Escribir respuesta..."
|
||||||
@keydown.enter.prevent="responder(t)"
|
class="flex-1 border border-slate-200 rounded-xl px-3 py-2 text-sm outline-none resize-y"
|
||||||
onfocus="this.style.borderColor='#8eb02f'" onblur="this.style.borderColor='#e2e8f0'">
|
@keydown.enter.exact.prevent="responder(t)"
|
||||||
|
onfocus="this.style.borderColor='#8eb02f'" onblur="this.style.borderColor='#e2e8f0'"></textarea>
|
||||||
|
<button @click="redactar(t)" :disabled="t._redactando"
|
||||||
|
title="Propone una respuesta con la base de conocimiento. No la envía."
|
||||||
|
class="px-3 py-2 rounded-xl border border-slate-300 text-sm text-slate-700 disabled:opacity-50 flex-shrink-0">
|
||||||
|
<span x-text="t._redactando ? 'Redactando…' : '✨ Borrador'"></span>
|
||||||
|
</button>
|
||||||
<button @click="responder(t)"
|
<button @click="responder(t)"
|
||||||
class="px-4 py-2 rounded-xl text-white text-sm font-medium transition-colors"
|
class="px-4 py-2 rounded-xl text-white text-sm font-medium transition-colors"
|
||||||
style="background:#8eb02f"
|
style="background:#8eb02f"
|
||||||
@@ -190,6 +202,18 @@ function ticketsAdmin() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async redactar(t) {
|
||||||
|
t._redactando = true;
|
||||||
|
try {
|
||||||
|
const { data } = await axios.post(`/app/tickets/${t.ID}/borrador`);
|
||||||
|
// Se escribe en el cuadro de respuesta, no se envía: lo revisa una persona.
|
||||||
|
t._reply = data.borrador || '';
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.response?.data?.error || e.message);
|
||||||
|
}
|
||||||
|
t._redactando = false;
|
||||||
|
},
|
||||||
|
|
||||||
async responder(t) {
|
async responder(t) {
|
||||||
const contenido = (t._reply || '').trim();
|
const contenido = (t._reply || '').trim();
|
||||||
if (!contenido) return;
|
if (!contenido) return;
|
||||||
|
|||||||
@@ -227,6 +227,7 @@ func SaveSoporteWebhookConfig(c *fiber.Ctx) error {
|
|||||||
ImapEncryption string `json:"imap_encryption"`
|
ImapEncryption string `json:"imap_encryption"`
|
||||||
ImapCarpeta string `json:"imap_carpeta"`
|
ImapCarpeta string `json:"imap_carpeta"`
|
||||||
ImapHorasAtras int `json:"imap_horas_atras"`
|
ImapHorasAtras int `json:"imap_horas_atras"`
|
||||||
|
AgenteBorradorID *uint `json:"agente_borrador_id"`
|
||||||
ClasificarConIA bool `json:"clasificar_con_ia"`
|
ClasificarConIA bool `json:"clasificar_con_ia"`
|
||||||
ContextoNegocio string `json:"contexto_negocio"`
|
ContextoNegocio string `json:"contexto_negocio"`
|
||||||
}
|
}
|
||||||
@@ -294,6 +295,7 @@ func SaveSoporteWebhookConfig(c *fiber.Ctx) error {
|
|||||||
ImapHorasAtras: b.ImapHorasAtras,
|
ImapHorasAtras: b.ImapHorasAtras,
|
||||||
ClasificarConIA: b.ClasificarConIA,
|
ClasificarConIA: b.ClasificarConIA,
|
||||||
ContextoNegocio: b.ContextoNegocio,
|
ContextoNegocio: b.ContextoNegocio,
|
||||||
|
AgenteBorradorID: b.AgenteBorradorID,
|
||||||
Activo: true,
|
Activo: true,
|
||||||
}
|
}
|
||||||
cfg.ID = b.ID
|
cfg.ID = b.ID
|
||||||
@@ -337,3 +339,38 @@ func RevisarBuzonAhora(c *fiber.Ctx) error {
|
|||||||
return c.JSON(fiber.Map{"ok": true, "message": fmt.Sprintf(
|
return c.JSON(fiber.Map{"ok": true, "message": 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)})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BorradorRespuestaTicket propone una respuesta usando la base de conocimiento.
|
||||||
|
// No envía nada: el borrador vuelve al cuadro de respuesta para que lo revise
|
||||||
|
// una persona.
|
||||||
|
// POST /app/tickets/:ticketID/borrador
|
||||||
|
func BorradorRespuestaTicket(c *fiber.Ctx) error {
|
||||||
|
id, err := strconv.ParseUint(c.Params("ticketID"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(400).JSON(fiber.Map{"error": "ID inválido"})
|
||||||
|
}
|
||||||
|
ticket, err := models.GetTicketByID(uint(id))
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(404).JSON(fiber.Map{"error": "Ticket no encontrado"})
|
||||||
|
}
|
||||||
|
borrador, err := services.RedactarBorradorTicket(ticket)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(422).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"borrador": borrador})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAgentesParaBorrador lista los agentes de uMind para elegir de cuál sale la
|
||||||
|
// documentación de los borradores.
|
||||||
|
// GET /app/soporte/agentes
|
||||||
|
func GetAgentesParaBorrador(c *fiber.Ctx) error {
|
||||||
|
items, err := models.GetTodosLosUmindAgentes()
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
salida := make([]fiber.Map, 0, len(items))
|
||||||
|
for _, a := range items {
|
||||||
|
salida = append(salida, fiber.Map{"id": a.ID, "nombre": a.Nombre})
|
||||||
|
}
|
||||||
|
return c.JSON(salida)
|
||||||
|
}
|
||||||
|
|||||||
@@ -530,6 +530,7 @@ func UserRoutes(app fiber.Router) {
|
|||||||
protected.Put("/tickets/:ticketID/estado", controllers.UpdateTicketEstadoAdmin)
|
protected.Put("/tickets/:ticketID/estado", controllers.UpdateTicketEstadoAdmin)
|
||||||
protected.Post("/tickets/:ticketID/mensaje", controllers.AdminResponderTicket)
|
protected.Post("/tickets/:ticketID/mensaje", controllers.AdminResponderTicket)
|
||||||
protected.Put("/tickets/:ticketID/asignar", controllers.AsignarTicket)
|
protected.Put("/tickets/:ticketID/asignar", controllers.AsignarTicket)
|
||||||
|
protected.Post("/tickets/:ticketID/borrador", controllers.BorradorRespuestaTicket)
|
||||||
protected.Get("/tickets/admins", controllers.GetSoporteAdmins)
|
protected.Get("/tickets/admins", controllers.GetSoporteAdmins)
|
||||||
|
|
||||||
// Configuración webhook de soporte
|
// Configuración webhook de soporte
|
||||||
@@ -538,6 +539,7 @@ func UserRoutes(app fiber.Router) {
|
|||||||
protected.Post("/soporte/webhook", controllers.SaveSoporteWebhookConfig)
|
protected.Post("/soporte/webhook", controllers.SaveSoporteWebhookConfig)
|
||||||
protected.Post("/soporte/webhook/probar-imap", controllers.ProbarImapSoporte)
|
protected.Post("/soporte/webhook/probar-imap", controllers.ProbarImapSoporte)
|
||||||
protected.Post("/soporte/webhook/revisar-buzon", controllers.RevisarBuzonAhora)
|
protected.Post("/soporte/webhook/revisar-buzon", controllers.RevisarBuzonAhora)
|
||||||
|
protected.Get("/soporte/agentes", controllers.GetAgentesParaBorrador)
|
||||||
|
|
||||||
// Configuración de notificaciones
|
// Configuración de notificaciones
|
||||||
protected.Get("/notif-config", middlewares.MenuMiddleware, controllers.NotifConfigIndex)
|
protected.Get("/notif-config", middlewares.MenuMiddleware, controllers.NotifConfigIndex)
|
||||||
|
|||||||
Reference in New Issue
Block a user