{"id":239628,"date":"2026-06-13T19:57:15","date_gmt":"2026-06-13T19:57:15","guid":{"rendered":"https:\/\/felipenespralsanchez.tech\/blog\/?p=239628"},"modified":"2026-06-15T23:46:51","modified_gmt":"2026-06-15T23:46:51","slug":"integrar-ia-wordpress-decisiones-tecnicas","status":"publish","type":"post","link":"https:\/\/felipenespralsanchez.tech\/blog\/integrar-ia-wordpress-decisiones-tecnicas\/","title":{"rendered":"Integrar IA en WordPress: las decisiones t\u00e9cnicas de un asistente para cursos"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">Quer\u00eda que los miembros de un club de membres\u00eda online pudieran preguntarle al instructor dentro del propio curso, sin abrir otra pesta\u00f1a ni contratar un SaaS aparte. De ah\u00ed sali\u00f3 el encargo: integrar IA en WordPress de forma que el asistente viviera dentro del tema, con la identidad del instructor y el contexto del curso.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">El modelo lo pone Google Gemini. El resto lo puse yo. Y como casi siempre, el peso no estuvo en las features, sino en las decisiones: qu\u00e9 mandar al modelo, qu\u00e9 hacer cuando falla, d\u00f3nde guardar el historial y cu\u00e1nta personalidad darle.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Este art\u00edculo es eso: las decisiones que tom\u00e9 montando el asistente, y la pieza de arquitectura que toca cada una.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">El problema<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">El instructor no puede estar en directo en cada unidad ni en cada sesi\u00f3n. Un alumno tiene una duda a las once de la noche y no hay nadie al otro lado. Esa es la grieta que quer\u00eda tapar.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">La salida f\u00e1cil habr\u00eda sido enchufar un Intercom, un Crisp o cualquier chatbot de terceros. La descart\u00e9 r\u00e1pido. Implica sacar los datos de los alumnos fuera del sistema, depender de un servicio externo que puede cambiar de precio o de pol\u00edtica, y pagar una cuota mensual por algo que solo necesito dentro de mi plataforma.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">El asistente ten\u00eda que funcionar en dos sitios: las sesiones grupales y las unidades de curso, que en el tema son dos custom post types distintos. Misma l\u00f3gica, dos contextos. Eso ya empujaba a construirlo dentro del tema en lugar de pegarle un widget externo por encima.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">La arquitectura en una frase<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">En una frase: el navegador habla con un proxy PHP, y el proxy habla con la API de Gemini.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">El navegador nunca toca la API directamente. Manda su mensaje a un endpoint propio del tema (<code>api\/chat.php<\/code>), y ese PHP es quien arma la petici\u00f3n, le a\u00f1ade la clave y llama a Google. As\u00ed la clave jam\u00e1s llega al frontend, y tengo un \u00fanico sitio donde validar qui\u00e9n pregunta.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Son tres piezas. La primera es el propio proxy, que valida identidad: cada petici\u00f3n exige un nonce de WordPress y un usuario autenticado. Sin las dos cosas, el endpoint no responde. La segunda es una tabla MySQL propia donde guardo el historial de la conversaci\u00f3n. La tercera es el system prompt, el texto que le da al modelo su papel de instructor; de ese hablo m\u00e1s abajo, porque ah\u00ed hubo decisi\u00f3n.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">El asistente aparece en la interfaz con un avatar y la identidad del instructor, no como un bot an\u00f3nimo. Y le puse voz: entrada y salida con la Web Speech API del navegador, que es gratis, dej\u00e1ndolo preparado para enchufar voces de pago si hace falta.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Las decisiones que no eran obvias<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">Mandar solo los \u00faltimos 20 mensajes<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Gemini cobra por tokens de entrada, y una conversaci\u00f3n crece sin parar. Si en cada pregunta le mando el historial completo, el coste y la latencia suben con cada turno sin que la respuesta mejore.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">As\u00ed que solo le paso los \u00faltimos 20 mensajes como contexto. El historial completo sigue en la base de datos (guardo hasta 100 mensajes por usuario y post), pero al modelo solo viaja la ventana reciente. Para una duda sobre la unidad que est\u00e1s viendo, 20 mensajes sobran. Encima puse un rate limit de una petici\u00f3n cada 2 segundos por usuario, con transients de WordPress, para que nadie martillee la API por error o a prop\u00f3sito.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Asumir que Gemini iba a fallar<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Las APIs de modelos devuelven 503 y 429 m\u00e1s de lo que te gustar\u00eda: el servicio saturado, tu cuota al l\u00edmite. Si trato eso como un error y le muestro al alumno un \u00abalgo sali\u00f3 mal\u00bb, el asistente queda como poco fiable cuando el problema es pasajero.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">As\u00ed que asum\u00ed el fallo como caso normal. Cada petici\u00f3n reintenta hasta 10 veces con backoff exponencial: espera 300 ms y, si vuelve a fallar, duplica la espera (0,6 s, 1,2 s\u2026) hasta un tope de 8 s por intento. La mayor\u00eda de los 503 se resuelven en el segundo o tercer intento sin que el usuario note nada.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>set_time_limit(120);\n$maxAttempts = 10;\nfor ($attempt = 1; $attempt &lt;= $maxAttempts; $attempt++) {\n    $ch = curl_init($apiUrl);\n    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);\n    curl_setopt($ch, CURLOPT_HTTPHEADER,     &#91;'Content-Type: application\/json']);\n    curl_setopt($ch, CURLOPT_POST,           true);\n    curl_setopt($ch, CURLOPT_POSTFIELDS,     json_encode($data));\n    curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);\n\n    $response  = curl_exec($ch);\n    $curlError = curl_error($ch);\n    $httpCode  = curl_getinfo($ch, CURLINFO_HTTP_CODE);\n    curl_close($ch);\n\n    if ($curlError) break;\n\n    $decoded = json_decode($response, true);\n\n    if ($httpCode !== 503 &amp;&amp; $httpCode !== 429) break;\n\n    if ($attempt &lt; $maxAttempts) {\n        $wait = min(300000 * (2 ** ($attempt - 1)), 8000000); \/\/ 0.3s\u21920.6\u21921.2\u2192...\u21928s\n        usleep($wait);\n    }\n}<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">System prompt heredable<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">El system prompt es lo que convierte a Gemini en \u00abel instructor de este curso\u00bb. Lo dej\u00e9 configurable desde el panel de WordPress, por sesi\u00f3n o por unidad. Pero rellenar un prompt a mano en cada unidad de un curso largo es tedioso y se presta a olvidos.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">La regla: si una unidad no tiene system prompt propio, hereda el del curso padre. As\u00ed defines el tono y el contexto una vez a nivel de curso, y solo escribes un prompt espec\u00edfico donde de verdad cambia algo. Menos campos que mantener y menos formas de que algo quede vac\u00edo por descuido.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Texto plano forzado<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Gemini, por defecto, responde con markdown: asteriscos para negritas, almohadillas para t\u00edtulos, guiones para listas. En un chat dentro del curso eso se renderiza como ruido: aparecen los s\u00edmbolos en crudo o tengo que montar un parser solo para limpiarlos.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">La soluci\u00f3n fue de las m\u00e1s baratas del proyecto: una instrucci\u00f3n al final del system prompt que obliga a responder siempre en texto plano, sin markdown. Un rengl\u00f3n. Me ahorr\u00f3 toda la l\u00f3gica de sanitizado en el frontend.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">El papel de Claude Code<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Buena parte de esto la constru\u00ed con Claude Code desde la terminal. No fue pedirle \u00abescr\u00edbeme un asistente de IA para WordPress\u00bb y pegar lo que saliera. Fue trabajar en ciclos cortos: le planteaba una pieza, revisaba lo que propon\u00eda, lo ajustaba y segu\u00eda.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Donde aporta de verdad es en el andamiaje: montar la llamada con curl, dejar la estructura de la tabla, cablear los transients del rate limit. Eso lo resuelve r\u00e1pido y me ahorra la parte mec\u00e1nica.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Donde tuve que llevar yo el tim\u00f3n fue en las decisiones de arquitectura. La ventana de 20 mensajes, el tope de reintentos, heredar el prompt del curso padre: eso no lo decide la herramienta, lo decido yo seg\u00fan lo que conozco del proyecto. Claude Code tiende a darte la soluci\u00f3n correcta y gen\u00e9rica; la adecuada para este club sal\u00eda de mi cabeza, y \u00e9l la implementaba. Esa divisi\u00f3n de trabajo es la que hace que el CLI me sirva en desarrollo web real y no solo para prototipos de juguete.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">\u00bfMont\u00e1rtelo t\u00fa o tirar de un plugin?<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">\u00bfMerece la pena montarse esto a mano? Depende, y voy a mojarme.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Si lo que quieres es un chatbot gen\u00e9rico de soporte en una web cualquiera, no. Hay plugins que lo hacen en una tarde y mantenerlos no es tu problema. Reinventarlo a medida ah\u00ed es ego de programador, no buena ingenier\u00eda.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Lo mont\u00e9 a medida porque necesitaba cosas que un plugin no me daba sin pelearme con \u00e9l: la identidad del instructor, el system prompt heredable por curso, el historial en mi propia base de datos y el control fino sobre qu\u00e9 mando al modelo y cu\u00e1nto. Cuando el asistente es parte del producto y no un a\u00f1adido, controlar la pieza entera compensa el trabajo.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">La pregunta \u00fatil no es \u00ab\u00bfpuedo construirlo?\u00bb. Casi siempre puedes. Es \u00ab\u00bfesta pieza es central para lo que ofrezco?\u00bb. Si lo es, constr\u00fayela y enti\u00e9ndela por dentro. Si no, paga el plugin y dedica el tiempo a lo que s\u00ed importa.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Mont\u00e9 un asistente de IA con Google Gemini dentro de un tema WordPress a medida, sin SaaS externo. Estas son las decisiones t\u00e9cnicas que tom\u00e9: el proxy PHP, la ventana de contexto, el backoff exponencial y el system prompt heredable.<\/p>\n","protected":false},"author":1,"featured_media":239631,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[1,27],"tags":[37,30,33,35,29],"class_list":["post-239628","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-blog","category-desarrollo-web","tag-claude-code","tag-google-gemini","tag-ia","tag-php","tag-wordpress"],"_links":{"self":[{"href":"https:\/\/felipenespralsanchez.tech\/blog\/wp-json\/wp\/v2\/posts\/239628","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/felipenespralsanchez.tech\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/felipenespralsanchez.tech\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/felipenespralsanchez.tech\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/felipenespralsanchez.tech\/blog\/wp-json\/wp\/v2\/comments?post=239628"}],"version-history":[{"count":1,"href":"https:\/\/felipenespralsanchez.tech\/blog\/wp-json\/wp\/v2\/posts\/239628\/revisions"}],"predecessor-version":[{"id":239629,"href":"https:\/\/felipenespralsanchez.tech\/blog\/wp-json\/wp\/v2\/posts\/239628\/revisions\/239629"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/felipenespralsanchez.tech\/blog\/wp-json\/wp\/v2\/media\/239631"}],"wp:attachment":[{"href":"https:\/\/felipenespralsanchez.tech\/blog\/wp-json\/wp\/v2\/media?parent=239628"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/felipenespralsanchez.tech\/blog\/wp-json\/wp\/v2\/categories?post=239628"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/felipenespralsanchez.tech\/blog\/wp-json\/wp\/v2\/tags?post=239628"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}