Hasta ahora la única forma de darle información a un agente era crawlear una
URL, una vez, para siempre. Tres cosas cambian:
Escribir a mano. Es la fuente más valiosa y la única que no está en ningún
documento: horarios, qué no hacen, la respuesta que dan quince veces por día.
También es la única que el dueño puede corregir en el momento en que ve al
agente contestar mal.
Subir un archivo. La lista de precios suele estar en un PDF, no en la web. Usa
el mismo extractor que los adjuntos de los canales, así que PDF, Word, texto e
imágenes entran sin código nuevo. El texto se extrae con la persona mirando la
pantalla: si el archivo no se puede leer, se dice ahí y no en un log.
Y lo importante: el conocimiento se congelaba el día que se cargaba. Si el
cliente cambiaba los precios en su sitio, el agente seguía dando los viejos con
total seguridad — sin error, sin aviso, nada. Ahora cada fuente muestra de
cuándo es ("leído hace 3 meses", en ámbar pasados dos meses), tiene botón de
actualizar, y las URLs pueden marcarse para releerse solas cada semana (cron a
las 4 AM). Reprocesar reemplaza los fragmentos en vez de sumarlos: si no,
quedaban las dos versiones compitiendo en la búsqueda y podía ganar la vieja.
De paso, el troceado dejaba fragmentos que arrancaban a mitad de palabra
("alabra…") porque el solape no se alineaba a un espacio.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1025 lines
38 KiB
Go
1025 lines
38 KiB
Go
package controllers
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
"github.com/sujit-baniya/fiber-boilerplate/app"
|
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
|
|
"github.com/sujit-baniya/fiber-boilerplate/rest/middlewares"
|
|
)
|
|
|
|
// UmindIndex renderiza el panel de administración de uMind.
|
|
func UmindIndex(c *fiber.Ctx) error {
|
|
return c.Render("umind", fiber.Map{
|
|
"user": c.Locals("user"),
|
|
"modules": c.Locals("userModules"),
|
|
}, "layouts/main")
|
|
}
|
|
|
|
var umindColorHexRegex = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
|
|
|
|
// umindColor valida el hex del color de marca del widget; si viene vacío o
|
|
// inválido cae al verde por defecto en vez de guardar basura (el valor se
|
|
// aplica tal cual como CSS custom property en el widget embebido).
|
|
func umindColor(color string) string {
|
|
color = strings.TrimSpace(color)
|
|
if umindColorHexRegex.MatchString(color) {
|
|
return color
|
|
}
|
|
return "#8eb02f"
|
|
}
|
|
|
|
// ─── Tenants ─────────────────────────────────────────────────────────────────
|
|
// Un tenant es el negocio/sitio dueño de los dominios permitidos — la config
|
|
// de IA, tono, tools, etc. viven en sus UmindAgente (ver más abajo).
|
|
|
|
func GetUmindTenants(c *fiber.Ctx) error {
|
|
page, _ := strconv.Atoi(c.Query("page", "1"))
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
limit := 20
|
|
offset := (page - 1) * limit
|
|
items, total, err := models.GetAllUmindTenants(limit, offset)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
items = tenantsVisibles(c, items)
|
|
if _, esStaff := middlewares.UmindScopeDe(c); !esStaff {
|
|
// Para el cliente la paginación global no tiene sentido (ve pocos
|
|
// tenants); el total pasa a ser el de lo que realmente puede ver.
|
|
total = int64(len(items))
|
|
}
|
|
return c.JSON(fiber.Map{
|
|
"items": items,
|
|
"total": total,
|
|
"totalPages": int(math.Ceil(float64(total) / float64(limit))),
|
|
"page": page,
|
|
})
|
|
}
|
|
|
|
type umindTenantReq struct {
|
|
Nombre string `json:"nombre"`
|
|
DominiosPermitidos []string `json:"dominios_permitidos"`
|
|
Activo bool `json:"activo"`
|
|
ClienteID *uint `json:"cliente_id"`
|
|
PlanID *uint `json:"plan_id"`
|
|
}
|
|
|
|
func (r umindTenantReq) dominiosLimpios() []string {
|
|
var out []string
|
|
for _, d := range r.DominiosPermitidos {
|
|
d = strings.ToLower(strings.TrimSpace(d))
|
|
if d != "" {
|
|
out = append(out, d)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func CreateUmindTenantHandler(c *fiber.Ctx) error {
|
|
var req umindTenantReq
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
|
|
}
|
|
if strings.TrimSpace(req.Nombre) == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "nombre es requerido"})
|
|
}
|
|
dominios := req.dominiosLimpios()
|
|
if len(dominios) == 0 {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agrega al menos un dominio permitido"})
|
|
}
|
|
|
|
tenant := &models.UmindTenant{
|
|
Nombre: strings.TrimSpace(req.Nombre),
|
|
DominiosPermitidos: strings.Join(dominios, ","),
|
|
Activo: true,
|
|
CreadoPorID: extraerUserID(c),
|
|
ClienteID: req.ClienteID,
|
|
PlanID: req.PlanID,
|
|
}
|
|
if err := models.CreateUmindTenant(tenant); err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.Status(fiber.StatusCreated).JSON(fiber.Map{"ok": true, "id": tenant.ID})
|
|
}
|
|
|
|
func UpdateUmindTenantHandler(c *fiber.Ctx) error {
|
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
|
|
}
|
|
if err := accesoTenant(c, uint(id)); err != nil {
|
|
return err
|
|
}
|
|
var req umindTenantReq
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
|
|
}
|
|
dominios := req.dominiosLimpios()
|
|
updates := map[string]interface{}{
|
|
"nombre": strings.TrimSpace(req.Nombre),
|
|
"dominios_permitidos": strings.Join(dominios, ","),
|
|
"activo": req.Activo,
|
|
"cliente_id": req.ClienteID,
|
|
"plan_id": req.PlanID,
|
|
}
|
|
if err := models.UpdateUmindTenant(uint(id), updates); err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.JSON(fiber.Map{"ok": true})
|
|
}
|
|
|
|
func DeleteUmindTenantHandler(c *fiber.Ctx) error {
|
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
|
|
}
|
|
if err := accesoTenant(c, uint(id)); err != nil {
|
|
return err
|
|
}
|
|
if err := models.DeleteUmindTenant(uint(id)); err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.JSON(fiber.Map{"ok": true})
|
|
}
|
|
|
|
// ─── Agentes ─────────────────────────────────────────────────────────────────
|
|
// Un tenant puede tener varios agentes independientes (ej. "Ventas",
|
|
// "Soporte"), cada uno con su propia config de IA, base de conocimiento,
|
|
// tools, canales y conexión de correo.
|
|
|
|
func GetUmindAgentesHandler(c *fiber.Ctx) error {
|
|
tenantID, err := strconv.ParseUint(c.Query("tenant_id"), 10, 64)
|
|
if err != nil || tenantID == 0 {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenant_id requerido"})
|
|
}
|
|
if err := accesoTenant(c, uint(tenantID)); err != nil {
|
|
return err
|
|
}
|
|
items, err := models.GetUmindAgentesByTenant(uint(tenantID))
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
|
|
// El cupo viaja con la lista para que la pantalla pueda mostrar "1 de 1"
|
|
// en vez de dejar que el límite aparezca recién como un error al guardar.
|
|
// Sin plan no hay límite (ver verificarCupoAgentes), y eso también hay que
|
|
// poder verlo: si no, un tenant sin plan parece un límite que no funciona.
|
|
ids := make([]uint, len(items))
|
|
for i, a := range items {
|
|
ids[i] = a.ID
|
|
}
|
|
resp := fiber.Map{
|
|
"items": items,
|
|
"usados": len(items),
|
|
"resumen": models.GetResumenAgentes(ids),
|
|
}
|
|
if plan := models.GetPlanDeTenant(uint(tenantID)); plan != nil {
|
|
resp["plan"] = fiber.Map{"nombre": plan.Nombre, "max_agentes": plan.MaxAgentes}
|
|
}
|
|
return c.JSON(resp)
|
|
}
|
|
|
|
type umindAgenteReq struct {
|
|
TenantID uint `json:"tenant_id"`
|
|
Nombre string `json:"nombre"`
|
|
AiConfigID *uint `json:"ai_config_id"`
|
|
Tono string `json:"tono"`
|
|
MensajeBienvenida string `json:"mensaje_bienvenida"`
|
|
Color string `json:"color"`
|
|
Activo bool `json:"activo"`
|
|
}
|
|
|
|
func CreateUmindAgenteHandler(c *fiber.Ctx) error {
|
|
var req umindAgenteReq
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
|
|
}
|
|
if req.TenantID == 0 {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenant_id requerido"})
|
|
}
|
|
if strings.TrimSpace(req.Nombre) == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "nombre es requerido"})
|
|
}
|
|
if err := accesoTenant(c, req.TenantID); err != nil {
|
|
return err
|
|
}
|
|
if _, err := models.GetUmindTenantByID(req.TenantID); err != nil {
|
|
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "tenant no encontrado"})
|
|
}
|
|
if err := verificarCupoAgentes(c, req.TenantID); err != nil {
|
|
return err
|
|
}
|
|
|
|
agente := &models.UmindAgente{
|
|
TenantID: req.TenantID,
|
|
Nombre: strings.TrimSpace(req.Nombre),
|
|
AiConfigID: req.AiConfigID,
|
|
Tono: req.Tono,
|
|
MensajeBienvenida: req.MensajeBienvenida,
|
|
Color: umindColor(req.Color),
|
|
Activo: true,
|
|
}
|
|
if err := models.CreateUmindAgente(agente); err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.Status(fiber.StatusCreated).JSON(fiber.Map{"ok": true, "id": agente.ID, "site_key": agente.SiteKey})
|
|
}
|
|
|
|
func UpdateUmindAgenteHandler(c *fiber.Ctx) error {
|
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
|
|
}
|
|
if _, err := accesoAgente(c, uint(id)); err != nil {
|
|
return err
|
|
}
|
|
var req umindAgenteReq
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
|
|
}
|
|
updates := map[string]interface{}{
|
|
"nombre": strings.TrimSpace(req.Nombre),
|
|
"ai_config_id": req.AiConfigID,
|
|
"tono": req.Tono,
|
|
"mensaje_bienvenida": req.MensajeBienvenida,
|
|
"color": umindColor(req.Color),
|
|
"activo": req.Activo,
|
|
}
|
|
if err := models.UpdateUmindAgente(uint(id), updates); err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.JSON(fiber.Map{"ok": true})
|
|
}
|
|
|
|
func DeleteUmindAgenteHandler(c *fiber.Ctx) error {
|
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
|
|
}
|
|
if _, err := accesoAgente(c, uint(id)); err != nil {
|
|
return err
|
|
}
|
|
if err := models.DeleteUmindAgente(uint(id)); err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.JSON(fiber.Map{"ok": true})
|
|
}
|
|
|
|
// ─── Documentos / ingesta ────────────────────────────────────────────────────
|
|
|
|
func GetUmindDocumentosHandler(c *fiber.Ctx) error {
|
|
agenteID, err := strconv.ParseUint(c.Query("agente_id"), 10, 64)
|
|
if err != nil || agenteID == 0 {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agente_id requerido"})
|
|
}
|
|
if _, err := accesoAgente(c, uint(agenteID)); err != nil {
|
|
return err
|
|
}
|
|
items, err := models.GetUmindDocumentosByAgente(uint(agenteID))
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.JSON(fiber.Map{"items": items})
|
|
}
|
|
|
|
// CreateUmindDocumentoHandler registra una nueva fuente (por ahora solo URL a
|
|
// crawlear) y dispara la ingesta en segundo plano — puede tardar varios
|
|
// segundos/minutos según cuántas páginas tenga el sitio.
|
|
func CreateUmindDocumentoHandler(c *fiber.Ctx) error {
|
|
var req struct {
|
|
AgenteID uint `json:"agente_id"`
|
|
URL string `json:"url"`
|
|
MaxPaginas int `json:"max_paginas"`
|
|
AutoActualizar bool `json:"auto_actualizar"`
|
|
}
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
|
|
}
|
|
if req.AgenteID == 0 {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agente_id requerido"})
|
|
}
|
|
if _, err := accesoAgente(c, req.AgenteID); err != nil {
|
|
return err
|
|
}
|
|
if strings.TrimSpace(req.URL) == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "url requerida"})
|
|
}
|
|
if _, err := models.GetUmindAgenteByID(req.AgenteID); err != nil {
|
|
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "agente no encontrado"})
|
|
}
|
|
|
|
doc := &models.UmindDocumento{
|
|
AgenteID: req.AgenteID,
|
|
Tipo: "url",
|
|
Origen: strings.TrimSpace(req.URL),
|
|
Estado: "procesando",
|
|
MaxPaginas: req.MaxPaginas,
|
|
AutoActualizar: req.AutoActualizar,
|
|
}
|
|
if err := models.CreateUmindDocumento(doc); err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
|
|
go services.IngestarAgente(req.AgenteID, doc.ID, doc.Origen, req.MaxPaginas)
|
|
|
|
return c.Status(fiber.StatusAccepted).JSON(fiber.Map{"ok": true, "id": doc.ID, "estado": "procesando"})
|
|
}
|
|
|
|
func DeleteUmindDocumentoHandler(c *fiber.Ctx) error {
|
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
|
|
}
|
|
if err := accesoDocumento(c, uint(id)); err != nil {
|
|
return err
|
|
}
|
|
if err := models.DeleteUmindDocumento(uint(id)); err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.JSON(fiber.Map{"ok": true})
|
|
}
|
|
|
|
// ─── Conversaciones ───────────────────────────────────────────────────────────
|
|
|
|
func GetUmindSesionesHandler(c *fiber.Ctx) error {
|
|
agenteID, err := strconv.ParseUint(c.Query("agente_id"), 10, 64)
|
|
if err != nil || agenteID == 0 {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agente_id requerido"})
|
|
}
|
|
if _, err := accesoAgente(c, uint(agenteID)); err != nil {
|
|
return err
|
|
}
|
|
items, err := models.GetUmindSesiones(uint(agenteID), 50)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.JSON(fiber.Map{"items": items})
|
|
}
|
|
|
|
func GetUmindHistorialHandler(c *fiber.Ctx) error {
|
|
agenteID, err := strconv.ParseUint(c.Query("agente_id"), 10, 64)
|
|
if err != nil || agenteID == 0 {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agente_id requerido"})
|
|
}
|
|
if _, err := accesoAgente(c, uint(agenteID)); err != nil {
|
|
return err
|
|
}
|
|
sessionID := c.Query("session_id")
|
|
if sessionID == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "session_id requerido"})
|
|
}
|
|
items, err := models.GetUmindHistorial(uint(agenteID), sessionID, 200)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.JSON(fiber.Map{"items": items})
|
|
}
|
|
|
|
// ─── Tools custom (webhooks) ───────────────────────────────────────────────
|
|
|
|
var umindNombreToolRegex = regexp.MustCompile(`^[a-z][a-z0-9_]{2,63}$`)
|
|
|
|
func GetUmindHerramientasHandler(c *fiber.Ctx) error {
|
|
agenteID, err := strconv.ParseUint(c.Query("agente_id"), 10, 64)
|
|
if err != nil || agenteID == 0 {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agente_id requerido"})
|
|
}
|
|
if _, err := accesoAgente(c, uint(agenteID)); err != nil {
|
|
return err
|
|
}
|
|
items, err := models.GetUmindHerramientasByAgente(uint(agenteID))
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
// No se devuelve el secreto cifrado ni el descifrado — solo si hay uno configurado.
|
|
out := make([]fiber.Map, len(items))
|
|
for i, h := range items {
|
|
out[i] = fiber.Map{
|
|
"ID": h.ID, "agente_id": h.AgenteID, "nombre": h.Nombre, "descripcion": h.Descripcion,
|
|
"parametros_json": h.ParametrosJSON, "url": h.URL, "auth_header_nombre": h.AuthHeaderNombre,
|
|
"auth_configurado": h.AuthHeaderValorEnc != "", "activa": h.Activa,
|
|
}
|
|
}
|
|
return c.JSON(fiber.Map{"items": out})
|
|
}
|
|
|
|
type umindHerramientaReq struct {
|
|
AgenteID uint `json:"agente_id"`
|
|
Nombre string `json:"nombre"`
|
|
Descripcion string `json:"descripcion"`
|
|
Parametros []models.UmindHerramientaParametro `json:"parametros"`
|
|
URL string `json:"url"`
|
|
AuthHeaderNombre string `json:"auth_header_nombre"`
|
|
AuthHeaderValor *string `json:"auth_header_valor"` // nil = no tocar (en updates)
|
|
Activa bool `json:"activa"`
|
|
}
|
|
|
|
func CreateUmindHerramientaHandler(c *fiber.Ctx) error {
|
|
var req umindHerramientaReq
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
|
|
}
|
|
if req.AgenteID == 0 {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agente_id requerido"})
|
|
}
|
|
if _, err := accesoAgente(c, req.AgenteID); err != nil {
|
|
return err
|
|
}
|
|
if !umindNombreToolRegex.MatchString(req.Nombre) {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "el nombre debe ser minúsculas/números/guion_bajo, empezar con letra (3-64 caracteres)"})
|
|
}
|
|
if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(req.URL)), "https://") {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "la URL debe ser https"})
|
|
}
|
|
|
|
parametrosJSON, err := models.ParametrosToJSON(req.Parametros)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "parametros inválidos"})
|
|
}
|
|
var authEnc string
|
|
if req.AuthHeaderValor != nil && *req.AuthHeaderValor != "" {
|
|
authEnc, err = services.CifrarSecretoUmind(*req.AuthHeaderValor)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
}
|
|
|
|
h := &models.UmindHerramienta{
|
|
AgenteID: req.AgenteID, Nombre: req.Nombre, Descripcion: strings.TrimSpace(req.Descripcion),
|
|
ParametrosJSON: parametrosJSON, URL: strings.TrimSpace(req.URL),
|
|
AuthHeaderNombre: strings.TrimSpace(req.AuthHeaderNombre), AuthHeaderValorEnc: authEnc, Activa: true,
|
|
}
|
|
if err := models.CreateUmindHerramienta(h); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.Status(fiber.StatusCreated).JSON(fiber.Map{"ok": true, "id": h.ID})
|
|
}
|
|
|
|
func UpdateUmindHerramientaHandler(c *fiber.Ctx) error {
|
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
|
|
}
|
|
if err := accesoHerramienta(c, uint(id)); err != nil {
|
|
return err
|
|
}
|
|
var req umindHerramientaReq
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
|
|
}
|
|
if !umindNombreToolRegex.MatchString(req.Nombre) {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "el nombre debe ser minúsculas/números/guion_bajo, empezar con letra (3-64 caracteres)"})
|
|
}
|
|
if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(req.URL)), "https://") {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "la URL debe ser https"})
|
|
}
|
|
parametrosJSON, err := models.ParametrosToJSON(req.Parametros)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "parametros inválidos"})
|
|
}
|
|
|
|
updates := map[string]interface{}{
|
|
"nombre": req.Nombre, "descripcion": strings.TrimSpace(req.Descripcion),
|
|
"parametros_json": parametrosJSON, "url": strings.TrimSpace(req.URL),
|
|
"auth_header_nombre": strings.TrimSpace(req.AuthHeaderNombre), "activa": req.Activa,
|
|
}
|
|
if req.AuthHeaderValor != nil {
|
|
if *req.AuthHeaderValor == "" {
|
|
updates["auth_header_valor_enc"] = ""
|
|
} else {
|
|
enc, err := services.CifrarSecretoUmind(*req.AuthHeaderValor)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
updates["auth_header_valor_enc"] = enc
|
|
}
|
|
}
|
|
if err := models.UpdateUmindHerramienta(uint(id), updates); err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.JSON(fiber.Map{"ok": true})
|
|
}
|
|
|
|
func DeleteUmindHerramientaHandler(c *fiber.Ctx) error {
|
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
|
|
}
|
|
if err := accesoHerramienta(c, uint(id)); err != nil {
|
|
return err
|
|
}
|
|
if err := models.DeleteUmindHerramienta(uint(id)); err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.JSON(fiber.Map{"ok": true})
|
|
}
|
|
|
|
// ─── Canales (Telegram / WhatsApp) ─────────────────────────────────────────
|
|
|
|
func GetUmindCanalesHandler(c *fiber.Ctx) error {
|
|
agenteID, err := strconv.ParseUint(c.Query("agente_id"), 10, 64)
|
|
if err != nil || agenteID == 0 {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agente_id requerido"})
|
|
}
|
|
if _, err := accesoAgente(c, uint(agenteID)); err != nil {
|
|
return err
|
|
}
|
|
items, err := models.GetUmindCanalesByAgente(uint(agenteID))
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
out := make([]fiber.Map, len(items))
|
|
for i, canal := range items {
|
|
webhookURL := ""
|
|
if canal.Tipo == "telegram" {
|
|
webhookURL = fmt.Sprintf("%s/webhooks/umind-telegram/%s", app.Http.Server.Url, canal.WebhookSecret)
|
|
} else if canal.Tipo == "whatsapp" {
|
|
webhookURL = fmt.Sprintf("%s/webhooks/umind-whatsapp/%s", app.Http.Server.Url, canal.WebhookSecret)
|
|
}
|
|
out[i] = fiber.Map{
|
|
"ID": canal.ID, "agente_id": canal.AgenteID, "tipo": canal.Tipo, "activo": canal.Activo,
|
|
"webhook_url": webhookURL, "ultimo_error": canal.UltimoError,
|
|
"usar_whisper_audio": canal.UsarWhisperAudio, "usar_ocr_imagenes": canal.UsarOcrImagenes,
|
|
"usar_archivos_docs": canal.UsarArchivosDocs,
|
|
}
|
|
}
|
|
return c.JSON(fiber.Map{"items": out})
|
|
}
|
|
|
|
type umindCanalReq struct {
|
|
AgenteID uint `json:"agente_id"`
|
|
Tipo string `json:"tipo"`
|
|
Credenciales map[string]string `json:"credenciales"`
|
|
Activo bool `json:"activo"`
|
|
UsarWhisperAudio bool `json:"usar_whisper_audio"`
|
|
UsarOcrImagenes bool `json:"usar_ocr_imagenes"`
|
|
UsarArchivosDocs bool `json:"usar_archivos_docs"`
|
|
}
|
|
|
|
func CreateUmindCanalHandler(c *fiber.Ctx) error {
|
|
var req umindCanalReq
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
|
|
}
|
|
if req.AgenteID == 0 {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agente_id requerido"})
|
|
}
|
|
if _, err := accesoAgente(c, req.AgenteID); err != nil {
|
|
return err
|
|
}
|
|
switch req.Tipo {
|
|
case "telegram":
|
|
if strings.TrimSpace(req.Credenciales["bot_token"]) == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "bot_token requerido"})
|
|
}
|
|
case "whatsapp":
|
|
for _, k := range []string{"phone_number_id", "access_token", "app_secret", "verify_token"} {
|
|
if strings.TrimSpace(req.Credenciales[k]) == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": k + " requerido"})
|
|
}
|
|
}
|
|
default:
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tipo debe ser telegram o whatsapp"})
|
|
}
|
|
|
|
credencialesEnc, err := services.CifrarCredencialesCanal(req.Credenciales)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
canal := &models.UmindCanal{
|
|
AgenteID: req.AgenteID, Tipo: req.Tipo, Activo: true, CredencialesEnc: credencialesEnc,
|
|
UsarWhisperAudio: req.UsarWhisperAudio, UsarOcrImagenes: req.UsarOcrImagenes,
|
|
UsarArchivosDocs: req.UsarArchivosDocs,
|
|
}
|
|
if err := models.CreateUmindCanal(canal); err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
|
|
if req.Tipo == "telegram" {
|
|
webhookURL := fmt.Sprintf("%s/webhooks/umind-telegram/%s", app.Http.Server.Url, canal.WebhookSecret)
|
|
if err := services.RegistrarWebhookTelegram(req.Credenciales["bot_token"], webhookURL); err != nil {
|
|
models.UpdateUmindCanal(canal.ID, map[string]interface{}{"ultimo_error": err.Error()})
|
|
}
|
|
}
|
|
return c.Status(fiber.StatusCreated).JSON(fiber.Map{"ok": true, "id": canal.ID, "webhook_secret": canal.WebhookSecret})
|
|
}
|
|
|
|
func UpdateUmindCanalHandler(c *fiber.Ctx) error {
|
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
|
|
}
|
|
if err := accesoCanal(c, uint(id)); err != nil {
|
|
return err
|
|
}
|
|
var req umindCanalReq
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
|
|
}
|
|
updates := map[string]interface{}{"activo": req.Activo, "usar_whisper_audio": req.UsarWhisperAudio, "usar_ocr_imagenes": req.UsarOcrImagenes, "usar_archivos_docs": req.UsarArchivosDocs}
|
|
if len(req.Credenciales) > 0 {
|
|
enc, err := services.CifrarCredencialesCanal(req.Credenciales)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
updates["credenciales_enc"] = enc
|
|
updates["ultimo_error"] = ""
|
|
}
|
|
if err := models.UpdateUmindCanal(uint(id), updates); err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.JSON(fiber.Map{"ok": true})
|
|
}
|
|
|
|
func DeleteUmindCanalHandler(c *fiber.Ctx) error {
|
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
|
|
}
|
|
if err := accesoCanal(c, uint(id)); err != nil {
|
|
return err
|
|
}
|
|
if err := models.DeleteUmindCanal(uint(id)); err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.JSON(fiber.Map{"ok": true})
|
|
}
|
|
|
|
// ─── Chat de prueba ─────────────────────────────────────────────────────────
|
|
|
|
// UmindChatPruebaHandler deja que el staff pruebe un agente puntual directo
|
|
// desde el panel, sin pasar por site_key/dominio (ya está gateado por la
|
|
// sesión con la que se llega acá).
|
|
func UmindChatPruebaHandler(c *fiber.Ctx) error {
|
|
var req struct {
|
|
AgenteID uint `json:"agente_id"`
|
|
SessionID string `json:"session_id"`
|
|
Mensaje string `json:"mensaje"`
|
|
}
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
|
|
}
|
|
if strings.TrimSpace(req.Mensaje) == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "mensaje requerido"})
|
|
}
|
|
agente, errAcceso := accesoAgente(c, req.AgenteID)
|
|
if errAcceso != nil {
|
|
return errAcceso
|
|
}
|
|
sessionID := strings.TrimSpace(req.SessionID)
|
|
if sessionID == "" {
|
|
sessionID = "staff-preview:" + strconv.FormatUint(uint64(extraerUserID(c)), 10)
|
|
}
|
|
respuesta, err := services.ProcessWidgetMessage(agente, sessionID, strings.TrimSpace(req.Mensaje))
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.JSON(fiber.Map{"session_id": sessionID, "respuesta": respuesta})
|
|
}
|
|
|
|
// ─── Auditoría ──────────────────────────────────────────────────────────────
|
|
|
|
// GetUmindEventosHandler expone el log de auditoría técnica de un agente —
|
|
// errores/eventos que antes solo se podían ver pidiendo los logs del
|
|
// servidor (fallos de AI, tools, correo, canales).
|
|
func GetUmindEventosHandler(c *fiber.Ctx) error {
|
|
agenteID, err := strconv.ParseUint(c.Query("agente_id"), 10, 64)
|
|
if err != nil || agenteID == 0 {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agente_id requerido"})
|
|
}
|
|
if _, err := accesoAgente(c, uint(agenteID)); err != nil {
|
|
return err
|
|
}
|
|
items, err := models.GetUmindEventosByAgente(uint(agenteID), 100)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.JSON(fiber.Map{"items": items})
|
|
}
|
|
|
|
// ─── Consumo ────────────────────────────────────────────────────────────────
|
|
|
|
// GetUmindUsoHandler devuelve el consumo de un tenant en un rango de fechas:
|
|
// el resumen agregado por tipo (lo que se factura) y el detalle línea a línea.
|
|
// Sin fechas, el rango es el mes en curso.
|
|
func GetUmindUsoHandler(c *fiber.Ctx) error {
|
|
tenantID, err := strconv.ParseUint(c.Query("tenant_id"), 10, 64)
|
|
if err != nil || tenantID == 0 {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenant_id requerido"})
|
|
}
|
|
if err := accesoTenant(c, uint(tenantID)); err != nil {
|
|
return err
|
|
}
|
|
desde, hasta := rangoFechas(c.Query("desde"), c.Query("hasta"))
|
|
|
|
resumen, err := models.GetResumenUso(uint(tenantID), desde, hasta)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
detalle, err := models.GetUsoUmind(uint(tenantID), desde, hasta, c.Query("tipo"))
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
pendiente, _ := models.SumarUsoPendiente(uint(tenantID))
|
|
|
|
total := 0.0
|
|
for _, r := range resumen {
|
|
total += r.Costo
|
|
}
|
|
|
|
out := fiber.Map{
|
|
"desde": desde.Format("2006-01-02"), "hasta": hasta.Format("2006-01-02"),
|
|
"resumen": resumen, "detalle": detalle,
|
|
"total_periodo": total, "pendiente_facturar": pendiente,
|
|
}
|
|
if plan := models.GetPlanDeTenant(uint(tenantID)); plan != nil {
|
|
out["plan"] = fiber.Map{
|
|
"nombre": plan.Nombre, "moneda": plan.Moneda,
|
|
"precio_mensual": plan.PrecioMensual, "tope_consumo_mensual": plan.TopeConsumoMensual,
|
|
"max_agentes": plan.MaxAgentes,
|
|
}
|
|
}
|
|
return c.JSON(out)
|
|
}
|
|
|
|
// rangoFechas parsea el filtro del panel. Ante una fecha inválida cae al mes
|
|
// en curso en vez de devolver error: es un filtro de lectura, no vale la pena
|
|
// romperle la pantalla al usuario por un query param mal escrito.
|
|
func rangoFechas(desdeStr, hastaStr string) (time.Time, time.Time) {
|
|
ahora := time.Now()
|
|
desde := time.Date(ahora.Year(), ahora.Month(), 1, 0, 0, 0, 0, ahora.Location())
|
|
hasta := desde.AddDate(0, 1, 0)
|
|
|
|
if d, err := time.Parse("2006-01-02", desdeStr); err == nil {
|
|
desde = d
|
|
}
|
|
if h, err := time.Parse("2006-01-02", hastaStr); err == nil {
|
|
hasta = h.AddDate(0, 0, 1) // inclusivo: "hasta el 31" incluye el 31 entero
|
|
}
|
|
if !hasta.After(desde) {
|
|
hasta = desde.AddDate(0, 1, 0)
|
|
}
|
|
return desde, hasta
|
|
}
|
|
|
|
// GetUmindReporteHandler entrega el reporte de actividad de un agente en
|
|
// Excel. Existe por una razón concreta: el cliente necesita algo que mostrar
|
|
// puertas adentro para justificar el gasto. Un panel que solo se ve entrando
|
|
// al portal no sirve para eso; un archivo que se reenvía, sí.
|
|
// Ruta: GET /umind/reporte.xlsx?agente_id=&desde=&hasta=
|
|
func GetUmindReporteHandler(c *fiber.Ctx) error {
|
|
agenteID, err := strconv.ParseUint(c.Query("agente_id"), 10, 64)
|
|
if err != nil || agenteID == 0 {
|
|
return c.Status(fiber.StatusBadRequest).SendString("agente_id requerido")
|
|
}
|
|
agente, errAcceso := accesoAgente(c, uint(agenteID))
|
|
if errAcceso != nil {
|
|
return errAcceso
|
|
}
|
|
desde, hasta := rangoFechas(c.Query("desde"), c.Query("hasta"))
|
|
|
|
negocio := ""
|
|
if t, err := models.GetUmindTenantByID(agente.TenantID); err == nil {
|
|
negocio = t.Nombre
|
|
}
|
|
|
|
sesiones, _ := models.GetUmindSesionesRango(agente.ID, desde, hasta)
|
|
resumen, _ := models.GetResumenUso(agente.TenantID, desde, hasta)
|
|
|
|
filas := [][]string{
|
|
{"Reporte de actividad"},
|
|
{"Negocio", negocio},
|
|
{"Agente", agente.Nombre},
|
|
{"Período", desde.Format("02/01/2006") + " al " + hasta.Format("02/01/2006")},
|
|
{},
|
|
{"Conversaciones atendidas", strconv.Itoa(len(sesiones))},
|
|
{},
|
|
{"Consumo del período"},
|
|
{"Tipo", "Cantidad", "Unidad", "Usos"},
|
|
}
|
|
etiquetas := map[string]string{
|
|
models.UsoTipoIA: "Inteligencia artificial",
|
|
models.UsoTipoOCR: "Lectura de imágenes",
|
|
models.UsoTipoWhisper: "Transcripción de audio",
|
|
}
|
|
for _, r := range resumen {
|
|
nombre := etiquetas[r.Tipo]
|
|
if nombre == "" {
|
|
nombre = r.Tipo
|
|
}
|
|
filas = append(filas, []string{
|
|
nombre,
|
|
strconv.FormatFloat(r.Cantidad, 'f', 0, 64),
|
|
r.Unidad,
|
|
strconv.FormatInt(r.Eventos, 10),
|
|
})
|
|
}
|
|
|
|
filas = append(filas, []string{}, []string{"Conversaciones"}, []string{"Fecha", "Mensajes", "Primer mensaje del visitante"})
|
|
for _, s := range sesiones {
|
|
filas = append(filas, []string{
|
|
s.Inicio.Format("02/01/2006 15:04"),
|
|
strconv.FormatInt(s.Mensajes, 10),
|
|
s.PrimerMensaje,
|
|
})
|
|
}
|
|
|
|
nombreArchivo := fmt.Sprintf("reporte-%s-%s.xlsx", slugSimple(agente.Nombre), hasta.Format("2006-01"))
|
|
c.Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
|
c.Set("Content-Disposition", `attachment; filename="`+nombreArchivo+`"`)
|
|
var buf bytes.Buffer
|
|
if err := services.EscribirXLSX(&buf, "Reporte", filas); err != nil {
|
|
return c.Status(500).SendString("no se pudo generar el reporte")
|
|
}
|
|
return c.Send(buf.Bytes())
|
|
}
|
|
|
|
// slugSimple deja solo minúsculas, números y guiones: un nombre de agente con
|
|
// acentos o espacios rompe el filename en algunos navegadores.
|
|
func slugSimple(s string) string {
|
|
var b strings.Builder
|
|
for _, r := range strings.ToLower(strings.TrimSpace(s)) {
|
|
switch {
|
|
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
|
b.WriteRune(r)
|
|
case r == ' ' || r == '-' || r == '_':
|
|
b.WriteRune('-')
|
|
}
|
|
}
|
|
out := strings.Trim(b.String(), "-")
|
|
if out == "" {
|
|
return "agente"
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ─── Fuentes de conocimiento que no son una URL ─────────────────────────────
|
|
|
|
// CreateUmindTextoHandler guarda como conocimiento un texto escrito a mano.
|
|
// Es la fuente más valiosa y la que no está en ningún lado: horarios, qué no
|
|
// hacen, la respuesta a la pregunta que les hacen todos los días.
|
|
// POST /umind/documentos/texto
|
|
func CreateUmindTextoHandler(c *fiber.Ctx) error {
|
|
var req struct {
|
|
AgenteID uint `json:"agente_id"`
|
|
Titulo string `json:"titulo"`
|
|
Contenido string `json:"contenido"`
|
|
}
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
|
|
}
|
|
if _, err := accesoAgente(c, req.AgenteID); err != nil {
|
|
return err
|
|
}
|
|
if strings.TrimSpace(req.Contenido) == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "escribí algo para que el agente lo aprenda"})
|
|
}
|
|
titulo := strings.TrimSpace(req.Titulo)
|
|
if titulo == "" {
|
|
titulo = "Nota"
|
|
}
|
|
|
|
doc := &models.UmindDocumento{
|
|
AgenteID: req.AgenteID,
|
|
Tipo: "texto",
|
|
Origen: titulo,
|
|
Contenido: req.Contenido,
|
|
Estado: "procesando",
|
|
}
|
|
if err := models.CreateUmindDocumento(doc); err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
go services.IngestarTexto(req.AgenteID, doc.ID, req.Contenido)
|
|
return c.Status(fiber.StatusAccepted).JSON(fiber.Map{"ok": true, "id": doc.ID, "estado": "procesando"})
|
|
}
|
|
|
|
// CreateUmindArchivoHandler carga un archivo como conocimiento. Usa el mismo
|
|
// extractor que los adjuntos de los canales: PDF, Word, texto e imágenes.
|
|
// POST /umind/documentos/archivo (multipart: agente_id, archivo)
|
|
func CreateUmindArchivoHandler(c *fiber.Ctx) error {
|
|
agenteID, _ := strconv.ParseUint(c.FormValue("agente_id"), 10, 64)
|
|
if _, err := accesoAgente(c, uint(agenteID)); err != nil {
|
|
return err
|
|
}
|
|
archivo, err := c.FormFile("archivo")
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "subí un archivo"})
|
|
}
|
|
if archivo.Size > 20<<20 {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "el archivo supera los 20 MB"})
|
|
}
|
|
f, err := archivo.Open()
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "no se pudo leer el archivo"})
|
|
}
|
|
defer f.Close()
|
|
datos, err := io.ReadAll(io.LimitReader(f, 20<<20))
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "no se pudo leer el archivo"})
|
|
}
|
|
|
|
// El texto se extrae acá y no en segundo plano: si el archivo no se puede
|
|
// leer, conviene decirlo mientras la persona está mirando la pantalla.
|
|
texto, err := services.ExtraerTextoDeArchivo(uint(agenteID), archivo.Filename, datos)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
|
|
doc := &models.UmindDocumento{
|
|
AgenteID: uint(agenteID),
|
|
Tipo: "archivo",
|
|
Origen: archivo.Filename,
|
|
Contenido: texto,
|
|
Estado: "procesando",
|
|
}
|
|
if err := models.CreateUmindDocumento(doc); err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
go services.IngestarTexto(uint(agenteID), doc.ID, texto)
|
|
return c.Status(fiber.StatusAccepted).JSON(fiber.Map{"ok": true, "id": doc.ID, "estado": "procesando"})
|
|
}
|
|
|
|
// ReprocesarUmindDocumentoHandler vuelve a leer una fuente. Para una URL la
|
|
// crawlea de nuevo —es la única forma de que el agente deje de contestar los
|
|
// precios del año pasado— y para el resto rearma los fragmentos del texto
|
|
// guardado.
|
|
// POST /umind/documentos/:id/reprocesar
|
|
func ReprocesarUmindDocumentoHandler(c *fiber.Ctx) error {
|
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
|
|
}
|
|
if err := accesoDocumento(c, uint(id)); err != nil {
|
|
return err
|
|
}
|
|
doc, err := models.GetUmindDocumentoByID(uint(id))
|
|
if err != nil {
|
|
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "fuente no encontrada"})
|
|
}
|
|
if doc.Estado == "procesando" {
|
|
return c.Status(409).JSON(fiber.Map{"error": "esta fuente ya se está procesando"})
|
|
}
|
|
services.ReprocesarDocumento(*doc)
|
|
return c.Status(fiber.StatusAccepted).JSON(fiber.Map{"ok": true, "estado": "procesando"})
|
|
}
|
|
|
|
// ActualizarUmindDocumentoHandler edita el texto de una fuente escrita a mano
|
|
// y la vuelve a procesar.
|
|
// PUT /umind/documentos/:id
|
|
func ActualizarUmindDocumentoHandler(c *fiber.Ctx) error {
|
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
|
|
}
|
|
if err := accesoDocumento(c, uint(id)); err != nil {
|
|
return err
|
|
}
|
|
doc, err := models.GetUmindDocumentoByID(uint(id))
|
|
if err != nil {
|
|
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "fuente no encontrada"})
|
|
}
|
|
|
|
var req struct {
|
|
Titulo string `json:"titulo"`
|
|
Contenido string `json:"contenido"`
|
|
AutoActualizar *bool `json:"auto_actualizar"`
|
|
}
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
|
|
}
|
|
|
|
updates := map[string]interface{}{}
|
|
if req.AutoActualizar != nil {
|
|
updates["auto_actualizar"] = *req.AutoActualizar
|
|
}
|
|
reprocesar := false
|
|
if doc.Tipo == "texto" && strings.TrimSpace(req.Contenido) != "" {
|
|
updates["contenido"] = req.Contenido
|
|
if t := strings.TrimSpace(req.Titulo); t != "" {
|
|
updates["origen"] = t
|
|
}
|
|
doc.Contenido = req.Contenido
|
|
reprocesar = true
|
|
}
|
|
if len(updates) > 0 {
|
|
if err := models.UpdateUmindDocumento(uint(id), updates); err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
}
|
|
if reprocesar {
|
|
services.ReprocesarDocumento(*doc)
|
|
}
|
|
return c.JSON(fiber.Map{"ok": true})
|
|
}
|