feat(umind): duplicar un agente para usarlo como plantilla del siguiente
El catálogo de rubros da un arranque genérico, pero el mejor punto de partida para el segundo restaurante es el primero — el que ya tiene el conocimiento real, el tono ajustado y las herramientas andando. Ahora se puede copiar: se lleva conocimiento, tono, bienvenida, color, config de IA y herramientas. Tres cosas no se copian, y es a propósito: Los canales. Llevan las credenciales de una cuenta concreta de WhatsApp o Telegram; copiarlas haría que dos agentes contesten por el mismo número. Las claves de las herramientas. Son secretos de un tercero, atados a una cuenta. La copia llega sin ellas y, si la herramienta las necesitaba, llega desactivada — para que la falta se note al configurarla y no cuando un cliente recibe un error. La site_key. Es la identidad pública del widget y tiene índice único: compartirla sería servir dos agentes distintos bajo el mismo nombre. El conocimiento se rehace desde cero en vez de copiar los vectores. Es más lento, pero los chunks viejos pueden venir de otro modelo de embeddings, y mezclar vectores de modelos distintos rompe la comparación por similitud — la búsqueda devolvería cualquier cosa. Permisos: se verifica el acceso al agente de origen y también al tenant destino. Copiar es crear, y crear en un tenant ajeno tampoco corresponde. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
af71b66bb8
commit
5086087e1b
@@ -93,6 +93,24 @@ async function cargar() {
|
||||
}
|
||||
}
|
||||
|
||||
// Un agente que ya funciona es la mejor plantilla del siguiente: el catálogo de
|
||||
// rubros da un arranque genérico, esto copia uno real con su conocimiento.
|
||||
async function duplicarAgente(a) {
|
||||
const nombre = prompt(`Nombre de la copia de "${a.nombre}":`, `${a.nombre} (copia)`)
|
||||
if (nombre === null) return
|
||||
error.value = ''
|
||||
try {
|
||||
const r = await api.post(apiUmind(`/umind/agentes/${a.ID}/duplicar`), {
|
||||
tenant_id: Number(tenantId.value),
|
||||
nombre: nombre.trim(),
|
||||
})
|
||||
await cargar()
|
||||
if (r.aviso) alert(r.aviso)
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
function nuevoAgente() {
|
||||
editing.value = null
|
||||
form.value = vacio()
|
||||
@@ -225,6 +243,11 @@ watch(() => props.id, cargar, { immediate: true })
|
||||
|
||||
<div class="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
||||
<button class="p-1 text-tenue hover:text-texto" title="Editar" @click.prevent.stop="editarAgente(a)">✎</button>
|
||||
<button
|
||||
class="p-1 text-tenue hover:text-texto"
|
||||
title="Duplicar: copia el conocimiento y las herramientas a un agente nuevo"
|
||||
@click.prevent.stop="duplicarAgente(a)"
|
||||
>⧉</button>
|
||||
<button class="p-1 text-tenue hover:text-red-600" title="Eliminar" @click.prevent.stop="eliminarAgente(a)">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||
)
|
||||
|
||||
// DuplicarAgente crea una copia de un agente en el tenant indicado, con su
|
||||
// conocimiento y sus herramientas.
|
||||
//
|
||||
// Es lo que convierte a un agente bien configurado en la plantilla de los que
|
||||
// vengan después: el catálogo de rubros da un punto de partida genérico, pero
|
||||
// el mejor punto de partida para el segundo restaurante es el primero.
|
||||
//
|
||||
// Lo que NO se copia, y por qué:
|
||||
// - Los canales (WhatsApp, Telegram): llevan credenciales de una cuenta
|
||||
// concreta. Copiarlas haría que dos agentes contesten por el mismo número.
|
||||
// - Las conversaciones: son de los clientes del otro negocio.
|
||||
// - El valor de los headers de auth de las herramientas: es un secreto de un
|
||||
// tercero, atado a una cuenta. La herramienta se copia sin él, para que
|
||||
// quien reciba la copia lo cargue.
|
||||
func DuplicarAgente(origenID, tenantDestino uint, nombreNuevo string) (*models.UmindAgente, error) {
|
||||
origen, err := models.GetUmindAgenteByID(origenID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("el agente que querés copiar no existe: %w", err)
|
||||
}
|
||||
if tenantDestino == 0 {
|
||||
tenantDestino = origen.TenantID
|
||||
}
|
||||
if nombreNuevo == "" {
|
||||
nombreNuevo = origen.Nombre + " (copia)"
|
||||
}
|
||||
|
||||
copia := &models.UmindAgente{
|
||||
TenantID: tenantDestino,
|
||||
Nombre: nombreNuevo,
|
||||
AiConfigID: origen.AiConfigID,
|
||||
Tono: origen.Tono,
|
||||
MensajeBienvenida: origen.MensajeBienvenida,
|
||||
Color: origen.Color,
|
||||
Activo: true,
|
||||
// SiteKey se genera sola en CreateUmindAgente: es la llave pública del
|
||||
// widget y tiene índice único, compartirla sería servir dos agentes
|
||||
// distintos bajo la misma identidad.
|
||||
}
|
||||
if err := models.CreateUmindAgente(copia); err != nil {
|
||||
return nil, fmt.Errorf("no se pudo crear la copia: %w", err)
|
||||
}
|
||||
|
||||
go copiarConocimiento(origen.ID, copia.ID)
|
||||
copiarHerramientas(origen.ID, copia.ID)
|
||||
|
||||
return copia, nil
|
||||
}
|
||||
|
||||
// copiarConocimiento rehace las fuentes en el agente nuevo. Se vuelven a
|
||||
// generar los embeddings en vez de copiar los vectores: es más lento, pero los
|
||||
// chunks viejos pueden venir de un modelo de embeddings distinto al actual, y
|
||||
// mezclarlos rompe la comparación por similitud.
|
||||
func copiarConocimiento(origenID, destinoID uint) {
|
||||
docs, err := models.GetUmindDocumentosByAgente(origenID)
|
||||
if err != nil {
|
||||
log.Printf("[UMIND] No se pudo leer el conocimiento del agente %d: %v", origenID, err)
|
||||
return
|
||||
}
|
||||
|
||||
copiados := 0
|
||||
for _, d := range docs {
|
||||
nuevo := &models.UmindDocumento{
|
||||
AgenteID: destinoID,
|
||||
Tipo: d.Tipo,
|
||||
Origen: d.Origen,
|
||||
Contenido: d.Contenido,
|
||||
MaxPaginas: d.MaxPaginas,
|
||||
AutoActualizar: d.AutoActualizar,
|
||||
Estado: "procesando",
|
||||
}
|
||||
if err := models.CreateUmindDocumento(nuevo); err != nil {
|
||||
log.Printf("[UMIND] No se pudo copiar la fuente %q: %v", d.Origen, err)
|
||||
continue
|
||||
}
|
||||
|
||||
switch {
|
||||
case d.Tipo == "url":
|
||||
IngestarAgente(destinoID, nuevo.ID, d.Origen, d.MaxPaginas)
|
||||
case d.Contenido != "":
|
||||
IngestarTexto(destinoID, nuevo.ID, d.Contenido)
|
||||
default:
|
||||
// Fuentes cargadas antes de que se guardara su texto: no hay de
|
||||
// dónde rehacerlas sin el archivo original.
|
||||
_ = models.UpdateUmindDocumentoEstado(nuevo.ID, "error",
|
||||
"esta fuente se copió de un agente donde no quedó guardado su texto: volvé a subirla", 0)
|
||||
continue
|
||||
}
|
||||
copiados++
|
||||
}
|
||||
log.Printf("[UMIND] Agente %d copiado desde %d: %d de %d fuentes", destinoID, origenID, copiados, len(docs))
|
||||
}
|
||||
|
||||
// copiarHerramientas replica las tools sin su secreto de autenticación.
|
||||
func copiarHerramientas(origenID, destinoID uint) {
|
||||
tools, err := models.GetUmindHerramientasByAgente(origenID)
|
||||
if err != nil {
|
||||
log.Printf("[UMIND] No se pudieron leer las herramientas del agente %d: %v", origenID, err)
|
||||
return
|
||||
}
|
||||
for _, t := range tools {
|
||||
nueva := &models.UmindHerramienta{
|
||||
AgenteID: destinoID,
|
||||
Nombre: t.Nombre,
|
||||
Descripcion: t.Descripcion,
|
||||
ParametrosJSON: t.ParametrosJSON,
|
||||
URL: t.URL,
|
||||
AuthHeaderNombre: t.AuthHeaderNombre,
|
||||
// AuthHeaderValorEnc queda vacío a propósito: es la credencial de
|
||||
// una cuenta concreta. La copia arranca desactivada si la
|
||||
// necesitaba, para que nadie descubra que faltaba en producción.
|
||||
Activa: t.Activa && (t.AuthHeaderNombre == "" || t.AuthHeaderValorEnc == ""),
|
||||
}
|
||||
if err := models.CreateUmindHerramienta(nueva); err != nil {
|
||||
log.Printf("[UMIND] No se pudo copiar la herramienta %q: %v", t.Nombre, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||
)
|
||||
|
||||
// Copiar una herramienta con su header de auth le daría al dueño del agente
|
||||
// nuevo la credencial de un tercero que no es suya. La copia tiene que llegar
|
||||
// sin el secreto — y desactivada, para que la falta se note al configurarla y
|
||||
// no cuando un cliente recibe un error.
|
||||
func TestLaCopiaDeHerramientaNoArrastraElSecreto(t *testing.T) {
|
||||
casos := []struct {
|
||||
nombre string
|
||||
headerNombre string
|
||||
headerValor string
|
||||
activaOrigen bool
|
||||
activaEsperada bool
|
||||
}{
|
||||
{"con auth: llega desactivada", "Authorization", "cifrado-xyz", true, false},
|
||||
{"sin auth: conserva su estado", "", "", true, true},
|
||||
{"sin auth y desactivada: sigue desactivada", "", "", false, false},
|
||||
}
|
||||
|
||||
for _, c := range casos {
|
||||
origen := models.UmindHerramienta{
|
||||
Nombre: "consultar_stock",
|
||||
AuthHeaderNombre: c.headerNombre,
|
||||
AuthHeaderValorEnc: c.headerValor,
|
||||
Activa: c.activaOrigen,
|
||||
}
|
||||
// Misma expresión que usa copiarHerramientas.
|
||||
activa := origen.Activa && (origen.AuthHeaderNombre == "" || origen.AuthHeaderValorEnc == "")
|
||||
if activa != c.activaEsperada {
|
||||
t.Errorf("%s: activa = %v, esperaba %v", c.nombre, activa, c.activaEsperada)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// El nombre por defecto tiene que distinguir la copia del original: dos
|
||||
// agentes con el mismo nombre en la misma lista no se pueden diferenciar.
|
||||
func TestNombrePorDefectoDeLaCopia(t *testing.T) {
|
||||
nombreNuevo := ""
|
||||
original := "Ventas"
|
||||
if nombreNuevo == "" {
|
||||
nombreNuevo = original + " (copia)"
|
||||
}
|
||||
if nombreNuevo == original {
|
||||
t.Error("la copia no puede llamarse igual que el original")
|
||||
}
|
||||
if !strings.Contains(nombreNuevo, original) {
|
||||
t.Errorf("el nombre de la copia debería reconocerse: %q", nombreNuevo)
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 96 96'%3E%3Crect width='96' height='96' rx='22' fill='%238eb02f'/%3E%3Cpath d='M32,42 V58 A14,14 0 0 0 60,58 V42' fill='none' stroke='%23fff' stroke-width='10' stroke-linecap='round' stroke-linejoin='round'/%3E%3Cpath d='M60,58 V64' fill='none' stroke='%23fff' stroke-width='10' stroke-linecap='round'/%3E%3Ccircle cx='60' cy='28' r='7' fill='%23fff'/%3E%3C/svg%3E" />
|
||||
<title>uMind Studio</title>
|
||||
<script type="module" crossorigin src="/orchestrator/assets/index-CbSZEemk.js"></script>
|
||||
<script type="module" crossorigin src="/orchestrator/assets/index-CRnmvnlL.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/orchestrator/assets/index-Culm0hM7.css">
|
||||
</head>
|
||||
<!-- Sin clase de fondo: el color lo pone body en style.css desde los tokens,
|
||||
|
||||
@@ -1040,3 +1040,51 @@ func ActualizarUmindDocumentoHandler(c *fiber.Ctx) error {
|
||||
func GetPlantillasRubroHandler(c *fiber.Ctx) error {
|
||||
return c.JSON(fiber.Map{"items": services.ResumenPlantillasRubro()})
|
||||
}
|
||||
|
||||
// DuplicarUmindAgenteHandler copia un agente configurado a otro tenant (o al
|
||||
// mismo), con su conocimiento y sus herramientas. Es lo que permite usar un
|
||||
// agente que ya funciona como plantilla del siguiente.
|
||||
// POST /umind/agentes/:id/duplicar {tenant_id, nombre}
|
||||
func DuplicarUmindAgenteHandler(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"})
|
||||
}
|
||||
// Acceso al origen: sin esto un cliente podría copiarse el agente de otro.
|
||||
if _, err := accesoAgente(c, uint(id)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var req struct {
|
||||
TenantID uint `json:"tenant_id"`
|
||||
Nombre string `json:"nombre"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
|
||||
}
|
||||
|
||||
destino := req.TenantID
|
||||
if destino == 0 {
|
||||
origen, err := models.GetUmindAgenteByID(uint(id))
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "agente no encontrado"})
|
||||
}
|
||||
destino = origen.TenantID
|
||||
}
|
||||
// Y acceso al destino: copiar es crear, y crear en un tenant ajeno tampoco.
|
||||
if err := accesoTenant(c, destino); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := verificarCupoAgentes(c, destino); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
copia, err := services.DuplicarAgente(uint(id), destino, strings.TrimSpace(req.Nombre))
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
|
||||
"ok": true, "id": copia.ID, "site_key": copia.SiteKey,
|
||||
"aviso": "El conocimiento se está copiando en segundo plano. Los canales y las claves de las herramientas no se copian: hay que configurarlos en la copia.",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ func RegistrarRutasUmind(g fiber.Router, scope fiber.Handler, escritura fiber.Ha
|
||||
g.Get("/umind/agentes", r(controllers.GetUmindAgentesHandler)...)
|
||||
g.Post("/umind/agentes", w(controllers.CreateUmindAgenteHandler)...)
|
||||
g.Put("/umind/agentes/:id", w(controllers.UpdateUmindAgenteHandler)...)
|
||||
g.Post("/umind/agentes/:id/duplicar", w(controllers.DuplicarUmindAgenteHandler)...)
|
||||
g.Delete("/umind/agentes/:id", w(controllers.DeleteUmindAgenteHandler)...)
|
||||
|
||||
g.Get("/umind/plantillas-rubro", r(controllers.GetPlantillasRubroHandler)...)
|
||||
|
||||
Reference in New Issue
Block a user