feat(soporte): botón de borrador que redacta con la base de conocimiento
En cada ticket, "✨ Borrador" propone una respuesta usando los fragmentos más parecidos de la base de conocimiento del agente de uMind que se elija en la configuración de soporte, más el hilo de la conversación. El borrador cae en el cuadro de respuesta y no se manda: lo revisa una persona y aprieta enviar. Contestarle a un cliente con lo que dijo un modelo, sin que nadie lo lea, es la forma más rápida de perderlo. Sin agente configurado el borrador se arma igual, solo con la conversación — peor, pero mejor que un error. El prompt le prohíbe inventar precios, plazos o pasos que no estén en la documentación, y le pide decir qué falta preguntar cuando no alcanza para resolver. El cuadro de respuesta pasa a textarea: un borrador de varios renglones no entraba en un input de una línea. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
bfcc320b7c
commit
1b599606f7
@@ -45,6 +45,9 @@ type SoporteWebhookConfig struct {
|
||||
// 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"`
|
||||
// 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
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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) {
|
||||
var a UmindAgente
|
||||
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())
|
||||
}
|
||||
@@ -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>
|
||||
</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">
|
||||
<button @click="guardar()"
|
||||
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,
|
||||
clasificar_con_ia: false,
|
||||
contexto_negocio: '',
|
||||
agente_borrador_id: '',
|
||||
},
|
||||
admins: [],
|
||||
agentes: [],
|
||||
tieneImapPassword: false,
|
||||
ocupado: false,
|
||||
mensajeImap: '',
|
||||
@@ -287,6 +307,10 @@ function soporteWebhook() {
|
||||
const r = await axios.get('/app/tickets/admins');
|
||||
this.admins = r.data || [];
|
||||
} catch {}
|
||||
try {
|
||||
const r = await axios.get('/app/soporte/agentes');
|
||||
this.agentes = r.data || [];
|
||||
} catch {}
|
||||
if (!this.cfg.api_key) {
|
||||
this.cfg.api_key = this.generarClave();
|
||||
}
|
||||
@@ -329,6 +353,7 @@ function soporteWebhook() {
|
||||
imap_horas_atras: Number(this.cfg.imap_horas_atras) || 0,
|
||||
clasificar_con_ia: !!this.cfg.clasificar_con_ia,
|
||||
contexto_negocio: this.cfg.contexto_negocio || '',
|
||||
agente_borrador_id: this.cfg.agente_borrador_id ? parseInt(this.cfg.agente_borrador_id) : null,
|
||||
};
|
||||
try {
|
||||
await axios.post('/app/soporte/webhook', payload);
|
||||
|
||||
@@ -103,10 +103,16 @@
|
||||
|
||||
<!-- Responder -->
|
||||
<div class="mt-3 flex gap-2">
|
||||
<input x-model="t._reply" type="text" placeholder="Escribir respuesta..."
|
||||
class="flex-1 border border-slate-200 rounded-xl px-3 py-2 text-sm outline-none"
|
||||
@keydown.enter.prevent="responder(t)"
|
||||
onfocus="this.style.borderColor='#8eb02f'" onblur="this.style.borderColor='#e2e8f0'">
|
||||
<!-- Textarea y no input: el borrador de la IA viene en varios renglones. -->
|
||||
<textarea x-model="t._reply" rows="2" placeholder="Escribir respuesta..."
|
||||
class="flex-1 border border-slate-200 rounded-xl px-3 py-2 text-sm outline-none resize-y"
|
||||
@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)"
|
||||
class="px-4 py-2 rounded-xl text-white text-sm font-medium transition-colors"
|
||||
style="background:#8eb02f"
|
||||
@@ -196,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) {
|
||||
const contenido = (t._reply || '').trim();
|
||||
if (!contenido) return;
|
||||
|
||||
@@ -204,31 +204,32 @@ func GetSoporteWebhookConfig(c *fiber.Ctx) error {
|
||||
|
||||
func SaveSoporteWebhookConfig(c *fiber.Ctx) error {
|
||||
type body struct {
|
||||
ID uint `json:"id"`
|
||||
Nombre string `json:"nombre"`
|
||||
Provider string `json:"provider"`
|
||||
ApiKey string `json:"api_key"`
|
||||
EmailDestino string `json:"email_destino"`
|
||||
ResponderAuto bool `json:"responder_auto"`
|
||||
MensajeAuto string `json:"mensaje_auto"`
|
||||
AsignarA *uint `json:"asignar_a"`
|
||||
SmtpHost string `json:"smtp_host"`
|
||||
SmtpPort int `json:"smtp_port"`
|
||||
SmtpUsername string `json:"smtp_username"`
|
||||
SmtpPassword string `json:"smtp_password"`
|
||||
SmtpEncryption string `json:"smtp_encryption"`
|
||||
SmtpFromAddr string `json:"smtp_from_addr"`
|
||||
SmtpFromName string `json:"smtp_from_name"`
|
||||
ImapActivo bool `json:"imap_activo"`
|
||||
ImapHost string `json:"imap_host"`
|
||||
ImapPort int `json:"imap_port"`
|
||||
ImapUsername string `json:"imap_username"`
|
||||
ImapPassword string `json:"imap_password"`
|
||||
ImapEncryption string `json:"imap_encryption"`
|
||||
ImapCarpeta string `json:"imap_carpeta"`
|
||||
ImapHorasAtras int `json:"imap_horas_atras"`
|
||||
ClasificarConIA bool `json:"clasificar_con_ia"`
|
||||
ContextoNegocio string `json:"contexto_negocio"`
|
||||
ID uint `json:"id"`
|
||||
Nombre string `json:"nombre"`
|
||||
Provider string `json:"provider"`
|
||||
ApiKey string `json:"api_key"`
|
||||
EmailDestino string `json:"email_destino"`
|
||||
ResponderAuto bool `json:"responder_auto"`
|
||||
MensajeAuto string `json:"mensaje_auto"`
|
||||
AsignarA *uint `json:"asignar_a"`
|
||||
SmtpHost string `json:"smtp_host"`
|
||||
SmtpPort int `json:"smtp_port"`
|
||||
SmtpUsername string `json:"smtp_username"`
|
||||
SmtpPassword string `json:"smtp_password"`
|
||||
SmtpEncryption string `json:"smtp_encryption"`
|
||||
SmtpFromAddr string `json:"smtp_from_addr"`
|
||||
SmtpFromName string `json:"smtp_from_name"`
|
||||
ImapActivo bool `json:"imap_activo"`
|
||||
ImapHost string `json:"imap_host"`
|
||||
ImapPort int `json:"imap_port"`
|
||||
ImapUsername string `json:"imap_username"`
|
||||
ImapPassword string `json:"imap_password"`
|
||||
ImapEncryption string `json:"imap_encryption"`
|
||||
ImapCarpeta string `json:"imap_carpeta"`
|
||||
ImapHorasAtras int `json:"imap_horas_atras"`
|
||||
AgenteBorradorID *uint `json:"agente_borrador_id"`
|
||||
ClasificarConIA bool `json:"clasificar_con_ia"`
|
||||
ContextoNegocio string `json:"contexto_negocio"`
|
||||
}
|
||||
var b body
|
||||
if err := c.BodyParser(&b); err != nil {
|
||||
@@ -270,31 +271,32 @@ func SaveSoporteWebhookConfig(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
cfg := &models.SoporteWebhookConfig{
|
||||
Nombre: b.Nombre,
|
||||
Provider: b.Provider,
|
||||
ApiKey: b.ApiKey,
|
||||
EmailDestino: b.EmailDestino,
|
||||
ResponderAuto: b.ResponderAuto,
|
||||
MensajeAuto: b.MensajeAuto,
|
||||
AsignarA: b.AsignarA,
|
||||
SmtpHost: b.SmtpHost,
|
||||
SmtpPort: port,
|
||||
SmtpUsername: b.SmtpUsername,
|
||||
SmtpPassword: b.SmtpPassword,
|
||||
SmtpEncryption: enc,
|
||||
SmtpFromAddr: b.SmtpFromAddr,
|
||||
SmtpFromName: b.SmtpFromName,
|
||||
ImapActivo: b.ImapActivo,
|
||||
ImapHost: b.ImapHost,
|
||||
ImapPort: imapPort,
|
||||
ImapUsername: b.ImapUsername,
|
||||
ImapPasswordEnc: passEnc,
|
||||
ImapEncryption: imapEnc,
|
||||
ImapCarpeta: carpeta,
|
||||
ImapHorasAtras: b.ImapHorasAtras,
|
||||
ClasificarConIA: b.ClasificarConIA,
|
||||
ContextoNegocio: b.ContextoNegocio,
|
||||
Activo: true,
|
||||
Nombre: b.Nombre,
|
||||
Provider: b.Provider,
|
||||
ApiKey: b.ApiKey,
|
||||
EmailDestino: b.EmailDestino,
|
||||
ResponderAuto: b.ResponderAuto,
|
||||
MensajeAuto: b.MensajeAuto,
|
||||
AsignarA: b.AsignarA,
|
||||
SmtpHost: b.SmtpHost,
|
||||
SmtpPort: port,
|
||||
SmtpUsername: b.SmtpUsername,
|
||||
SmtpPassword: b.SmtpPassword,
|
||||
SmtpEncryption: enc,
|
||||
SmtpFromAddr: b.SmtpFromAddr,
|
||||
SmtpFromName: b.SmtpFromName,
|
||||
ImapActivo: b.ImapActivo,
|
||||
ImapHost: b.ImapHost,
|
||||
ImapPort: imapPort,
|
||||
ImapUsername: b.ImapUsername,
|
||||
ImapPasswordEnc: passEnc,
|
||||
ImapEncryption: imapEnc,
|
||||
ImapCarpeta: carpeta,
|
||||
ImapHorasAtras: b.ImapHorasAtras,
|
||||
ClasificarConIA: b.ClasificarConIA,
|
||||
ContextoNegocio: b.ContextoNegocio,
|
||||
AgenteBorradorID: b.AgenteBorradorID,
|
||||
Activo: true,
|
||||
}
|
||||
cfg.ID = b.ID
|
||||
if err := models.SaveSoporteWebhookConfig(cfg); err != nil {
|
||||
@@ -337,3 +339,38 @@ func RevisarBuzonAhora(c *fiber.Ctx) error {
|
||||
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)})
|
||||
}
|
||||
|
||||
// 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.Post("/tickets/:ticketID/mensaje", controllers.AdminResponderTicket)
|
||||
protected.Put("/tickets/:ticketID/asignar", controllers.AsignarTicket)
|
||||
protected.Post("/tickets/:ticketID/borrador", controllers.BorradorRespuestaTicket)
|
||||
protected.Get("/tickets/admins", controllers.GetSoporteAdmins)
|
||||
|
||||
// Configuración webhook de soporte
|
||||
@@ -538,6 +539,7 @@ func UserRoutes(app fiber.Router) {
|
||||
protected.Post("/soporte/webhook", controllers.SaveSoporteWebhookConfig)
|
||||
protected.Post("/soporte/webhook/probar-imap", controllers.ProbarImapSoporte)
|
||||
protected.Post("/soporte/webhook/revisar-buzon", controllers.RevisarBuzonAhora)
|
||||
protected.Get("/soporte/agentes", controllers.GetAgentesParaBorrador)
|
||||
|
||||
// Configuración de notificaciones
|
||||
protected.Get("/notif-config", middlewares.MenuMiddleware, controllers.NotifConfigIndex)
|
||||
|
||||
Reference in New Issue
Block a user