🤖 Modulo IA — Generador de contenido con OpenAI/LLM
Actualizado: 2026-04
Ejemplo basado en codigo real
Este ejemplo esta basado en el modulo ecom_omnimind, un modulo de produccion que usa OpenAI/OpenRouter para generar descripciones de producto, meta tags SEO, FAQs, bullet points y traducciones automaticas.
#Patron: modulo de IA para ecommerce
Los modulos de IA en PrestaShop siguen un patron comun: conectarse a una API de LLM (OpenAI, Anthropic, OpenRouter...), enviar el contexto del producto, y recibir contenido generado. Este patron incluye:
#Arquitectura del modulo
Estructura de archivos
text
ecom_omnimind/
├── ecom_omnimind.php # Clase principal + hooks
├── classes/
│ ├── LLMClient.php # Cliente API generico
│ ├── PromptBuilder.php # Construye prompts con contexto PS
│ ├── ContentLog.php # ObjectModel para logs
│ └── TokenCounter.php # Conteo de tokens
├── controllers/admin/
│ ├── AdminOmniMindDashboardController.php
│ ├── AdminOmniMindProductTabController.php
│ └── AdminOmniMindAjaxController.php # AJAX endpoint
├── views/
│ ├── templates/admin/
│ │ ├── product_tab.tpl # Tab en ficha producto
│ │ └── dashboard.tpl
│ └── js/
│ └── product-ai.js # JS para generacion AJAX
└── config.xml
#Cliente API para OpenAI / LLM
classes/LLMClient.php
php
<?php
if (!defined('_PS_VERSION_')) { exit; }
class LLMClient
{
private string $apiKey;
private string $apiUrl;
private string $model;
private float $temperature;
public function __construct()
{
$this->apiKey = Configuration::get('OMNIMIND_API_KEY');
$this->apiUrl = Configuration::get('OMNIMIND_API_URL') ?: 'https://api.openai.com/v1';
$this->model = Configuration::get('OMNIMIND_MODEL') ?: 'gpt-4o-mini';
$this->temperature = (float) (Configuration::get('OMNIMIND_TEMPERATURE') ?: 0.7);
}
/**
* Enviar prompt al LLM y obtener respuesta
*/
public function generate(string $systemPrompt, string $userPrompt, ?int $maxTokens = 2000): ?string
{
$payload = [
'model' => $this->model,
'messages' => [
['role' => 'system', 'content' => $systemPrompt],
['role' => 'user', 'content' => $userPrompt],
],
'temperature' => $this->temperature,
'max_tokens' => $maxTokens,
];
$ch = curl_init($this->apiUrl . '/chat/completions');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . $this->apiKey,
],
CURLOPT_TIMEOUT => 60, // LLMs pueden tardar
CURLOPT_CONNECTTIMEOUT => 10,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($httpCode !== 200 || $error) {
PrestaShopLogger::addLog(
'LLMClient error: HTTP ' . $httpCode . ' - ' . $error,
3, null, 'LLMClient'
);
return null;
}
$data = json_decode($response, true);
return $data['choices'][0]['message']['content'] ?? null;
}
/**
* Generar con retry (para rate limits)
*/
public function generateWithRetry(string $system, string $user, int $retries = 3): ?string
{
for ($i = 0; $i < $retries; $i++) {
$result = $this->generate($system, $user);
if ($result !== null) return $result;
if ($i < $retries - 1) sleep(2 * ($i + 1)); // Backoff: 2s, 4s
}
return null;
}
}
#Admin AJAX Controller — generar contenido
controllers/admin/AdminOmniMindAjaxController.php
php
<?php
class AdminOmniMindAjaxController extends ModuleAdminController
{
public function __construct()
{
parent::__construct();
$this->ajax = true; // Deshabilitar layout
}
public function ajaxProcessGenerateDescription()
{
$idProduct = (int) Tools::getValue('id_product');
$idLang = (int) Tools::getValue('id_lang', $this->context->language->id);
$type = Tools::getValue('type', 'description'); // description, meta_title, meta_description, features
if (!$idProduct) {
die(json_encode(['success' => false, 'error' => 'Product ID required']));
}
// Cargar datos del producto
$product = new Product($idProduct, true, $idLang);
if (!Validate::isLoadedObject($product)) {
die(json_encode(['success' => false, 'error' => 'Product not found']));
}
// Construir prompt con contexto del producto
$promptBuilder = new PromptBuilder();
$systemPrompt = $promptBuilder->getSystemPrompt($type, $idLang);
$userPrompt = $promptBuilder->buildProductPrompt($product, $type, $idLang);
// Llamar al LLM
$client = new LLMClient();
$content = $client->generateWithRetry($systemPrompt, $userPrompt);
if ($content === null) {
die(json_encode(['success' => false, 'error' => 'LLM generation failed']));
}
// Guardar log
ContentLog::logGeneration($idProduct, $type, $content);
die(json_encode([
'success' => true,
'content' => $content,
'type' => $type,
'tokens' => TokenCounter::estimate($content),
]));
}
public function ajaxProcessSaveContent()
{
$idProduct = (int) Tools::getValue('id_product');
$idLang = (int) Tools::getValue('id_lang');
$type = Tools::getValue('type');
$content = Tools::getValue('content', false); // false = no strip tags
$product = new Product($idProduct);
if (!Validate::isLoadedObject($product)) {
die(json_encode(['success' => false, 'error' => 'Product not found']));
}
switch ($type) {
case 'description':
$product->description[$idLang] = $content;
break;
case 'description_short':
$product->description_short[$idLang] = $content;
break;
case 'meta_title':
$product->meta_title[$idLang] = pSQL($content);
break;
case 'meta_description':
$product->meta_description[$idLang] = pSQL($content);
break;
}
$success = $product->update();
die(json_encode(['success' => $success]));
}
}
#Tab en la ficha de producto (BO)
Hook displayAdminProductsExtra — tab en producto
php
// En la clase principal del modulo:
public function hookDisplayAdminProductsExtra($params)
{
$idProduct = (int) ($params['id_product'] ?? Tools::getValue('id_product'));
if (!$idProduct) return '';
$product = new Product($idProduct, true, $this->context->language->id);
$logs = ContentLog::getByProduct($idProduct, 10); // Ultimos 10 logs
$this->context->smarty->assign([
'product' => $product,
'id_product' => $idProduct,
'id_lang' => $this->context->language->id,
'languages' => Language::getLanguages(true),
'logs' => $logs,
'ajax_url' => $this->context->link->getAdminLink('AdminOmniMindAjax'),
'module_dir' => $this->_path,
]);
return $this->display(__FILE__, 'views/templates/admin/product_tab.tpl');
}
// Para PS 8.1+ con Product V2, usar el hook moderno:
public function hookActionProductFormBuilderModifier(array $params): void
{
// Ver guia de Product Form V2 para la implementacion completa
}
views/js/product-ai.js — AJAX generation
javascript
document.addEventListener('DOMContentLoaded', function() {
const ajaxUrl = typeof omnimindAjaxUrl !== 'undefined' ? omnimindAjaxUrl : '';
document.querySelectorAll('[data-generate-ai]').forEach(function(btn) {
btn.addEventListener('click', async function(e) {
e.preventDefault();
const type = this.dataset.generateAi;
const idProduct = this.dataset.productId;
const idLang = document.getElementById('ai-lang-select')?.value || 1;
const resultDiv = document.getElementById('ai-result-' + type);
btn.disabled = true;
btn.textContent = 'Generando...';
resultDiv.innerHTML = '<div class="spinner"></div>';
try {
const response = await fetch(ajaxUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
ajax: 1,
action: 'GenerateDescription',
id_product: idProduct,
id_lang: idLang,
type: type,
}),
});
const data = await response.json();
if (data.success) {
resultDiv.innerHTML = '<textarea class="form-control" rows="8">' +
data.content.replace(/</g, '<') + '</textarea>' +
'<button class="btn btn-primary mt-2" data-save-ai="' + type + '">' +
'Guardar en producto</button>' +
'<small class="text-muted">~' + data.tokens + ' tokens</small>';
} else {
resultDiv.innerHTML = '<div class="alert alert-danger">' + data.error + '</div>';
}
} catch (err) {
resultDiv.innerHTML = '<div class="alert alert-danger">Error de conexion</div>';
}
btn.disabled = false;
btn.textContent = 'Generar con IA';
});
});
});
#Procesamiento batch via cron
Cron endpoint para generar contenido masivo
php
// controllers/front/batch.php
class EcomOmnimindBatchModuleFrontController extends ModuleFrontController
{
public function initContent()
{
$token = Tools::getValue('token');
if ($token !== Configuration::get('OMNIMIND_CRON_TOKEN')) {
die(json_encode(['error' => 'Unauthorized']));
}
$type = Tools::getValue('type', 'description');
$limit = (int) Tools::getValue('limit', 10);
$idLang = (int) Tools::getValue('id_lang', Configuration::get('PS_LANG_DEFAULT'));
// Obtener productos sin descripcion
$products = Db::getInstance()->executeS('
SELECT p.id_product, pl.name
FROM ' . _DB_PREFIX_ . 'product p
JOIN ' . _DB_PREFIX_ . 'product_lang pl ON pl.id_product = p.id_product
AND pl.id_lang = ' . $idLang . '
AND pl.id_shop = ' . (int) Context::getContext()->shop->id . '
WHERE (pl.description IS NULL OR pl.description = "")
AND p.active = 1
LIMIT ' . $limit
);
$client = new LLMClient();
$promptBuilder = new PromptBuilder();
$results = [];
foreach ($products as $row) {
$product = new Product((int) $row['id_product'], true, $idLang);
$system = $promptBuilder->getSystemPrompt($type, $idLang);
$user = $promptBuilder->buildProductPrompt($product, $type, $idLang);
$content = $client->generateWithRetry($system, $user);
if ($content) {
$product->description[$idLang] = $content;
$product->update();
ContentLog::logGeneration((int) $row['id_product'], $type, $content);
$results[] = ['id' => $row['id_product'], 'name' => $row['name'], 'status' => 'ok'];
} else {
$results[] = ['id' => $row['id_product'], 'name' => $row['name'], 'status' => 'error'];
}
sleep(1); // Rate limiting
}
die(json_encode(['processed' => count($results), 'results' => $results]));
}
}
// Cron: wget 'https://tienda.com/module/ecom_omnimind/batch?token=XXX&type=description&limit=20'
#Sistema de logs y auditoria
classes/ContentLog.php — ObjectModel de logs
php
<?php
class ContentLog extends ObjectModel
{
public $id_product;
public $type; // description, meta_title, features, faqs...
public $content;
public $tokens_used;
public $model_used;
public $date_add;
public static $definition = [
'table' => 'omnimind_logs',
'primary' => 'id_log',
'fields' => [
'id_product' => ['type' => self::TYPE_INT, 'validate' => 'isUnsignedId', 'required' => true],
'type' => ['type' => self::TYPE_STRING, 'validate' => 'isGenericName', 'size' => 50],
'content' => ['type' => self::TYPE_HTML, 'validate' => 'isCleanHtml'],
'tokens_used' => ['type' => self::TYPE_INT, 'validate' => 'isUnsignedInt'],
'model_used' => ['type' => self::TYPE_STRING, 'validate' => 'isGenericName', 'size' => 100],
'date_add' => ['type' => self::TYPE_DATE, 'validate' => 'isDate'],
],
];
public static function logGeneration(int $idProduct, string $type, string $content): void
{
$log = new self();
$log->id_product = $idProduct;
$log->type = $type;
$log->content = $content;
$log->tokens_used = TokenCounter::estimate($content);
$log->model_used = Configuration::get('OMNIMIND_MODEL') ?: 'gpt-4o-mini';
$log->date_add = date('Y-m-d H:i:s');
$log->add();
}
public static function getByProduct(int $idProduct, int $limit = 20): array
{
return Db::getInstance()->executeS('
SELECT * FROM ' . _DB_PREFIX_ . 'omnimind_logs
WHERE id_product = ' . $idProduct . '
ORDER BY date_add DESC LIMIT ' . $limit
);
}
}
#Configuracion del proveedor LLM
HelperForm para configuracion de IA
php
// Configuracion multi-proveedor
$providerOptions = [
['id' => 'openai', 'name' => 'OpenAI (GPT-4o, GPT-4o-mini)'],
['id' => 'anthropic', 'name' => 'Anthropic (Claude 4)'],
['id' => 'openrouter', 'name' => 'OpenRouter (Multi-modelo)'],
];
$modelOptions = [
['id' => 'gpt-4o-mini', 'name' => 'GPT-4o Mini (rapido, barato)'],
['id' => 'gpt-4o', 'name' => 'GPT-4o (equilibrado)'],
['id' => 'claude-sonnet-4-6', 'name' => 'Claude Sonnet 4.6 (rapido)'],
['id' => 'claude-opus-4-6', 'name' => 'Claude Opus 4.6 (mejor calidad)'],
];
$fields = [
['type' => 'select', 'label' => 'Proveedor', 'name' => 'OMNIMIND_PROVIDER',
'options' => ['query' => $providerOptions, 'id' => 'id', 'name' => 'name']],
['type' => 'text', 'label' => 'API Key', 'name' => 'OMNIMIND_API_KEY',
'desc' => 'Tu API key del proveedor seleccionado', 'size' => 60],
['type' => 'text', 'label' => 'API URL', 'name' => 'OMNIMIND_API_URL',
'desc' => 'URL base de la API. Por defecto: https://api.openai.com/v1'],
['type' => 'select', 'label' => 'Modelo', 'name' => 'OMNIMIND_MODEL',
'options' => ['query' => $modelOptions, 'id' => 'id', 'name' => 'name']],
['type' => 'text', 'label' => 'Temperatura', 'name' => 'OMNIMIND_TEMPERATURE',
'desc' => '0.0 = determinista, 1.0 = creativo. Recomendado: 0.7', 'size' => 5],
['type' => 'textarea', 'label' => 'System Prompt custom', 'name' => 'OMNIMIND_SYSTEM_PROMPT',
'desc' => 'Prompt de sistema personalizado. Dejar vacio para usar el por defecto.',
'rows' => 6, 'cols' => 60],
];
Descargar en Markdown
Pensado para pegar en ChatGPT, Claude u otra IA. Incluye solo el contenido de esta pagina.