diff --git a/main/exercise/export/aiken/aiken_import.inc.php b/main/exercise/export/aiken/aiken_import.inc.php index c18a0cc26a2..053fce3c5c3 100755 --- a/main/exercise/export/aiken/aiken_import.inc.php +++ b/main/exercise/export/aiken/aiken_import.inc.php @@ -44,8 +44,8 @@ function aiken_display_form() } /** - * Generates aiken format using AI api. - * Requires plugin ai_helper to connect to the api. + * Generates Aiken format using AI APIs (supports multiple providers). + * Requires plugin ai_helper to connect to the API. */ function generateAikenForm() { @@ -53,6 +53,12 @@ function generateAikenForm() return false; } + $plugin = AiHelperPlugin::create(); + $availableApis = $plugin->getApiList(); + + $configuredApi = $plugin->get('api_name'); + $hasSingleApi = count($availableApis) === 1 || isset($availableApis[$configuredApi]); + $form = new FormValidator( 'aiken_generate', 'post', @@ -60,20 +66,31 @@ function generateAikenForm() null ); $form->addElement('header', get_lang('AIQuestionsGenerator')); - $form->addElement('text', 'quiz_name', [get_lang('QuestionsTopic'), get_lang('QuestionsTopicHelp')]); + + if ($hasSingleApi) { + $apiName = $availableApis[$configuredApi] ?? $configuredApi; + $form->addHtml('
' + . sprintf(get_lang('UsingAIProviderX'), ''.htmlspecialchars($apiName).'').'
'); + } + + $form->addElement('text', 'quiz_name', get_lang('QuestionsTopic')); $form->addRule('quiz_name', get_lang('ThisFieldIsRequired'), 'required'); - $form->addElement('number', 'nro_questions', [get_lang('NumberOfQuestions'), get_lang('AIQuestionsGeneratorNumberHelper')]); + $form->addElement('number', 'nro_questions', get_lang('NumberOfQuestions')); $form->addRule('nro_questions', get_lang('ThisFieldIsRequired'), 'required'); $options = [ 'multiple_choice' => get_lang('MultipleAnswer'), ]; - $form->addElement( - 'select', - 'question_type', - get_lang('QuestionType'), - $options - ); + $form->addElement('select', 'question_type', get_lang('QuestionType'), $options); + + if (!$hasSingleApi) { + $form->addElement( + 'select', + 'ai_provider', + get_lang('AIProvider'), + array_combine(array_keys($availableApis), array_keys($availableApis)) + ); + } $generateUrl = api_get_path(WEB_PLUGIN_PATH).'ai_helper/tool/answers.php'; $language = api_get_interface_language(); @@ -87,10 +104,9 @@ function generateAikenForm() var btnGenerate = $(this); var quizName = $("[name=\'quiz_name\']").val(); var nroQ = parseInt($("[name=\'nro_questions\']").val()); - var qType = $("[name=\'question_type\']").val(); - var valid = (quizName != \'\' && nroQ > 0); - var qWeight = 1; - + var qType = $("[name=\'question_type\']").val();' + . (!$hasSingleApi ? 'var provider = $("[name=\'ai_provider\']").val();' : 'var provider = "' . $configuredApi . '";') . + 'var valid = (quizName != \'\' && nroQ > 0); if (valid) { btnGenerate.attr("disabled", true); btnGenerate.text("'.get_lang('PleaseWaitThisCouldTakeAWhile').'"); @@ -100,7 +116,8 @@ function generateAikenForm() "quiz_name": quizName, "nro_questions": nroQ, "question_type": qType, - "language": "'.$language.'" + "language": "'.$language.'", + "ai_provider": provider }).done(function (data) { btnGenerate.attr("disabled", false); btnGenerate.text("'.get_lang('Generate').'"); diff --git a/main/lp/LpAiHelper.php b/main/lp/LpAiHelper.php index ad1bcadc731..9d56ea510e7 100644 --- a/main/lp/LpAiHelper.php +++ b/main/lp/LpAiHelper.php @@ -19,6 +19,11 @@ public function __construct() */ public function aiHelperForm() { + $plugin = AiHelperPlugin::create(); + $availableApis = $plugin->getApiList(); + $configuredApi = $plugin->get('api_name'); + $hasSingleApi = count($availableApis) === 1 || isset($availableApis[$configuredApi]); + $form = new FormValidator( 'lp_ai_generate', 'post', @@ -26,6 +31,13 @@ public function aiHelperForm() null ); $form->addElement('header', get_lang('LpAiGenerator')); + + if ($hasSingleApi) { + $apiName = $availableApis[$configuredApi] ?? $configuredApi; + $form->addHtml('
' + .sprintf(get_lang('UsingAIProviderX'), ''.htmlspecialchars($apiName).'').'
'); + } + $form->addElement('text', 'lp_name', [get_lang('LpAiTopic'), get_lang('LpAiTopicHelp')]); $form->addRule('lp_name', get_lang('ThisFieldIsRequired'), 'required'); $form->addElement('number', 'nro_items', [get_lang('LpAiNumberOfItems'), get_lang('LpAiNumberOfItemsHelper')]); @@ -46,75 +58,55 @@ public function aiHelperForm() $sessionId = api_get_session_id(); $redirectSuccess = api_get_path(WEB_CODE_PATH).'lp/lp_controller.php?'.api_get_cidreq().'&action=add_item&type=step&isStudentView=false&lp_id='; $form->addHtml(''); - - $form->addButton( - 'create_lp_button', - get_lang('LearnpathAddLearnpath'), - '', - 'default', - 'default', - null, - ['id' => 'create-lp-ai'] - ); + } + }); + }); + '); + $form->addButton('create_lp_button', get_lang('LearnpathAddLearnpath'), '', 'default', 'default', null, ['id' => 'create-lp-ai']); echo $form->returnForm(); } } diff --git a/plugin/ai_helper/AiHelperPlugin.php b/plugin/ai_helper/AiHelperPlugin.php index cc546430bed..2b50655d4a7 100644 --- a/plugin/ai_helper/AiHelperPlugin.php +++ b/plugin/ai_helper/AiHelperPlugin.php @@ -3,20 +3,21 @@ use Chamilo\PluginBundle\Entity\AiHelper\Requests; use Doctrine\ORM\Tools\SchemaTool; - +require_once __DIR__ . '/src/deepseek/DeepSeek.php'; /** * Description of AiHelperPlugin. * - * @author Christian Beeznest + * @author Christian Beeznest */ class AiHelperPlugin extends Plugin { public const TABLE_REQUESTS = 'plugin_ai_helper_requests'; public const OPENAI_API = 'openai'; + public const DEEPSEEK_API = 'deepseek'; protected function __construct() { - $version = '1.1'; + $version = '1.2'; $author = 'Christian Fasanando'; $message = 'Description'; @@ -39,7 +40,7 @@ protected function __construct() } /** - * Get the list of apis availables. + * Get the list of APIs available. * * @return array */ @@ -47,20 +48,19 @@ public function getApiList() { $list = [ self::OPENAI_API => 'OpenAI', + self::DEEPSEEK_API => 'DeepSeek', ]; return $list; } /** - * Get the completion text from openai. + * Get the completion text from the selected API. * - * @return string + * @return string|array */ - public function openAiGetCompletionText( - string $prompt, - string $toolName - ) { + public function getCompletionText(string $prompt, string $toolName) + { if (!$this->validateUserTokensLimit(api_get_user_id())) { return [ 'error' => true, @@ -68,49 +68,225 @@ public function openAiGetCompletionText( ]; } - require_once __DIR__.'/src/openai/OpenAi.php'; + $apiName = $this->get('api_name'); + + switch ($apiName) { + case self::OPENAI_API: + return $this->openAiGetCompletionText($prompt, $toolName); + case self::DEEPSEEK_API: + return $this->deepSeekGetCompletionText($prompt, $toolName); + default: + return [ + 'error' => true, + 'message' => 'API not supported.', + ]; + } + } + + /** + * Get completion text from OpenAI. + */ + public function openAiGetCompletionText(string $prompt, string $toolName) + { + try { + require_once __DIR__.'/src/openai/OpenAi.php'; + + $apiKey = $this->get('api_key'); + $organizationId = $this->get('organization_id'); + + $ai = new OpenAi($apiKey, $organizationId); + + $params = [ + 'model' => 'gpt-3.5-turbo-instruct', + 'prompt' => $prompt, + 'temperature' => 0.2, + 'max_tokens' => 2000, + 'frequency_penalty' => 0, + 'presence_penalty' => 0.6, + 'top_p' => 1.0, + ]; + + $complete = $ai->completion($params); + $result = json_decode($complete, true); + + if (isset($result['error'])) { + $errorMessage = $result['error']['message'] ?? 'Unknown error'; + error_log("OpenAI Error: $errorMessage"); + return [ + 'error' => true, + 'message' => $errorMessage, + ]; + } + + $resultText = $result['choices'][0]['text'] ?? ''; + + if (!empty($resultText)) { + $this->saveRequest([ + 'user_id' => api_get_user_id(), + 'tool_name' => $toolName, + 'prompt' => $prompt, + 'prompt_tokens' => (int) ($result['usage']['prompt_tokens'] ?? 0), + 'completion_tokens' => (int) ($result['usage']['completion_tokens'] ?? 0), + 'total_tokens' => (int) ($result['usage']['total_tokens'] ?? 0), + ]); + } + + return $resultText ?: 'No response generated.'; + + } catch (Exception $e) { + return [ + 'error' => true, + 'message' => 'An error occurred while connecting to OpenAI: ' . $e->getMessage(), + ]; + } + } + /** + * Get completion text from DeepSeek. + */ + public function deepSeekGetCompletionText(string $prompt, string $toolName) + { $apiKey = $this->get('api_key'); - $organizationId = $this->get('organization_id'); - $ai = new OpenAi($apiKey, $organizationId); + $url = 'https://api.deepseek.com/chat/completions'; - $temperature = 0.2; - $model = 'gpt-3.5-turbo-instruct'; - $maxTokens = 2000; - $frequencyPenalty = 0; - $presencePenalty = 0.6; - $topP = 1.0; + $payload = [ + 'model' => 'deepseek-chat', + 'messages' => [ + [ + 'role' => 'system', + 'content' => ($toolName === 'quiz') + ? 'You are a helpful assistant that generates Aiken format questions.' + : 'You are a helpful assistant that generates learning path contents.', + ], + [ + 'role' => 'user', + 'content' => $prompt, + ], + ], + 'stream' => false, + ]; - $complete = $ai->completion([ - 'model' => $model, - 'prompt' => $prompt, - 'temperature' => $temperature, - 'max_tokens' => $maxTokens, - 'frequency_penalty' => $frequencyPenalty, - 'presence_penalty' => $presencePenalty, - 'top_p' => $topP, + $ch = curl_init($url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload)); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Content-Type: application/json', + "Authorization: Bearer $apiKey", ]); - $result = json_decode($complete, true); - $resultText = ''; - if (!empty($result['choices'])) { - $resultText = $result['choices'][0]['text']; - // saves information of user results. - $values = [ - 'user_id' => api_get_user_id(), - 'tool_name' => $toolName, - 'prompt' => $prompt, - 'prompt_tokens' => (int) $result['usage']['prompt_tokens'], - 'completion_tokens' => (int) $result['usage']['completion_tokens'], - 'total_tokens' => (int) $result['usage']['total_tokens'], + $response = curl_exec($ch); + + if ($response === false) { + error_log('cURL error: ' . curl_error($ch)); + curl_close($ch); + return ['error' => true, 'message' => 'Request to AI provider failed.']; + } + + curl_close($ch); + + $result = json_decode($response, true); + + if (isset($result['error'])) { + return [ + 'error' => true, + 'message' => $result['error']['message'] ?? 'Unknown error', ]; - $this->saveRequest($values); } + $resultText = $result['choices'][0]['message']['content'] ?? ''; + $this->saveRequest([ + 'user_id' => api_get_user_id(), + 'tool_name' => $toolName, + 'prompt' => $prompt, + 'prompt_tokens' => 0, + 'completion_tokens' => 0, + 'total_tokens' => 0, + ]); + return $resultText; } + /** + * Generate questions based on the selected AI provider. + * + * @param int $nQ Number of questions + * @param string $lang Language for the questions + * @param string $topic Topic of the questions + * @param string $questionType Type of questions (e.g., 'multiple_choice') + * @return string Questions generated in Aiken format + * @throws Exception If an error occurs + */ + public function generateQuestions(int $nQ, string $lang, string $topic, string $questionType = 'multiple_choice'): string + { + $apiName = $this->get('api_name'); + + switch ($apiName) { + case self::OPENAI_API: + return $this->generateOpenAiQuestions($nQ, $lang, $topic, $questionType); + case self::DEEPSEEK_API: + return $this->generateDeepSeekQuestions($nQ, $lang, $topic, $questionType); + default: + throw new Exception("Unsupported API provider: $apiName"); + } + } + + /** + * Generate questions using OpenAI. + */ + private function generateOpenAiQuestions(int $nQ, string $lang, string $topic, string $questionType): string + { + $prompt = sprintf( + 'Generate %d "%s" questions in Aiken format in the %s language about "%s", making sure there is a \'ANSWER\' line for each question. \'ANSWER\' lines must only mention the letter of the correct answer, not the full answer text and not a parenthesis. The line starting with \'ANSWER\' must not be separated from the last possible answer by a blank line. Each answer starts with an uppercase letter, a dot, one space and the answer text without quotes. Include an \'ANSWER_EXPLANATION\' line after the \'ANSWER\' line for each question. The terms between single quotes above must not be translated. There must be a blank line between each question.', + $nQ, + $questionType, + $lang, + $topic + ); + + $result = $this->openAiGetCompletionText($prompt, 'quiz'); + if (isset($result['error']) && true === $result['error']) { + throw new Exception($result['message']); + } + + return $result; + } + + /** + * Generate questions using DeepSeek. + */ + private function generateDeepSeekQuestions(int $nQ, string $lang, string $topic, string $questionType): string + { + $apiKey = $this->get('api_key'); + $prompt = sprintf( + 'Generate %d "%s" questions in Aiken format in the %s language about "%s", making sure there is a \'ANSWER\' line for each question. \'ANSWER\' lines must only mention the letter of the correct answer, not the full answer text and not a parenthesis. The line starting with \'ANSWER\' must not be separated from the last possible answer by a blank line. Each answer starts with an uppercase letter, a dot, one space and the answer text without quotes. Include an \'ANSWER_EXPLANATION\' line after the \'ANSWER\' line for each question. The terms between single quotes above must not be translated. There must be a blank line between each question.', + $nQ, + $questionType, + $lang, + $topic + ); + $payload = [ + 'model' => 'deepseek-chat', + 'messages' => [ + [ + 'role' => 'system', + 'content' => 'You are a helpful assistant that generates Aiken format questions.', + ], + [ + 'role' => 'user', + 'content' => $prompt, + ], + ], + 'stream' => false, + ]; + + $deepSeek = new DeepSeek($apiKey); + $response = $deepSeek->generateQuestions($payload); + + return $response; + } + /** * Validates tokens limit of a user per current month. */ diff --git a/plugin/ai_helper/README.md b/plugin/ai_helper/README.md index 9e18f646172..fb245d8b86b 100755 --- a/plugin/ai_helper/README.md +++ b/plugin/ai_helper/README.md @@ -1,46 +1,68 @@ -AI Helper plugin +AI Helper Plugin ====== -Version 1.1 +Version 1.2 -> This plugin is meant to be later integrated into Chamilo (in a major version -release). +> This plugin is designed to integrate AI functionality into Chamilo, providing tools for generating educational content, such as quizzes or learning paths, using AI providers like OpenAI or DeepSeek. -The AI helper plugin integrates into parts of the platform that seem the most useful to teachers/trainers or learners. -Because available Artificial Intelligence (to use the broad term) now allows us to ask for meaningful texts to be generated, we can use those systems to pre-generate content, then let the teacher/trainer review the content before publication. +--- -Currently, this plugin is only integrated into: +### Overview - - exercises: in the Aiken import form, scrolling down - - learnpaths: option to create one with openai +The AI Helper plugin integrates into parts of the Chamilo platform that are most useful to teachers/trainers or learners. It allows pre-generating content, letting teachers/trainers review it before publishing. -### OpenAI/ChatGPT +Currently, this plugin is integrated into: -The plugin, created in early 2023, currently only supports OpenAI's ChatGPT API. -Create an account at https://platform.openai.com/signup (if you already have an API account, go -to https://platform.openai.com/login), then generate a secret key at https://platform.openai.com/account/api-keys -or click on "Personal" -> "View API keys". -Click the "Create new secret key" button, copy the key and use it to fill the "API key" field on the -plugin configuration page. +- **Exercises:** In the Aiken import form, with options to generate questions using OpenAI or DeepSeek. +- **Learnpaths:** Option to create structured learning paths with OpenAI or DeepSeek. -# Changelog +--- -## v1.1 +### Supported AI Providers -Added tracking for requests and differential settings to enable only in exercises, only in learning paths, or both. +#### OpenAI/ChatGPT +The plugin, created in early 2023, supports OpenAI's ChatGPT API. +- **Setup:** +1. Create an account at [OpenAI](https://platform.openai.com/signup) (or login if you already have one). +2. Generate a secret key at [API Keys](https://platform.openai.com/account/api-keys). +3. Click "Create new secret key", copy the key, and paste it into the "API key" field in the plugin configuration. + +#### DeepSeek +DeepSeek is an alternative Open Source AI provider. +- **Setup:** +1. Create an account at [DeepSeek](https://www.deepseek.com/) (or login if you already have one). +2. Generate an API key at [API Keys](https://platform.deepseek.com/api_keys). +3. Click "Create new API key", copy the key, and paste it into the "API key" field in the plugin configuration. + +--- + +### Features + +- Generate quizzes in the Aiken format using AI. +- Create structured learning paths with AI assistance. +- Support for multiple AI providers, enabling easy switching between OpenAI and DeepSeek. +- Tracks API requests for monitoring usage and limits. + +--- + +### Database Requirements + +No additional database changes are required for v1.2. +The existing table `plugin_ai_helper_requests` is sufficient for tracking requests from both OpenAI and DeepSeek. + +If you're updating from **v1.0**, ensure the following table exists: -To update from v1.0, execute the following queries manually. ```sql CREATE TABLE plugin_ai_helper_requests ( -id int(11) NOT NULL AUTO_INCREMENT, -user_id int(11) NOT NULL, -tool_name varchar(255) COLLATE utf8_unicode_ci NOT NULL, -requested_at datetime DEFAULT NULL, -request_text varchar(255) COLLATE utf8_unicode_ci NOT NULL, -prompt_tokens int(11) NOT NULL, -completion_tokens int(11) NOT NULL, -total_tokens int(11) NOT NULL, -PRIMARY KEY (id) + id int(11) NOT NULL AUTO_INCREMENT, + user_id int(11) NOT NULL, + tool_name varchar(255) COLLATE utf8_unicode_ci NOT NULL, + requested_at datetime DEFAULT NULL, + request_text varchar(255) COLLATE utf8_unicode_ci NOT NULL, + prompt_tokens int(11) NOT NULL, + completion_tokens int(11) NOT NULL, + total_tokens int(11) NOT NULL, + PRIMARY KEY (id) ) DEFAULT CHARACTER SET utf8 COLLATE `utf8_unicode_ci` ENGINE = InnoDB; ``` If you got this update through Git, you will also need to run `composer install` to update the autoload mechanism. diff --git a/plugin/ai_helper/lang/english.php b/plugin/ai_helper/lang/english.php index 17bd047420e..36ede273f74 100755 --- a/plugin/ai_helper/lang/english.php +++ b/plugin/ai_helper/lang/english.php @@ -7,7 +7,9 @@ $strings['tool_enable'] = 'Enable plugin'; $strings['api_name'] = 'AI API to use'; $strings['api_key'] = 'Api key'; -$strings['api_key_help'] = 'Secret key generated for your Ai api'; +$strings['DeepSeek'] = 'DeepSeek'; +$strings['api_key_help'] = 'The API key for the selected AI provider.'; +$strings['api_name_help'] = 'Select the AI provider to use for question generation.'; $strings['organization_id'] = 'Organization ID'; $strings['organization_id_help'] = 'In case your api account is from an organization.'; $strings['OpenAI'] = 'OpenAI'; diff --git a/plugin/ai_helper/lang/spanish.php b/plugin/ai_helper/lang/spanish.php index 0ff50eb51de..f78c97814a0 100755 --- a/plugin/ai_helper/lang/spanish.php +++ b/plugin/ai_helper/lang/spanish.php @@ -16,3 +16,6 @@ $strings['tokens_limit'] = "Limite de tokens IA"; $strings['tokens_limit_help'] = 'Limitar la cantidad máxima de tokens disponibles por usuario por mes para evitar un alto costo de servicio.'; $strings['ErrorTokensLimit'] = 'Lo sentimos, ha alcanzado la cantidad máxima de tokens o solicitudes configuradas por el administrador de la plataforma para el mes calendario actual. Comuníquese con su equipo de soporte o espere hasta el próximo mes antes de poder enviar nuevas solicitudes a AI Helper.'; +$strings['DeepSeek'] = 'DeepSeek'; +$strings['deepseek_api_key'] = 'Clave API de DeepSeek'; +$strings['deepseek_api_key_help'] = 'Clave API para conectarse con DeepSeek.'; diff --git a/plugin/ai_helper/src/deepseek/DeepSeek.php b/plugin/ai_helper/src/deepseek/DeepSeek.php new file mode 100644 index 00000000000..7c0f2eacdaf --- /dev/null +++ b/plugin/ai_helper/src/deepseek/DeepSeek.php @@ -0,0 +1,87 @@ +apiKey = $apiKey; + $this->headers = [ + 'Content-Type: application/json', + "Authorization: Bearer {$this->apiKey}", + ]; + } + + /** + * Generate questions using the DeepSeek API. + * + * @param array $payload Data to send to the API + * @return string Decoded response from the API + * @throws Exception If an error occurs during the request + */ + public function generateQuestions(array $payload): string + { + $url = Url::completionsUrl(); + $response = $this->sendRequest($url, 'POST', $payload); + + if (empty($response)) { + throw new Exception('The DeepSeek API returned no response.'); + } + + $result = json_decode($response, true); + + // Validate errors returned by the API + if (isset($result['error'])) { + throw new Exception("DeepSeek API Error: {$result['error']['message']}"); + } + + // Ensure the response contains the expected "choices" field + if (!isset($result['choices'][0]['message']['content'])) { + throw new Exception('Unexpected response format from the DeepSeek API.'); + } + + return $result['choices'][0]['message']['content']; + } + + /** + * Send a request to the DeepSeek API. + * + * @param string $url Endpoint to send the request to + * @param string $method HTTP method (e.g., GET, POST) + * @param array $data Data to send as JSON + * @return string Raw response from the API + * @throws Exception If a cURL error occurs + */ + private function sendRequest(string $url, string $method, array $data = []): string + { + $ch = curl_init($url); + + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data)); + curl_setopt($ch, CURLOPT_HTTPHEADER, $this->headers); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + + if (curl_errno($ch)) { + $errorMessage = curl_error($ch); + curl_close($ch); + throw new Exception("cURL Error: {$errorMessage}"); + } + + curl_close($ch); + + // Validate HTTP status codes + if ($httpCode < 200 || $httpCode >= 300) { + throw new Exception("Request to DeepSeek failed with HTTP status code: {$httpCode}"); + } + + return $response; + } +} diff --git a/plugin/ai_helper/src/deepseek/Url.php b/plugin/ai_helper/src/deepseek/Url.php new file mode 100644 index 00000000000..bfd1e2c8226 --- /dev/null +++ b/plugin/ai_helper/src/deepseek/Url.php @@ -0,0 +1,17 @@ +streamMethod = $stream; } @@ -287,14 +283,11 @@ public function setTimeout(int $timeout) $this->timeout = $timeout; } - private function sendRequest( - string $url, - string $method, - array $opts = [] - ) { + private function sendRequest(string $url, string $method, array $opts = []): string + { $post_fields = json_encode($opts); - if (array_key_exists('file', $opts) || array_key_exists('image', $opts)) { + if (isset($opts['file']) || isset($opts['image'])) { $this->headers[0] = $this->contentTypes["multipart/form-data"]; $post_fields = $opts; } else { @@ -313,11 +306,11 @@ private function sendRequest( CURLOPT_HTTPHEADER => $this->headers, ]; - if ($opts == []) { + if (empty($opts)) { unset($curl_info[CURLOPT_POSTFIELDS]); } - if (array_key_exists('stream', $opts) && $opts['stream']) { + if (isset($opts['stream']) && $opts['stream']) { $curl_info[CURLOPT_WRITEFUNCTION] = $this->streamMethod; } @@ -325,8 +318,24 @@ private function sendRequest( curl_setopt_array($curl, $curl_info); $response = curl_exec($curl); + $httpCode = curl_getinfo($curl, CURLINFO_HTTP_CODE); + + if (curl_errno($curl)) { + $errorMessage = curl_error($curl); + curl_close($curl); + throw new Exception("cURL Error: {$errorMessage}"); + } + curl_close($curl); + if ($httpCode === 429) { + throw new Exception("Insufficient quota. Please check your OpenAI account plan and billing details."); + } + + if ($httpCode < 200 || $httpCode >= 300) { + throw new Exception("HTTP Error: {$httpCode}, Response: {$response}"); + } + return $response; } } diff --git a/plugin/ai_helper/tool/answers.php b/plugin/ai_helper/tool/answers.php index 4ee9018dea8..97263c355c5 100644 --- a/plugin/ai_helper/tool/answers.php +++ b/plugin/ai_helper/tool/answers.php @@ -2,56 +2,30 @@ /* For license terms, see /license.txt */ /** - Answer questions based on existing knowledge. +Answer questions based on existing knowledge. */ require_once __DIR__.'/../../../main/inc/global.inc.php'; require_once __DIR__.'/../AiHelperPlugin.php'; -require_once __DIR__.'/../src/openai/OpenAi.php'; $plugin = AiHelperPlugin::create(); -$apiList = $plugin->getApiList(); -$apiName = $plugin->get('api_name'); - -if (!in_array($apiName, array_keys($apiList))) { - throw new Exception("Ai API is not available for this request."); +try { + $nQ = (int) $_REQUEST['nro_questions']; + $lang = (string) $_REQUEST['language']; + $topic = (string) $_REQUEST['quiz_name']; + $questionType = $_REQUEST['question_type'] ?? 'multiple_choice'; + + $resultText = $plugin->generateQuestions($nQ, $lang, $topic, $questionType); + + echo json_encode([ + 'success' => true, + 'text' => trim($resultText), + ]); +} catch (Exception $e) { + error_log("Error: " . $e->getMessage()); + echo json_encode([ + 'success' => false, + 'text' => $e->getMessage(), + ]); } -switch ($apiName) { - case AiHelperPlugin::OPENAI_API: - - $questionTypes = [ - 'multiple_choice' => 'multiple choice', - 'unique_answer' => 'unique answer', - ]; - - $nQ = (int) $_REQUEST['nro_questions']; - $lang = (string) $_REQUEST['language']; - $topic = (string) $_REQUEST['quiz_name']; - $questionType = $questionTypes[$_REQUEST['question_type']] ?? $questionTypes['multiple_choice']; - - $prompt = 'Generate %d "%s" questions in Aiken format in the %s language about "%s", making sure there is a \'ANSWER\' line for each question. \'ANSWER\' lines must only mention the letter of the correct answer, not the full answer text and not a parenthesis. The line starting with \'ANSWER\' must not be separated from the last possible answer by a blank line. Each answer starts with an uppercase letter, a dot, one space and the answer text without quotes. Include an \'ANSWER_EXPLANATION\' line after the \'ANSWER\' line for each question. The terms between single quotes above must not be translated. There must be a blank line between each question.'; - $prompt = sprintf($prompt, $nQ, $questionType, $lang, $topic); - - $resultText = $plugin->openAiGetCompletionText($prompt, 'quiz'); - - if (isset($resultText['error']) && true === $resultText['error']) { - echo json_encode([ - 'success' => false, - 'text' => $resultText['message'], - ]); - exit; - } - - // Returns the text answers generated. - $return = ['success' => false, 'text' => '']; - if (!empty($resultText)) { - $return = [ - 'success' => true, - 'text' => trim($resultText), - ]; - } - - echo json_encode($return); - break; -} diff --git a/plugin/ai_helper/tool/learnpath.php b/plugin/ai_helper/tool/learnpath.php index b8fb74294d4..7a49e104791 100644 --- a/plugin/ai_helper/tool/learnpath.php +++ b/plugin/ai_helper/tool/learnpath.php @@ -18,199 +18,202 @@ $apiName = $plugin->get('api_name'); if (!in_array($apiName, array_keys($apiList))) { - throw new Exception("Ai API is not available for this request."); + echo json_encode(['success' => false, 'text' => 'AI Provider not available.']); + exit; } -switch ($apiName) { - case AiHelperPlugin::OPENAI_API: - - $courseLanguage = (string) $_REQUEST['language']; - $chaptersCount = (int) $_REQUEST['nro_items']; - $topic = (string) $_REQUEST['lp_name']; - $wordsCount = (int) $_REQUEST['words_count']; - $courseCode = (string) $_REQUEST['course_code']; - $sessionId = (int) $_REQUEST['session_id']; - $addTests = ('true' === $_REQUEST['add_tests']); - $nQ = ($addTests ? (int) $_REQUEST['nro_questions'] : 0); - - $messageGetItems = 'Generate the table of contents of a course in "%s" in %d or less chapters on the topic of "%s" and return it as a list of items separated by CRLF. Do not provide chapter numbering. Do not include a conclusion chapter.'; - $prompt = sprintf($messageGetItems, $courseLanguage, $chaptersCount, $topic); - $resultText = $plugin->openAiGetCompletionText($prompt, 'learnpath'); - - if (isset($resultText['error']) && true === $resultText['error']) { - echo json_encode([ - 'success' => false, - 'text' => $resultText['message'], - ]); - exit; - } - $lpItems = []; - if (!empty($resultText)) { - $style = api_get_css_asset('bootstrap/dist/css/bootstrap.min.css'); - $style .= api_get_css_asset('fontawesome/css/font-awesome.min.css'); - $style .= api_get_css(ChamiloApi::getEditorDocStylePath()); - $style .= api_get_css_asset('ckeditor/plugins/codesnippet/lib/highlight/styles/default.css'); - $style .= api_get_asset('ckeditor/plugins/codesnippet/lib/highlight/highlight.pack.js'); - $style .= ''; - - $items = explode("\n", $resultText); - $position = 1; - foreach ($items as $item) { - if (substr($item, 0, 2) === '- ') { - $item = substr($item, 2); - } - $explodedItem = preg_split('/\d\./', $item); - $title = count($explodedItem) > 1 ? $explodedItem[1] : $explodedItem[0]; - if (!empty($title)) { - $lpItems[$position]['title'] = trim($title); - $messageGetItemContent = 'In the context of "%s", generate a document with HTML tags in "%s" with %d words of content or less, about "%s", as to be included as one chapter in a larger document on "%s". Consider the context is established for the reader and you do not need to repeat it.'; - $promptItem = sprintf($messageGetItemContent, $topic, $courseLanguage, $wordsCount, $title, $topic); - $resultContentText = $plugin->openAiGetCompletionText($promptItem, 'learnpath'); - $lpItemContent = (!empty($resultContentText) ? trim($resultContentText) : ''); - if (false !== stripos($lpItemContent, '')) { - $lpItemContent = preg_replace("||i", "\r\n$style\r\n\\0", $lpItemContent); - } else { - $lpItemContent = ''.trim($title).''.$style.''.$lpItemContent.''; - } - $lpItems[$position]['content'] = $lpItemContent; - $position++; - } +$courseLanguage = (string) $_REQUEST['language']; +$chaptersCount = (int) $_REQUEST['nro_items']; +$topic = (string) $_REQUEST['lp_name']; +$wordsCount = (int) $_REQUEST['words_count']; +$courseCode = (string) $_REQUEST['course_code']; +$sessionId = (int) $_REQUEST['session_id']; +$addTests = ('true' === $_REQUEST['add_tests']); +$nQ = ($addTests ? (int) $_REQUEST['nro_questions'] : 0); + +$messageGetItems = 'Generate the table of contents of a course in "%s" in %d or fewer chapters on the topic "%s". Return it as a list of items separated by new lines. Do not include a conclusion chapter.'; +$prompt = sprintf($messageGetItems, $courseLanguage, $chaptersCount, $topic); + +$resultText = $plugin->getCompletionText($prompt, 'learnpath'); + +if (isset($resultText['error']) && $resultText['error']) { + echo json_encode(['success' => false, 'text' => $resultText['message']]); + exit; +} + +if (empty($resultText)) { + echo json_encode(['success' => false, 'text' => 'AI returned no results.']); + exit; +} + +$lpItems = []; +if (!empty($resultText)) { + $style = api_get_css_asset('bootstrap/dist/css/bootstrap.min.css'); + $style .= api_get_css_asset('fontawesome/css/font-awesome.min.css'); + $style .= api_get_css(ChamiloApi::getEditorDocStylePath()); + $style .= api_get_css_asset('ckeditor/plugins/codesnippet/lib/highlight/styles/default.css'); + $style .= api_get_asset('ckeditor/plugins/codesnippet/lib/highlight/highlight.pack.js'); + $style .= ''; + + $items = explode("\n", $resultText); + $position = 1; + foreach ($items as $item) { + if (substr($item, 0, 2) === '- ') { + $item = substr($item, 2); + } + $explodedItem = preg_split('/\d\./', $item); + $title = count($explodedItem) > 1 ? $explodedItem[1] : $explodedItem[0]; + if (!empty($title)) { + $lpItems[$position]['title'] = trim($title); + $messageGetItemContent = 'In the context of "%s", generate a document with HTML tags in "%s" with %d words of content or less, about "%s", as to be included as one chapter in a larger document on "%s". Consider the context is established for the reader and you do not need to repeat it.'; + $promptItem = sprintf($messageGetItemContent, $topic, $courseLanguage, $wordsCount, $title, $topic); + $resultContentText = $plugin->getCompletionText($promptItem, 'learnpath'); + if (isset($resultContentText['error']) && $resultContentText['error']) { + continue; + } + $lpItemContent = (!empty($resultContentText) ? trim($resultContentText) : ''); + if (false !== stripos($lpItemContent, '')) { + $lpItemContent = preg_replace("||i", "\r\n$style\r\n\\0", $lpItemContent); + } else { + $lpItemContent = ''.trim($title).''.$style.''.$lpItemContent.''; } + $lpItems[$position]['content'] = $lpItemContent; + $position++; } + } +} - // Create the learnpath and return the id generated. - $return = ['success' => false, 'lp_id' => 0]; - if (!empty($lpItems)) { - $lpId = learnpath::add_lp( - $courseCode, - $topic, - '', - 'chamilo', - 'manual' +// Create the learnpath and return the id generated. +$return = ['success' => false, 'lp_id' => 0]; +if (!empty($lpItems)) { + $lpId = learnpath::add_lp( + $courseCode, + $topic, + '', + 'chamilo', + 'manual' + ); + + if (!empty($lpId)) { + learnpath::toggle_visibility($lpId, 0); + $courseInfo = api_get_course_info($courseCode); + $lp = new \learnpath( + $courseCode, + $lpId, + api_get_user_id() + ); + $lp->generate_lp_folder($courseInfo, $topic); + $order = 1; + $lpItemsIds = []; + foreach ($lpItems as $dspOrder => $item) { + $documentId = $lp->create_document( + $courseInfo, + $item['content'], + $item['title'], + 'html' ); - if (!empty($lpId)) { - learnpath::toggle_visibility($lpId, 0); - $courseInfo = api_get_course_info($courseCode); - $lp = new \learnpath( - $courseCode, - $lpId, - api_get_user_id() + if (!empty($documentId)) { + $prevDocItem = (isset($lpItemsIds[$order - 1]) ? (int) $lpItemsIds[$order - 1]['item_id'] : 0); + $lpItemId = $lp->add_item( + 0, + $prevDocItem, + 'document', + $documentId, + $item['title'], + '', + 0, + 0, + api_get_user_id(), + $order ); - $lp->generate_lp_folder($courseInfo, $topic); - $order = 1; - $lpItemsIds = []; - foreach ($lpItems as $dspOrder => $item) { - $documentId = $lp->create_document( - $courseInfo, - $item['content'], - $item['title'], - 'html' - ); - - if (!empty($documentId)) { - $prevDocItem = (isset($lpItemsIds[$order - 1]) ? (int) $lpItemsIds[$order - 1]['item_id'] : 0); - $lpItemId = $lp->add_item( - 0, - $prevDocItem, - 'document', - $documentId, - $item['title'], - '', - 0, - 0, - api_get_user_id(), - $order - ); - $lpItemsIds[$order]['item_id'] = $lpItemId; - $lpItemsIds[$order]['item_type'] = 'document'; - if ($addTests && !empty($lpItemId)) { - $promptQuiz = 'Generate %d "%s" questions in Aiken format in the %s language about "%s", making sure there is a \'ANSWER\' line for each question. \'ANSWER\' lines must only mention the letter of the correct answer, not the full answer text and not a parenthesis. The line starting with \'ANSWER\' must not be separated from the last possible answer by a blank line. Each answer starts with an uppercase letter, a dot, one space and the answer text without quotes. Include an \'ANSWER_EXPLANATION\' line after the \'ANSWER\' line for each question. The terms between single quotes above must not be translated. There must be a blank line between each question. Show the question directly without any prefix.'; - $promptQuiz = sprintf($promptQuiz, $nQ, $courseLanguage, $item['title'], $topic); - $resultQuizText = $plugin->openAiGetCompletionText($promptQuiz, 'quiz'); - if (!empty($resultQuizText)) { - $request = []; - $request['quiz_name'] = get_lang('Exercise').': '.$item['title']; - $request['nro_questions'] = $nQ; - $request['course_id'] = api_get_course_int_id($courseCode); - $request['aiken_format'] = trim($resultQuizText); - $exerciseId = aikenImportExercise(null, $request); - if (!empty($exerciseId)) { - $order++; - $prevQuizItem = (isset($lpItemsIds[$order - 1]) ? (int) $lpItemsIds[$order - 1]['item_id'] : 0); - $lpQuizItemId = $lp->add_item( - 0, - $prevQuizItem, - 'quiz', - $exerciseId, - $request['quiz_name'], - '', - 0, - 0, - api_get_user_id(), - $order - ); - if (!empty($lpQuizItemId)) { - $maxScore = (float) $nQ; - $minScore = round($nQ / 2, 2); - $lpItemsIds[$order]['item_id'] = $lpQuizItemId; - $lpItemsIds[$order]['item_type'] = 'quiz'; - $lpItemsIds[$order]['min_score'] = $minScore; - $lpItemsIds[$order]['max_score'] = $maxScore; - } - } + $lpItemsIds[$order]['item_id'] = $lpItemId; + $lpItemsIds[$order]['item_type'] = 'document'; + if ($addTests && !empty($lpItemId)) { + $promptQuiz = 'Generate %d "%s" questions in Aiken format in the %s language about "%s", making sure there is a \'ANSWER\' line for each question. \'ANSWER\' lines must only mention the letter of the correct answer, not the full answer text and not a parenthesis. The line starting with \'ANSWER\' must not be separated from the last possible answer by a blank line. Each answer starts with an uppercase letter, a dot, one space and the answer text without quotes. Include an \'ANSWER_EXPLANATION\' line after the \'ANSWER\' line for each question. The terms between single quotes above must not be translated. There must be a blank line between each question. Show the question directly without any prefix.'; + $promptQuiz = sprintf($promptQuiz, $nQ, $courseLanguage, $item['title'], $topic); + $resultQuizText = $plugin->getCompletionText($promptQuiz, 'quiz'); + if (!empty($resultQuizText)) { + $request = []; + $request['quiz_name'] = get_lang('Exercise').': '.$item['title']; + $request['nro_questions'] = $nQ; + $request['course_id'] = api_get_course_int_id($courseCode); + $request['aiken_format'] = trim($resultQuizText); + $exerciseId = aikenImportExercise(null, $request); + if (!empty($exerciseId)) { + $order++; + $prevQuizItem = (isset($lpItemsIds[$order - 1]) ? (int) $lpItemsIds[$order - 1]['item_id'] : 0); + $lpQuizItemId = $lp->add_item( + 0, + $prevQuizItem, + 'quiz', + $exerciseId, + $request['quiz_name'], + '', + 0, + 0, + api_get_user_id(), + $order + ); + if (!empty($lpQuizItemId)) { + $maxScore = (float) $nQ; + $minScore = round($nQ / 2, 2); + $lpItemsIds[$order]['item_id'] = $lpQuizItemId; + $lpItemsIds[$order]['item_type'] = 'quiz'; + $lpItemsIds[$order]['min_score'] = $minScore; + $lpItemsIds[$order]['max_score'] = $maxScore; } } } - $order++; } + } + $order++; + } - // Add the final item - if ($addTests) { - $finalTitle = get_lang('EndOfLearningPath'); - $finalContent = file_get_contents(api_get_path(SYS_CODE_PATH).'lp/final_item_template/template.html'); - $finalDocId = $lp->create_document( - $courseInfo, - $finalContent, - $finalTitle - ); - $prevFinalItem = (isset($lpItemsIds[$order - 1]) ? (int) $lpItemsIds[$order - 1]['item_id'] : 0); - $lpFinalItemId = $lp->add_item( - 0, - $prevFinalItem, - TOOL_LP_FINAL_ITEM, - $finalDocId, - $finalTitle, - '', - 0, - 0, - api_get_user_id(), - $order - ); - $lpItemsIds[$order]['item_id'] = $lpFinalItemId; - $lpItemsIds[$order]['item_type'] = TOOL_LP_FINAL_ITEM; - - // Set lp items prerequisites - if (count($lpItemsIds) > 0) { - for ($i = 1; $i <= count($lpItemsIds); $i++) { - $prevIndex = ($i - 1); - if (isset($lpItemsIds[$prevIndex])) { - $itemId = $lpItemsIds[$i]['item_id']; - $prerequisite = $lpItemsIds[$prevIndex]['item_id']; - $minScore = ('quiz' === $lpItemsIds[$prevIndex]['item_type'] ? $lpItemsIds[$prevIndex]['min_score'] : 0); - $maxScore = ('quiz' === $lpItemsIds[$prevIndex]['item_type'] ? $lpItemsIds[$prevIndex]['max_score'] : 100); - $lp->edit_item_prereq($itemId, $prerequisite, $minScore, $maxScore); - } - } + // Add the final item + if ($addTests) { + $finalTitle = get_lang('EndOfLearningPath'); + $finalContent = file_get_contents(api_get_path(SYS_CODE_PATH).'lp/final_item_template/template.html'); + $finalDocId = $lp->create_document( + $courseInfo, + $finalContent, + $finalTitle + ); + $prevFinalItem = (isset($lpItemsIds[$order - 1]) ? (int) $lpItemsIds[$order - 1]['item_id'] : 0); + $lpFinalItemId = $lp->add_item( + 0, + $prevFinalItem, + TOOL_LP_FINAL_ITEM, + $finalDocId, + $finalTitle, + '', + 0, + 0, + api_get_user_id(), + $order + ); + $lpItemsIds[$order]['item_id'] = $lpFinalItemId; + $lpItemsIds[$order]['item_type'] = TOOL_LP_FINAL_ITEM; + + // Set lp items prerequisites + if (count($lpItemsIds) > 0) { + for ($i = 1; $i <= count($lpItemsIds); $i++) { + $prevIndex = ($i - 1); + if (isset($lpItemsIds[$prevIndex])) { + $itemId = $lpItemsIds[$i]['item_id']; + $prerequisite = $lpItemsIds[$prevIndex]['item_id']; + $minScore = ('quiz' === $lpItemsIds[$prevIndex]['item_type'] ? $lpItemsIds[$prevIndex]['min_score'] : 0); + $maxScore = ('quiz' === $lpItemsIds[$prevIndex]['item_type'] ? $lpItemsIds[$prevIndex]['max_score'] : 100); + $lp->edit_item_prereq($itemId, $prerequisite, $minScore, $maxScore); } } } - $return = [ - 'success' => true, - 'lp_id' => $lpId, - ]; } - echo json_encode($return); - break; + } + $return = [ + 'success' => true, + 'lp_id' => $lpId, + ]; } +echo json_encode($return);