MDL-83216 AI: Provider instances ordering

This commit is contained in:
meirzamoodle
2025-02-26 08:45:58 +07:00
parent 5670447ece
commit 2f10fde01e
12 changed files with 553 additions and 13 deletions
+94
View File
@@ -0,0 +1,94 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace core_ai\external;
use core_external\external_api;
use core_external\external_function_parameters;
use core_external\external_single_structure;
use core_external\external_value;
/**
* Web Service to control the order of a provider instance.
*
* @package core_ai
* @category external
* @copyright Meirza <meirza.arson@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class set_provider_order extends external_api {
/**
* Returns description of method parameters
*
* @return external_function_parameters
*/
public static function execute_parameters(): external_function_parameters {
return new external_function_parameters([
'plugin' => new external_value(PARAM_INT, ' The provider instance ID', VALUE_REQUIRED),
'direction' => new external_value(PARAM_INT, 'The direction to move', VALUE_REQUIRED),
]);
}
/**
* Set the provider instance order.
*
* @param int $providerid The provider instance ID
* @param int $direction The direction to move the provider instance
* @return array
*/
public static function execute(
int $providerid,
int $direction,
): array {
[
'plugin' => $providerid,
'direction' => $direction,
] = self::validate_parameters(self::execute_parameters(), [
'plugin' => $providerid,
'direction' => $direction,
]);
$context = \context_system::instance();
self::validate_context($context);
require_capability('moodle/site:config', $context);
$manager = \core\di::get(\core_ai\manager::class);
$aiproviders = $manager->get_provider_instances(['id' => $providerid]);
$aiprovider = reset($aiproviders);
if ($aiprovider) {
$manager->change_provider_order($providerid, $direction);
}
$directionstring = $direction === \core\plugininfo\aiprovider::MOVE_UP
? \core\plugininfo\aiprovider::UP
: \core\plugininfo\aiprovider::DOWN;
$message = get_string('providermoved' . $directionstring, 'ai', $aiprovider->name);
$messagetype = \core\notification::SUCCESS;
\core\notification::add($message, $messagetype);
return [];
}
/**
* Describe the return structure of the external service.
*
* @return external_single_structure
*/
public static function execute_returns(): external_single_structure {
return new external_single_structure([]);
}
}
+147 -5
View File
@@ -19,7 +19,7 @@ namespace core_ai;
use core\exception\coding_exception;
use core_ai\aiactions\base;
use core_ai\aiactions\responses;
use core\plugininfo\aiprovider as aiproviderplugin;
/**
* AI subsystem manager.
*
@@ -79,7 +79,7 @@ class manager {
*/
public function get_providers_for_actions(array $actions, bool $enabledonly = false): array {
$providers = [];
$instances = $this->get_provider_instances();
$instances = $this->get_sorted_providers();
foreach ($actions as $action) {
$providers[$action] = [];
foreach ($instances as $instance) {
@@ -388,6 +388,11 @@ class manager {
$id = $this->db->insert_record('ai_providers', $provider->to_record());
// Ensure the provider instance order config gets updated if the provider is enabled.
if ($enabled) {
$this->update_provider_order($id, \core\plugininfo\aiprovider::ENABLE);
}
return $provider->with(id: $id);
}
@@ -410,7 +415,7 @@ class manager {
* Get the provider records according to the filter.
*
* @param array|null $filter The filterable elements to get the records from.
* @return \stdClass[]
* @return array
*/
public function get_provider_records(?array $filter = null): array {
return $this->db->get_records(
@@ -428,7 +433,6 @@ class manager {
*
* @param null|array $filter The database filter to apply when fetching provider records.
* @return array An array of instantiated provider objects.
* @throws \dml_exception If there is a database error during record retrieval.
*/
public function get_provider_instances(?array $filter = null): array {
// Filter out any null values from the array (providers that couldn't be instantiated).
@@ -469,7 +473,6 @@ class manager {
* @param array|null $config the configuration of the provider instance to be updated.
* @param array|null $actionconfig the action configuration of the provider instance to be updated.
* @return provider
* @throws \dml_exception
*/
public function update_provider_instance(
provider $provider,
@@ -515,6 +518,7 @@ class manager {
if (!$provider->enabled) {
$provider = $provider->with(enabled: true);
$this->db->update_record('ai_providers', $provider->to_record());
$this->update_provider_order($provider->id, aiproviderplugin::ENABLE);
}
return $provider;
@@ -536,8 +540,146 @@ class manager {
$provider = $provider->with(enabled: false);
$this->db->update_record('ai_providers', $provider->to_record());
}
$this->update_provider_order($provider->id, aiproviderplugin::DISABLE);
}
return $provider;
}
/**
* Sorts provider instances by configured order.
*
* @param array $unsorted of provider instance objects
* @return array of provider instance objects
*/
public static function sort_providers_by_order(array $unsorted): array {
$sorted = [];
$orderarray = explode(',', get_config('core_ai', 'provider_order'));
foreach ($orderarray as $notused => $providerid) {
foreach ($unsorted as $key => $provider) {
if ($provider->id == $providerid) {
$sorted[] = $provider;
unset($unsorted[$key]);
}
}
}
return array_merge($sorted, $unsorted);
}
/**
* Get the configured ai providers from the manager.
*
* @return array
*/
public function get_sorted_providers(): array {
$unsorted = $this->get_provider_instances();
$orders = $this->sort_providers_by_order($unsorted);
$sortedplugins = [];
foreach ($orders as $order) {
$sortedplugins[$order->id] = $unsorted[$order->id];
}
return $sortedplugins;
}
/**
* Change the order of the provider instance relative to other provider instances.
*
* When possible, the change will be stored into the config_log table, to let admins check when/who has modified it.
*
* @param int $providerid The provider ID.
* @param int $direction The direction to move the provider instance. Negative numbers mean up, Positive mean down.
* @return bool Whether the provider has been updated or not.
*/
public function change_provider_order(int $providerid, int $direction): bool {
$activefactors = array_keys($this->get_sorted_providers());
$key = array_search($providerid, $activefactors);
if ($key === false) {
return false;
}
$movedown = ($direction === aiproviderplugin::MOVE_DOWN && $key < count($activefactors) - 1);
$moveup = ($direction === aiproviderplugin::MOVE_UP && $key >= 1);
if ($movedown || $moveup) {
$this->update_provider_order($providerid, $direction);
return true;
}
return false;
}
/**
* Update the provider instance order configuration.
*
* @param int $providerid The provider ID.
* @param string|int $action
*
* @throws dml_exception
*/
public function update_provider_order(int $providerid, string|int $action): void {
$order = explode(',', get_config('core_ai', 'provider_order'));
$key = array_search($providerid, $order);
switch ($action) {
case aiproviderplugin::MOVE_UP:
if ($key >= 1) {
$fsave = $order[$key];
$order[$key] = $order[$key - 1];
$order[$key - 1] = $fsave;
}
break;
case aiproviderplugin::MOVE_DOWN:
if ($key < (count($order) - 1)) {
$fsave = $order[$key];
$order[$key] = $order[$key + 1];
$order[$key + 1] = $fsave;
}
break;
case aiproviderplugin::ENABLE:
if (!$key) {
$order[] = $providerid;
}
break;
case aiproviderplugin::DISABLE:
if ($key) {
unset($order[$key]);
}
break;
}
$this->set_provider_config(['provider_order' => implode(',', $order)], 'core_ai');
\core\session\manager::gc(); // Remove stale sessions.
\core_plugin_manager::reset_caches();
}
/**
* Sets config variable for given provider instance.
*
* @param array $data The data to set.
* @param string $plugin The plugin name.
*
* @return bool true or exception.
* @throws dml_exception
*/
public function set_provider_config(array $data, string $plugin): bool|dml_exception {
$providerconf = get_config($plugin);
foreach ($data as $key => $newvalue) {
if (empty($providerconf->$key)) {
add_to_config_log($key, null, $newvalue, $plugin);
set_config($key, $newvalue, $plugin);
} else if ($providerconf->$key != $newvalue) {
add_to_config_log($key, $providerconf->$key, $newvalue, $plugin);
set_config($key, $newvalue, $plugin);
}
}
return true;
}
}
@@ -20,6 +20,7 @@ use context_system;
use core_table\dynamic as dynamic_table;
use flexible_table;
use moodle_url;
use html_writer;
/**
* Table to manage AI provider plugins.
@@ -34,6 +35,9 @@ class aiprovider_management_table extends flexible_table implements dynamic_tabl
*/
protected array $aiproviders = [];
/** @var int The number of enabled provider instances. */
protected int $enabledprovidercount = 0;
/**
* Constructor for the AI provider table.
*/
@@ -46,6 +50,10 @@ class aiprovider_management_table extends flexible_table implements dynamic_tabl
$this->setup();
$tableclasses = $this->attributes['class'] . ' ' . $this->get_table_id();
$this->set_attribute('class', $tableclasses);
$this->enabledprovidercount = count(array_filter($this->aiproviders, function ($provider) {
return $provider->enabled;
}));
}
/**
@@ -135,11 +143,7 @@ class aiprovider_management_table extends flexible_table implements dynamic_tabl
* @return array
*/
protected function get_providers(): array {
$providers = \core\di::get(\core_ai\manager::class)->get_provider_records();
if (!empty($providers)) {
\core_collator::asort_objects_by_property($providers, 'id');
}
return $providers;
return \core\di::get(\core_ai\manager::class)->get_sorted_providers();
}
/**
@@ -166,6 +170,7 @@ class aiprovider_management_table extends flexible_table implements dynamic_tabl
'name' => get_string('name'),
'provider' => get_string('provider', 'core_ai'),
'enabled' => get_string('pluginenabled', 'core_plugin'),
'order' => get_string('order', 'core'),
'settings' => get_string('settings', 'core'),
'delete' => get_string('delete'),
];
@@ -305,4 +310,112 @@ class aiprovider_management_table extends flexible_table implements dynamic_tabl
];
return $OUTPUT->render_from_template('core_ai/admin_delete_provider', $params);
}
/**
* Get the web service method used to order provider instances.
*
* @return null|string
*/
protected function get_sortorder_service(): ?string {
return 'core_ai_set_provider_order';
}
/**
* Generates the HTML for the order column with up and down controls.
*
* @param \stdClass $row An object representing a row of data.
* @return string The HTML string for the order controls, or an empty string if no controls are needed.
*/
protected function col_order(\stdClass $row): string {
global $OUTPUT;
if (!$row->enabled) {
return '';
}
if ($this->enabledprovidercount <= 1) {
// There is only one row.
return '';
}
$hasup = true;
$hasdown = true;
if (empty($this->currentrow)) {
// This is the top row.
$hasup = false;
}
if ($this->currentrow === ($this->enabledprovidercount - 1)) {
// This is the last row.
$hasdown = false;
}
$dataattributes = [
'data-method' => $this->get_sortorder_service(),
'data-action' => 'move',
'data-plugin' => $row->id,
];
if ($hasup) {
$upicon = html_writer::link(
$this->get_base_action_url([
'sesskey' => sesskey(),
'action' => \core\plugininfo\aiprovider::UP,
'id' => $row->id,
]),
$OUTPUT->pix_icon(
pix: 't/up',
alt: '',
),
array_merge($dataattributes, [
'data-direction' => \core\plugininfo\aiprovider::UP,
'role' => 'button',
'aria-label' => get_string('moveitemup', 'core', $row->name),
'title' => get_string('moveitemup', 'core', $row->name),
'class' => 'btn btn-link btn-icon pt-2 pl-2',
]),
);
} else {
$upicon = '';
}
if ($hasdown) {
$downicon = html_writer::link(
$this->get_base_action_url([
'sesskey' => sesskey(),
'action' => \core\plugininfo\aiprovider::DOWN,
'id' => $row->id,
]),
$OUTPUT->pix_icon(
pix: 't/down',
alt: '',
),
array_merge($dataattributes, [
'data-direction' => \core\plugininfo\aiprovider::DOWN,
'role' => 'button',
'aria-label' => get_string('moveitemdown', 'core', $row->name),
'title' => get_string('moveitemdown', 'core', $row->name),
'class' => 'btn btn-link btn-icon pt-2 pl-2',
]),
);
} else {
$downicon = '';
}
$spacer = ($hasup && $hasdown) ? $OUTPUT->spacer() : '';
return html_writer::div($upicon . $spacer . $downicon, '', ['class' => 'w-25 d-flex justify-content-center']);
}
/**
* Get the action URL for this table.
*
* The action URL is used to perform all actions when JS is not available.
*
* @param array $params
* @return moodle_url
*/
protected function get_base_action_url(array $params = []): moodle_url {
return new moodle_url('/ai/configure_providers.php', $params);
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Configure provider instance order settings.
*
* @package core_ai
* @copyright Meirza <meirza.arson@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
require_once(__DIR__ . '../../config.php');
require_login(autologinguest: false);
require_capability('moodle/site:config', context_system::instance());
require_sesskey();
$PAGE->set_url('/ai/configure_providers.php');
$action = optional_param('action', '', PARAM_ALPHA);
$id = optional_param('id', '', PARAM_INT);
$manager = \core\di::get(\core_ai\manager::class);
$providerrecord = $manager->get_provider_records(filter: ['id' => $id]);
$returnurl = new moodle_url('/admin/settings.php?section=aiprovider');
if (empty($providerrecord) || !$providerrecord) {
throw new moodle_exception('error:providernotfound', 'core_ai', $returnurl);
}
if (empty($action) || !in_array($action, \core\plugininfo\aiprovider::get_provider_actions())) {
throw new moodle_exception('error:actionnotfound', 'core_ai', $returnurl, $action);
}
switch ($action) {
case \core\plugininfo\aiprovider::UP:
$manager->change_provider_order($id, \core\plugininfo\aiprovider::MOVE_UP);
break;
case \core\plugininfo\aiprovider::DOWN:
$manager->change_provider_order($id, \core\plugininfo\aiprovider::MOVE_DOWN);
break;
}
redirect($returnurl);
+63
View File
@@ -0,0 +1,63 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace core_ai\external;
/**
* Test provider order external api calls.
*
* @package core_ai
* @copyright Meirza <meirza.arson@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @covers \core_ai\external\set_provider_order
*/
final class provider_order_test extends \advanced_testcase {
/**
* Test set provider order.
*/
public function test_set_provider_order(): void {
$this->resetAfterTest();
// Create the provider instances.
$manager = \core\di::get(\core_ai\manager::class);
$openai = $manager->create_provider_instance(
classname: '\aiprovider_openai\provider',
name: 'openai instance',
enabled: true, // Must be set to true to activate the sort function.
config: ['data' => 'openai configurations'],
);
$azureai = $manager->create_provider_instance(
classname: '\aiprovider_azureai\provider',
name: 'azureai instance',
enabled: true, // Must be set to true to activate the sort function.
config: ['data' => 'azureai configurations'],
);
$this->setAdminUser();
// Move the OpenAI instance to the bottom, and AzureAI will automatically move to the top.
set_provider_order::execute($openai->id, \core\plugininfo\aiprovider::MOVE_DOWN);
$providers = array_keys($manager->get_sorted_providers());
$this->assertEquals($providers[0], $azureai->id);
$this->assertEquals($providers[1], $openai->id);
// Move the OpenAI instance to the top, and AzureAI will automatically move to the bottom.
set_provider_order::execute($openai->id, \core\plugininfo\aiprovider::MOVE_UP);
$providers = array_keys($manager->get_sorted_providers());
$this->assertEquals($providers[0], $openai->id);
$this->assertEquals($providers[1], $azureai->id);
}
}
+26 -1
View File
@@ -280,7 +280,7 @@ final class manager_test extends \advanced_testcase {
);
$config['apiendpoint'] = 'https://example.com';
$manager->create_provider_instance(
$provider2 = $manager->create_provider_instance(
classname: '\aiprovider_azureai\provider',
name: 'dummy2',
enabled: true,
@@ -312,6 +312,31 @@ final class manager_test extends \advanced_testcase {
// Assert that there is no provider for the generate text action.
$this->assertCount(1, $providers[generate_text::class]);
$this->assertCount(2, $providers[summarise_text::class]);
// Ordering the provider instances.
// Re-enable the generate text action for the Openai provider.
$manager->set_action_state(
plugin: $provider1->provider,
actionbasename: generate_text::class::get_basename(),
enabled: 1,
instanceid: $provider1->id,
);
// Move the $provider2 to the first provider for the generate text action.
$manager->change_provider_order($provider2->id, \core\plugininfo\aiprovider::MOVE_UP);
// Get the new providers for the actions.
$providers = $manager->get_providers_for_actions($actions);
// Assert whether provider2 is the first provider and provider1 is the last provider for the generate text action.
$this->assertEquals($providers[generate_text::class][0], $provider2);
$this->assertEquals($providers[generate_text::class][1], $provider1);
// Move the $provider2 to the last provider for the generate text action.
$manager->change_provider_order($provider2->id, \core\plugininfo\aiprovider::MOVE_DOWN);
// Get the new providers for the actions.
$providers = $manager->get_providers_for_actions($actions);
// Assert whether provider1 is the first provider and provider2 is the last provider for the generate text action.
$this->assertEquals($providers[generate_text::class][0], $provider1);
$this->assertEquals($providers[generate_text::class][1], $provider2);
}
/**
+5 -1
View File
@@ -88,7 +88,9 @@ $string['declineaipolicy'] = 'Decline';
$string['enableglobalratelimit'] = 'Set site-wide rate limit';
$string['enableglobalratelimit_help'] = 'Limit the number of requests that the AI provider can receive across the entire site every hour.';
$string['enableuserratelimit'] = 'Set user rate limit';
$string['enableuserratelimit_help'] = 'Limit the number of requests each user can make to the AI provider every hour.';
$string['enableuserratelimit_help'] = 'Limit the number of requests each user can make to the OpenAI API provider every hour.';
$string['error:actionnotfound'] = 'Action \'{$a}\' is not supported.';
$string['error:providernotfound'] = 'The AI provider instance is not found.';
$string['globalratelimit'] = 'Maximum number of site-wide requests';
$string['globalratelimit_help'] = 'The number of site-wide requests allowed per hour.';
$string['manageaiplacements'] = 'Manage AI placements';
@@ -153,6 +155,8 @@ $string['providerinstancedeleted'] = '{$a} AI provider instance deleted.';
$string['providerinstancedeletefailed'] = 'Cannot delete the {$a} AI provider instance. The provider is either in use or there is a database issue. Check if the provider is active or contact your database administrator for help.';
$string['providerinstancedisablefailed'] = 'Cannot disable the AI provider instance. The provider is either in use or there is a database issue. Check if the provider is active or contact your database administrator for help.';
$string['providerinstanceupdated'] = '{$a} AI provider instance updated.';
$string['providermoveddown'] = '{$a} moved down';
$string['providermovedup'] = '{$a} moved up';
$string['providername'] = 'Name for instance';
$string['providers'] = 'Providers';
$string['providersettings'] = 'Settings';
+2
View File
@@ -1446,6 +1446,8 @@ $string['movedown'] = 'Move down';
$string['movefilestohere'] = 'Move files to here';
$string['movefull'] = 'Move {$a} to this location';
$string['movehere'] = 'Move to here';
$string['moveitemdown'] = 'Move {$a} down';
$string['moveitemup'] = 'Move {$a} up';
$string['moveleft'] = 'Move left';
$string['moremenu'] = 'More';
$string['moreactions'] = 'More actions';
+21
View File
@@ -27,6 +27,18 @@ use moodle_url;
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class aiprovider extends base {
/** @var string Enable a plugin */
public const ENABLE = 'enable';
/** @var string Disable a plugin */
public const DISABLE = 'disable';
/** @var string Move a plugin up in the plugin order */
public const UP = 'up';
/** @var string Move a plugin down in the plugin order */
public const DOWN = 'down';
#[\Override]
public function is_uninstall_allowed(): bool {
return true;
@@ -65,4 +77,13 @@ class aiprovider extends base {
}
return $enabled;
}
/**
* Returns the list of available actions with provider.
*
* @return array
*/
public static function get_provider_actions(): array {
return [self::UP, self::DOWN];
}
}
+6
View File
@@ -3292,6 +3292,12 @@ $functions = array(
'type' => 'write',
'ajax' => true,
],
'core_ai_set_provider_order' => [
'classname' => \core_ai\external\set_provider_order::class,
'description' => 'Set the order of a provider',
'type' => 'write',
'ajax' => true,
],
'core_sms_set_gateway_status' => [
'classname' => 'core_sms\external\sms_gateway_status',
'description' => 'Set the sms gateway status',
+11
View File
@@ -1464,5 +1464,16 @@ function xmldb_main_upgrade($oldversion) {
upgrade_main_savepoint(true, 2025022100.02);
}
if ($oldversion < 2025030400.01) {
$providers = $DB->get_records('ai_providers', ['enabled' => 1]);
// Formatting the value.
$value = ','. implode(',', array_column($providers, 'id'));
// Create the order config setting.
set_config('provider_order', $value, 'core_ai');
// Main savepoint reached.
upgrade_main_savepoint(true, 2025030400.01);
}
return true;
}
+1 -1
View File
@@ -29,7 +29,7 @@
defined('MOODLE_INTERNAL') || die();
$version = 2025030400.00; // YYYYMMDD = weekly release date of this DEV branch.
$version = 2025030400.01; // YYYYMMDD = weekly release date of this DEV branch.
// RR = release increments - 00 in DEV branches.
// .XX = incremental changes.
$release = '5.0dev+ (Build: 20250304)'; // Human-friendly version name