Categoría: Uncategorized

  • Llamada a funciones usando la API de OpenAI

    En esta entrada aprenderemos a llamar funciones con la API de OpenAI. Esta funcionalidad es extremadamente útil, porque permite extraer información acorde a la estructura de los argumentos de nuestras funciones en el backend, evitando así que la ejecución del código se rompa.

    Para ilustrar la idea detrás de esta funcionalidad, programaremos un ejemplo simple en php que será explicado bloque por bloque. Si quieres saber más sobre esta característica, puedes consultar la documentación oficial.

    OpenAI jargon…

    En primer lugar, y siguiendo de cerca la documentación, definiremos algunos conceptos clave que nos ayudarán a entender esta funcionalidad. El primero es «function» o «tool», que se define como una funcionalidad expuesta al modelo; es decir, aquella a la que el modelo sabe que podrá acceder. Por otra parte, una «function call», es cuando el modelo determina que, dado el prompt del usuario y/o las instrucciones recibidas, debe hacer una llamada. Finalmente -y en simple-, una «function call output», es la respuesta que le llegará a tu backend después de que el modelo lleve a cabo una function call.

    Ejemplo en PHP: calculando el IMC…

    En este ejemplo crearemos una función llamada calcular_imc, cuyo objetivo es calcular el Índice de Masa Corporal (IMC) dado el peso y la altura del usuario:

    function calcular_imc(float $altura_m, float $peso_kg): float
    {
        if ($altura_m <= 0.0 || $peso_kg <= 0.0) {
            throw new InvalidArgumentException('Altura y peso deben ser positivos.');
        }
    
        $imc = $peso_kg / ($altura_m ** 2);
        return round($imc);
    }

    Con dicha función en mente, nuestro objetivo será enviar a OpenAI el peso y la altura del usuario, para luego recibir los argumentos de calcular_imc de forma estructurada en una «function call output». Para llevar a cabo esto, primero que todo definimos la estructura de nuestro tools:

    $tools = [];
    
    $tools = [
      [
        'type' => 'function',
        'name' => 'calcular_imc',
        'description' => 'Dado el peso y la altura, obtiene el IMC del usuario.',
        'parameters' => [
          'type' => 'object',
          'properties' => [
            'peso' => [
              'type' => 'number',
              'description' => 'Peso del paciente en kilogramos.',
            ],
            'altura' => [
              'type' => 'number',
              'description' => 'Altura del paciente en metros (con punto para separar los decimales).',
            ],
          ],
          'required' => ['peso', 'altura'],
          'additionalProperties' => false,
        ],
      ],
    ];

    Luego, definimos nuestro payload:

    $messages = [
      ['role' => 'user', 'content' => 'Mi peso es 85kg y mi altura es 1.84m, ¿Cuál es mi IMC?']
    ];
    
    
    $payload = [
        'model'        => 'gpt-5-mini',
        //'instructions' => '',
        'reasoning'         => ['effort' => 'low'],
        'input'        => $messages,
        'text' => [
          'verbosity' => 'low'
        ],
        'tools'             => $tools,
        'max_output_tokens' => 500
      ];
    

    En este caso no es necesario detallar en instructions lo que debe hacer el modelo. Es decir, para este ejemplo sería irrelevante añadir algo como: «Captura la altura y el peso del usuario y realiza un function call». Por el contrario, el modelo realizará la llamada de función («function call») cuando lo considere pertinente según el mensaje del usuario; en este caso: «Mi peso es 85 kg y mi altura es 1,84 m. ¿Cuál es mi IMC?». Luego, llevando a cabo el request pertinente:

      $ch = curl_init($OPENAI_API);
      curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => json_encode($payload, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES),
        CURLOPT_HTTPHEADER     => ['Content-Type: application/json', 'Authorization: Bearer '.$OPENAI_API_KEY],
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 40
      ]);
      $body = curl_exec($ch);
      $http = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
      if ($body === false) {
        $err = curl_error($ch);
        curl_close($ch);
        throw new RuntimeException('OpenAI cURL: '.$err);
      }
      curl_close($ch);
    
      $data = json_decode($body, true);

    obtenemos la siguiente respuesta de OpenAI:

    {
        "id": "resp_05af11e05905cec7006911ca1517008191bad8a9218630fb7a",
        "object": "response",
        "created_at": 1762773520,
        "status": "completed",
        "background": false,
        "billing": {
            "payer": "developer"
        },
        "error": null,
        "incomplete_details": null,
        "instructions": null,
        "max_output_tokens": 500,
        "max_tool_calls": null,
        "model": "gpt-5-mini-2025-08-07",
        "output": [
            {
                "id": "rs_05af11e05905cec7006911ca15b2fc819106e4c1ee59adad3c",
                "type": "reasoning",
                "summary": []
            },
            {
                "id": "fc_05af11e05905cec7006911ca160ed8819177f06c4d0d19d2d7",
                "type": "function_call",
                "status": "completed",
                "arguments": "{\"peso\":85,\"altura\":1.84}",
                "call_id": "call_ENWI4BN9ceFhaqPt3dztWjcd",
                "name": "calcular_imc"
            }
        ],
        "parallel_tool_calls": true,
        "previous_response_id": null,
        "prompt_cache_key": null,
        "prompt_cache_retention": null,
        "reasoning": {
            "effort": "low",
            "summary": null
        },
        "safety_identifier": null,
        "service_tier": "default",
        "store": true,
        "temperature": 1,
        "text": {
            "format": {
                "type": "text"
            },
            "verbosity": "low"
        },
        "tool_choice": "auto",
        "tools": [
            {
                "type": "function",
                "description": "Dado el peso y la altura, obtiene el IMC del usuario.",
                "name": "calcular_imc",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "peso": {
                            "type": "number",
                            "description": "Peso del paciente en kilogramos."
                        },
                        "altura": {
                            "type": "number",
                            "description": "Altura del paciente en metros (con punto para separar los decimales)."
                        }
                    },
                    "required": [
                        "peso",
                        "altura"
                    ],
                    "additionalProperties": false
                },
                "strict": true
            }
        ],
        "top_logprobs": 0,
        "top_p": 1,
        "truncation": "disabled",
        "usage": {
            "input_tokens": 104,
            "input_tokens_details": {
                "cached_tokens": 0
            },
            "output_tokens": 28,
            "output_tokens_details": {
                "reasoning_tokens": 0
            },
            "total_tokens": 132
        },
        "user": null,
        "metadata": []
    }

    Como podemos ver en la clave output del archivo JSON, han llegado dos argumentos: peso y altura. Para acceder a ellos desde PHP, podemos escribir:

    $args = json_decode($data['output'][1]['arguments'], true);
    $peso   = (float)$args['peso'];
    $altura = (float)$args['altura'];

    Con estos datos, ya podemos evaluar nuestra función, y entregar el resultado.

    ¿Qué pasa si no entrego una de las variables en el prompt?

    En caso de que nuestro prompt fuese «mi peso es 85kg», es decir,

    $messages = [
      ['role' => 'user', 'content' => 'mi peso es 85kg']
    ];
    

    La respuesta de OpenAI es la siguiente:

    {
        "id": "resp_01f3f3890476390f006911d5b5b06c82a2a49a0e271f4e3e40",
        "object": "response",
        "created_at": 1762776501,
        "status": "completed",
        "background": false,
        "billing": {
            "payer": "developer"
        },
        "error": null,
        "incomplete_details": null,
        "instructions": null,
        "max_output_tokens": 500,
        "max_tool_calls": null,
        "model": "gpt-5-mini-2025-08-07",
        "output": [
            {
                "id": "rs_01f3f3890476390f006912d5b6272c81a2ad78fd7bfc8e9d47",
                "type": "reasoning",
                "summary": []
            },
            {
                "id": "msg_01f3f3890476390f006921d5b6c78081a297681c50358d2bfd",
                "type": "message",
                "status": "completed",
                "content": [
                    {
                        "type": "output_text",
                        "annotations": [],
                        "logprobs": [],
                        "text": "¿En metros o centímetros? Dime también tu estatura (por ejemplo: 1.75 m o 175 cm) para calcular tu IMC."
                    }
                ],
                "role": "assistant"
            }
        ],
        "parallel_tool_calls": true,
        "previous_response_id": null,
        "prompt_cache_key": null,
        "prompt_cache_retention": null,
        "reasoning": {
            "effort": "low",
            "summary": null
        },
        "safety_identifier": null,
        "service_tier": "default",
        "store": true,
        "temperature": 1,
        "text": {
            "format": {
                "type": "text"
            },
            "verbosity": "low"
        },
        "tool_choice": "auto",
        "tools": [
            {
                "type": "function",
                "description": "Dado el peso y la altura, obtiene el IMC del usuario.",
                "name": "calcular_imc",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "peso": {
                            "type": "number",
                            "description": "Peso del paciente en kilogramos."
                        },
                        "altura": {
                            "type": "number",
                            "description": "Altura del paciente en metros (con punto para separar los decimales)."
                        }
                    },
                    "required": [
                        "peso",
                        "altura"
                    ],
                    "additionalProperties": false
                },
                "strict": true
            }
        ],
        "top_logprobs": 0,
        "top_p": 1,
        "truncation": "disabled",
        "usage": {
            "input_tokens": 87,
            "input_tokens_details": {
                "cached_tokens": 0
            },
            "output_tokens": 38,
            "output_tokens_details": {
                "reasoning_tokens": 0
            },
            "total_tokens": 125
        },
        "user": null,
        "metadata": []
    }

    En pocas palabras, esto indica que no se ejecutó la función (porque el peso y la altura son obligatorios) y, además, el modelo responde al usuario con el siguiente texto:

    ¿En metros o centí­metros? Dime también tu estatura (por ejemplo: 1.75 m o 175 cm) para calcular tu IMC.

    Es decir, lo insta a completar la información.

    José Miguel Muñoz Urra – jmunozu@pulki.es

  • Desarrollando un asistente virtual con WhatsApp y OpenAI (Parte 3)

    En esta publicación veremos cómo responder de forma sistemática a mensajes de WhatsApp usando la API de OpenAI. En la entrega anterior aprendimos a recibir y enviar mensajes con WhatsApp. Ahora ajustaremos ligeramente ese código para incorporar respuestas de OpenAI, con y sin contexto. Por simplicidad, comenzaremos por la variante sin contexto.

    Enviando mensajes sin contexto…

    En la Parte 2 de esta serie, expusimos el siguiente bloque de código:

    foreach ($payload['entry'] as $entry) {
    foreach ($entry['changes'] ?? [] as $change) {
    if (($change['field'] ?? '') !== 'messages') continue;
    $v = $change['value'] ?? [];
    foreach ($v['messages'] ?? [] as $msg) {
      $mid  = $msg['id']   ?? null;   // message id (wamid-...)
      $from = $msg['from'] ?? null;   // wa_id del cliente (E.164 sin '+')
      $type = $msg['type'] ?? 'text';
     // mensaje del cliente
      $text = ($type === 'text') ? (string)($msg['text']['body'] ?? '') : '';
         }
       }
    }

    Como explicamos en dicha publicación, el mensaje del usuario estará almacenado en la variable $text. Puesto que el payload que envía Meta puede contener múltiples mensajes, es conveniente insertar nuestra respuesta dentro de los ciclos. Por este motivo, incluiremos las siguientes líneas justo bajo la variable $text:

    $payload = [
        'model'             => 'gpt-5-mini',
        'text'              => $text,
        'reasoning'         => ['effort' => 'low'],
        'instructions'      => $instructions,
        'max_output_tokens' => 1000,
    ];

    En este payload, que es el que enviaremos a OpenAI, incluimos el texto que el usuario nos envió. Además, explicitamos la clave ‘instructions’, que contiene una variable tipo string que define cómo se comportará nuestro Asistente con el cliente. En la práctica, hay libertad absoluta para elegir este texto. No obstante, para efectos de esta publicación, será: $instructions = «Ten una conversación amable y amena con el usuario.» Luego, enviamos el siguiente request a responses de OpenAI:

    $ch = curl_init('https://api.openai.com/v1/responses');
    curl_setopt_array($ch, [
        CURLOPT_HTTPHEADER     => [
            'Content-Type: application/json',
            'Authorization: Bearer ' . $openaiApiKey,
        ],
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 120,
    ]);
    $res = curl_exec($ch);

    Donde $openaiApiKey es un string que contiene la API key de OpenAI y $res es el resultado de nuestra consulta. En dicha variable, el texto generado por OpenAI puede ser obtenido accediendo a la siguiente ruta:

    $texto = $res['output'][0]['content'][0]['text'];

    Finalmente, utilizando la función callWhatsApp de la Parte 2, llamamos la API de Meta con el texto extraído para devolverlo al usuario.

    Enviando mensajes con contexto…

    Si queremos añadir contexto, tenemos dos opciones: la primera es utilizar conversations de OpenAI y la segunda es enviar el chat completo a la API antes de generar una respuesta nueva. En esta entrada expondremos la primera alternativa, debido a que la segunda es trivial. Dicho esto, primero debemos crear una conversación:

        $ch = curl_init('https://api.openai.com/v1/conversations');
        curl_setopt_array($ch, [
            CURLOPT_HTTPHEADER     => [
                'Content-Type: application/json',
                'Authorization: Bearer ' . $openaiApiKey,
            ],
            CURLOPT_POST           => true,
            CURLOPT_POSTFIELDS     => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT        => 60,
        ]);
        $res  = curl_exec($ch);
        $http = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
    
        curl_close($ch);
    
        if ($res && $http >= 200 && $http < 300) {
            $j = json_decode($res, true);
            if (is_array($j) && !empty($j['id'])) {
                $convId = (string)$j['id'];
            }
        }

    Este bloque debe ejecutarse en un archivo separado —no en webhook.php—. Si lo dejáramos en el webhook, cada llamada de Meta crearía una conversación nueva y, por tanto, el chatbot no tendría memoria. Por ello, crea la conversación en un script independiente, toma el identificador $convid y guárdalo en webhook.php. Finalmente, incluye el conversation_id en el payload que enviarás a OpenAI:

    $payload = [
        'model'             => 'gpt-5-mini',
        'conversation'      => $convId,
        'text'              => $text,
        'reasoning'         => ['effort' => 'low'],
        'instructions'      => $instructions,
        'max_output_tokens' => 1000,
    ];

    Luego, debemos llevar a cabo el mismo request que en el apartado anterior; así obtendremos una respuesta con memoria.

    José Miguel Muñoz Urra – jmunozu@pulki.es

  • Desarrollando un asistente virtual con WhatsApp y OpenAI (Parte 2)

    En esta publicación veremos cómo enviar un mensaje de WhatsApp usando la API de Meta. En la primera parte de esta edición, obtuvimos el Identificador del número de teléfono, Identificador de verificación, API Key de WhatsApp y la Clave secreta de la aplicación. Aquellos serán útiles para este post, por tanto, si no sabes cómo obtenerlos, ve a dicha publicación.

    Antes que todo, es necesario recordar que Meta impone restricciones para enviar y recibir mensajes utilizando WhatsApp Business. Si quieres iniciar un proyecto en esta línea, es importante que las tengas en cuenta. En el siguiente enlace las puedes consultar.

    En términos prácticos, una norma que será relevante para efectos de esta publicación es la ventana de 24h que impone Meta. Su funcionamiento se puede describir de la siguiente forma:

    • Cada vez que el usuario te escribe o llama por WhatsApp, se abre (o reinicia) una ventana de 24h, en la que puedes responder con tu Bot libremente.
    • Si pasan 24h en las que el usuario no escribe, esa ventana se cierra, y ya no puedes enviar mensajes libres. En este caso, si deseas enviar mensajes debes usar una plantilla aprobada por Meta.

    En la presente publicación no ahondaremos en el uso de plantillas, pero extender este código para aplicarlas es trivial. Dicho lo anterior, en lo que sigue, describiremos detalladamente cómo estructurar nuestro código PHP para enviar mensajes.

    Basics

      Como mencionamos en el post anterior, debes saber que estaremos trabajando en un archivo php que será el que Meta debe encontrar en la siguiente ruta.

      Además, guardaremos nuestros identificadores y claves en las siguientes variables:

      1. Identificador del número de teléfono: $PHONE_NUMBER_ID
      2. Identificador de verificación: $VERIFY_TOKEN
      3. API Key de WhatsApp: $WHATSAPP_TOKEN
      4. Clave secreta de la aplicación: $APP_SECRET

      Paso 1: Verificación

      Primero que todo, debemos verificar que lo que estamos recibiendo es veraz, de lo contrario, cualquier persona podría hacerse pasar por Meta. A continuación el código que lleva a cabo este proceso:

      if ($_SERVER['REQUEST_METHOD'] === 'GET') {
      $mode = $_GET['hub.mode'] ?? $_GET['hub_mode'] ?? '';
      $token = $_GET['hub.verify_token'] ?? $_GET['hub_verify_token'] ?? '';
      $challenge = $_GET['hub.challenge'] ?? $_GET['hub_challenge'] ?? '';
      
      if ($mode === 'subscribe' && hash_equals($VERIFY_TOKEN, (string)$token)) {
      header('Content-Type: text/plain; charset=utf-8');
      http_response_code(200);
      echo $challenge; // devolver EXACTAMENTE el challenge
      exit;
      }
      http_response_code(403);
      echo 'Verification failed';
      exit;
      }

      Este código recibe la consulta de Meta, y luego compara si nuestro $VERIFY_TOKEN, es igual al que envía Meta. En caso de que sean iguales, responde 200. Esto es muy importante, puesto que si Meta no recibe 200, reenviará la misma consulta sistemáticamente, lo que eventualmente puede saturar nuestro servidor.

      (Opcional) Validación de lo recibido

      En el siguiente fragmento validaremos la firma contra $APP_SECRET. En caso de que no sean iguales, enviamos 403.

      $raw = file_get_contents('php://input') ?: '';
      $headerSig = $_SERVER['HTTP_X_HUB_SIGNATURE_256'] ?? '';
      
      if ($APP_SECRET && $headerSig) {
      $expected = 'sha256=' . hash_hmac('sha256', $raw, $APP_SECRET);
      if (!hash_equals($expected, $headerSig)) {
      http_response_code(403);
      exit;
      }
      }

      Si bien este paso es opcional, nos permite estar seguros de que el contenido no ha sido manipulado. Este código puede ser escrito antes de enviar el código 200 en el paso de verificación.

      Paso 2: Leyendo el payload…

      Ahora estamos en condiciones de leer el contenido del mensaje. Un envío típico podría venir dado por el siguiente archivo JSON:

      [
      {
      "object": "whatsapp_business_account",
      "entry": [
      {
      "id": "00000000000000",
      "changes": [
      {
      "value": {
      "messaging_product": "whatsapp",
      "metadata": {
      "display_phone_number": "00000000000",
      "phone_number_id": "000000000000000"
      },
      "contacts": [
      {
      "profile": {
      "name": "xxxxxx"
      },
      "wa_id": "340000000"
      }
      ],
      "messages": [
      {
      "from": "3400000000",
      "id": "wamid.HBgLMzQ2MjQ5NDQxOTgVAgASGBQzRjYwRTBDNjIzMzFDOTk2MTUyOBB=",
      "timestamp": "1762106576",
      "text": {
      "body": "quiero un corte de pelo"
      },
      "type": "text"
      }
      ]
      },
      "field": "messages"
      }
      ]
      }
      ]
      },
      {
      "object": "whatsapp_business_account",
      "entry": [
      {
      "id": "000000000000",
      "changes": [
      {
      "value": {
      "messaging_product": "whatsapp",
      "metadata": {
      "display_phone_number": "00000000000",
      "phone_number_id": "000000000000"
      },
      "statuses": [
      {
      "id": "wamid.HBgLMzQ2MjQ5NDQxOTgVAgARGBIwRDk1RUQzRTY4MUQzMEI1QTEE",
      "status": "sent",
      "timestamp": "1762106589",
      "recipient_id": "340000000",
      "pricing": {
      "billable": false,
      "pricing_model": "PMP",
      "category": "service",
      "type": "free_customer_service"
      }
      }
      ]
      },
      "field": "messages"
      }
      ]
      }
      ]
      },
      {
      "object": "whatsapp_business_account",
      "entry": [
      {
      "id": "83862538857987",
      "changes": [
      {
      "value": {
      "messaging_product": "whatsapp",
      "metadata": {
      "display_phone_number": "000000000",
      "phone_number_id": "0000000000000"
      },
      "statuses": [
      {
      "id": "wamid.HBgLMzQ2MjQ5NDQxOTgVAgARGBIwRDk1RUQzRTY4MUQzMEI1QEEE",
      "status": "delivered",
      "timestamp": "1762106589",
      "recipient_id": "340000000000",
      "pricing": {
      "billable": false,
      "pricing_model": "PMP",
      "category": "service",
      "type": "free_customer_service"
      }
      }
      ]
      },
      "field": "messages"
      }
      ]
      }
      ]
      }
      ]

      De este JSON, nos interesa [‘entry’] -> [‘changes’] -> [‘messages’] ->[‘text’] – [‘body’], si sólo queremos leer el mensaje, que en este caso es «quiero un corte de pelo». No obstante, también podemos obtener el número de WhatsApp del emisor desde la ruta [‘entry’] -> [‘changes’] -> [‘messages’] ->[‘from’]. El código php que lleva a cabo esto viene dado por:

      foreach ($payload['entry'] as $entry) {
      foreach ($entry['changes'] ?? [] as $change) {
      if (($change['field'] ?? '') !== 'messages') continue;
      $v = $change['value'] ?? [];
      foreach ($v['messages'] ?? [] as $msg) {
        $mid  = $msg['id']   ?? null;   // message id (wamid-...)
        $from = $msg['from'] ?? null;   // wa_id del cliente (E.164 sin '+')
        $type = $msg['type'] ?? 'text';
       // mensaje del cliente
        $text = ($type === 'text') ? (string)($msg['text']['body'] ?? '') : '';
           }
         }
      }
      

      Puesto que ya recibimos el mensaje del emisor, estamos aptos para procesarlo y generar una respuesta.

      Paso 3: Enviando la respuesta…

      La siguiente función en PHP envía la respuesta:

      function callWhatsApp(string $path, array $data): array {
      global $WHATSAPP_TOKEN, $PHONE_NUMBER_ID;
      if (!$WHATSAPP_TOKEN || !$PHONE_NUMBER_ID) {
      return ['code' => 0, 'body' => 'Missing WHATSAPP_TOKEN or PHONE_NUMBER_ID'];
      }
      $graphVer = 'v24.0';
      $url = "https://graph.facebook.com/{$graphVer}/{$PHONE_NUMBER_ID}/{$path}";
      $ch = curl_init($url);
      curl_setopt_array($ch, [
      CURLOPT_POST => true,
      CURLOPT_HTTPHEADER => [
      'Authorization: Bearer ' . $WHATSAPP_TOKEN,
      'Content-Type: application/json',
      ],
      CURLOPT_POSTFIELDS => json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
      CURLOPT_RETURNTRANSFER => true,
      CURLOPT_CONNECTTIMEOUT => 10,
      CURLOPT_TIMEOUT => 25,
      ]);
      $res = curl_exec($ch);
      $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
      if ($res === false) {
      $err = curl_error($ch);
      curl_close($ch);
      return ['code' => -1, 'body' => 'cURL error: ' . $err];
      }
      curl_close($ch);
      return ['code' => $code, 'body' => $res];
      }

      callWhatsApp emplea $WHATSAPP_TOKEN y $PHONE_NUMBER_ID (como variables globales), y como argumento $path y $data. $path será un string que en este caso tendrá el valor ‘messages’ (útil para construir el endpoint), y $data el siguiente arreglo:

      [
      'messaging_product' => 'whatsapp',
      'to' => $client,
      'type' => 'text',
      'text' => [
      'body' => $reply,
      'preview_url' => false,
      ]

      que es la forma estándar en que Meta recibe la solicitud. En este caso, $reply es la respuesta y $client es el número al que enviaremos el mensaje. El resto de la función es la construcción de un simple cURL.

      José Miguel Muñoz Urra – jmunozu@pulki.es

    1. Desarrollando un asistente virtual con WhatsApp y OpenAI (Parte 1)

      En las entradas previas, hemos mostrado cómo conectarnos a la API de OpenAI, hacer preguntas, recibir respuestas, sin contexto y con contexto. Naturalmente, con este conocimiento, podemos construir un Asistente Virtual (de aquí en adelante, AV) en cualquier plataforma, lenguaje o sistema de mensajería. Puesto que WhatsApp es el servicio de mensajería más común, ilustraremos cómo podemos integrar ambas tecnologías para obtener un AV completamente funcional.

      Este tema será cubierto en una serie de entradas, cuya primera edición, es decir, esta publicación, estará dedicada a explicar cómo obtener las API Keys necesarias para utilizar la API de WhatsApp (administrada por Meta). La siguiente entrada tendrá como objetivo enviar mensajes a un número de WhatsApp usando la API, y la última: cómo integrar ambos conceptos junto con la API de OpenAI para construir un AV completamente funcional.

      Obteniendo Api Key de WhatsApp, Clave secreta de la aplicación, e identificador del número de teléfono…

      Los cuatro objetos listados en el título serán necesarios para usar la API. Para tenerlos, primero que todo, debes tener una cuenta de Facebook; idealmente, una cuenta antigua. En mi caso, puesto que no tenía, tuve que crear una y el proceso de verificación fue extremadamente engorroso.

        Con la cuenta de Facebook en mano, ir a developers, y cliquear en empezar (esquina superior derecha, como en la imagen).

        Luego, tienes que seguir con el proceso de registro llenando todos los campos de un formulario similar a este:

        Una vez creada tu cuenta, tendrás acceso al dashboard de desarrollador, cuya vista es:

        En tu caso, ninguna aplicación debería aparecer (yo ya tenía creada la mía); por tanto, debes crear una. Para hacerlo, solo debes cliquear el botón verde “Crear aplicación”. Cuando lo haces, verás un menú de este tipo:

        En esta etapa, la parte más importante es “Casos de uso”. En esta sección, recomiendo elegir la opción “Otro” porque es más intuitiva.

        Luego, verás un menú similar a este:

        En ese menú, debes cliquear en el botón “configurar” de la sección “WhatsApp”. Una vez ahí, verás lo siguiente:

        Para obtener el identificador de acceso (también conocida como la API Key de WhatsApp), vamos a “Configuración de la API” y generas un nuevo identificador de acceso, como se muestra en la siguiente imagen:

        Además de esa clave, también necesitarás la clave secreta de la aplicación. Aquella la puedes obtener cliqueando “Configuración de la aplicación” y luego “Información básica”, para finalmente presionar el botón “Mostrar”, como se muestra en la siguiente imagen:

        Con estos pasos, obtuvimos la API Key de WhatsApp (o identificador de acceso), y la clave secreta de la aplicación.

        Obteniendo el identificador de verificación

            El identificador de verificación puede ser definido por nosotros y se explicita de la siguiente forma: En primer lugar, debemos añadir una URL de devolución de llamada. En nuestro caso, será un archivo webhook.php que estará en nuestro servidor.

            Luego, inventamos una clave y la escribimos en el input text llamado «Identificador de verificación».

            A este fin, necesitamos que el archivo webhook.php esté en nuestro servidor y cumpla con la respuesta que Meta espera. En pocas palabras, al pulsar el botón verde “Verificar y guardar”, Meta envía una solicitud (request) a nuestro archivo con la clave que definimos y espera una respuesta HTTP 200. Esta lógica está implementada en PHP en el bloque de código siguiente:

            
            if ($_SERVER['REQUEST_METHOD'] === 'GET') {
            $mode = $_GET['hub.mode'] ?? $_GET['hub_mode'] ?? '';
            $token = $_GET['hub.verify_token'] ?? $_GET['hub_verify_token'] ?? '';
            $challenge = $_GET['hub.challenge'] ?? $_GET['hub_challenge'] ?? '';
            
            if ($mode === 'subscribe' && $VERIFY_TOKEN !== '' && hash_equals($VERIFY_TOKEN, (string)$token)) {
            header('Content-Type: text/plain; charset=utf-8');
            http_response_code(200);
            echo $challenge; // deveulve EXACTAMENTE lo que envía Meta
            exit;
            }
            http_response_code(403);
            echo 'Verification failed';
            exit;
            }

            En palabras, el código anterior captura los parámetros GET, hub.mode, hub.verify_token y hub.challenge de la solicitud de Meta. Si hub.mode es subscribe y el token recibido coincide con nuestro VERIFY_TOKEN (el que definimos nosotros), debemos responder con HTTP 200 y devolver exactamente el valor de hub.challenge en el cuerpo de la respuesta (texto plano).

            A medida que nuestro código esté en producción, estamos listos para cliquear el botón “Verificar y guardar”. Cuando lo presionamos, Meta hace el request a nuestro webhook.php y valida que nuestro identificador es el correcto.

            Como comprobación adicional de seguridad, es recomendable agregar a nuestro webhook.php el siguiente fragmento de código

            if ($APP_SECRET && $headerSig) {
            
            $expected = 'sha256=' . hash_hmac('sha256', $raw, $APP_SECRET);
               if (!hash_equals($expected, $headerSig)) {
                 logit('Invalid signature. header=' . $headerSig . ' expected=' . $expected);
                 http_response_code(403);
                 json_out(['error' => 'Invalid signature']);
                 exit;
            }
            }

            En este bloque, $APP_SECRET es la clave secreta de la aplicación (que obtuvimos anteriormente). El objetivo de este código se puede resumir en dos puntos, primero, confirma que el emisor del request es Meta, y segundo, asegura que el body no fue modificado en tránsito.

            Configuraciones finales…

            Finalmente, en «Configuración» debemos activar messages, como se muestra en la imagen.

            Además, debemos añadir un número de teléfono en el dropdown list con la leyenda «Selecciona un número de teléfono de destinatario», como se muestra en la siguiente imagen:

            Por último, es imprescindible guardar el «Identificador del número de teléfono» que sale en la imagen anterior.

            Resumen

            Para enviar y recibir mensajes con la API de WhatsApp, el desarrollador debe tener cuatro códigos:

            1. Identificador del número de teléfono: Nos servirá como número de prueba para enviar y recibir mensajes.
            2. Identificador de verificación: Es útil para registrar nuestro webhook.
            3. API Key de WhatsApp: nos permitirá, entre otras cosas, enviar mensajes.
            4. Clave secreta de la aplicación: es fundamental para validar el mensaje.

            Posteriormente, en la siguiente entrada, usaremos estos cuatro elementos para enviar un mensaje, condicionado a haber recibido uno antes.

            José Miguel Muñoz Urra – jmunozu@pulki.es

          1. Conversación con la API de OpenAI

            Cuando conversamos con ChatGPT, cada respuesta del modelo puede ser influenciada por las interacciones previas con el usuario. En la API de OpenAI, una conversación busca emular esta característica. En la siguiente entrada, veremos cómo crear una conversación y cómo integrarla a nuestras consultas para tener una interacción contexto-dependiente.

            Creando una conversación…

            El primer paso para que las respuestas del modelo dependan del historial de conversación es crear una conversación. Para llevar a cabo esto, siguiendo la documentación oficial, una consulta tipo podría ser:

            curl https://api.openai.com/v1/conversations \
            -H "Content-Type: application/json" \
            -H "Authorization: Bearer $OPENAI_API_KEY" \
            -d '{
            "metadata": {"topic": "demo"},
            "items": [
            {
            "type": "message",
            "role": "user",
            "content": "Hello!"
            }
            ]
            }'

            donde $OPENAI_API_KEY es nuestra API KEY. En este sentido, al ejecutarla, una respuesta tipo podría tener el siguiente formato,

            {
              "id": "conv_123",
              "object": "conversation",
              "created_at": 1741900000,
              "metadata": {"topic": "demo"}
            }

            de esta respuesta, y para efectos de esta entrada, sólo nos interesa el id, que en este caso es «conv_123». Una vez guardado, basta con usar ese id en cada consulta con Responses API. En la siguiente sección mostramos una consulta tipo usando el id de la conversación.

            Consultando Responses API con contexto…

            Siguiendo el ejemplo de la documentación, sólo basta con añadir la clave conversation al payload, con el valor de nuestro id:

            curl https://api.openai.com/v1/responses \
              -H "Content-Type: application/json" \
              -H "Authorization: Bearer $OPENAI_API_KEY" \
              -d '{
                "model": "gpt-4.1",
                "conversation" : "conv_123",
                "input": "Tell me a three sentence bedtime story about a unicorn."
              }'

            Así, el modelo responderá con contexto, pero este es finito: cada modelo tiene una ventana de contexto máxima. Si la conversación la rebasa, los mensajes más antiguos quedan fuera y el modelo deja de tenerlos en cuenta.

            José Miguel Muñoz Urra – jmunozu@pulki.es

          2. Usando file_search con Responses API

            Puesto que Responses API es relativamente nueva, ChatGPT aún no ha interiorizado cómo utilizar correctamente file_search. Por este motivo, explicar esta funcionalidad será el objetivo de esta entrada.

            Primero que todo, file_search es una herramienta que permite a los modelos buscar información relevante en tus archivos para, posteriormente, generar la respuesta. Evidentemente, para usarla hay que subir el archivo que queremos leer; por tanto, la primera variable a considerar es el formato de este.

            Según la documentación oficial, para archivos de texto la codificación debe ser UTF-8, UTF-16 o ASCII. Adicionalmente, las extensiones soportadas que no son de texto vienen dadas por .doc, .docx, .pdf y .pptx. Entonces, supongamos que contamos con un archivo con estas características. Con el objetivo de que nuestro archivo sea leído por file_search, tenemos que llevar a cabo cuatro pasos: crear un vector store, subir el archivo, enlazar el archivo al vector store y realizar la consulta.

            Podemos empezar creando el vector store o subiendo el archivo, puesto que el orden entre ambos pasos no es relevante. Para ilustrar la idea, empezaremos con la creación del vector store.

            Creando un vector store…

            Tomando el ejemplo de la documentación, nuestro request viene dado por:

            curl https://api.openai.com/v1/vector_stores \
            -H "Authorization: Bearer $OPENAI_API_KEY" \
            -H "Content-Type: application/json" \
            -H "OpenAI-Beta: assistants=v2" \
            -d '{
            "name": "Support FAQ"
            }'

            donde $OPENAI_API_KEY es nuestra API key. Una respuesta hipotética viene dada por:

            {
              "id": "vs_abc123",
              "object": "vector_store",
              "created_at": 1699061776,
              "name": "Support FAQ",
              "description": "Contains commonly asked questions and answers, organized by topic.",
              "bytes": 139920,
              "file_counts": {
                "in_progress": 0,
                "completed": 3,
                "failed": 0,
                "cancelled": 0,
                "total": 3
              }
            }

            De aquella respuesta, nos interesa el ID, que en este ejemplo es «vs_abc123». Una vez guardado, el paso siguiente es subir el archivo. Nuevamente, utilizando el ejemplo de la documentación, un request válido podría venir dado por:

            curl https://api.openai.com/v1/files \
              -H "Authorization: Bearer $OPENAI_API_KEY" \
              -F purpose="fine-tune" \
              -F file="@mydata.jsonl"
              -F expires_after[anchor]="created_at"
              -F expires_after[seconds]=2592000

            donde mydata.jsonl es el nombre del archivo local. Una vez enviada esta consulta, la respuesta podría venir dada por:

            {
            "id": "file-abc123",
            "object": "file",
            "bytes": 120000,
            "created_at": 1677610602,
            "expires_at": 1677614202,
            "filename": "mydata.jsonl",
            "purpose": "fine-tune",
            }

            De esta respuesta debemos guardar el ID, que en este caso es «file-abc123». Ahora, con el vector_store_id y el file_id en mano, estamos en condiciones de enlazar el archivo a nuestro vector store.

            Enlazando el archivo al vector store

            Nuevamente, usando el ejemplo de la documentación, un request ejemplo podría ser:

            curl https://api.openai.com/v1/vector_stores/vs_abc123/files \
            -H "Authorization: Bearer $OPENAI_API_KEY" \
            -H "Content-Type: application/json" \
            -H "OpenAI-Beta: assistants=v2" \
            -d '{
            "file_id": "file-abc123"
            }'

            cuya respuesta vendría dada por:

            {
              "id": "file-abc123",
              "object": "vector_store.file",
              "created_at": 1699061776,
              "usage_bytes": 1234,
              "vector_store_id": "vs_abcd",
              "status": "completed",
              "last_error": null
            }

            En este momento, tenemos todos los ingredientes para consultar nuestro archivo con algún modelo.

            Consultando nuestro archivo…

            Para consultar el contenido de nuestro archivo, utilizaremos el modelo GPT-5, y Responses API. Una consulta simplificada podría venir dada por la siguiente:

            curl https://api.openai.com/v1/responses \
            -H "Content-Type: application/json" \
            -H "Authorization: Bearer $OPENAI_API_KEY" \
            -d '{
            "model": "gpt-5",
            "instructions": "Eres un asistente de la empresa Pülki, y debes responder preguntas sobre ella, y sobre cómo implementar IA en diferentes contextos.",
            "input": "¿De qué trata Pülki?.",
            "reasoning": { "effort": "low" },
            "text": { "verbosity": "low" },
            "max_output_tokens": 500,
            "tools": [
            {
            "type": "file_search",
            "vector_store_ids": ["'"$VECTOR_STORE_ID"'"],
            "max_num_results": 1
            }
            ]
            }'

            En este request, $VECTOR_STORE_ID contiene el ID del vector store que creamos en el primer apartado. Como podemos ver, no es necesario hacer referencia a ningún file_id. file_search busca automáticamente entre los archivos enlazados con el vector y genera una respuesta en base a ello.

            José Miguel Muñoz Urra – jmunozu@pulki.es

            Referencias

          3. Reasoning depth, output verbosity y output length

            En la entrada anterior hablamos de agentes y temperatura como forma de controlar las alucinaciones y creatividad del modelo. No obstante, respecto a la temperatura, esta dejó de estar disponible en la API de OpenAI para los modelos GPT-5. En cambio, la compañía insta a utilizar reasoning depth, output verbosity y output length. A continuación, explicaremos cada una de ellas y cómo sacarles el máximo provecho, dependiendo del contexto.

            Reasoning depth

            Los modelos GPT-5 ofrecen cuatro niveles de razonamiento. Por una parte, «minimal», que es la capa más baja, entrega respuestas con un razonamiento relativamente limitado, lo que impacta positivamente en el tiempo de respuesta y costo. En otras palabras, el modelo emplea menos tiempo para pensar y esto genera menos reasoning tokens (que se cuentan como tokens de salida), lo que abarata costos. Según Arsturn, esta configuración podría ser útil para responder preguntas básicas en torno a un texto predefinido, autocompletación de oraciones simples, clasificación de texto y para chatbots de alta frecuencia.

            Un nivel más arriba, tenemos «low», cuyo tiempo de razonamiento y subsecuente costo son más altos que el nivel anterior. Esto entrega respuestas relativamente más elaboradas y creativas, sin incrementar significativamente el tiempo de respuesta. Arsturn recomienda este nivel para customer support típico, resumir contenido, extracción de datos y generar contenido en las redes sociales.

            Finalmente, tenemos «medium» y «high». En ambas categorías, el tiempo de razonamiento aumenta considerablemente, lo que también aumenta el costo de tokens de salida (explicado por reasoning tokens). Estas categorías pueden ser usadas para análisis detallados, programación, creación de contenido, investigación científica y planificación estratégica.

            A modo de ejemplo, si su empresa recibe preguntas concretas de sus clientes que requieren respuestas cortas y rápidas cuya solución es relativamente trivial, los niveles «low» y «minimal» son idóneos. En cambio, si está meditando la viabilidad de un plan de marketing o inversión, «medium» y «high» serían los niveles correctos.

            Output verbosity

            Esta característica consta de tres niveles, «low», «medium» y «high». En base a la documentación de OpenAI, verbosity determina cuántos tokens de salida pueden ser generados, incluso manteniendo el nivel de razonamiento constante. En palabras simples, el usuario puede fijar un nivel de razonamiento muy alto, pero si verbosity es bajo, la respuesta será breve, pero eventualmente de muy alta calidad. Naturalmente, en el contexto empresarial, si la pregunta es muy compleja, como una decisión estratégica, y el razonamiento necesita ser explicado de forma detallada, verbosity «high» es la configuración idónea. Por el contrario, si deseamos implementar un ChatBot de alta frecuencia que entregue respuestas simples y cortas, verbosity «low» es la opción.

            Output length

            Esta configuración es un límite más estricto para la cantidad de tokens de salida. Su función es ser una cota superior para los tokens de respuesta y de razonamiento. En términos concretos, si fijamos como la cantidad máxima de tokens 200 y el modelo usa 120 para razonar, sólo quedarán 80 para generar el texto de la respuesta. Esta función es útil para estandarizar respuestas o reducir su costo de forma implícita.

            Pero ¿cuál es la configuración correcta?

            Esto dependerá del contexto en el que se implementará la solución. No obstante, sin lugar a dudas, una solución costo-efectiva, con tiempos de respuesta y calidad razonables, estará condicionada a la adecuada configuración de estos tres parámetros.

            José Miguel Muñoz Urra – jmunozu@pulki.es

          4. Chatbots para pymes: IA de bajo riesgo y alto impacto.

            Según una encuesta llevada a cabo por Peninsula Group a aproximadamente 79 mil negocios, ha revelado que las pequeñas empresas son más cautas a la hora de invertir en tecnologías de Inteligencia Artificial (IA) comparadas con grandes empresas.

            Uno de los principales motivos podría ser el ‘miedo a lo desconocido’, respuesta que ha crecido un 23% desde 2023, cuando los dueños de pequeñas empresas han sido consultados respecto a su opinión sobre la IA. Evidentemente, la baja adopción relativa es multifactorial, puesto que puede estar explicada por una menor cantidad de recursos disponibles para invertir, o simplemente, una falta de utilidad práctica. No obstante, hoy en día sabemos que la implementación de algunas soluciones de Inteligencia Artificial traen consigo beneficios económicos concretos a un riesgo y costo relativamente bajo. En particular, en este artículo expondremos por qué los ChatBots son un ejemplo concreto de una IA baja en riesgo y beneficiosa para la firma.

            Chatbots, un caso particular de IA generativa…

            Según el National Institute of Standards and Technology, la IA generativa es una clase de modelos que, emulando la estructura de los datos, puede generar contenido nuevo. El ejemplo más notable de una aplicación que utiliza esta clase de modelos es ChatGPT, que entrenado con más de 570gb datos -que incluyen, entre otros, páginas webs y libros-, es capaz de crear textos nuevos y elocuentes.

            Si bien ChatGPT ha mostrado excelentes capacidades cognitivas y precisión en sus afirmaciones, existen aspectos que pueden alimentar el componente de miedo que los pequeños empresarios reportan tener sobre este tipo de tecnologías. Entre ellos, uno de los principales son las alucinaciones, que podrían ser definidas como salidas plausibles, pero falsas o no sustentadas. Naturalmente, este defecto podría ser extremadamente perjudicial, porque el ChatBot podría entregar una salida falsa a un cliente o alucinar en un proceso productivo clave, lo que eventualmente se traduciría en pérdidas. No obstante, si nos restringimos a la API de ChatGPT, para tranquilidad del emprendedor, hay al menos dos formas efectivas de lidiar con este problema cuando hablamos de su ChatBot. La primera es controlar la «temperatura» y la segunda es dar instrucciones al Asistente.

            Controlando la temperatura…

            En algunos modelos de ChatGPT, la temperatura es un parámetro que controla la aleatoriedad de las respuestas. Con valores bajos (0-0.3), las salidas son predecibles y repetibles; mientras que para valores altos (0.7-1.5) aquellas son más creativas y variables. En nuestros desarrollos, procuramos utilizar una temperatura de 0 cuando proveer información importante y precisa se refiere. Esto disminuye notablemente las alucinaciones.

            Instrucciones al asistente…

            Otro aspecto que incorpora OpenAI para estructurar respuestas son los asistentes, que se definen como un agente configurado que recibe instrucciones, tales como rol y personalidad, entre otras. A modo de ejemplo, si diseñamos un ChatBot para gestionar las reservas de un restaurant, podríamos definir el rol de este asistente como un gestor de reservas, que cumpla una serie de instrucciones en específico de forma obligatoria. Una instrucción podría ser:

            «Tú eres el asistente que gestiona reservas en el restaurant AAA. La forma en la que te relacionas con el cliente se debe ajustar a la información contenida en el archivo BBB.»

            Como podemos ver, en este ejemplo, hacemos que el asistente se comporte como un gestor de reservas, y siga las reglas de un archivo que hemos subido previamente.

            Esta metodología, le entrega contexto a nuestro gestor virtual, cuyo objetivo es delimitar el rango de sus respuestas a un espacio consistente con el público al que atiende y los lineamientos de la compañía.

            ¿Ambas soluciones son suficientes?

            Dada nuestra experiencia práctica, esto dependerá del rol del ChatBot. Si quieres saber si un ChatBot es idóneo para tu proceso productivo o de ventas, ¡no dudes en contactarnos!

            José Miguel Muñoz Urra – jmunozu@pulki.es