From c3ea9a8ca420d644b1d26e2ef4a1dc7d27cfacb0 Mon Sep 17 00:00:00 2001 From: Peter Mayer Date: Tue, 3 Sep 2024 21:25:30 +0200 Subject: [PATCH 1/3] MBS-9335 aipurpose_genai: Add purpose for ai based question generation --- classes/base_connector.php | 10 +-- purposes/genai/classes/privacy/provider.php | 47 +++++++++++++ purposes/genai/classes/purpose.php | 75 +++++++++++++++++++++ purposes/genai/lang/de/aipurpose_genai.php | 29 ++++++++ purposes/genai/lang/en/aipurpose_genai.php | 29 ++++++++ purposes/genai/version.php | 31 +++++++++ tools/chatgpt/classes/connector.php | 8 ++- tools/gemini/classes/connector.php | 46 +++++++------ tools/ollama/classes/connector.php | 50 +++++++++----- 9 files changed, 279 insertions(+), 46 deletions(-) create mode 100644 purposes/genai/classes/privacy/provider.php create mode 100644 purposes/genai/classes/purpose.php create mode 100644 purposes/genai/lang/de/aipurpose_genai.php create mode 100644 purposes/genai/lang/en/aipurpose_genai.php create mode 100644 purposes/genai/version.php diff --git a/classes/base_connector.php b/classes/base_connector.php index 37d0547..0944e4f 100644 --- a/classes/base_connector.php +++ b/classes/base_connector.php @@ -176,9 +176,9 @@ public function make_request(array $data): request_response { $return = request_response::create_from_result($response->getBody()); } else { $return = request_response::create_from_error( - $response->getStatusCode(), - get_string('error_sendingrequestfailed', 'local_ai_manager'), - $response->getBody(), + $response->getStatusCode(), + get_string('error_sendingrequestfailed', 'local_ai_manager'), + $response->getBody(), ); } return $return; @@ -238,8 +238,8 @@ protected function create_error_response_from_exception(ClientExceptionInterface */ protected function get_headers(): array { return [ - 'Authorization' => 'Bearer ' . $this->get_api_key(), - 'Content-Type' => 'application/json;charset=utf-8', + 'Authorization' => 'Bearer ' . $this->get_api_key(), + 'Content-Type' => 'application/json;charset=utf-8', ]; } } diff --git a/purposes/genai/classes/privacy/provider.php b/purposes/genai/classes/privacy/provider.php new file mode 100644 index 0000000..1b3b63f --- /dev/null +++ b/purposes/genai/classes/privacy/provider.php @@ -0,0 +1,47 @@ +. + +/** + * aipurpose_genai privacy provider class. + * + * @package aipurpose_genai + * @copyright ISB Bayern, 2024 + * @author Dr. Peter Mayer + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace aipurpose_genai\privacy; + +/** + * aipurpose_genai privacy provider class. + * + * @package aipurpose_genai + * @copyright ISB Bayern, 2024 + * @author Dr. Peter Mayer + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class provider implements \core_privacy\local\metadata\null_provider { + + /** + * Get the language string identifier with the component's language + * file to explain why this plugin stores no data. + * + * @return string + */ + public static function get_reason(): string { + return 'privacy:metadata'; + } +} diff --git a/purposes/genai/classes/purpose.php b/purposes/genai/classes/purpose.php new file mode 100644 index 0000000..4247345 --- /dev/null +++ b/purposes/genai/classes/purpose.php @@ -0,0 +1,75 @@ +. + +/** + * Purpose genai methods + * + * @package aipurpose_genai + * @copyright ISB Bayern, 2024 + * @author Dr. Peter Mayer + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace aipurpose_genai; + +use local_ai_manager\base_purpose; + +/** + * Purpose genai methods + * + * @package aipurpose_genai + * @copyright ISB Bayern, 2024 + * @author Dr. Peter Mayer + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class purpose extends base_purpose { + + /** + * Get the request options. + * + * @param array $options + * @return array + */ + #[\Override] + public function get_request_options(array $options): array { + + if (array_key_exists('messages', $options)) { + $messages = []; + foreach($options['messages'] as $message) { + switch($message['role']) { + case 'user': + $messages[] = ['sender' => 'user', 'message' => $message['content']]; + break; + case 'system': + $messages[] = ['sender' => 'system', 'message' => $message['content']]; + break; + } + } + return ['conversationcontext' => $messages]; + } + return []; + } + + /** + * Get the additional purpose options + * + * @return array + */ + #[\Override] + public function get_additional_purpose_options(): array { + return ['messages' => base_purpose::PARAM_ARRAY]; + } +} diff --git a/purposes/genai/lang/de/aipurpose_genai.php b/purposes/genai/lang/de/aipurpose_genai.php new file mode 100644 index 0000000..ff6cb63 --- /dev/null +++ b/purposes/genai/lang/de/aipurpose_genai.php @@ -0,0 +1,29 @@ +. + +/** + * Lang strings for aipurpose_genai - DE. + * + * @package aipurpose_genai + * @copyright ISB Bayern, 2024 + * @author Dr. Peter Mayer + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +$string['pluginname'] = 'Testfragengenerierung'; +$string['privacy:metadata'] = 'Das Zweck-Subplugin "Testfragengenerierung" speichert keine persönlichen Daten.'; +$string['requestcount'] = 'GenAI-Anfragen'; +$string['requestcount_shortened'] = 'GenAI-'; diff --git a/purposes/genai/lang/en/aipurpose_genai.php b/purposes/genai/lang/en/aipurpose_genai.php new file mode 100644 index 0000000..add0a2c --- /dev/null +++ b/purposes/genai/lang/en/aipurpose_genai.php @@ -0,0 +1,29 @@ +. + +/** + * Lang strings for aipurpose_genai - EN. + * + * @package aipurpose_genai + * @copyright ISB Bayern, 2024 + * @author Dr. Peter Mayer + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +$string['pluginname'] = 'Questiongeneration'; +$string['privacy:metadata'] = 'The local ai_manager purpose subplugin "Questiongeneration" does not store any personal data.'; +$string['requestcount'] = 'GenAI requests'; +$string['requestcount_shortened'] = 'GenAI'; diff --git a/purposes/genai/version.php b/purposes/genai/version.php new file mode 100644 index 0000000..cbe47e5 --- /dev/null +++ b/purposes/genai/version.php @@ -0,0 +1,31 @@ +. + +/** + * Version file for aipurpose_genai. + * + * @package aipurpose_genai + * @copyright ISB Bayern, 2024 + * @author Dr. Peter Mayer + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +defined('MOODLE_INTERNAL') || die(); + +$plugin->version = 2024090300; +$plugin->requires = 2023042403; +$plugin->release = '0.0.1'; +$plugin->component = 'aipurpose_genai'; +$plugin->maturity = MATURITY_ALPHA; diff --git a/tools/chatgpt/classes/connector.php b/tools/chatgpt/classes/connector.php index ac51cf9..a757bdd 100644 --- a/tools/chatgpt/classes/connector.php +++ b/tools/chatgpt/classes/connector.php @@ -39,6 +39,7 @@ public function get_models_by_purpose(): array { 'feedback' => $chatgptmodels, 'singleprompt' => $chatgptmodels, 'translate' => $chatgptmodels, + 'genai' => $chatgptmodels, ]; } @@ -90,16 +91,21 @@ public function get_prompt_data(string $prompttext, array $requestoptions): arra ]; } } - $messages[] = ['role' => 'user', 'content' => $prompttext]; + + if (!empty($prompttext)) { + $messages[] = ['role' => 'user', 'content' => $prompttext]; + } $parameters = [ 'temperature' => $this->instance->get_temperature(), 'messages' => $messages, ]; + if (!$this->instance->azure_enabled()) { // If azure is enabled, the model will be preconfigured in the azure resource, so we do not need to send it. $parameters['model'] = $this->instance->get_model(); } + return $parameters; } diff --git a/tools/gemini/classes/connector.php b/tools/gemini/classes/connector.php index d7e643e..49aaabc 100644 --- a/tools/gemini/classes/connector.php +++ b/tools/gemini/classes/connector.php @@ -35,10 +35,11 @@ class connector extends \local_ai_manager\base_connector { public function get_models_by_purpose(): array { $textmodels = ['gemini-1.0-pro-latest', 'gemini-1.0-pro-vision-latest', 'gemini-1.5-flash-latest', 'gemini-1.5-pro-latest']; return [ - 'chat' => $textmodels, - 'feedback' => $textmodels, - 'singleprompt' => $textmodels, - 'translate' => $textmodels, + 'chat' => $textmodels, + 'feedback' => $textmodels, + 'singleprompt' => $textmodels, + 'translate' => $textmodels, + 'genai' => $textmodels, ]; } @@ -61,12 +62,13 @@ public function execute_prompt_completion(StreamInterface $result, array $option $textanswer .= $part['text']; } return prompt_response::create_from_result( - $this->instance->get_model(), - new usage( - (float) $content['usageMetadata']['totalTokenCount'], - (float) $content['usageMetadata']['promptTokenCount'], - (float) $content['usageMetadata']['candidatesTokenCount']), - $textanswer, + $this->instance->get_model(), + new usage( + (float) $content['usageMetadata']['totalTokenCount'], + (float) $content['usageMetadata']['promptTokenCount'], + (float) $content['usageMetadata']['candidatesTokenCount'] + ), + $textanswer, ); } @@ -90,24 +92,24 @@ public function get_prompt_data(string $prompttext, array $requestoptions): arra throw new \moodle_exception('Bad message format'); } $messages[] = [ - 'role' => $role, - 'parts' => [ - ['text' => $message['message']], - ], + 'role' => $role, + 'parts' => [ + ['text' => $message['message']], + ], ]; } } $messages[] = [ - 'role' => 'user', - 'parts' => [ - ['text' => $prompttext], - ], + 'role' => 'user', + 'parts' => [ + ['text' => $prompttext], + ], ]; return [ - 'contents' => $messages, - 'generationConfig' => [ - 'temperature' => $this->instance->get_temperature(), - ], + 'contents' => $messages, + 'generationConfig' => [ + 'temperature' => $this->instance->get_temperature(), + ], ]; } diff --git a/tools/ollama/classes/connector.php b/tools/ollama/classes/connector.php index ed65349..0e271dc 100644 --- a/tools/ollama/classes/connector.php +++ b/tools/ollama/classes/connector.php @@ -33,13 +33,25 @@ class connector extends \local_ai_manager\base_connector { #[\Override] public function get_models_by_purpose(): array { - $textmodels = ['gemma', 'llama3', 'llama3.1', 'mistral', 'codellama', 'qwen', 'phi3', 'mixtral', 'dolphin-mixtral', 'llava', - 'tinyllama']; + $textmodels = [ + 'gemma', + 'llama3', + 'llama3.1', + 'mistral', + 'codellama', + 'qwen', + 'phi3', + 'mixtral', + 'dolphin-mixtral', + 'llava', + 'tinyllama' + ]; return [ - 'chat' => $textmodels, - 'feedback' => $textmodels, - 'singleprompt' => $textmodels, - 'translate' => $textmodels, + 'chat' => $textmodels, + 'feedback' => $textmodels, + 'singleprompt' => $textmodels, + 'translate' => $textmodels, + 'genai' => $textmodels, ]; } @@ -58,9 +70,11 @@ public function execute_prompt_completion(StreamInterface $result, array $option $responsetokencount = isset($content['eval_count']) ? $content['eval_count'] : 0.0; $totaltokencount = $prompttokencount + $responsetokencount; - return prompt_response::create_from_result($content['model'], - new usage($totaltokencount, $prompttokencount, $prompttokencount), - $content['message']['content']); + return prompt_response::create_from_result( + $content['model'], + new usage($totaltokencount, $prompttokencount, $prompttokencount), + $content['message']['content'] + ); } #[\Override] @@ -82,20 +96,20 @@ public function get_prompt_data(string $prompttext, array $requestoptions): arra throw new \moodle_exception('Bad message format'); } $messages[] = [ - 'role' => $role, - 'content' => $message['message'], + 'role' => $role, + 'content' => $message['message'], ]; } } $messages[] = ['role' => 'user', 'content' => $prompttext]; $data = [ - 'model' => $this->instance->get_model(), - 'messages' => $messages, - 'stream' => false, - 'keep_alive' => '60m', - 'options' => [ - 'temperature' => $this->instance->get_temperature(), - ], + 'model' => $this->instance->get_model(), + 'messages' => $messages, + 'stream' => false, + 'keep_alive' => '60m', + 'options' => [ + 'temperature' => $this->instance->get_temperature(), + ], ]; return $data; } From 471400f8c616704760ae737cbc0b0107f189cff2 Mon Sep 17 00:00:00 2001 From: Peter Mayer Date: Wed, 4 Sep 2024 23:00:01 +0200 Subject: [PATCH 2/3] to squash --- classes/manager.php | 74 +++++++++++++++++++----------- purposes/genai/classes/purpose.php | 4 +- 2 files changed, 50 insertions(+), 28 deletions(-) diff --git a/classes/manager.php b/classes/manager.php index 0ab581e..96b7ead 100644 --- a/classes/manager.php +++ b/classes/manager.php @@ -112,9 +112,9 @@ public function perform_request(string $prompttext, array $options = []): prompt $options = $this->sanitize_options($options); } catch (\Exception $exception) { return prompt_response::create_from_error( - 400, - get_string('error_http400', 'local_ai_manager'), - $exception->getMessage() + 400, + get_string('error_http400', 'local_ai_manager'), + $exception->getMessage() ); } @@ -140,14 +140,16 @@ public function perform_request(string $prompttext, array $options = []): prompt if ($userusage->get_currentusage() >= $this->configmanager->get_max_requests($this->purpose, $userinfo->get_role())) { $period = format_time($this->configmanager->get_max_requests_period()); return prompt_response::create_from_error( - 429, - get_string( - 'error_http429', - 'local_ai_manager', - ['count' => $this->configmanager->get_max_requests($this->purpose, $userinfo->get_role()), - 'period' => $period] - ), - '' + 429, + get_string( + 'error_http429', + 'local_ai_manager', + [ + 'count' => $this->configmanager->get_max_requests($this->purpose, $userinfo->get_role()), + 'period' => $period + ] + ), + '' ); } @@ -168,21 +170,35 @@ public function perform_request(string $prompttext, array $options = []): prompt $endtime = microtime(true); $duration = round($endtime - $starttime, 2); if ($requestresult->get_code() !== 200) { - $promptresponse = prompt_response::create_from_error($requestresult->get_code(), $requestresult->get_errormessage(), - $requestresult->get_debuginfo()); + $promptresponse = prompt_response::create_from_error( + $requestresult->get_code(), + $requestresult->get_errormessage(), + $requestresult->get_debuginfo() + ); get_ai_response_failed::create_from_prompt_response($promptdata, $promptresponse, $duration)->trigger(); return $promptresponse; } $promptcompletion = $this->connector->execute_prompt_completion($requestresult->get_response(), $options); - if (!empty($options['forcenewitemid']) && !empty($options['component']) && - !empty($options['contextid'] && !empty($options['itemid']))) { - if ($DB->record_exists('local_ai_manager_request_log', - ['component' => $options['component'], 'contextid' => $options['contextid'], 'itemid' => $options['itemid']])) { + if ( + !empty($options['forcenewitemid']) && !empty($options['component']) && + !empty($options['contextid'] && !empty($options['itemid'])) + ) { + if ($DB->record_exists( + 'local_ai_manager_request_log', + [ + 'component' => $options['component'], + 'contextid' => $options['contextid'], + 'itemid' => $options['itemid'] + ] + )) { $existingitemid = $options['itemid']; unset($options['itemid']); $this->log_request($prompttext, $promptcompletion, $duration, $requestoptions, $options); - $promptresponse = prompt_response::create_from_error(409, get_string('error_http409', 'local_ai_manager', - $existingitemid), ''); + $promptresponse = prompt_response::create_from_error(409, get_string( + 'error_http409', + 'local_ai_manager', + $existingitemid + ), ''); get_ai_response_failed::create_from_prompt_response($promptdata, $promptresponse, $duration)->trigger(); return $promptresponse; } @@ -205,9 +221,13 @@ public function perform_request(string $prompttext, array $options = []): prompt * @param array $options part of $requestoptions, contains the options directly passed to the manager * @return int the record id of the log record which has been stored to the database */ - public function log_request(string $prompttext, prompt_response $promptcompletion, float $executiontime, - array $requestoptions = [], - array $options = []): int { + public function log_request( + string $prompttext, + prompt_response $promptcompletion, + float $executiontime, + array $requestoptions = [], + array $options = [] + ): int { global $DB, $USER; // phpcs:disable moodle.Commenting.TodoComment.MissingInfoInline @@ -267,13 +287,15 @@ private function sanitize_options(array $options): array { foreach ($options as $key => $value) { if (!array_key_exists($key, $this->purpose->get_available_purpose_options())) { throw new \coding_exception('Option ' . $key . ' is not allowed for the purpose ' . - $this->purpose->get_plugin_name()); + $this->purpose->get_plugin_name()); } if (is_array($this->purpose->get_available_purpose_options()[$key])) { - if (!in_array($value[0], array_map(fn($valueobject) => $valueobject['key'], - $this->purpose->get_available_purpose_options()[$key]))) { + if (!in_array($value[0], array_map( + fn($valueobject) => $valueobject['key'], + $this->purpose->get_available_purpose_options()[$key] + ))) { throw new \coding_exception('Value ' . $value[0] . ' for option ' . $key . ' is not allowed for the purpose ' . - $this->purpose->get_plugin_name()); + $this->purpose->get_plugin_name()); } } else { if ($this->purpose->get_available_purpose_options()[$key] === base_purpose::PARAM_ARRAY) { diff --git a/purposes/genai/classes/purpose.php b/purposes/genai/classes/purpose.php index 4247345..0e936ba 100644 --- a/purposes/genai/classes/purpose.php +++ b/purposes/genai/classes/purpose.php @@ -48,8 +48,8 @@ public function get_request_options(array $options): array { if (array_key_exists('messages', $options)) { $messages = []; - foreach($options['messages'] as $message) { - switch($message['role']) { + foreach ($options['messages'] as $message) { + switch ($message['role']) { case 'user': $messages[] = ['sender' => 'user', 'message' => $message['content']]; break; From 717ca87c0f889f4cfe115619fc5072dd67756a2c Mon Sep 17 00:00:00 2001 From: Peter Mayer Date: Thu, 5 Sep 2024 10:24:51 +0200 Subject: [PATCH 3/3] to squashd --- classes/manager.php | 4 ++-- purposes/genai/classes/purpose.php | 2 +- tools/ollama/classes/connector.php | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/classes/manager.php b/classes/manager.php index 96b7ead..c48eddf 100644 --- a/classes/manager.php +++ b/classes/manager.php @@ -146,7 +146,7 @@ public function perform_request(string $prompttext, array $options = []): prompt 'local_ai_manager', [ 'count' => $this->configmanager->get_max_requests($this->purpose, $userinfo->get_role()), - 'period' => $period + 'period' => $period, ] ), '' @@ -188,7 +188,7 @@ public function perform_request(string $prompttext, array $options = []): prompt [ 'component' => $options['component'], 'contextid' => $options['contextid'], - 'itemid' => $options['itemid'] + 'itemid' => $options['itemid'], ] )) { $existingitemid = $options['itemid']; diff --git a/purposes/genai/classes/purpose.php b/purposes/genai/classes/purpose.php index 0e936ba..2791f77 100644 --- a/purposes/genai/classes/purpose.php +++ b/purposes/genai/classes/purpose.php @@ -48,7 +48,7 @@ public function get_request_options(array $options): array { if (array_key_exists('messages', $options)) { $messages = []; - foreach ($options['messages'] as $message) { + foreach ($options['messages'] as $message) { switch ($message['role']) { case 'user': $messages[] = ['sender' => 'user', 'message' => $message['content']]; diff --git a/tools/ollama/classes/connector.php b/tools/ollama/classes/connector.php index 0e271dc..424d793 100644 --- a/tools/ollama/classes/connector.php +++ b/tools/ollama/classes/connector.php @@ -44,7 +44,7 @@ public function get_models_by_purpose(): array { 'mixtral', 'dolphin-mixtral', 'llava', - 'tinyllama' + 'tinyllama', ]; return [ 'chat' => $textmodels,