ÿØÿà JFIF    ÿÛ „  ( %!1"%)+...383-7(-.+  -%%--------+------------/----------------------------ÿÀ  ¶" ÿÄ     ÿÄ @    !1AQaq‘¡"±ÁÑð2’BRráS‚ñ3b¢CT²Ò #cÿÄ    ÿÄ '   1!AQ2a"‘±#ÿÚ   ? ò(/p‘—–¢·,M|¯G»R£üešT. ôŸEoCFñ‡ 4ÔkåóxR|µÁ¨ÓDà!Ç´µ˜‡p ÍQ‹§!þž^õWsŒy  åÁ§$á«%ºq˜™JyyË0y«^Ϗ -3ÃÏËÈ£Æ\P’ä„s&•‹Üs üô'àWt¹pñCp$bÚF`·7.^)¾!)ÒÙ˜–ÅÏš^h¾ø³·ìe±ŒaeÖ—Í,e=B}Jéߦ$$½—šÙÜCC¯¶è8β–Åkèwx LÉngT±æ¹£Ócœ¿Í/©Éÿ ‰ÚCÒ­mq;Tt¶–}0"zÖPcd1š­¤tˆ„ÐÓâqú[™öUCŠ2޿د­É(¸ßúçšmW,Ò ºf‹Ån™ÄLêÁ8üÁkÁ€L¥ÍuJ1œôrÂSŒÓöt!7EUíïb)AËi¬â-ÙHk8òÁiXYwÅ)O^"ÇØriÛô{»wRUKÙÏÚlÓ¬€‚’שT ] ªb—ÊîKC^×M“xùäñz¬zJ× {4[¹M'IÆeH1LCWÕ]œûº WDæà°OÅ‚¹c²ÞuA–rL`HšpCez‡V’— Ž‚Ë„–ñ­'µR0ÕÖ=À:±G»C\nÆÉ5*¢ †3šhQ4ýÒi$Õ1"Ü]¢p-q3zcQ$ªö¨¥æf›‘»´ÝÚHá„e²JÇ–iÊ:·à¬Øc5Åk»Laª*wi [0Ówk ¦a¦0Õ²ÅÅ‚T¸’³q:Æ<¹ú Ò˜Æ}2VE•Ѐp¡Ô3Úº …h³^LJºçì¥Ánó|˜ÖˆÎˆ+« ¦dø‘Mº–d\ªDðÛ„Ì×LÝ——Ð9Ç|•FèçVm™¨€™3õS–9Äå¢<̉Ìm¢±`·º˜&Z²ž Ë®D«¶»6™‰©­HÈ Àá«h­i³04æ’hxœi3ï¨(ÓL·†‹¶ËpŒ ƒÜÒ? :õdsÀ –3æÓ·Üjâ¬9—]8n¼&]I F£¼…ì–—Œ0v°LǐÅfÛ2TÖ’aµ˜N@ÖBSÏ^à´4-±°b×nÈÈœÆD=KdŒø…ÄÈK,›ÖR§fjü$^ÔÎ!„6^ s:Üun\ôkCÞò÷<—Lúl Í›G¹ÀPá¼Nc¡šÒ…ÙWͳœ±vÊáЄ×9‹P‰cEØ‘™›µŸP<×g1 º©Ø¬wiÁ^l5× I/';Ë+´Z‡ÓÄFÕp[Èð’ HÄ ó 9¬Dl4“é±Ï”V=^Hð;žLädÉa‚°Ë,#/Ž~²BÔÛ QãñQtIeórVB,ÏÂf‹4#Μc‹É3ZIÖ Œ¢¥·üxa‡Ãçè„jŠ[5 Ä!U?,3žÎ×…ðdÓ÷háŠA‰ìÄ4÷ÃÜZÃE{‰®+W\ZÍE[‰»µk»MÝ­`¢©†˜ÃV‹-f¢¡†¢a«eŠ%«Y¨©q%jâd,Ôq`) ¤¦…šˆ ¦E>0Ùb²t–Ÿ  ÷jsςҒKÈcø(é›;k(’#ôŒQ ™C> õ³M9óºÆµ¤çâ;uÀ)5>RÔ¸fÓ~Ȧ—’-$fe«"FâUˆÊwµPÏu w+"åÉó¨6s™Û9(À¨ù©hE°½†n`m~“@©›p!Vkhµ Á1šbEqqÊ@ºèl›2 –Yã%‘‚hÛ{a¶‡ÅÞM²¬çL34®Çl]®‹ŽØŒm'RÜe35æöˆ­Á­ cP9šeÁköo´L³Ì=p8]”‡B¶,šº|ɏdzXˆØk*ÇÚ{#ñ‰pã(€¶‡9ý'šÜ³½¯kƒ†Â’ëSOƒ•Å®H6+a¢µˆ‹Y¨b˜b+XˆÖ!°u!¢"µˆˆn2ˆ Å0Ä`Å Ô®c(ÓÜGº•Ä»Œ âWî%q.áÐÄ÷î%umÍ£+ÜQ,Vn¦-GphU¸¢X­&,Gt ·K¢ÅÅ·¥RÄÊÉbKn N­R2g$VµÕó Qr#–ÓZUÏð0ÎtÙÀœz ëöo“Îk¯n„T°"SÞ+³/…€½"ižãO%Í%&Δâ‘ËØ´IˆÓ°Ë‰ÞM3.™yçÁzit›`4C`k¢¼á!álÄï sÂ[×!¤l¾æo"ƒIÔ'@ªu)3›ô®“@Z à¾] üœ'‡4¸PÞkÁ-qÄH‰ÓÍBd´ÿ —¹°ÄA&ÊUC„öz‚‚RæŠtf¬>˜ÑidšEçÑL÷áý¡V±h§­c¿¥¨ƒ@g¼…Ýö^ÞËc.ž„æ“I[ ª+·zÐÒÚ,½Ð»¶€C›7jk$๖€Ž¾ÅÙpp¶ÍþéΤ ½ØÎŒq ñpæžÅ¥#Øâ49ÃÂK^MYV“,¶Éz_òæ:…;Õ«¦I3âJ£¤û5$7Èxün\â]Êe¯(Ÿ&Ž‹¶2<6Ćf× æ¢ƱqWı[(ÙÂŽç‚T7¨@ÈÉÍß‚ïÙ"&3TYlGށ†)†#5ŠA©^T2ÆÁ†)©†©†¤yGXÁ†©"†)©¼ÅV WS†#©]R–r‹q5ÕbâWþBh¯u+ªÅÄ×ü„nÑ^êkªÁj‰jeœWˆÕÕ`µDµ2Ì+Ä µDµµ5Ô{¤Þ0RF’H÷AÛ<ÊÁ§ ƈæ0Ñ£ê4 öÚµ[¬/1²€â<-¬šö²r×0o×w ´xÐÒË„ g01¬„åEM蛇Ã\6µE›‘fÙO k÷CºnÓ1`iîÒ² áÂ!ÑLÄÿ ³¬\õ*:M=Ç»c‹2[‰ØsÕLò\ŒK L^™üÄN\¶#» Ç\……ßïIœÉ$¸ÎñÍ®ÔHÇ Ûí/´>Li”„†g^$ 7NÍÑÑ2M2ªNg=k½Ð}Ÿl6‡ —JWˆ”ÞéT·+µ¦üÂFè¢Vs6ýÆ4»2+2IÝ9ô9®ÇGÙÇ1+Ä8;*Õ§ž½D*îÑWÚçá3Ma¤·ˆ¥t}š²Ü…Q+Æüµ8HíS”üxGéÁˆ¦Ël„éÈ 5 µÄƒ6äÜ(kIš¯S†fÖ&¸®×v|Äyˆ*禌Éù@7-®Ä[]ÎÖ¿ê`º…=:…žJVnݳvðÖKE¼‚ »mà ÷f®:Ë ™–4rð¡4` ܹçž~‹C=œ…¾Ïj|VDtK.ƒ"9 Ï ­–éXá³6WÒïIL-Ї Ç PK*\5 |configure_providers.phpnu[. /** * Configure provider instance order settings. * * @package core_ai * @copyright Meirza * @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); PK*\A77classes/ai_image.phpnu[. namespace core_ai; use core\exception\moodle_exception; /** * AI Image. * * @package core_ai * @copyright 2024 Huong Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class ai_image { /** @var array Image information. */ private array $imageinfo; /** @var false|\GdImage Image object. */ private false|\GDImage $imgobject; /** * Constructor for the image processing class. * * Initializes the class with the provided image path, setting up the image object and its properties. * The constructor checks if the GD library functions for PNG and JPEG are available, ensures the image file * exists and is readable, and then creates an image resource object based on the file type (JPEG, PNG, or GIF). * * @param string $imagepath The path to the image file. */ public function __construct( /** @var string Image path. */ private string $imagepath, ) { ini_set('gd.jpeg_ignore_warning', 1); if (!file_exists($imagepath) || !is_readable($imagepath)) { throw new moodle_exception('invalidfile', debuginfo: $imagepath); } $imageinfo = getimagesize($imagepath); if (empty($imageinfo)) { throw new moodle_exception('invalidfile', debuginfo: $imagepath); } $this->imageinfo = $imageinfo; switch ($this->imageinfo['mime']) { case 'image/jpeg': if (!function_exists('imagecreatefromjpeg')) { throw new moodle_exception('gdfeaturenotsupported', a: 'jpeg'); } $this->imgobject = imagecreatefromjpeg($imagepath); break; case 'image/png': if (!function_exists('imagecreatefrompng')) { throw new moodle_exception('gdfeaturenotsupported', a: 'png'); } $this->imgobject = imagecreatefrompng($imagepath); break; case 'image/gif': if (!function_exists('imagecreatefromgif')) { throw new moodle_exception('gdfeaturenotsupported', a: 'gif'); } $this->imgobject = imagecreatefromgif($imagepath); break; default: throw new moodle_exception('gdmimetypenotsupported', debuginfo: $this->imageinfo['mime']); } } /** * Get the predominant color of a specific area of the image. * * This method analyzes a rectangular area of the image, calculating the * average color by summing up the red, green, and blue components of all pixels * within the area, and then dividing by the total number of pixels. * * @param int $x X coordinate of the top-left corner of the area. * @param int $y Y coordinate of the top-left corner of the area. * @param int $width Width of the area. * @param int $height Height of the area. * @return array RGB array of the predominant color, with keys 'red', 'green', and 'blue'. */ private function get_predominant_color(int $x, int $y, int $width, int $height): array { // If the width or height is smaller than 10 pixels, sample the entire image. if (imagesx($this->imgobject) < 10 || imagesy($this->imgobject) < 10) { $x = 0; $y = 0; $width = imagesx($this->imgobject); $height = imagesy($this->imgobject); } // Initialize variables to accumulate the total red, green, and blue values. $redtotal = $greentotal = $bluetotal = 0; // Initialize a counter for the number of pixels processed. $pixelcount = 0; // Iterate over each pixel within the specified area of the image. for ($i = $x; $i < $x + $width; $i++) { for ($j = $y; $j < $y + $height; $j++) { // Retrieve the color index of the current pixel. $rgb = imagecolorat( image: $this->imgobject, x: $i, y: $j); // Extract the red component (shift the bits 16 places to the right and mask the rest). $red = ($rgb >> 16) & 0xFF; // Extract the green component (shift the bits 8 places to the right and mask the rest). $green = ($rgb >> 8) & 0xFF; // Extract the blue component (mask directly to get the blue value). $blue = $rgb & 0xFF; // Accumulate the red, green, and blue values. $redtotal += $red; $greentotal += $green; $bluetotal += $blue; // Increment the pixel counter. $pixelcount++; } } // Calculate the average red, green, and blue values by dividing the total by the number of pixels. return [ 'red' => $redtotal / $pixelcount, 'green' => $greentotal / $pixelcount, 'blue' => $bluetotal / $pixelcount, ]; } /** * Determine if the color is dark based on its RGB values. * * This method uses a formula to calculate the luminance of a color. * Luminance is a weighted sum of the red, green, and blue components, with green having the highest weight * because the human eye is more sensitive to green. * A luminance value below 128 is generally considered dark. * * @param array $color RGB array with keys 'red', 'green', and 'blue'. * @return bool True if the color is dark, false if it is light. */ private function is_color_dark(array $color): bool { // Calculate the luminance using the standard formula. // Luminance = 0.299 * Red + 0.587 * Green + 0.114 * Blue. // The coefficients correspond to the human eye's sensitivity to these colors. $luminance = (0.299 * $color['red'] + 0.587 * $color['green'] + 0.114 * $color['blue']); // Return true if the luminance is below 128 (dark), otherwise return false (light). return $luminance < 128; } /** * Draw a pill-shaped rounded rectangle. * The pill is composed of two half circles and a single rectangle. * * @param int $x1 Top-left X coordinate of the rectangle. * @param int $y1 Top-left Y coordinate of the rectangle. * @param int $x2 Bottom-right X coordinate of the rectangle. * @param int $y2 Bottom-right Y coordinate of the rectangle. * @param int $radius Radius of the rounded corners (half the pill height). * @param int $color Color for the pill background. */ private function draw_rounded_rectangle( int $x1, int $y1, int $x2, int $y2, int $radius, int $color ): void { // Draw two half circles at the ends of the pill. // Left half circle. imagefilledarc( image: $this->imgobject, center_x: $x1 + $radius, // Center X coordinate. center_y: ($y1 + $y2) / 2, // Center Y coordinate. width: $radius * 2, // Width of the circle (diameter). height: $radius * 2, // Height of the circle (diameter). start_angle: 90, end_angle: 270, color: $color, style: IMG_ARC_PIE ); // Right half circle. imagefilledarc( image: $this->imgobject, center_x: $x2 - $radius, center_y: ($y1 + $y2) / 2, width: $radius * 2, height: $radius * 2, start_angle: 270, end_angle: 90, color: $color, style: IMG_ARC_PIE ); // Draw the rectangle joining the two half circles. imagefilledrectangle( image: $this->imgobject, x1: $x1 + $radius, // Start after the left half circle. y1: $y1, // Top of the rectangle. x2: $x2 - $radius, // End before the right half circle. y2: $y2, // Bottom of the rectangle. color: $color ); } /** * Add watermark to image. * * @param string $watermark Watermark text. * @param array $options Watermark options. * @param array $pos Watermark position. * @return $this */ public function add_watermark( string $watermark = '', array $options = [], array $pos = [10, 10], ): static { global $CFG; if (empty($watermark)) { $watermark = get_string('contentwatermark', 'core_ai'); } if (empty($options)) { $options = [ 'font' => $CFG->libdir . '/default.ttf', 'fontsize' => '20', 'angle' => 0, 'ttf' => true, ]; } $imagewidth = imagesx($this->imgobject); $imageheight = imagesy($this->imgobject); // Determine the size of the area to analyze: 10% of the image width and height. $areawidth = (int)($imagewidth * 0.1); $areaheight = (int)($imageheight * 0.1); // Dynamically calculate the bottom-left corner coordinates. $bottomleftcolor = $this->get_predominant_color( x: 0, y: $imageheight - $areaheight, width: $areawidth, height: $areaheight ); // Set text color based on the background color. if ($this->is_color_dark($bottomleftcolor)) { $clr = imagecolorallocate( // White for dark background. image: $this->imgobject, red: 255, green: 255, blue: 255 ); $bgclr = imagecolorallocatealpha( // Black (80% transparent). image: $this->imgobject, red: 0, green: 0, blue: 0, alpha: (int)(127 * 0.2) ); } else { $clr = imagecolorallocate( // Black for light background. image: $this->imgobject, red: 0, green: 0, blue: 0 ); $bgclr = imagecolorallocatealpha( // White (80% transparent). image: $this->imgobject, red: 255, green: 255, blue: 255, alpha: (int)(127 * 0.2) ); } // Encode the text properly. $text = iconv( from_encoding: 'ISO-8859-8', to_encoding: 'UTF-8', string: $watermark ); // Calculate text bounding box for determining pill siz), different for TTF and non-TTF fonts. if (!empty($options['ttf'])) { // For TTF fonts, use imagettfbbox to get the text's bounding box. $bbox = imagettfbbox($options['fontsize'], $options['angle'], $options['font'], $text); $textwidth = abs($bbox[4] - $bbox[0]); $textheight = abs($bbox[5] - $bbox[1]); } else { // For non-TTF fonts, use imagefontwidth and imagefontheight. $textwidth = strlen($text) * imagefontwidth($options['fontsize']); $textheight = imagefontheight($options['fontsize']); } // Pill background dimensions. $padding = 10; $pillwidth = $textwidth + $padding * 2; $pillheight = $textheight + $padding * 2; // Position for the pill background. $x = $pos[0]; $y = $imageheight - ($pos[1] + $pillheight); // Adjust Y based on the pill height. // Draw the pill background. $this->draw_rounded_rectangle( x1: $x, y1: $y, x2: $x + $pillwidth, y2: $y + $pillheight, radius: $pillheight / 2, color: $bgclr ); // Correct the position of the text to center it inside the pill. $textx = $x + (($pillwidth - $textwidth) / 2); // Center text horizontally in the pill. $texty = $y + ((($pillheight - $textheight) / 2) * .75) + $textheight; // Center vertically, adjusting for baseline. // Draw the text on top of the pill background. if (!empty($options['ttf'])) { imagettftext( image: $this->imgobject, size: $options['fontsize'], angle: $options['angle'], x: (int)$textx, y: (int)$texty, color: $clr, font_filename: $options['font'], text: $text, ); } else { imagestring( image: $this->imgobject, font: $options['fontsize'], x: (int)$textx, y: (int)$texty, string: $text, color: $clr, ); } return $this; } /** * Save image. * * @param string $newpath New path to save image. * @return bool Whether the save was successful */ public function save(string $newpath = ''): bool { if (empty($newpath)) { $newpath = $this->imagepath; } switch($this->imageinfo['mime']) { case 'image/jpeg': return imagejpeg(image: $this->imgobject, file: $newpath); case 'image/png': return imagepng(image: $this->imgobject, file: $newpath); case 'image/gif': return imagegif(image: $this->imgobject, file: $newpath); default: return false; } } } PK*\A,,;classes/reportbuilder/local/entities/ai_action_register.phpnu[. namespace core_ai\reportbuilder\local\entities; use core\di; use core_ai\manager; use core\component; use core_reportbuilder\local\entities\base; use core_reportbuilder\local\filters\boolean_select; use core_reportbuilder\local\filters\date; use core_reportbuilder\local\filters\number; use core_reportbuilder\local\filters\select; use core_reportbuilder\local\helpers\format; use core_reportbuilder\local\report\column; use core_reportbuilder\local\report\filter; use core\output\help_icon; use lang_string; /** * AI action register entity. * * Defines all the columns and filters that can be added to reports that use this entity. * * @package core_ai * @copyright 2024 David Woloszyn * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class ai_action_register extends base { #[\Override] protected function get_default_tables(): array { return [ 'ai_action_register', ]; } #[\Override] protected function get_default_entity_title(): lang_string { return new lang_string('aiactionregister', 'core_ai'); } #[\Override] public function initialise(): base { $columns = $this->get_all_columns(); foreach ($columns as $column) { $this->add_column($column); } // All the filters defined by the entity can also be used as conditions. $filters = $this->get_all_filters(); foreach ($filters as $filter) { $this ->add_filter($filter) ->add_condition($filter); } return $this; } /** * Returns list of all available columns. * * @return column[] */ protected function get_all_columns(): array { $mainalias = $this->get_table_alias('ai_action_register'); $generatetextalias = 'aagt'; $summarisetextalias = 'aast'; $explaintextalias = 'aaet'; // Action name column. $columns[] = (new column( 'actionname', new lang_string('action', 'core_ai'), $this->get_entity_name(), )) ->add_joins($this->get_joins()) ->set_type(column::TYPE_TEXT) ->add_field("{$mainalias}.actionname") ->set_is_sortable(true) ->add_callback(static function(string $actionname): string { return get_string("action_{$actionname}", 'core_ai'); }); // Provider column. $columns[] = (new column( 'provider', new lang_string('provider', 'core_ai'), $this->get_entity_name(), )) ->add_joins($this->get_joins()) ->set_type(column::TYPE_TEXT) ->add_field("{$mainalias}.provider") ->set_is_sortable(true) ->add_callback(static function(string $provider): string { if (get_string_manager()->string_exists('pluginname', $provider)) { return get_string('pluginname', $provider); } else { // Return as is if the lang string does not exist. return $provider; } }); // Success column. $columns[] = (new column( 'success', new lang_string('success', 'moodle'), $this->get_entity_name(), )) ->add_joins($this->get_joins()) ->set_type(column::TYPE_BOOLEAN) ->add_field("{$mainalias}.success") ->set_is_sortable(true) ->set_callback([format::class, 'boolean_as_text']); // Time created column. $columns[] = (new column( 'timecreated', new lang_string('timegenerated', 'core_ai'), $this->get_entity_name(), )) ->add_joins($this->get_joins()) ->set_type(column::TYPE_TIMESTAMP) ->add_field("{$mainalias}.timecreated") ->set_is_sortable(true) ->add_callback([format::class, 'userdate']); // Prompt tokens column. // Only available for summarise_text, generate_text actions and explain_text actions. $columns[] = (new column( 'prompttokens', new lang_string('prompttokens', 'core_ai'), $this->get_entity_name(), )) ->add_joins($this->get_joins()) ->add_join(" LEFT JOIN {ai_action_generate_text} {$generatetextalias} ON {$mainalias}.actionid = {$generatetextalias}.id AND {$mainalias}.actionname = 'generate_text'") ->add_join(" LEFT JOIN {ai_action_summarise_text} {$summarisetextalias} ON {$mainalias}.actionid = {$summarisetextalias}.id AND {$mainalias}.actionname = 'summarise_text'") ->add_join(" LEFT JOIN {ai_action_explain_text} {$explaintextalias} ON {$mainalias}.actionid = {$explaintextalias}.id AND {$mainalias}.actionname = 'explain_text'") ->set_type(column::TYPE_INTEGER) ->add_field("COALESCE({$generatetextalias}.prompttokens, {$summarisetextalias}.prompttokens, {$explaintextalias}.prompttokens)", 'prompttokens') ->set_is_sortable(true) ->set_help_icon(new help_icon('prompttokens', 'core_ai')) ->add_callback(static function(?int $value): string { return $value ?? get_string('unknownvalue', 'core_ai'); }); // Completion tokens column. // Only available for summarise_text, generate_text actions and explain_text actions. $columns[] = (new column( 'completiontokens', new lang_string('completiontokens', 'core_ai'), $this->get_entity_name(), )) ->add_joins($this->get_joins()) ->add_join(" LEFT JOIN {ai_action_generate_text} {$generatetextalias} ON {$mainalias}.actionid = {$generatetextalias}.id AND {$mainalias}.actionname = 'generate_text'") ->add_join(" LEFT JOIN {ai_action_summarise_text} {$summarisetextalias} ON {$mainalias}.actionid = {$summarisetextalias}.id AND {$mainalias}.actionname = 'summarise_text'") ->add_join(" LEFT JOIN {ai_action_explain_text} {$explaintextalias} ON {$mainalias}.actionid = {$explaintextalias}.id AND {$mainalias}.actionname = 'explain_text'") ->set_type(column::TYPE_INTEGER) ->add_field("COALESCE({$generatetextalias}.completiontoken, {$summarisetextalias}.completiontoken, {$explaintextalias}.completiontoken)", 'completiontokens') ->set_is_sortable(true) ->set_help_icon(new help_icon('completiontokens', 'core_ai')) ->add_callback(static function(?int $value): string { return $value ?? get_string('unknownvalue', 'core_ai'); }); return $columns; } /** * Return list of all available filters. * * @return filter[] */ protected function get_all_filters(): array { $mainalias = $this->get_table_alias('ai_action_register'); $generatetextalias = 'aagt'; $summarisetextalias = 'aast'; $explaintextalias = 'aaet'; // Action name filter. $filters[] = (new filter( select::class, 'actionname', new lang_string('action', 'core_ai'), $this->get_entity_name(), "{$mainalias}.actionname", )) ->add_joins($this->get_joins()) ->set_options([ 'explain_text' => new lang_string('action_explain_text', 'core_ai'), 'generate_image' => new lang_string('action_generate_image', 'core_ai'), 'generate_text' => new lang_string('action_generate_text', 'core_ai'), 'summarise_text' => new lang_string('action_summarise_text', 'core_ai'), ]); // Provider filter. $filters[] = (new filter( select::class, 'provider', new lang_string('provider', 'core_ai'), $this->get_entity_name(), "{$mainalias}.provider", )) ->add_joins($this->get_joins()) ->set_options_callback(static function(): array { $providers = []; $records = di::get(manager::class)->get_provider_records(); foreach ($records as $record) { $component = component::get_component_from_classname($record->provider); $providers[$component] = get_string('pluginname', $component); } return $providers; }); // Time created filter. $filters[] = (new filter( date::class, 'timecreated', new lang_string('timegenerated', 'core_ai'), $this->get_entity_name(), "{$mainalias}.timecreated", )) ->add_joins($this->get_joins()) ->set_limited_operators([ date::DATE_ANY, date::DATE_RANGE, date::DATE_PREVIOUS, date::DATE_CURRENT, ]); // Prompt tokens filter. $filters[] = (new filter( number::class, 'prompttokens', new lang_string('prompttokens', 'core_ai'), $this->get_entity_name(), "COALESCE({$generatetextalias}.prompttokens, {$summarisetextalias}.prompttokens, {$explaintextalias}.prompttokens)", )) ->add_joins($this->get_joins()); // Completion tokens filter. $filters[] = (new filter( number::class, 'completiontokens', new lang_string('completiontokens', 'core_ai'), $this->get_entity_name(), "COALESCE({$generatetextalias}.completiontoken, {$summarisetextalias}.completiontoken, {$explaintextalias}.completiontoken)", )) ->add_joins($this->get_joins()); // Success filter. $filters[] = (new filter( boolean_select::class, 'success', new lang_string('success', 'moodle'), $this->get_entity_name(), "{$mainalias}.success", )) ->add_joins($this->get_joins()); return $filters; } } PK*\ ;classes/reportbuilder/local/entities/ai_policy_register.phpnu[. namespace core_ai\reportbuilder\local\entities; use core_reportbuilder\local\entities\base; use core_reportbuilder\local\filters\date; use core_reportbuilder\local\helpers\format; use core_reportbuilder\local\report\column; use core_reportbuilder\local\report\filter; use lang_string; /** * AI policy register entity. * * Defines all the columns and filters that can be added to reports that use this entity. * * @package core_ai * @copyright 2024 David Woloszyn * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class ai_policy_register extends base { #[\Override] protected function get_default_tables(): array { return [ 'ai_policy_register', ]; } #[\Override] protected function get_default_entity_title(): lang_string { return new lang_string('aipolicyregister', 'core_ai'); } #[\Override] public function initialise(): base { $columns = $this->get_all_columns(); foreach ($columns as $column) { $this->add_column($column); } // All the filters defined by the entity can also be used as conditions. $filters = $this->get_all_filters(); foreach ($filters as $filter) { $this ->add_filter($filter) ->add_condition($filter); } return $this; } /** * Returns list of all available columns. * * @return column[] */ protected function get_all_columns(): array { $tablealias = $this->get_table_alias('ai_policy_register'); // Time accepted column. $columns[] = (new column( 'timeaccepted', new lang_string('dateaccepted', 'core_ai'), $this->get_entity_name(), )) ->add_joins($this->get_joins()) ->set_type(column::TYPE_TIMESTAMP) ->add_field("{$tablealias}.timeaccepted") ->set_is_sortable(true) ->add_callback([format::class, 'userdate']); return $columns; } /** * Return list of all available filters. * * @return filter[] */ protected function get_all_filters(): array { $tablealias = $this->get_table_alias('ai_policy_register'); // Time accepted filter. $filters[] = (new filter( date::class, 'timeaccepted', new lang_string('dateaccepted', 'core_ai'), $this->get_entity_name(), "{$tablealias}.timeaccepted", )) ->add_joins($this->get_joins()) ->set_limited_operators([ date::DATE_ANY, date::DATE_RANGE, date::DATE_PREVIOUS, date::DATE_CURRENT, ]); return $filters; } } PK*\ljj3classes/reportbuilder/local/systemreports/usage.phpnu[. namespace core_ai\reportbuilder\local\systemreports; use core_reportbuilder\system_report; use core_ai\reportbuilder\local\entities\ai_action_register; use core\reportbuilder\local\entities\context; use core_reportbuilder\local\entities\user; /** * AI usage system report. * * @package core_ai * @copyright 2024 David Woloszyn * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class usage extends system_report { #[\Override] protected function initialise(): void { $entitymain = new ai_action_register(); $entitymainalias = $entitymain->get_table_alias('ai_action_register'); $this->set_main_table('ai_action_register', $entitymainalias); $this->add_entity($entitymain); // Join the 'user' entity to our main entity. $entityuser = new user(); $entituseralias = $entityuser->get_table_alias('user'); $this->add_entity($entityuser->add_join( "LEFT JOIN {user} {$entituseralias} ON {$entituseralias}.id = {$entitymainalias}.userid" )); // Join the 'context' entity to our main entity. $entitycontext = new context(); $entitycontextalias = $entitycontext->get_table_alias('context'); $this->add_entity($entitycontext->add_join( "LEFT JOIN {context} {$entitycontextalias} ON {$entitycontextalias}.id = {$entitymainalias}.contextid" )); // Now we can call our helper methods to add the content we want to include in the report. $this->add_columns(); $this->add_filters(); // Set if report can be downloaded. $this->set_downloadable(true, get_string('aiusage', 'core_ai')); } #[\Override] protected function can_view(): bool { return has_capability('moodle/ai:viewaiusagereport', $this->get_context()); } #[\Override] public static function get_name(): string { return get_string('aiusage', 'core_ai'); } /** * Adds the columns we want to display in the report. * * They are all provided by the entities we previously added in the {@see initialise} method, referencing each by their * unique identifier. */ public function add_columns(): void { $this->add_columns_from_entities([ 'ai_action_register:provider', 'ai_action_register:actionname', 'ai_action_register:timecreated', 'ai_action_register:prompttokens', 'ai_action_register:completiontokens', 'ai_action_register:success', 'context:name', 'user:fullnamewithlink', ]); // It's possible to set a default initial sort direction for one column. $this->set_initial_sort_column('ai_action_register:timecreated', SORT_DESC); } /** * Adds the filters we want to display in the report. * * They are all provided by the entities we previously added in the {@see initialise} method, referencing each by their * unique identifier. */ protected function add_filters(): void { $this->add_filters_from_entities([ 'ai_action_register:actionname', 'ai_action_register:provider', 'ai_action_register:timecreated', 'ai_action_register:prompttokens', 'ai_action_register:completiontokens', 'ai_action_register:success', 'context:level', 'user:fullname', ]); } } PK*\c?classes/reportbuilder/local/systemreports/policy_acceptance.phpnu[. namespace core_ai\reportbuilder\local\systemreports; use core_ai\reportbuilder\local\entities\ai_policy_register; use core_reportbuilder\system_report; use core_reportbuilder\local\entities\user; /** * AI policy acceptance system report. * * @package core_ai * @copyright 2024 David Woloszyn * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class policy_acceptance extends system_report { #[\Override] protected function initialise(): void { $entitymain = new ai_policy_register(); $entitymainalias = $entitymain->get_table_alias('ai_policy_register'); $this->set_main_table('ai_policy_register', $entitymainalias); $this->add_entity($entitymain); // Join the 'user' entity to our main entity. $entityuser = new user(); $entituseralias = $entityuser->get_table_alias('user'); $this->add_entity($entityuser->add_join( "LEFT JOIN {user} {$entituseralias} ON {$entituseralias}.id = {$entitymainalias}.userid" )); // Any columns required by actions should be defined here to ensure they're always available. $this->add_base_fields("{$entitymainalias}.id, {$entitymainalias}.userid"); // Now we can call our helper methods to add the content we want to include in the report. $this->add_columns(); $this->add_filters(); // Set if report can be downloaded. $this->set_downloadable(true, get_string('aipolicyacceptance', 'core_ai')); } #[\Override] protected function can_view(): bool { return has_capability('moodle/ai:viewaipolicyacceptancereport', $this->get_context()); } #[\Override] public static function get_name(): string { return get_string('aipolicyacceptance', 'core_ai'); } /** * Adds the columns we want to display in the report. * * They are all provided by the entities we previously added in the {@see initialise} method, referencing each by their * unique identifier. */ public function add_columns(): void { $columns = [ 'user:fullnamewithlink', 'ai_policy_register:timeaccepted', ]; $this->add_columns_from_entities($columns); // It's possible to set a default initial sort direction for one column. $this->set_initial_sort_column('ai_policy_register:timeaccepted', SORT_DESC); } /** * Adds the filters we want to display in the report. * * They are all provided by the entities we previously added in the {@see initialise} method, referencing each by their * unique identifier. */ protected function add_filters(): void { $filters = [ 'user:fullname', 'ai_policy_register:timeaccepted', ]; $this->add_filters_from_entities($filters); } } PK*\?~ ~ classes/process_base.phpnu[. namespace core_ai; use core_ai\aiactions\base; use core_ai\aiactions\responses\response_base; /** * Base class for provider processors. * * Each provider processor should extend this class. * * @package core_ai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ abstract class process_base { /** * Class constructor. * * @param provider $provider The provider that will process the action. * @param base $action The action to process. */ public function __construct( /** @var provider The provider that will process the action. */ protected readonly provider $provider, /** @var base The action to process. */ protected readonly base $action, ) { } /** * Process the AI request. * * @return response_base The result of the action. */ public function process(): response_base { // Check the rate limiter. $ratelimitcheck = $this->provider->is_request_allowed($this->action); if ($ratelimitcheck !== true) { return $this->get_response( success: false, errorcode: $ratelimitcheck['errorcode'], errormessage: $ratelimitcheck['errormessage'], ); } // Format the action response object. return $this->prepare_response($this->query_ai_api()); } /** * Query the AI service. * * @return array The response from the AI service. */ abstract protected function query_ai_api(): array; /** * Prepare the response object. * * @param array $responsedata The response object. * @return response_base The action response object. */ private function prepare_response(array $responsedata): response_base { if ($responsedata['success']) { $response = $this->get_response( success: true, ); $response->set_response_data($responsedata); return $response; } else { return $this->get_response( success: false, errorcode: $responsedata['errorcode'], errormessage: $responsedata['errormessage'], ); } } /** * Get the instantiated Response Class for the action described by this processor. * * @param mixed ...$args The arguments to pass to the response class constructor. * @return response_base */ protected function get_response(...$args): response_base { $responseclassname = $this->action::get_response_classname(); return new $responseclassname( ...$args, ); } } PK*\n44!classes/form/ai_provider_form.phpnu[. namespace core_ai\form; use moodleform; defined('MOODLE_INTERNAL') || die(); require_once($CFG->libdir . '/formslib.php'); /** * AI provider instance form. * * @package core_ai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class ai_provider_form extends moodleform { /** * Get the custom data. * * @return mixed The custom data. */ public function get_customdata(): mixed { return $this->_customdata; } #[\Override] protected function definition() { global $PAGE; $PAGE->requires->js_call_amd('core_ai/providerchooser', 'init'); $mform = $this->_form; $providerconfigs = $this->_customdata['providerconfigs'] ?? []; $returnurl = $this->_customdata['returnurl'] ?? null; // AI provider chooser. // Get all enabled AI provider plugins. Users can select one of them to create a new AI provider instance. $providerplugins = []; $enabledproviderplugins = \core\plugininfo\aiprovider::get_enabled_plugins(); foreach ($enabledproviderplugins as $pluginname => $notusing) { $plugin = 'aiprovider_' . $pluginname; $providerplugins[$plugin] = get_string('pluginname', $plugin); } // Provider chooser. $mform->addElement( 'select', 'aiprovider', get_string('providertype', 'core_ai'), $providerplugins, ['data-aiproviderchooser-field' => 'selector'], ); if (isset($providerconfigs['id'])) { $mform->hardFreeze('aiprovider'); } // Provider instance name. $mform->addElement( 'text', 'name', get_string('providername', 'core_ai'), 'maxlength="255" size="20"', ); $mform->setType('name', PARAM_TEXT); $mform->addRule('name', get_string('required'), 'required', null, 'client'); $mform->addRule('name', get_string('maximumchars', '', 255), 'maxlength', 255); $mform->registerNoSubmitButton('updateaiprovider'); $mform->addElement( 'submit', 'updateaiprovider', 'update AI provider', ['data-aiproviderchooser-field' => 'updateButton', 'class' => 'd-none'] ); // Dispatch a hook for plugins to add their fields. $providerplugindefault = array_key_first($providerplugins); $hook = new \core_ai\hook\after_ai_provider_form_hook( mform: $mform, plugin: $providerconfigs['aiprovider'] ?? $providerplugindefault, ); \core\di::get(\core\hook\manager::class)->dispatch($hook); // Add the provider plugin name to form customdata. $this->_customdata['aiprovider'] = $hook->plugin; // Add rate limiting settings. // Setting to enable/disable global rate limiting. $mform->addElement( 'checkbox', 'enableglobalratelimit', get_string('enableglobalratelimit', 'core_ai'), get_string('enableglobalratelimit_help', 'core_ai'), ); // Setting to set how many requests per hour are allowed for the global rate limit. // Should only be enabled when global rate limiting is enabled. $mform->addElement( 'text', 'globalratelimit', get_string('globalratelimit', 'core_ai'), 'maxlength="10" size="4"', ); $mform->setType('globalratelimit', PARAM_INT); $mform->addHelpButton('globalratelimit', 'globalratelimit', 'core_ai'); $mform->hideIf('globalratelimit', 'enableglobalratelimit', 'notchecked'); // Setting to enable/disable user rate limiting. $mform->addElement( 'checkbox', 'enableuserratelimit', get_string('enableuserratelimit', 'core_ai'), get_string('enableuserratelimit_help', 'core_ai'), ); // Setting to set how many requests per hour are allowed for the user rate limit. // Should only be enabled when user rate limiting is enabled. $mform->addElement( 'text', 'userratelimit', get_string('userratelimit', 'core_ai'), 'maxlength="10" size="4"', ); $mform->setType('userratelimit', PARAM_INT); $mform->addHelpButton('userratelimit', 'userratelimit', 'core_ai'); $mform->hideIf('userratelimit', 'enableuserratelimit', 'notchecked'); // Form buttons. $buttonarray = []; // If provider config is empty this is a new instance. if (empty($providerconfigs['id'])) { $buttonarray[] = $mform->createElement('submit', 'createandreturn', get_string('btninstancecreate', 'core_ai')); } else { // We're updating an existing provider. $buttonarray[] = $mform->createElement('submit', 'updateandreturn', get_string('btninstanceupdate', 'core_ai')); } $buttonarray[] = $mform->createElement('cancel'); $mform->addGroup($buttonarray, 'buttonar', '', [' '], false); $mform->closeHeaderBefore('buttonar'); if ($returnurl) { $mform->addElement('hidden', 'returnurl', $returnurl); $mform->setType('returnurl', PARAM_LOCALURL); } if (isset($providerconfigs['id'])) { $mform->addElement('hidden', 'id', $providerconfigs['id']); $mform->setType('id', PARAM_INT); } $this->set_data($providerconfigs); } #[\Override] public function validation($data, $files) { $errors = parent::validation($data, $files); // Ensure both global/user rate limits (if enabled) contain positive values. if (!empty($data['enableglobalratelimit']) && $data['globalratelimit'] <= 0) { $errors['globalratelimit'] = get_string('err_positiveint', 'core_form'); } if (!empty($data['enableuserratelimit']) && $data['userratelimit'] <= 0) { $errors['userratelimit'] = get_string('err_positiveint', 'core_form'); } return $errors; } } PK*\poJ J %classes/form/action_settings_form.phpnu[. namespace core_ai\form; use moodleform; defined('MOODLE_INTERNAL') || die(); require_once($CFG->libdir . '/formslib.php'); /** * Generate action settings form. * * @package core_ai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class action_settings_form extends moodleform { #[\Override] protected function definition() { } #[\Override] protected function after_definition() { parent::after_definition(); $this->_form->_registerCancelButton('cancel'); } #[\Override] public function definition_after_data() { // Dispatch a hook for plugins to add their fields. $hook = new \core_ai\hook\after_ai_action_settings_form_hook( mform: $this->_form, plugin: $this->_customdata['providername'], ); \core\di::get(\core\hook\manager::class)->dispatch($hook); // Add action buttons. $this->add_action_buttons(); } /** * Get the default values for the form. * * @return array */ public function get_defaults(): array { $data = $this->_form->exportValues(); unset( $data['sesskey'], // We do not need to return sesskey. $data['_qf__'.$this->_formname], // We do not need the submission marker. $data['provider'], // We do not need the provider. $data['providerid'], // We do not need the provider id. $data['action'] // We do not need the action. ); if (empty($data)) { return []; } else { return $data; } } } PK*\4 &classes/external/set_policy_status.phpnu[. namespace core_ai\external; use core_ai\manager; use core_external\external_api; use core_external\external_function_parameters; use core_external\external_value; /** * External API to set a users AI policy acceptance. * * @package core_ai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class set_policy_status extends external_api { /** * Set policy parameters. * * @since Moodel 5.0 * @return external_function_parameters */ public static function execute_parameters(): external_function_parameters { return new external_function_parameters([ 'contextid' => new external_value( PARAM_INT, 'The context ID', VALUE_REQUIRED, ), ]); } /** * Set a users AI policy acceptance. * * @since Moodel 5.0 * @param int $contextid The context ID. * @return array The generated content. */ public static function execute( int $contextid, ): array { global $USER; // Parameter validation. [ 'contextid' => $contextid, ] = self::validate_parameters(self::execute_parameters(), [ 'contextid' => $contextid, ]); $usercontext = \core\context\user::instance($USER->id); require_capability('moodle/ai:acceptpolicy', $usercontext); return [ 'success' => manager::user_policy_accepted($USER->id, $contextid), ]; } /** * Generate content return value. * * @since Moodel 5.0 * @return external_function_parameters */ public static function execute_returns(): external_function_parameters { return new external_function_parameters([ 'success' => new external_value( PARAM_BOOL, 'Was the request successful', VALUE_REQUIRED, ), ]); } } PK*\8classes/external/set_action.phpnu[. namespace core_ai\external; use core\context\system; use core_ai\manager; use core_external\external_api; use core_external\external_function_parameters; use core_external\external_value; /** * External API to set provider action enabled. * * @package core_ai * @copyright Meirza * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class set_action 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_TEXT, 'The name of the plugin and the action to change state for', VALUE_REQUIRED, ), 'state' => new external_value( PARAM_INT, 'The target state', VALUE_REQUIRED, ), 'providerid' => new external_value( PARAM_INT, 'The provider id', VALUE_DEFAULT, 0 ), ]); } /** * Set the providers action state. * * @param string $plugin The name of the plugin and the action to change state for. * @param int $state The target state. * @param int $providerid The provider id. * @return array */ public static function execute( string $plugin, int $state, int $providerid = 0 ): array { // Parameter validation. [ 'plugin' => $plugin, 'state' => $state, 'providerid' => $providerid, ] = self::validate_parameters(self::execute_parameters(), [ 'plugin' => $plugin, 'state' => $state, 'providerid' => $providerid, ]); $context = system::instance(); self::validate_context($context); require_capability('moodle/site:config', $context); [$plugin, $action] = explode('-', $plugin); $actionname = get_string("action_$action", 'core_ai'); if (!empty($state)) { \core\notification::add( get_string('plugin_enabled', 'core_admin', $actionname), \core\notification::SUCCESS ); } else { \core\notification::add( get_string('plugin_disabled', 'core_admin', $actionname), \core\notification::SUCCESS ); } $manager = \core\di::get(manager::class); $manager->set_action_state( plugin: $plugin, actionbasename: $action, enabled: $state, instanceid: $providerid ); return []; } /** * Describe the return structure of the external service. * * @return external_function_parameters */ public static function execute_returns(): external_function_parameters { return new external_function_parameters([]); } } PK*\> 'classes/external/set_provider_order.phpnu[. 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 * @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([]); } } PK*\5R &classes/external/get_policy_status.phpnu[. namespace core_ai\external; use core_ai\manager; use core_external\external_api; use core_external\external_function_parameters; use core_external\external_value; /** * External API to get a users AI policy acceptance. * * @package core_ai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class get_policy_status extends external_api { /** * Get policy parameters. * * @since Moodle 4.5 * @return external_function_parameters */ public static function execute_parameters(): external_function_parameters { return new external_function_parameters([ 'userid' => new external_value( PARAM_INT, 'The user ID', VALUE_REQUIRED, ), ]); } /** * Set a users AI policy acceptance. * * @since Moodle 4.5 * @param int $userid The user ID. * @return array The generated content. */ public static function execute( int $userid, ): array { global $USER; // Parameter validation. [ 'userid' => $userid, ] = self::validate_parameters(self::execute_parameters(), [ 'userid' => $userid, ]); // Context validation and permission check. // Get the context from the passed in ID. $context = \core\context\user::instance($userid); self::validate_context($context); if ($userid === $USER->id) { require_capability('moodle/ai:fetchpolicy', $context); } else { require_capability('moodle/ai:fetchanyuserpolicystatus', $context); } return [ 'status' => manager::get_user_policy_status($userid), ]; } /** * Generate content return value. * * @since Moodle 4.5 * @return external_function_parameters */ public static function execute_returns(): external_function_parameters { return new external_function_parameters([ 'status' => new external_value( PARAM_BOOL, 'True if the policy was accepted, false otherwise.', VALUE_REQUIRED, ), ]); } } PK*\X Ƕ(classes/external/set_provider_status.phpnu[. namespace core_ai\external; use context_system; use core_external\external_api; use core_external\external_function_parameters; use core_external\external_single_structure; use core_external\external_value; /** * Webservice to enable or disable AI provider. * * @package core_ai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class set_provider_status extends external_api { /** * Set provider status parameters. * * @since Moodle 4.5 * @return external_function_parameters */ public static function execute_parameters(): external_function_parameters { return new external_function_parameters([ 'plugin' => new external_value(PARAM_INT, 'Provider ID', VALUE_REQUIRED), 'state' => new external_value(PARAM_INT, 'Enabled or disabled', VALUE_REQUIRED), ]); } /** * Set a provider status. * * @since Moodle 4.5 * @param int $plugin The provider ID. * @param int $state The state of the provider. * @return array The generated content. */ public static function execute(int $plugin, int $state): array { // Parameter validation. [ 'plugin' => $providerid, 'state' => $state, ] = self::validate_parameters(self::execute_parameters(), [ 'plugin' => $plugin, 'state' => $state, ]); $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) { return [ 'result' => false, 'message' => get_string('notfound', 'error'), 'messagetype' => \core\notification::ERROR, ]; } if (!empty($state)) { $manager->enable_provider_instance(provider: $aiprovider); $message = get_string('plugin_enabled', 'core_admin', $aiprovider->name); $messagetype = \core\notification::SUCCESS; } else { $providerresult = $manager->disable_provider_instance(provider: $aiprovider); if ($providerresult->enabled) { $message = get_string('providerinstancedisablefailed', 'core_ai'); $messagetype = \core\notification::ERROR; } else { $message = get_string('plugin_disabled', 'core_admin', $aiprovider->name); $messagetype = \core\notification::SUCCESS; } } \core\notification::add($message, $messagetype); return [ 'result' => $messagetype === \core\notification::SUCCESS ? true : false, 'message' => $message, 'messagetype' => $messagetype, ]; } /** * Generate content return value. * * @since Moodle 4.5 * @return external_single_structure */ public static function execute_returns(): external_single_structure { return new external_single_structure( [ 'result' => new external_value(PARAM_BOOL, 'Whether the status was changed, true or false'), 'message' => new external_value(PARAM_TEXT, 'Messages'), 'messagetype' => new external_value(PARAM_TEXT, 'Message type'), ] ); } } PK*\0l-classes/external/delete_provider_instance.phpnu[. namespace core_ai\external; use context_system; use core_external\external_api; use core_external\external_function_parameters; use core_external\external_single_structure; use core_external\external_value; /** * External API to delete a provider instance. * * @package core_ai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class delete_provider_instance extends external_api { /** * Get provider parameters. * * @since Moodel 5.0 * @return external_function_parameters */ public static function execute_parameters(): external_function_parameters { return new external_function_parameters([ 'providerid' => new external_value(PARAM_INT, 'Provider ID', VALUE_REQUIRED), ]); } /** * Delete a provider instance. * * @since Moodel 5.0 * @param int $providerid The provider ID. * @return array The generated content. */ public static function execute(int $providerid): array { [ 'providerid' => $providerid, ] = self::validate_parameters(self::execute_parameters(), [ 'providerid' => $providerid, ]); $context = context_system::instance(); self::validate_context($context); require_capability('moodle/site:config', $context); // Get AI provider instance. $manager = \core\di::get(\core_ai\manager::class); $aiproviders = $manager->get_provider_instances(['id' => $providerid]); $aiprovider = reset($aiproviders); if (!$aiprovider) { return [ 'result' => false, 'message' => get_string('notfound', 'error'), 'messagetype' => \core\notification::ERROR, ]; } $providerresult = $manager->delete_provider_instance(provider: $aiprovider); if (!$providerresult) { $message = get_string('providerinstancedeletefailed', 'core_ai', $aiprovider->name); $messagetype = \core\notification::ERROR; } else { $message = get_string('providerinstancedeleted', 'core_ai', $aiprovider->name); $messagetype = \core\notification::SUCCESS; } \core\notification::add($message, $messagetype); // Update and return the result array in one place. return [ 'result' => $providerresult, 'message' => $message, 'messagetype' => $messagetype, ]; } /** * Generate content return value. * * @since Moodel 5.0 * @return external_single_structure */ public static function execute_returns(): external_single_structure { return new external_single_structure( [ 'result' => new external_value(PARAM_BOOL, 'Whether the status was changed, true or false'), 'message' => new external_value(PARAM_TEXT, 'Messages'), 'messagetype' => new external_value(PARAM_TEXT, 'Message type'), ] ); } } PK*\6^ classes/privacy/provider.phpnu[. namespace core_ai\privacy; use core_privacy\local\metadata\collection; use core_privacy\local\request\approved_contextlist; use core_privacy\local\request\approved_userlist; use core_privacy\local\request\contextlist; use core_privacy\local\request\transform; use core_privacy\local\request\userlist; use core_privacy\local\request\writer; /** * Privacy Subsystem for core_ai implementing null_provider. * * @package core_ai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider implements \core_privacy\local\metadata\provider, \core_privacy\local\request\subsystem\provider, \core_privacy\local\request\core_userlist_provider { /** * Returns meta data about this system. * * @param collection $collection The initialised collection to add items to. * @return collection A listing of user data stored through this system. */ public static function get_metadata(collection $collection): collection { $collection->add_database_table('ai_policy_register', [ 'userid' => 'privacy:metadata:ai_policy_register:userid', 'contextid' => 'privacy:metadata:ai_policy_register:contextid', 'timeaccepted' => 'privacy:metadata:ai_policy_register:timeaccepted', ], 'privacy:metadata:ai_policy_register'); $collection->add_database_table('ai_action_register', [ 'actionname' => 'privacy:metadata:ai_action_register:actionname', 'actionid' => 'privacy:metadata:ai_action_register:actionid', 'success' => 'privacy:metadata:ai_action_register:success', 'userid' => 'privacy:metadata:ai_action_register:userid', 'provider' => 'privacy:metadata:ai_action_register:provider', 'timecreated' => 'privacy:metadata:ai_action_register:timecreated', 'timecompleted' => 'privacy:metadata:ai_action_register:timecompleted', 'model' => 'privacy:metadata:ai_action_register:model', ], 'privacy:metadata:ai_action_register'); $collection->add_database_table('ai_action_generate_image', [ 'prompt' => 'privacy:metadata:ai_action_generate_image:prompt', 'numberimages' => 'privacy:metadata:ai_action_generate_image:numberimages', 'quality' => 'privacy:metadata:ai_action_generate_image:quality', 'aspectratio' => 'privacy:metadata:ai_action_generate_image:aspectratio', 'style' => 'privacy:metadata:ai_action_generate_image:style', 'sourceurl' => 'privacy:metadata:ai_action_generate_image:sourceurl', 'revisedprompt' => 'privacy:metadata:ai_action_generate_image:revisedprompt', ], 'privacy:metadata:ai_action_generate_image'); $collection->add_database_table('ai_action_generate_text', [ 'prompt' => 'privacy:metadata:ai_action_generate_text:prompt', 'responseid' => 'privacy:metadata:ai_action_generate_text:responseid', 'fingerprint' => 'privacy:metadata:ai_action_generate_text:fingerprint', 'generatedcontent' => 'privacy:metadata:ai_action_generate_text:generatedcontent', 'prompttokens' => 'privacy:metadata:ai_action_generate_text:prompttokens', 'completiontoken' => 'privacy:metadata:ai_action_generate_text:completiontoken', ], 'privacy:metadata:ai_action_generate_text'); $collection->add_database_table('ai_action_summarise_text', [ 'prompt' => 'privacy:metadata:ai_action_summarise_text:prompt', 'responseid' => 'privacy:metadata:ai_action_summarise_text:responseid', 'fingerprint' => 'privacy:metadata:ai_action_summarise_text:fingerprint', 'generatedcontent' => 'privacy:metadata:ai_action_summarise_text:generatedcontent', 'prompttokens' => 'privacy:metadata:ai_action_summarise_text:prompttokens', 'completiontoken' => 'privacy:metadata:ai_action_summarise_text:completiontoken', ], 'privacy:metadata:ai_action_summarise_text'); $collection->add_database_table('ai_action_explain_text', [ 'prompt' => 'privacy:metadata:ai_action_explain_text:prompt', 'responseid' => 'privacy:metadata:ai_action_explain_text:responseid', 'fingerprint' => 'privacy:metadata:ai_action_explain_text:fingerprint', 'generatedcontent' => 'privacy:metadata:ai_action_explain_text:generatedcontent', 'prompttokens' => 'privacy:metadata:ai_action_explain_text:prompttokens', 'completiontoken' => 'privacy:metadata:ai_action_explain_text:completiontoken', ], 'privacy:metadata:ai_action_explain_text'); return $collection; } /** * Get the list of contexts that contain user information for the specified user. * * @param int $userid The user to search. * @return contextlist $contextlist The contextlist containing the list of contexts used in this plugin. */ public static function get_contexts_for_userid(int $userid): contextlist { $contextlist = new contextlist(); // AI policy. $sql = 'SELECT DISTINCT ctx.id FROM {context} ctx JOIN {ai_policy_register} apr ON apr.contextid = ctx.id WHERE apr.userid = :userid'; $contextlist->add_from_sql($sql, ['userid' => $userid]); // AI action generate text. $sql = "SELECT DISTINCT ctx.id FROM {context} ctx JOIN {ai_action_register} aar ON aar.contextid = ctx.id JOIN {ai_action_generate_text} aagt ON aagt.id = aar.actionid WHERE aar.actionname = 'generate_text' AND aar.userid = :userid"; $contextlist->add_from_sql($sql, ['userid' => $userid]); // AI action generate image. $sql = "SELECT DISTINCT ctx.id FROM {context} ctx JOIN {ai_action_register} aar ON aar.contextid = ctx.id JOIN {ai_action_generate_image} aagi ON aagi.id = aar.actionid WHERE aar.actionname = 'generate_image' AND aar.userid = :userid"; $contextlist->add_from_sql($sql, ['userid' => $userid]); // AI action summarise text. $sql = "SELECT DISTINCT ctx.id FROM {context} ctx JOIN {ai_action_register} aar ON aar.contextid = ctx.id JOIN {ai_action_summarise_text} aast ON aast.id = aar.actionid WHERE aar.actionname = 'summarise_text' AND aar.userid = :userid"; $contextlist->add_from_sql($sql, ['userid' => $userid]); // AI action explain text. $sql = "SELECT DISTINCT ctx.id FROM {context} ctx JOIN {ai_action_register} aar ON aar.contextid = ctx.id JOIN {ai_action_explain_text} aaet ON aaet.id = aar.actionid WHERE aar.actionname = 'explain_text' AND aar.userid = :userid"; $contextlist->add_from_sql($sql, ['userid' => $userid]); return $contextlist; } /** * Export all user data for the specified user, in the specified contexts. * * @param approved_contextlist $contextlist The approved contexts to export information for. */ public static function export_user_data(approved_contextlist $contextlist): void { global $DB; // AI policy. $userid = $contextlist->get_user()->id; [$contextsql, $contextparams] = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED); $sql = 'SELECT apr.timeaccepted, ctx.id AS contextid FROM {context} ctx JOIN {ai_policy_register} apr ON apr.contextid = ctx.id WHERE apr.userid = :userid AND ctx.id ' . $contextsql; $params = [ 'userid' => $userid, ]; $params += $contextparams; $policydetails = $DB->get_recordset_sql($sql, $params); foreach ($policydetails as $policydetail) { $subcontexts = [ get_string('ai', 'core_ai'), ]; $name = 'policy'; $details = (object) [ 'contextid' => $policydetail->contextid, 'timeaccepted' => transform::datetime($policydetail->timeaccepted), ]; $context = \context::instance_by_id($policydetail->contextid); writer::with_context($context)->export_related_data($subcontexts, $name, $details); } $policydetails->close(); // AI action generate text. $sql = "SELECT aar.actionname, aar.success, aar.provider, aar.timecreated, aar.timecompleted, aar.contextid, aagt.prompt, aagt.responseid, aagt.fingerprint, aagt.generatedcontent, aagt.prompttokens, aagt.completiontoken, aar.model FROM {ai_action_register} aar JOIN {ai_action_generate_text} aagt ON aar.actionid = aagt.id JOIN {context} ctx ON aar.contextid = ctx.id WHERE aar.actionname = 'generate_text' AND aar.userid = :userid AND ctx.id " . $contextsql; $params = [ 'userid' => $userid, ]; $params += $contextparams; $textgeneratedetails = $DB->get_recordset_sql($sql, $params); foreach ($textgeneratedetails as $textgeneratedetail) { $subcontexts = [ get_string('ai', 'core_ai'), get_string('action_generate_text', 'core_ai'), date('c', $textgeneratedetail->timecreated), ]; $details = (object) [ 'actionname' => $textgeneratedetail->actionname, 'contextid' => $textgeneratedetail->contextid, 'prompt' => $textgeneratedetail->prompt, 'responseid' => $textgeneratedetail->responseid, 'fingerprint' => $textgeneratedetail->fingerprint, 'generatedcontent' => $textgeneratedetail->generatedcontent, 'prompttokens' => $textgeneratedetail->prompttokens, 'completiontoken' => $textgeneratedetail->completiontoken, 'model' => $textgeneratedetail->model, 'success' => transform::yesno($textgeneratedetail->success), 'provider' => $textgeneratedetail->provider, 'timecreated' => transform::datetime($textgeneratedetail->timecreated), 'timecompleted' => transform::datetime($textgeneratedetail->timecompleted), ]; $name = 'action_generate_text'; $context = \context::instance_by_id($textgeneratedetail->contextid); writer::with_context($context)->export_related_data($subcontexts, $name, $details); } $textgeneratedetails->close(); // AI action generate image. $sql = "SELECT aar.actionname, aar.success, aar.provider, aar.timecreated, aar.timecompleted, aar.contextid, aagi.prompt, aagi.numberimages, aagi.quality, aagi.aspectratio, aagi.style, aagi.sourceurl, aagi.revisedprompt, aar.model FROM {ai_action_register} aar JOIN {ai_action_generate_image} aagi ON aar.actionid = aagi.id JOIN {context} ctx ON aar.contextid = ctx.id WHERE aar.actionname = 'generate_image' AND aar.userid = :userid AND ctx.id " . $contextsql; $params = [ 'userid' => $userid, ]; $params += $contextparams; $imagegeneratedetails = $DB->get_recordset_sql($sql, $params); foreach ($imagegeneratedetails as $imagegeneratedetail) { $subcontexts = [ get_string('ai', 'core_ai'), get_string('action_generate_image', 'core_ai'), date('c', $imagegeneratedetail->timecreated), ]; $details = (object) [ 'actionname' => $imagegeneratedetail->actionname, 'contextid' => $imagegeneratedetail->contextid, 'prompt' => $imagegeneratedetail->prompt, 'numberimages' => $imagegeneratedetail->numberimages, 'quality' => $imagegeneratedetail->quality, 'aspectratio' => $imagegeneratedetail->aspectratio, 'style' => $imagegeneratedetail->style, 'sourceurl' => $imagegeneratedetail->sourceurl, 'revisedprompt' => $imagegeneratedetail->revisedprompt, 'model' => $imagegeneratedetail->model, 'success' => transform::yesno($imagegeneratedetail->success), 'provider' => $imagegeneratedetail->provider, 'timecreated' => transform::datetime($imagegeneratedetail->timecreated), 'timecompleted' => transform::datetime($imagegeneratedetail->timecompleted), ]; $name = 'action_generate_image'; $context = \context::instance_by_id($imagegeneratedetail->contextid); writer::with_context($context)->export_related_data($subcontexts, $name, $details); } $imagegeneratedetails->close(); // AI action summarise text. $sql = "SELECT aar.actionname, aar.success, aar.provider, aar.timecreated, aar.timecompleted, aar.contextid, aast.prompt, aast.responseid, aast.fingerprint, aast.generatedcontent, aast.prompttokens, aast.completiontoken, aar.model FROM {ai_action_register} aar JOIN {ai_action_summarise_text} aast ON aar.actionid = aast.id JOIN {context} ctx ON aar.contextid = ctx.id WHERE aar.actionname = 'summarise_text' AND aar.userid = :userid AND ctx.id " . $contextsql; $params = [ 'userid' => $userid, ]; $params += $contextparams; $textsummarisedetails = $DB->get_recordset_sql($sql, $params); foreach ($textsummarisedetails as $textsummarisedetail) { $subcontexts = [ get_string('ai', 'core_ai'), get_string('action_summarise_text', 'core_ai'), date('c', $textsummarisedetail->timecreated), ]; $details = (object) [ 'actionname' => $textsummarisedetail->actionname, 'contextid' => $textsummarisedetail->contextid, 'prompt' => $textsummarisedetail->prompt, 'responseid' => $textsummarisedetail->responseid, 'fingerprint' => $textsummarisedetail->fingerprint, 'generatedcontent' => $textsummarisedetail->generatedcontent, 'prompttokens' => $textsummarisedetail->prompttokens, 'completiontoken' => $textsummarisedetail->completiontoken, 'model' => $textsummarisedetail->model, 'success' => transform::yesno($textsummarisedetail->success), 'provider' => $textsummarisedetail->provider, 'timecreated' => transform::datetime($textsummarisedetail->timecreated), 'timecompleted' => transform::datetime($textsummarisedetail->timecompleted), ]; $name = 'action_summarise_text'; $context = \context::instance_by_id($textsummarisedetail->contextid); writer::with_context($context)->export_related_data($subcontexts, $name, $details); } $textsummarisedetails->close(); // AI action explain text. $sql = "SELECT aar.actionname, aar.success, aar.provider, aar.timecreated, aar.timecompleted, aar.contextid, aaet.prompt, aaet.responseid, aaet.fingerprint, aaet.generatedcontent, aaet.prompttokens, aaet.completiontoken, aar.model FROM {ai_action_register} aar JOIN {ai_action_explain_text} aaet ON aar.actionid = aaet.id JOIN {context} ctx ON aar.contextid = ctx.id WHERE aar.actionname = 'explain_text' AND aar.userid = :userid AND ctx.id " . $contextsql; $params = [ 'userid' => $userid, ]; $params += $contextparams; $textexplaindetails = $DB->get_recordset_sql($sql, $params); foreach ($textexplaindetails as $textexplaindetail) { $subcontexts = [ get_string('ai', 'core_ai'), get_string('action_explain_text', 'core_ai'), date('c', $textexplaindetail->timecreated), ]; $details = (object) [ 'actionname' => $textexplaindetail->actionname, 'contextid' => $textexplaindetail->contextid, 'prompt' => $textexplaindetail->prompt, 'responseid' => $textexplaindetail->responseid, 'fingerprint' => $textexplaindetail->fingerprint, 'generatedcontent' => $textexplaindetail->generatedcontent, 'prompttokens' => $textexplaindetail->prompttokens, 'completiontoken' => $textexplaindetail->completiontoken, 'model' => $textexplaindetail->model, 'success' => transform::yesno($textexplaindetail->success), 'provider' => $textexplaindetail->provider, 'timecreated' => transform::datetime($textexplaindetail->timecreated), 'timecompleted' => transform::datetime($textexplaindetail->timecompleted), ]; $name = 'action_explain_text'; $context = \context::instance_by_id($textexplaindetail->contextid); writer::with_context($context)->export_related_data($subcontexts, $name, $details); } $textexplaindetails->close(); } /** * Delete all data for all users in the specified context. * * @param \context $context The specific context to delete data for. */ public static function delete_data_for_all_users_in_context(\context $context): void { global $DB; // Policy. $sql = 'SELECT DISTINCT apr.id FROM {ai_policy_register} apr JOIN {context} ctx ON apr.contextid = ctx.id WHERE ctx.id = :contextid'; $params = [ 'contextid' => $context->id, ]; $policydetails = $DB->get_records_sql($sql, $params); if ($policydetails) { $DB->delete_records_list('ai_policy_register', 'id', array_keys($policydetails)); } // AI action generate text. $sql = "SELECT DISTINCT aagt.id FROM {ai_action_register} aar JOIN {ai_action_generate_text} aagt ON aar.actionid = aagt.id JOIN {context} ctx ON aar.contextid = ctx.id WHERE aar.actionname = 'generate_text' AND ctx.id = :contextid"; $params = [ 'contextid' => $context->id, ]; $aagtids = $DB->get_records_sql_menu($sql, $params); if ($aagtids) { [$aagtidsql, $aagtidparams] = $DB->get_in_or_equal(array_keys($aagtids), SQL_PARAMS_NAMED); $sql = "UPDATE {ai_action_generate_text} SET prompt = '', responseid = '', fingerprint = '', generatedcontent = '' WHERE id " . $aagtidsql; $DB->execute($sql, $aagtidparams); } // AI action generate image. $sql = "SELECT DISTINCT aagi.id FROM {ai_action_register} aar JOIN {ai_action_generate_image} aagi ON aar.actionid = aagi.id JOIN {context} ctx ON aar.contextid = ctx.id WHERE aar.actionname = 'generate_image' AND ctx.id = :contextid"; $params = [ 'contextid' => $context->id, ]; $aagiids = $DB->get_records_sql_menu($sql, $params); if ($aagiids) { [$aagiidsql, $aagiidparams] = $DB->get_in_or_equal(array_keys($aagiids), SQL_PARAMS_NAMED); $sql = "UPDATE {ai_action_generate_image} SET prompt = '', sourceurl = '', revisedprompt = '' WHERE id " . $aagiidsql; $DB->execute($sql, $aagiidparams); } // AI action summarise text. $sql = "SELECT DISTINCT aast.id FROM {ai_action_register} aar JOIN {ai_action_summarise_text} aast ON aar.actionid = aast.id JOIN {context} ctx ON aar.contextid = ctx.id WHERE aar.actionname = 'summarise_text' AND ctx.id = :contextid"; $params = [ 'contextid' => $context->id, ]; $aastids = $DB->get_records_sql_menu($sql, $params); if ($aastids) { [$aastidsql, $aastidparams] = $DB->get_in_or_equal(array_keys($aastids), SQL_PARAMS_NAMED); $sql = "UPDATE {ai_action_summarise_text} SET prompt = '', responseid = '', fingerprint = '', generatedcontent = '' WHERE id " . $aastidsql; $DB->execute($sql, $aastidparams); } // AI action explain text. $sql = "SELECT DISTINCT aaet.id FROM {ai_action_register} aar JOIN {ai_action_explain_text} aaet ON aar.actionid = aaet.id JOIN {context} ctx ON aar.contextid = ctx.id WHERE aar.actionname = 'explain_text' AND ctx.id = :contextid"; $params = [ 'contextid' => $context->id, ]; $aaetids = $DB->get_records_sql_menu($sql, $params); if ($aaetids) { [$aaetidsql, $aaetidparams] = $DB->get_in_or_equal(array_keys($aaetids), SQL_PARAMS_NAMED); $sql = "UPDATE {ai_action_explain_text} SET prompt = '', responseid = '', fingerprint = '', generatedcontent = '' WHERE id " . $aaetidsql; $DB->execute($sql, $aaetidparams); } } /** * Delete all user data for the specified user, in the specified contexts. * * @param approved_contextlist $contextlist The approved contexts and user information to delete information for. */ public static function delete_data_for_user(approved_contextlist $contextlist): void { global $DB; // Policy. $userid = $contextlist->get_user()->id; [$contextsql, $contextparams] = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED); $sql = "SELECT DISTINCT apr.id AS policyid FROM {context} ctx JOIN {ai_policy_register} apr ON apr.contextid = ctx.id WHERE apr.userid = :userid AND ctx.id " . $contextsql; $params = [ 'userid' => $userid, ]; $params += $contextparams; $policydetails = $DB->get_recordset_sql($sql, $params); $policyids = []; foreach ($policydetails as $policydetail) { $policyids[] = $policydetail->policyid; } $policydetails->close(); $DB->delete_records_list('ai_policy_register', 'id', $policyids); // AI action generate text. $sql = "SELECT DISTINCT aagt.id AS textgenerateid FROM {ai_action_register} aar JOIN {ai_action_generate_text} aagt ON aar.actionid = aagt.id JOIN {context} ctx ON aar.contextid = ctx.id WHERE aar.actionname = 'generate_text' AND aar.userid = :userid AND ctx.id " . $contextsql; $textgeneratedetails = $DB->get_recordset_sql($sql, $params); $aagtids = []; foreach ($textgeneratedetails as $textgeneratedetail) { $aagtids[] = $textgeneratedetail->textgenerateid; } $textgeneratedetails->close(); if ($aagtids) { [$aagtidsql, $aagtidparams] = $DB->get_in_or_equal($aagtids, SQL_PARAMS_NAMED); $sql = "UPDATE {ai_action_generate_text} SET prompt = '', responseid = '', fingerprint = '', generatedcontent = '' WHERE id " . $aagtidsql; $DB->execute($sql, $aagtidparams); } // AI action generate image. $sql = "SELECT DISTINCT aagi.id AS imagegenerateid FROM {ai_action_register} aar JOIN {ai_action_generate_image} aagi ON aar.actionid = aagi.id JOIN {context} ctx ON aar.contextid = ctx.id WHERE aar.actionname = 'generate_image' AND aar.userid = :userid AND ctx.id " . $contextsql; $imagegeneratedetails = $DB->get_recordset_sql($sql, $params); $aagiids = []; foreach ($imagegeneratedetails as $imagegeneratedetail) { $aagiids[] = $imagegeneratedetail->imagegenerateid; } $imagegeneratedetails->close(); if ($aagiids) { [$aagiidsql, $aagiidparams] = $DB->get_in_or_equal($aagiids, SQL_PARAMS_NAMED); $sql = "UPDATE {ai_action_generate_image} SET prompt = '', sourceurl = '', revisedprompt = '' WHERE id " . $aagiidsql; $DB->execute($sql, $aagiidparams); } // AI action summarise text. $sql = "SELECT DISTINCT aast.id AS textsummariseid FROM {ai_action_register} aar JOIN {ai_action_summarise_text} aast ON aar.actionid = aast.id JOIN {context} ctx ON aar.contextid = ctx.id WHERE aar.actionname = 'summarise_text' AND aar.userid = :userid AND ctx.id " . $contextsql; $textsummarisedetails = $DB->get_recordset_sql($sql, $params); $aastids = []; foreach ($textsummarisedetails as $textsummarisedetail) { $aastids[] = $textsummarisedetail->textsummariseid; } $textsummarisedetails->close(); if ($aastids) { [$aastidsql, $aastidparams] = $DB->get_in_or_equal($aastids, SQL_PARAMS_NAMED); $sql = "UPDATE {ai_action_summarise_text} SET prompt = '', responseid = '', fingerprint = '', generatedcontent = '' WHERE id " . $aastidsql; $DB->execute($sql, $aastidparams); } // AI action explain text. $sql = "SELECT DISTINCT aaet.id AS textexplainid FROM {ai_action_register} aar JOIN {ai_action_explain_text} aaet ON aar.actionid = aaet.id JOIN {context} ctx ON aar.contextid = ctx.id WHERE aar.actionname = 'explain_text' AND aar.userid = :userid AND ctx.id " . $contextsql; $textexplaindetails = $DB->get_recordset_sql($sql, $params); $aaetids = []; foreach ($textexplaindetails as $textexplaindetail) { $aaetids[] = $textexplaindetail->textexplainid; } $textexplaindetails->close(); if ($aaetids) { [$aaetidsql, $aaetidparams] = $DB->get_in_or_equal($aaetids, SQL_PARAMS_NAMED); $sql = "UPDATE {ai_action_explain_text} SET prompt = '', responseid = '', fingerprint = '', generatedcontent = '' WHERE id " . $aaetidsql; $DB->execute($sql, $aaetidparams); } } /** * Get the list of users who have data within a context. * * @param userlist $userlist The userlist containing the list of users who have data in this context/plugin combination. */ public static function get_users_in_context(userlist $userlist): void { $context = $userlist->get_context(); // AI policy. $sql = 'SELECT DISTINCT apr.userid FROM {context} ctx JOIN {ai_policy_register} apr ON apr.contextid = ctx.id WHERE apr.contextid = :contextid'; $userlist->add_from_sql('userid', $sql, ['contextid' => $context->id]); // AI action generate text. $sql = "SELECT DISTINCT aar.userid FROM {context} ctx JOIN {ai_action_register} aar ON aar.contextid = ctx.id JOIN {ai_action_generate_text} aagt ON aagt.id = aar.actionid WHERE aar.actionname = 'generate_text' AND aar.contextid = :contextid"; $userlist->add_from_sql('userid', $sql, ['contextid' => $context->id]); // AI action generate image. $sql = "SELECT DISTINCT aar.userid FROM {context} ctx JOIN {ai_action_register} aar ON aar.contextid = ctx.id JOIN {ai_action_generate_image} aagi ON aagi.id = aar.actionid WHERE aar.actionname = 'generate_image' AND aar.contextid = :contextid"; $userlist->add_from_sql('userid', $sql, ['contextid' => $context->id]); // AI action summarise text. $sql = "SELECT DISTINCT aar.userid FROM {context} ctx JOIN {ai_action_register} aar ON aar.contextid = ctx.id JOIN {ai_action_summarise_text} aast ON aast.id = aar.actionid WHERE aar.actionname = 'summarise_text' AND aar.contextid = :contextid"; $userlist->add_from_sql('userid', $sql, ['contextid' => $context->id]); // AI action explain text. $sql = "SELECT DISTINCT aar.userid FROM {context} ctx JOIN {ai_action_register} aar ON aar.contextid = ctx.id JOIN {ai_action_explain_text} aaet ON aaet.id = aar.actionid WHERE aar.actionname = 'explain_text' AND aar.contextid = :contextid"; $userlist->add_from_sql('userid', $sql, ['contextid' => $context->id]); } /** * Delete multiple users within a single context. * * @param approved_userlist $userlist The approved context and user information to delete information for. */ public static function delete_data_for_users(approved_userlist $userlist) { global $DB; $context = $userlist->get_context(); $userids = $userlist->get_userids(); [$useridssql, $useridsparams] = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED); $params = [ 'contextid' => $context->id, ]; $params += $useridsparams; // Policy. $sql = "SELECT DISTINCT apr.id FROM {ai_policy_register} apr JOIN {context} ctx ON apr.contextid = ctx.id WHERE ctx.id = :contextid AND apr.userid " . $useridssql; $policydetails = $DB->get_records_sql($sql, $params); if ($policydetails) { $DB->delete_records_list('ai_policy_register', 'id', array_keys($policydetails)); } // AI action generate text. $sql = "SELECT DISTINCT aagt.id FROM {ai_action_register} aar JOIN {ai_action_generate_text} aagt ON aar.actionid = aagt.id JOIN {context} ctx ON aar.contextid = ctx.id WHERE aar.actionname = 'generate_text' AND ctx.id = :contextid AND aar.userid " . $useridssql; $aagtids = $DB->get_records_sql_menu($sql, $params); if ($aagtids) { [$aagtidsql, $aagtidparams] = $DB->get_in_or_equal(array_keys($aagtids), SQL_PARAMS_NAMED); $sql = "UPDATE {ai_action_generate_text} SET prompt = '', responseid = '', fingerprint = '', generatedcontent = '' WHERE id " . $aagtidsql; $DB->execute($sql, $aagtidparams); } // AI action generate image. $sql = "SELECT DISTINCT aagi.id FROM {ai_action_register} aar JOIN {ai_action_generate_image} aagi ON aar.actionid = aagi.id JOIN {context} ctx ON aar.contextid = ctx.id WHERE aar.actionname = 'generate_image' AND ctx.id = :contextid AND aar.userid " . $useridssql; $aagiids = $DB->get_records_sql_menu($sql, $params); if ($aagiids) { [$aagiidsql, $aagiidparams] = $DB->get_in_or_equal(array_keys($aagiids), SQL_PARAMS_NAMED); $sql = "UPDATE {ai_action_generate_image} SET prompt = '', sourceurl = '', revisedprompt = '' WHERE id " . $aagiidsql; $DB->execute($sql, $aagiidparams); } // AI action summarise text. $sql = "SELECT DISTINCT aast.id FROM {ai_action_register} aar JOIN {ai_action_summarise_text} aast ON aar.actionid = aast.id JOIN {context} ctx ON aar.contextid = ctx.id WHERE aar.actionname = 'summarise_text' AND ctx.id = :contextid AND aar.userid " . $useridssql; $aastids = $DB->get_records_sql_menu($sql, $params); if ($aastids) { [$aastidsql, $aastidparams] = $DB->get_in_or_equal(array_keys($aastids), SQL_PARAMS_NAMED); $sql = "UPDATE {ai_action_summarise_text} SET prompt = '', responseid = '', fingerprint = '', generatedcontent = '' WHERE id " . $aastidsql; $DB->execute($sql, $aastidparams); } // AI action explain text. $sql = "SELECT DISTINCT aaet.id FROM {ai_action_register} aar JOIN {ai_action_explain_text} aaet ON aar.actionid = aaet.id JOIN {context} ctx ON aar.contextid = ctx.id WHERE aar.actionname = 'explain_text' AND ctx.id = :contextid AND aar.userid " . $useridssql; $aaetids = $DB->get_records_sql_menu($sql, $params); if ($aaetids) { [$aaetidsql, $aaetidparams] = $DB->get_in_or_equal(array_keys($aaetids), SQL_PARAMS_NAMED); $sql = "UPDATE {ai_action_explain_text} SET prompt = '', responseid = '', fingerprint = '', generatedcontent = '' WHERE id " . $aaetidsql; $DB->execute($sql, $aaetidparams); } } } PK*\u϶classes/rate_limiter.phpnu[. namespace core_ai; use Psr\Clock\ClockInterface; /** * Rate limiting functionality that can be used by AI providers. * * @package core_ai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class rate_limiter { /** @var int TIME_WINDOW Time window in seconds (1 hour). */ public const TIME_WINDOW = HOURSECS; /** @var null|rate_limiter Singleton instance of the rate limiter. */ private static ?rate_limiter $instance = null; /** @var \cache_application Cache instance for rate limiter. */ private \cache_application $cache; /** * Constructor. * * @param ClockInterface $clock Clock instance for time management. */ public function __construct( /** @var ClockInterface Clock instance for time management. */ private ClockInterface $clock, ) { $this->cache = \cache::make('core', 'ai_ratelimit'); } /** * Check global rate limit for a component. * * @param string $component Name of the component. * @param int $ratelimit Number of requests per time window. * @return bool True if request is allowed, false otherwise. */ public function check_global_rate_limit(string $component, int $ratelimit): bool { $currenttime = $this->clock->now()->getTimestamp(); return $this->check_limit("global_{$component}", $ratelimit, $currenttime); } /** * Check user rate limit for a component. * * @param string $component Name of the component. * @param int $ratelimit Number of requests per time window. * @param int $userid User ID for user-specific rate limit. * @return bool True if request is allowed, false otherwise. */ public function check_user_rate_limit(string $component, int $ratelimit, int $userid): bool { $currenttime = $this->clock->now()->getTimestamp(); // Check and update user limit. return $this->check_limit("user_{$component}_{$userid}", $ratelimit, $currenttime); } /** * Helper function to check limit in cache. * * @param string $key Cache key. * @param int $ratelimit Number of requests per time window. * @param int $currenttime Current timestamp. * @return bool True if request is allowed, false otherwise. */ private function check_limit(string $key, int $ratelimit, int $currenttime): bool { $ratedata = $this->cache->get($key); if ($ratedata === false) { // No data found, initialize rate data. $ratedata = ['count' => 0, 'start_time' => $currenttime]; } // Remove expired rate data. if ($currenttime - $ratedata['start_time'] > self::TIME_WINDOW) { $ratedata['count'] = 0; $ratedata['start_time'] = $currenttime; } // Check rate limit. if ($ratedata['count'] < $ratelimit) { $ratedata['count']++; $this->cache->set($key, $ratedata); return true; } // Rate limit exceeded. return false; } } PK*\\WWclasses/aimodel/base.phpnu[. namespace core_ai\aimodel; use MoodleQuickForm; /** * Base Model class. * * @package core_ai * @copyright 2025 Huong Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ abstract class base { /** * Get the display name of the model. * This name is used to display the model in the UI. * * @return string The display name of the model. */ abstract public function get_model_display_name(): string; /** * Get the name of the model. * This name is used to identify the model. The system will use this model name to make the request to the AI services. * * @return string The name of the model. */ abstract public function get_model_name(): string; /** * Add the model settings to the form. * * @param MoodleQuickForm $mform The form to add the model settings to. */ public function add_model_settings(MoodleQuickForm $mform): void { } /** * Check if the model has settings. * * @return bool Whether the model has settings. */ public function has_model_settings(): bool { return false; } } PK*\Uccclasses/manager.phpnu[. 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. * * @package core_ai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class manager { /** * Create a new AI manager. * * @param \moodle_database $db */ public function __construct( /** @var \moodle_database The database instance */ protected readonly \moodle_database $db, ) { } /** * Get communication provider class name from the plugin name. * * @param string $plugin The component name. * @return string The class name of the provider. */ private static function get_ai_plugin_classname(string $plugin): string { if (str_starts_with($plugin, 'aiprovider_')) { return "{$plugin}\\provider"; } else if (str_starts_with($plugin, 'aiplacement_')) { return "{$plugin}\\placement"; } else { // Explode if neither. throw new coding_exception("Plugin name does not start with 'aiprovider_' or 'aiplacement_': {$plugin}"); } } /** * Get the list of actions that this provider or placement supports, * given the name of the plugin. * * @param string $pluginname The name of the plugin to get the actions for. * @return array An array of action class names. */ public static function get_supported_actions(string $pluginname): array { $pluginclassname = static::get_ai_plugin_classname($pluginname); return $pluginclassname::get_action_list(); } /** * Given a list of actions get the provider instances that support them. * * Will return an array of arrays, indexed by action name. * * @param array $actions An array of fully qualified action class names. * @param bool $enabledonly If true, only return enabled providers. * @return array An array of provider instances indexed by action name. */ public function get_providers_for_actions(array $actions, bool $enabledonly = false): array { $providers = []; $instances = $this->get_sorted_providers(); foreach ($actions as $action) { $providers[$action] = []; foreach ($instances as $instance) { // Check the plugin is enabled and the provider is configured before making the action available. if ($enabledonly && (!$instance->enabled || !$this->is_action_enabled($instance->provider, $action, $instance->id)) || $enabledonly && !$instance->is_provider_configured()) { continue; } if (in_array($action, $instance->get_action_list())) { $providers[$action][] = $instance; } } } return $providers; } /** * Call the action provider. * * The named provider will process the action and return the result. * * @param provider $provider The provider to call. * @param base $action The action to process. * @return responses\response_base The result of the action. */ protected function call_action_provider(provider $provider, base $action): responses\response_base { $classname = 'process_' . $action->get_basename(); $classpath = substr($provider::class, 0, strpos($provider::class, '\\') + 1); $processclass = $classpath . $classname; $processor = new $processclass($provider, $action); return $processor->process($action); } /** * Process an action. * * This is the entry point for processing an action. * * @param base $action The action to process. Action must be configured. * @return responses\response_base The result of the action. */ public function process_action(base $action): responses\response_base { // Get the action response_base name. $actionname = $action::class; $responseclassname = 'core_ai\\aiactions\\responses\\response_' . $action->get_basename(); // Get the providers that support the action. $providers = $this->get_providers_for_actions([$actionname], true); // Loop through the providers and process the action. foreach ($providers[$actionname] as $provider) { $result = $this->call_action_provider($provider, $action); // Store the result (success or failure). $this->store_action_result($provider, $action, $result); // If the result is successful, return the result. // No need to keep looping. if ($result->get_success()) { return $result; } } // If we get here we've all available providers have failed. // Return the result if we have one. if (isset($result)) { return $result; } // Response if there are no providers available. return new $responseclassname( success: false, errorcode: -1, errormessage: 'No providers available to process the action.'); } /** * Store the action result. * * @param provider $provider The provider that processed the action. * @param base $action The action that was processed. * @param responses\response_base $response The result of the action. * @return int The id of the stored action result. */ private function store_action_result( provider $provider, base $action, responses\response_base $response, ): int { global $DB; // Store the action result. $record = (object) [ 'actionname' => $action->get_basename(), 'success' => $response->get_success(), 'userid' => $action->get_configuration('userid'), 'contextid' => $action->get_configuration('contextid'), 'provider' => $provider->get_name(), 'errorcode' => $response->get_errorcode(), 'errormessage' => $response->get_errormessage(), 'timecreated' => $action->get_configuration('timecreated'), 'timecompleted' => $response->get_timecreated(), 'model' => $response->get_model_used(), ]; try { // Do everything in a transaction. $transaction = $DB->start_delegated_transaction(); // Create the record for the action result. $record->actionid = $action->store($response); $recordid = $DB->insert_record('ai_action_register', $record); // Commit the transaction. $transaction->allow_commit(); } catch (\Exception $e) { // Rollback the transaction. $transaction->rollback($e); // Re throw the exception. throw $e; } return $recordid; } /** * Set the policy acceptance for a given user. * * @param int $userid The user id. * @param int $contextid The context id the policy was accepted in. * @return bool True if the policy was set, false otherwise. */ public static function user_policy_accepted(int $userid, int $contextid): bool { global $DB; $record = (object) [ 'userid' => $userid, 'contextid' => $contextid, 'timeaccepted' => \core\di::get(\core\clock::class)->time(), ]; if ($DB->insert_record('ai_policy_register', $record)) { $policycache = \cache::make('core', 'ai_policy'); return $policycache->set($userid, true); } else { return false; } } /** * Get the user policy. * * @param int $userid The user id. * @return bool True if the policy was accepted, false otherwise. */ public static function get_user_policy_status(int $userid): bool { $policycache = \cache::make('core', 'ai_policy'); return $policycache->get($userid); } /** * Set the action state for a given plugin. * * @param string $plugin The name of the plugin. * @param string $actionbasename The action to be set. * @param int $enabled The state to be set (e.g., enabled or disabled). * @param int $instanceid The instance id of the instance. * @return bool Returns true if the configuration was successfully set, false otherwise. */ public function set_action_state( string $plugin, string $actionbasename, int $enabled, int $instanceid = 0 ): bool { $actionclass = 'core_ai\\aiactions\\' . $actionbasename; $oldvalue = $this->is_action_enabled($plugin, $actionclass, $instanceid); // Check if we are setting an action for a provider or placement. if (str_contains($plugin, 'aiprovider')) { // Handle provider actions. $providers = $this->get_provider_instances(['id' => $instanceid]); $provider = reset($providers); // Update the enabled state of the action. $actionconfig = $provider->actionconfig; $actionconfig[$actionclass]['enabled'] = (bool)$enabled; return $this->update_provider_instance( provider: $provider, actionconfig: $actionconfig)->actionconfig[$actionclass]['enabled']; } else { // Handle placement actions. // Only set value if there is no config setting or if the value is different from the previous one. if ($oldvalue !== (bool)$enabled) { set_config($actionbasename, $enabled, $plugin); add_to_config_log('disabled', !$oldvalue, !$enabled, $plugin); \core_plugin_manager::reset_caches(); return true; } return false; } } /** * Check if an action is enabled for a given provider. * * @param string $plugin The name of the plugin. * @param string $actionclass The fully qualified action class name to be checked. * @param int $instanceid The instance id of the plugin. * @return bool Returns the configuration value of the action for the given plugin. */ private function is_provider_action_enabled(string $plugin, string $actionclass, int $instanceid): bool { // If there is no instance id, we are checking the provider itself. // So get the defaults. if ($instanceid === 0) { // Get the defaults for this provider type. $classname = "\\{$plugin}\\provider"; $defaultconfig = $classname::initialise_action_settings(); // Return the default value. return array_key_exists($actionclass, $defaultconfig) && $defaultconfig[$actionclass]['enabled']; } else { // Get the provider instance. $providers = $this->get_provider_instances(['id' => $instanceid]); $provider = reset($providers); return array_key_exists($actionclass, $provider->actionconfig) && $provider->actionconfig[$actionclass]['enabled']; } } /** * Check if an action is enabled for a given plugin. * * @param string $plugin The name of the plugin. * @param string $actionclass The fully qualified action class name to be checked. * @param int $instanceid The instance id of the plugin. * @return bool Returns the configuration value of the action for the given plugin. */ public function is_action_enabled(string $plugin, string $actionclass, int $instanceid = 0): bool { if (str_contains($plugin, 'aiprovider')) { // Handle provider actions. return $this->is_provider_action_enabled($plugin, $actionclass, $instanceid); } else { // Handle placement actions. $value = get_config($plugin, $actionclass::get_basename()); // If not exist in DB, set it to true (enabled). if ($value === false) { return true; } return (bool) $value; } } /** * Check if an action is available. * Action is available if it is enabled for at least one enabled provider. * * @param string $actionclass The fully qualified action class name to be checked. * @return bool */ public function is_action_available(string $actionclass): bool { $providers = $this->get_providers_for_actions([$actionclass], true); // Check if the requested action is enabled for at least one provider. foreach ($providers as $provideractions) { foreach ($provideractions as $provider) { $classnamearray = explode('\\', $provider::class); $pluginname = reset($classnamearray); if ($this->is_action_enabled($pluginname, $actionclass)) { return true; } } } // There are no providers with this action enabled. return false; } /** * Create a new provider instance. * * @param string $classname Classname of the provider. * @param string $name The name of the provider config. * @param bool $enabled The enabled state of the provider. * @param array|null $config The config json. * @param array|null $actionconfig The action config json. * @return provider */ public function create_provider_instance( string $classname, string $name, bool $enabled = false, ?array $config = null, ?array $actionconfig = null, ): provider { if (!class_exists($classname) || !is_a($classname, provider::class, true)) { throw new \coding_exception("Provider class not valid: {$classname}"); } $provider = new $classname( enabled: $enabled, name: $name, config: json_encode($config ?? []), actionconfig: $actionconfig ? json_encode($actionconfig) : '', ); $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); } /** * Get single provider record according to the filter * * @param array $filter The filterable elements to get the record from * @param int $strictness * @return \stdClass|false */ public function get_provider_record(array $filter = [], int $strictness = IGNORE_MISSING): \stdClass|false { return $this->db->get_record( table: 'ai_providers', conditions: $filter, strictness: $strictness, ); } /** * Get the provider records according to the filter. * * @param array|null $filter The filterable elements to get the records from. * @return array */ public function get_provider_records(?array $filter = null): array { return $this->db->get_records( table: 'ai_providers', conditions: $filter, ); } /** * Get a list of all provider instances. * * This method retrieves provider records from the database, attempts to instantiate * each provider class, and returns an array of provider instances. It filters out * any records where the provider class does not exist. * * @param null|array $filter The database filter to apply when fetching provider records. * @return array An array of instantiated provider objects. */ public function get_provider_instances(?array $filter = null): array { // Filter out any null values from the array (providers that couldn't be instantiated). return array_filter( // Apply a callback function to each provider record to instantiate the provider. array_map( function ($record): ?provider { // Check if the provider class specified in the record exists. if (!class_exists($record->provider)) { // Log a debugging message if the provider class is not found. debugging( "Unable to find a provider class for {$record->provider}", DEBUG_DEVELOPER, ); // Return null to indicate that the provider could not be instantiated. return null; } // Instantiate the provider class with the record's data. return new $record->provider( enabled: $record->enabled, id: $record->id, name: $record->name, config: $record->config, actionconfig: $record->actionconfig, ); }, // Retrieve the provider records from the database with the optional filter. $this->get_provider_records($filter), ) ); } /** * Update provider instance. * * @param provider $provider The provider instance. * @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 */ public function update_provider_instance( provider $provider, ?array $config = null, ?array $actionconfig = null ): provider { $provider = $provider->with( name: $provider->name, config: $config ?? $provider->config, actionconfig: $actionconfig ?? $provider->actionconfig, ); $this->db->update_record('ai_providers', $provider->to_record()); return $provider; } /** * Delete the provider instance. * * @param provider $provider The provider instance. * @return bool */ public function delete_provider_instance(provider $provider): bool { // Dispatch the hook before deleting the record. $hook = new \core_ai\hook\before_provider_deleted( provider: $provider, ); $hookmanager = \core\di::get(\core\hook\manager::class)->dispatch($hook); if ($hookmanager->isPropagationStopped()) { $deleted = false; } else { $deleted = $this->db->delete_records('ai_providers', ['id' => $provider->id]); } return $deleted; } /** * Enable a provider instance. * * @param provider $provider * @return provider */ public function enable_provider_instance(provider $provider): provider { 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; } /** * Disable a provider. * * @param provider $provider * @return provider */ public function disable_provider_instance(provider $provider): provider { if ($provider->enabled) { $hook = new \core_ai\hook\before_provider_disabled( provider: $provider, ); $hookmanager = \core\di::get(\core\hook\manager::class)->dispatch($hook); if (!$hookmanager->isPropagationStopped()) { $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; } } PK*\1ג2-classes/admin/admin_settingspage_provider.phpnu[. namespace core_ai\admin; use admin_settingpage; /** * AI provider plugin admin settings pages manager. * * This class extends the admin_settingpage class and is used to manage admin settings pages. * It includes a method to hide the "Save changes" button from the admin provider specific settings page. * * @package core_ai * @copyright Meirza * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class admin_settingspage_provider extends admin_settingpage { #[\Override] public function show_save(): bool { return false; } } PK*\ .classes/admin/admin_setting_action_manager.phpnu[. namespace core_ai\admin; use admin_setting; use coding_exception; /** * Admin setting plugin manager. * * @package core_ai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class admin_setting_action_manager extends admin_setting { /** * Constructor. * * @param string $pluginname The name of the plugin these actions related too. * @param string $tableclass The class of the management table to use. * @param string $name The unique name. * @param string $visiblename The localised name. * @param string $description The localised long description in Markdown format. * @param string $defaultsetting The default setting. */ public function __construct( /** @var string The name of the plugin these actions related too */ protected string $pluginname, /** @var string The class of the management table to use */ protected string $tableclass, string $name, string $visiblename, string $description = '', string $defaultsetting = '', ) { $this->nosave = true; parent::__construct($name, $visiblename, $description, $defaultsetting); } #[\Override] public function get_setting(): bool { return true; } #[\Override] public function write_setting($data): string { // Do not write any setting. return ''; } #[\Override] public function output_html($data, $query = ''): string { $table = new $this->tableclass($this->pluginname); if ( !($table instanceof \core_ai\table\aiprovider_action_management_table) && !($table instanceof \core_ai\table\aiplacement_action_management_table) ) { throw new coding_exception(sprintf( "% must be an instance aiprovider_action_management_table or aiplacement_action_management_table", $this->tableclass )); } return highlight($query, $table->get_content()); } } PK*\ҪQe e 0classes/admin/admin_setting_provider_manager.phpnu[. namespace core_ai\admin; use admin_setting; use coding_exception; /** * Admin setting provider manager. * * @package core_ai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class admin_setting_provider_manager extends admin_setting { /** * Constructor. * * @param string $pluginname The name of the plugin these actions related too. * @param string $tableclass The class of the management table to use. * @param string $name The unique name. * @param string $visiblename The localised name. * @param string $description The localised long description in Markdown format. * @param string $defaultsetting The default setting. */ public function __construct( /** @var string The name of the plugin these actions related too */ protected string $pluginname, /** @var string The class of the management table to use */ protected string $tableclass, string $name, string $visiblename, string $description = '', string $defaultsetting = '', ) { $this->nosave = true; parent::__construct($name, $visiblename, $description, $defaultsetting); } #[\Override] public function get_setting(): bool { return true; } #[\Override] public function write_setting($data): string { // Do not write any setting. return ''; } #[\Override] public function output_html($data, $query = ''): string { $table = new $this->tableclass($this->pluginname); if ( !($table instanceof \core_ai\table\aiprovider_management_table) ) { throw new coding_exception(sprintf( "% must be an instance aiprovider_management_table", $this->tableclass )); } return highlight($query, $table->get_content()); } } PK*\%_classes/placement.phpnu[. namespace core_ai; /** * Class placement. * * @package core_ai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ abstract class placement { /** * Get the actions that this placement supports. * * Returns an array of action class names. * * @return array An array of action class names. */ abstract public static function get_action_list(): array; /** * Given an action class name. * * Returns an array of sub actions that this placement supports. * * @param string $classname The action class name. * @return array An array of supported sub actions. */ public function get_sub_actions(string $classname): array { return []; } } PK*\cQ-qq3classes/hook/after_ai_action_settings_form_hook.phpnu[. namespace core_ai\hook; use MoodleQuickForm; /** * Hook after AI action settings form is initiated. * * @package core_ai * @copyright 2025 Huong Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @property-read MoodleQuickForm $mform The form element * @property-read ?string $plugin Name of the model */ #[\core\attribute\label('Allows plugins to add form elements for action settings setup.')] #[\core\attribute\tags('ai')] class after_ai_action_settings_form_hook { /** * Constructor for the hook. * * @param MoodleQuickForm $mform The moodle form instance. * @param ?string $plugin The name of the plugin */ public function __construct( /** @var MoodleQuickForm The moodle form instance. */ public readonly MoodleQuickForm $mform, /** @var ?string The name of the plugin */ public readonly ?string $plugin, ) { } } PK*\g/"")classes/hook/before_provider_disabled.phpnu[. namespace core_ai\hook; use core_ai\provider; use core\hook\stoppable_trait; /** * Hook before ai provider is disabled. * * @package core_ai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @property-read provider $provider The provider instance */ #[\core\attribute\label('Allows plugins or features to check the usage of an AI provider before disabling the provider.')] #[\core\attribute\tags('ai')] class before_provider_disabled implements \Psr\EventDispatcher\StoppableEventInterface { use stoppable_trait; /** * Constructor for the hook. * * @param provider $provider The provider instance. */ public function __construct( /** @var provider The provider instance. */ public readonly provider $provider, ) { } } PK*\/2(classes/hook/before_provider_deleted.phpnu[. namespace core_ai\hook; use core_ai\provider; use core\hook\stoppable_trait; /** * Hook before AI provider is deleted. * * @package core_ai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @property-read provider $provider The provider instance */ #[\core\attribute\label('Allows plugins to check the usage of an ai provider before deleting the instance record.')] #[\core\attribute\tags('ai')] class before_provider_deleted implements \Psr\EventDispatcher\StoppableEventInterface { use stoppable_trait; /** * Constructor for the hook. * * @param provider $provider The provider instance. */ public function __construct( /** @var provider The provider instance. */ public readonly provider $provider, ) { } } PK*\(IAjj,classes/hook/after_ai_provider_form_hook.phpnu[. namespace core_ai\hook; use MoodleQuickForm; /** * Hook after AI provider setup form is initiated. * * @package core_ai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @property-read MoodleQuickForm $mform The form element * @property-read ?string $plugin Name of the plugin */ #[\core\attribute\label('Allows plugins to add form elements for ai provider setup.')] #[\core\attribute\tags('ai')] class after_ai_provider_form_hook { /** * Constructor for the hook. * * @param MoodleQuickForm $mform The moodle form instance. * @param ?string $plugin The name of the plugin */ public function __construct( /** @var MoodleQuickForm The moodle form instance. */ public readonly MoodleQuickForm $mform, /** @var ?string The name of the plugin */ public readonly ?string $plugin, ) { } } PK*\q/eclasses/provider.phpnu[. namespace core_ai; use core_ai\form\action_settings_form; use Psr\Http\Message\RequestInterface; use Spatie\Cloneable\Cloneable; /** * Class provider. * * @package core_ai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ abstract class provider { use Cloneable; /** @var string $provider The provider used to make this instance */ public readonly string $provider; /** @var array The configuration for this instance. */ public readonly array $config; /** @var array The action specific settings for this instance. */ public readonly array $actionconfig; /** * Create a new provider. * * @param bool $enabled Whether the gateway is enabled * @param string $name The name of the provider config. * @param string $config The configuration for this instance. * @param string $actionconfig The action specific settings for this instance. * @param int|null $id The id of the provider in the database. */ public function __construct( /** @var bool Whether the gateway is enabled */ public readonly bool $enabled, /** @var string The name of the provider config. */ public string $name, string $config, string $actionconfig = '', /** @var null|int The ID of the provider in the database, or null if it has not been persisted yet. */ public readonly ?int $id = null, ) { $this->provider = strstr(get_class($this), '\\', true); $this->config = json_decode($config, true); if ($actionconfig == '') { $this->actionconfig = static::initialise_action_settings(); } else { $this->actionconfig = json_decode($actionconfig, true); } } /** * Get the actions that this provider supports. * * Returns an array of action class names. * * @return array An array of action class names. */ abstract public static function get_action_list(): array; /** * Initialise the action settings array. * * @return array The initialised action settings. */ public static function initialise_action_settings(): array { $actions = static::get_action_list(); $actionconfig = []; foreach ($actions as $action) { $actionconfig[$action] = [ 'enabled' => true, 'settings' => static::get_action_setting_defaults($action), ]; } return $actionconfig; } /** * Given an action class name, return an array of sub actions * that this provider supports. * * @param string $classname The action class name. * @return array An array of supported sub actions. */ public function get_sub_actions(string $classname): array { return []; } /** * Get the name of the provider. * * @return string The name of the provider. */ public function get_name(): string { return \core\component::get_component_from_classname(get_class($this)); } /** * Get any action settings for this provider. * * @param string $action The action class name. * @param array $customdata The customdata for the form. * @return action_settings_form|bool The settings form for this action or false in no settings. */ public static function get_action_settings( string $action, array $customdata = [], ): action_settings_form|bool { return false; } /** * Get the default settings for an action. * * @param string $action The action class name. * @return array The default settings for the action. */ public static function get_action_setting_defaults(string $action): array { return []; } /** * Check if the request is allowed by the rate limiter. * * @param aiactions\base $action The action to check. * @return array|bool True on success, array of error details on failure. */ public function is_request_allowed(aiactions\base $action): array|bool { $ratelimiter = \core\di::get(rate_limiter::class); $component = \core\component::get_component_from_classname(get_class($this)); // Check the user rate limit. if (isset($this->config['enableuserratelimit']) && $this->config['enableuserratelimit']) { if (!$ratelimiter->check_user_rate_limit( component: $component, ratelimit: $this->config['userratelimit'], userid: $action->get_configuration('userid') )) { return [ 'success' => false, 'errorcode' => 429, 'errormessage' => 'User rate limit exceeded', ]; } } // Check the global rate limit. if (isset($this->config['enableglobalratelimit']) && $this->config['enableglobalratelimit']) { if (!$ratelimiter->check_global_rate_limit( component: $component, ratelimit: $this->config['globalratelimit'] )) { return [ 'success' => false, 'errorcode' => 429, 'errormessage' => 'Global rate limit exceeded', ]; } } return true; } /** * Check if a provider has the minimal configuration to work. * * @return bool Return true if configured. */ public function is_provider_configured(): bool { return false; } /** * Update a request to add any headers required by the provider (if needed). * AI providers will need to override this method to add their own headers. * * @param RequestInterface $request * @return RequestInterface */ public function add_authentication_headers(RequestInterface $request): RequestInterface { return $request; } /** * Generate a user id. * * This is a hash of the site id and user id, * this means we can determine who made the request * but don't pass any personal data to the AI provider. * * @param string $userid The user id. * @return string The generated user id. */ public function generate_userid(string $userid): string { global $CFG; return hash('sha256', $CFG->siteidentifier . $userid); } /** * Convert this object to a stdClass, suitable for saving to the database. * * @return \stdClass */ public function to_record(): \stdClass { return (object) [ 'id' => $this->id, 'name' => $this->name, 'provider' => get_class($this), 'enabled' => $this->enabled, 'config' => json_encode($this->config), 'actionconfig' => json_encode($this->actionconfig), ]; } } PK*\V7classes/table/aiprovider_management_table_filterset.phpnu[. namespace core_ai\table; /** * AI provider management table filter set. * * @package core_ai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class aiprovider_management_table_filterset extends \core_table\local\filter\filterset { } PK*\u5classes/table/aiplacement_action_management_table.phpnu[. namespace core_ai\table; use core_ai\manager; use core_table\dynamic as dynamic_table; use flexible_table; use moodle_url; use stdClass; /** * Table to manage AI actions used in placement plugins. * * @package core_ai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class aiplacement_action_management_table extends flexible_table implements dynamic_table { /** @var string The name of the plugin these actions related too */ protected string $pluginname; /** @var array The list of actions this manager covers */ protected array $actions; /** @var \core_ai\manager The AI manager */ protected \core_ai\manager $manager; /** * Constructor. * * @param string $uniqueid The table unique id. */ public function __construct(string $uniqueid) { // Parse the unique id and get the plugin name. $parseuniqueid = explode('-', $uniqueid); $pluginname = end($parseuniqueid); $this->pluginname = $pluginname; // Get the list of actions that this provider supports. $this->actions = manager::get_supported_actions($this->pluginname); parent::__construct($this->get_table_id()); $this->setup_column_configuration(); $this->set_filterset(new aiplacement_action_management_table_filterset()); $this->manager = \core\di::get(manager::class); $this->setup(); } #[\Override] public function get_context(): \context_system { return \context_system::instance(); } /** * Set up the column configuration for this table. */ protected function setup_column_configuration(): void { $columnlist = $this->get_column_list(); $this->define_columns(array_keys($columnlist)); $this->define_headers(array_values($columnlist)); } /** * Get the ID of the table. * * @return string */ protected function get_table_id(): string { return "aiplacementaction_management_table-{$this->pluginname}"; } /** * Get the web service method used to toggle state. * * @return null|string */ protected function get_toggle_service(): ?string { return 'core_ai_set_action'; } /** * Get the JS module used to manage this table. * * This should be a class which extends 'core_admin/plugin_management_table'. * * @return string */ protected function get_table_js_module(): string { return 'core_admin/plugin_management_table'; } #[\Override] protected function get_dynamic_table_html_end(): string { global $PAGE; $PAGE->requires->js_call_amd($this->get_table_js_module(), 'init'); return parent::get_dynamic_table_html_end(); } /** * Get a list of the column titles * @return string[] */ protected function get_column_list(): array { return [ 'namedesc' => get_string('name', 'core'), 'enabled' => get_string('pluginenabled', 'core_plugin'), ]; } /** * Show the name column content. * * @param stdClass $row * @return string */ protected function col_namedesc(stdClass $row): string { global $OUTPUT; $params = [ 'name' => $row->action::get_name(), 'description' => $row->action::get_description(), ]; $output = $OUTPUT->render_from_template('core_admin/table/namedesc', $params); if (!$this->manager->is_action_available($row->action)) { $providerurl = new moodle_url('/admin/settings.php', ['section' => 'aiprovider']); $output .= $OUTPUT->render_from_template('core_ai/admin_noproviders', [ 'providerurl' => $providerurl->out(), ]); } return $output; } /** * Show the enable/disable column content. * * @param stdClass $row * @return string */ protected function col_enabled(stdClass $row): string { global $OUTPUT; $enabled = $row->enabled; $identifier = $enabled ? 'disableplugin' : 'enableplugin'; $labelstr = get_string($identifier, 'core_admin', $row->action::get_name()); $params = [ 'id' => 'admin-toggle-' . $row->action::get_basename(), 'checked' => $enabled, 'dataattributes' => [ 'name' => 'id', 'value' => $row->action, 'toggle-method' => $this->get_toggle_service(), 'action' => 'togglestate', 'plugin' => $this->pluginname . "-" . $row->action::get_basename(), 'state' => $enabled ? 1 : 0, ], 'title' => $labelstr, 'label' => $labelstr, 'labelclasses' => 'visually-hidden', ]; return $OUTPUT->render_from_template('core_admin/setting_configtoggle', $params); } /** * Get any class to add to the row. * * @param mixed $row * @return string */ protected function get_row_class($row): string { if (!$row->enabled) { return 'dimmed_text'; } return ''; } /** * Get the table content. */ public function get_content(): string { ob_start(); $this->out(); $content = ob_get_contents(); ob_end_clean(); return $content; } /** * Print the table. */ public function out(): void { foreach ($this->actions as $actionclass) { // Construct the row data. $rowdata = (object) [ 'action' => $actionclass, 'enabled' => $this->manager->is_action_enabled($this->pluginname, $actionclass), ]; $this->add_data_keyed( $this->format_row($rowdata), $this->get_row_class($rowdata) ); } $this->finish_output(false); } #[\Override] public function is_downloadable($downloadable = null): bool { return false; } #[\Override] public function guess_base_url(): void { $url = new moodle_url('/'); $this->define_baseurl($url); } #[\Override] public function has_capability(): bool { return has_capability('moodle/site:config', $this->get_context()); } } PK*\n??.classes/table/aiplacement_management_table.phpnu[. namespace core_ai\table; use moodle_url; /** * Table to manage AI Placement plugins. * * @package core_ai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class aiplacement_management_table extends \core_admin\table\plugin_management_table { #[\Override] protected function get_plugintype(): string { return 'aiplacement'; } #[\Override] protected function get_action_url(array $params = []): moodle_url { return new moodle_url('/admin/ai.php', $params); } #[\Override] protected function get_column_list(): array { $columns = [ 'name' => get_string('placement', 'core_ai'), ]; if ($this->supports_disabling()) { $columns['enabled'] = get_string('pluginenabled', 'core_plugin'); } if ($this->supports_ordering()) { $columns['order'] = get_string('order', 'core'); } $columns['settings'] = get_string('settings', 'core'); $columns['uninstall'] = get_string('uninstallplugin', 'core_admin'); return $columns; } } PK*\*?classes/table/aiplacement_action_management_table_filterset.phpnu[. namespace core_ai\table; /** * This file contains the dynamic interface. * * @package core_ai * @copyright Meirza * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class aiplacement_action_management_table_filterset extends \core_table\local\filter\filterset { } PK*\M22-classes/table/aiprovider_management_table.phpnu[. namespace core_ai\table; 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. * * @package core_ai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class aiprovider_management_table extends flexible_table implements dynamic_table { /** * @var array $aiproviders List of configured provider instances. */ protected array $aiproviders = []; /** @var int The number of enabled provider instances. */ protected int $enabledprovidercount = 0; /** * Constructor for the AI provider table. */ public function __construct() { parent::__construct($this->get_table_id()); $this->aiproviders = $this->get_providers(); $this->setup_column_configuration(); $this->set_filterset(new aiprovider_management_table_filterset()); $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; })); } /** * Get the plugin type for the table. * * @return string * @deprecated since 5.0 */ #[\core\attribute\deprecated(replacement: null, since: '5.0', mdl: 'MDL-82977')] protected function get_plugintype(): string { \core\deprecation::emit_deprecation(__FUNCTION__); return 'aiprovider'; } #[\Override] public function get_context(): context_system { return context_system::instance(); } #[\Override] public function has_capability(): bool { return has_capability('moodle/site:config', $this->get_context()); } /** * Get the table id for the table. * * @return string */ protected function get_table_id(): string { return 'aiproviders_table'; } /** * Get the js module needed for the table. * * This module can include table specific ajax calls etc. * * @return string */ protected function get_table_js_module(): string { return 'core_ai/aiprovider_instance_management_table'; } /** * Webservice for toggle. * * @return string */ protected function get_toggle_service(): string { return 'core_ai_set_provider_status'; } /** * Webservice for delete. * * @return string */ protected function get_delete_service(): string { return 'core_ai_delete_provider_instance'; } /** * Get the action URL for the table. * * @param array $params The params to pass to the URL. * @return moodle_url * @deprecated since 5.0 */ #[\core\attribute\deprecated(replacement: null, since: '5.0', mdl: 'MDL-82977')] protected function get_action_url(array $params = []): moodle_url { \core\deprecation::emit_deprecation(__FUNCTION__); return new moodle_url('/admin/ai.php', $params); } #[\Override] protected function get_dynamic_table_html_end(): string { global $PAGE; $PAGE->requires->js_call_amd($this->get_table_js_module(), 'init'); return parent::get_dynamic_table_html_end(); } /** * Get the configured ai providers from the manager. * * @return array */ protected function get_providers(): array { return \core\di::get(\core_ai\manager::class)->get_sorted_providers(); } /** * Setup the column configs for the table. */ protected function setup_column_configuration(): void { $columnlist = $this->get_column_list(); $this->define_columns(array_keys($columnlist)); $this->define_headers(array_values($columnlist)); } #[\Override] public function guess_base_url(): void { $this->define_baseurl(new moodle_url('/admin/ai.php')); } /** * Get the column list for the table. * * @return array */ protected function get_column_list(): array { return [ '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'), ]; } /** * Get the content of the table. * * @return string */ public function get_content(): string { ob_start(); $this->out(); $content = ob_get_contents(); ob_end_clean(); return $content; } /** * Add the row data for the table. */ public function out(): void { foreach ($this->aiproviders as $provider) { $rowdata = (object) [ 'id' => $provider->id, 'name' => $provider->name, 'provider' => $provider->provider, 'enabled' => (int)$provider->enabled, ]; $this->add_data_keyed( $this->format_row($rowdata), $this->get_row_class($rowdata) ); } $this->finish_output(false); } /** * Get the class for row whether is dimmed or not according to enabled or disabled. * * @param \stdClass $row The row object * @return string */ protected function get_row_class(\stdClass $row): string { if ($row->enabled) { return ''; } return 'dimmed_text'; } /** * The column for the provider instance Name. * * @param \stdClass $row The row object * @return string */ public function col_name(\stdClass $row): string { return $row->name; } /** * The column for the provider plugin name. * * @param \stdClass $row The row object * @return string */ public function col_provider(\stdClass $row): string { $component = \core\component::get_component_from_classname($row->provider); return get_string('pluginname', $component); } /** * The column for enabled or disabled status of the provider instance. * * @param \stdClass $row The row object * @return string */ public function col_enabled(\stdClass $row): string { global $OUTPUT; $enabled = $row->enabled; if ($enabled) { $labelstr = get_string('disableplugin', 'core_admin', $row->name); } else { $labelstr = get_string('enableplugin', 'core_admin', $row->name); } $params = [ 'id' => 'ai-provider-toggle-' . $row->id, 'checked' => $enabled, 'dataattributes' => [ 'name' => 'id', 'value' => $row->provider, 'toggle-method' => $this->get_toggle_service(), 'action' => 'togglestate', 'plugin' => $row->id, // Set plugin attribute to provider ID. 'state' => $enabled ? 1 : 0, ], 'title' => $labelstr, 'label' => $labelstr, 'labelclasses' => 'visually-hidden', ]; return $OUTPUT->render_from_template('core_admin/setting_configtoggle', $params); } /** * The column to show the settings of the provider instance. * * @param \stdClass $row The row object. * @return string */ public function col_settings(\stdClass $row): string { $settingsurl = new moodle_url('/ai/configure.php', ['id' => $row->id]); return \html_writer::link($settingsurl, get_string('settings')); } /** * The column to show the delete action for the provider instance. * * @param \stdClass $row The row object. * @return string */ public function col_delete(\stdClass $row): string { global $OUTPUT; // Render the delete button from the template. $component = \core\component::get_component_from_classname($row->provider); $provider = get_string('pluginname', $component); $params = [ 'id' => $row->id, 'name' => $row->name, 'provider' => $provider, 'delete-method' => $this->get_delete_service(), ]; 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', ]), ); } 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', ]), ); } 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); } } PK*\qNm#//4classes/table/aiprovider_action_management_table.phpnu[. namespace core_ai\table; use core_ai\manager; use core_table\dynamic as dynamic_table; use flexible_table; use moodle_url; use stdClass; /** * Table to manage AI actions used in provider plugins. * * @package core_ai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class aiprovider_action_management_table extends flexible_table implements dynamic_table { /** @var string The name of the plugin these actions related too */ protected string $pluginname; /** @var int the provider instance id */ protected int $providerid; /** @var array The list of actions this manager covers */ protected array $actions; /** @var \core_ai\manager The AI manager */ protected \core_ai\manager $manager; /** * Constructor. * * @param string $uniqueid The table unique id. */ public function __construct(string $uniqueid) { $parseuniqueid = explode('-', $uniqueid); $this->pluginname = reset($parseuniqueid); $this->providerid = (int)end($parseuniqueid); // Get the list of actions that this provider supports. $this->actions = \core_ai\manager::get_supported_actions($this->pluginname); parent::__construct($uniqueid); $this->setup_column_configuration(); $this->set_filterset(new aiprovider_action_management_table_filterset()); $this->manager = \core\di::get(manager::class); $this->setup(); } #[\Override] public function get_context(): \context_system { return \context_system::instance(); } /** * Set up the column configuration for this table. */ protected function setup_column_configuration(): void { $columnlist = $this->get_column_list(); $this->define_columns(array_keys($columnlist)); $this->define_headers(array_values($columnlist)); } /** * Get the ID of the table. * * @return string */ protected function get_table_id(): string { return "aiprovider_action_management_table-{$this->pluginname}"; } /** * Get the web service method used to toggle state. * * @return null|string */ protected function get_toggle_service(): ?string { return 'core_ai_set_action'; } /** * Get the JS module used to manage this table. * * This should be a class which extends 'core_admin/plugin_management_table'. * * @return string */ protected function get_table_js_module(): string { return 'core_ai/aiprovider_action_management_table'; } #[\Override] protected function get_dynamic_table_html_end(): string { global $PAGE; $PAGE->requires->js_call_amd( $this->get_table_js_module(), 'init', [$this->providerid] ); return parent::get_dynamic_table_html_end(); } /** * Get a list of the column titles * * @return string[] */ protected function get_column_list(): array { return [ 'namedesc' => get_string('action', 'core_ai'), 'enabled' => get_string('pluginenabled', 'core_plugin'), 'settings' => get_string('settings', 'core'), ]; } /** * Show the name column content. * * @param stdClass $row * @return string */ protected function col_namedesc(stdClass $row): string { global $OUTPUT; $params = [ 'name' => $row->action::get_name(), 'description' => $row->action::get_description(), ]; return $OUTPUT->render_from_template('core_admin/table/namedesc', $params); } /** * Show the enable/disable column content. * * @param stdClass $row * @return string */ protected function col_enabled(stdClass $row): string { global $OUTPUT; $enabled = $row->enabled; $identifier = $enabled ? 'disableplugin' : 'enableplugin'; $labelstr = get_string($identifier, 'core_admin', $row->action::get_name()); $params = [ 'id' => 'admin-toggle-' . $row->action::get_basename(), 'checked' => $enabled, 'dataattributes' => [ 'name' => 'id', 'value' => $row->action, 'toggle-method' => $this->get_toggle_service(), 'action' => 'togglestate', 'plugin' => $this->pluginname . "-" . $row->action::get_basename(), 'state' => $enabled ? 1 : 0, ], 'title' => $labelstr, 'label' => $labelstr, 'labelclasses' => 'visually-hidden', ]; return $OUTPUT->render_from_template('core_admin/setting_configtoggle', $params); } /** * Show the settings column content. * * @param stdClass $row * @return string */ protected function col_settings(stdClass $row): string { $providerclass = "\\$this->pluginname\\provider"; $mform = $providerclass::get_action_settings($row->action); // Only show the per action settings options if we have a form to display. if ($mform) { $urlparams = [ 'provider' => $this->pluginname, 'action' => $row->action, 'providerid' => $this->providerid, ]; $url = new \moodle_url('/ai/configure_actions.php', $urlparams); return \html_writer::link($url, get_string('settings')); } else { return ''; } } /** * Get any class to add to the row. * * @param mixed $row * @return string */ protected function get_row_class($row): string { if (!$row->enabled) { return 'dimmed_text'; } return ''; } /** * Get the table content. */ public function get_content(): string { ob_start(); $this->out(); $content = ob_get_contents(); ob_end_clean(); return $content; } /** * Print the table. */ public function out(): void { foreach ($this->actions as $actionclass) { // Construct the row data. $rowdata = (object) [ 'action' => $actionclass, 'enabled' => $this->manager->is_action_enabled( plugin: $this->pluginname, actionclass: $actionclass, instanceid: $this->providerid), ]; $this->add_data_keyed( $this->format_row($rowdata), $this->get_row_class($rowdata), ); } $this->finish_output(false); } #[\Override] public function is_downloadable($downloadable = null): bool { return false; } #[\Override] public function guess_base_url(): void { $url = new moodle_url('/'); $this->define_baseurl($url); } /** * Check capability for users accessing the action management table. * * @return bool */ public function has_capability(): bool { return has_capability('moodle/site:config', $this->get_context()); } } PK*\ZL>classes/table/aiprovider_action_management_table_filterset.phpnu[. namespace core_ai\table; /** * This file contains the dynamic interface. * * @package core_ai * @copyright Meirza * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class aiprovider_action_management_table_filterset extends \core_table\local\filter\filterset { } PK*\jaxclasses/cache/policy.phpnu[. namespace core_ai\cache; use cache_definition; /** * Cache class for AI policy. * * @package core_ai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class policy implements \cache_data_source { /** @var policy|null the singleton instance of this class. */ protected static ?policy $instance = null; #[\Override] public static function get_instance_for_cache(cache_definition $definition): policy { if (is_null(self::$instance)) { self::$instance = new self(); } return self::$instance; } #[\Override] public function load_for_cache($key) { global $DB; return $DB->record_exists('ai_policy_register', ['userid' => $key]); } #[\Override] public function load_many_for_cache(array $keys): array { global $DB; $return = []; [$insql, $inparams] = $DB->get_in_or_equal($keys); $sql = "SELECT userid FROM {ai_policy_register} WHERE userid " . $insql; $results = $DB->get_fieldset_sql($sql, $inparams); foreach ($keys as $key) { $return[$key] = in_array($key, $results); } return $return; } } PK*\c6 $classes/aiactions/summarise_text.phpnu[. namespace core_ai\aiactions; use core_ai\aiactions\responses\response_base; /** * Summarise text class. * * @package core_ai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class summarise_text extends base { /** * Create a new instance of the summarise_text action. * * It’s responsible for performing any setup tasks, * such as getting additional data from the database etc. * * @param int $contextid The context id the action was created in. * @param int $userid The user id making the request. * @param string $prompttext The prompt text used to generate the image. */ public function __construct( int $contextid, /** @var int The user id requesting the action. */ protected int $userid, /** @var string The prompt text used to generate the summary text */ protected string $prompttext, ) { parent::__construct($contextid); } #[\Override] public function store(response_base $response): int { global $DB; $responsearr = $response->get_response_data(); $record = new \stdClass(); $record->prompt = $this->prompttext; $record->responseid = $responsearr['id']; // Can be null. $record->fingerprint = $responsearr['fingerprint']; // Can be null. $record->generatedcontent = $responsearr['generatedcontent']; // Can be null. $record->finishreason = $responsearr['finishreason']; // Can be null. $record->prompttokens = $responsearr['prompttokens']; // Can be null. $record->completiontoken = $responsearr['completiontokens']; // Can be null. return $DB->insert_record($this->get_tablename(), $record); } } PK*\K #classes/aiactions/generate_text.phpnu[. namespace core_ai\aiactions; use core_ai\aiactions\responses\response_base; /** * Generate text class. * * @package core_ai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class generate_text extends base { /** * Create a new instance of the generate_text action. * * It’s responsible for performing any setup tasks, * such as getting additional data from the database etc. * * @param int $contextid The context id the action was created in. * @param int $userid The user id making the request. * @param string $prompttext The prompt text used to generate the image. */ public function __construct( int $contextid, /** @var int The user id requesting the action. */ protected int $userid, /** @var string The prompt text used to generate the text */ protected string $prompttext, ) { parent::__construct($contextid); } #[\Override] public function store(response_base $response): int { global $DB; $responsearr = $response->get_response_data(); $record = new \stdClass(); $record->prompt = $this->prompttext; $record->responseid = $responsearr['id']; // Can be null. $record->fingerprint = $responsearr['fingerprint']; // Can be null. $record->generatedcontent = $responsearr['generatedcontent']; // Can be null. $record->finishreason = $responsearr['finishreason']; // Can be null. $record->prompttokens = $responsearr['prompttokens']; // Can be null. $record->completiontoken = $responsearr['completiontokens']; // Can be null. return $DB->insert_record($this->get_tablename(), $record); } } PK*\)poU "classes/aiactions/explain_text.phpnu[. namespace core_ai\aiactions; use core_ai\aiactions\responses\response_base; /** * Explain text class. * * @package core_ai * @copyright 2024 David Woloszyn * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class explain_text extends base { /** * Create a new instance of the explain_text action. * * It’s responsible for performing any setup tasks, * such as getting additional data from the database etc. * * @param int $contextid The context id the action was created in. * @param int $userid The user id making the request. * @param string $prompttext The prompt text used to generate the image. */ public function __construct( int $contextid, /** @var int The user id requesting the action. */ protected int $userid, /** @var string The prompt text used to generate the summary text */ protected string $prompttext, ) { parent::__construct($contextid); } #[\Override] public function store(response_base $response): int { global $DB; $responsearr = $response->get_response_data(); $record = new \stdClass(); $record->prompt = $this->prompttext; $record->responseid = $responsearr['id']; // Can be null. $record->fingerprint = $responsearr['fingerprint']; // Can be null. $record->generatedcontent = $responsearr['generatedcontent']; // Can be null. $record->finishreason = $responsearr['finishreason']; // Can be null. $record->prompttokens = $responsearr['prompttokens']; // Can be null. $record->completiontoken = $responsearr['completiontokens']; // Can be null. return $DB->insert_record($this->get_tablename(), $record); } } PK*\Y $classes/aiactions/generate_image.phpnu[. namespace core_ai\aiactions; use core_ai\aiactions\responses\response_base; /** * Generate images class. * * @package core_ai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class generate_image extends base { /** * Create a new instance of the generate_image action. * * It’s responsible for performing any setup tasks, * such as getting additional data from the database etc. * * @param int $contextid The context id the action was created in. * @param int $userid The user id making the request. * @param string $prompttext The prompt text used to generate the image. * @param string $quality The quality of the generated image. * @param string $aspectratio The aspect ratio of the generated image. * @param int $numimages The number of images to generate. * @param string $style The visual style of the generated image. */ public function __construct( int $contextid, /** @var int The user id requesting the action. */ protected int $userid, /** @var string The prompt text used to generate the image */ protected string $prompttext, /** @var string The quality of the generated image */ protected string $quality, /** @var string The aspect ratio of the generated image */ protected string $aspectratio, /** @var int The number of images to generate */ protected int $numimages, /** @var string The visual style of the generated image */ protected string $style, ) { parent::__construct($contextid); } #[\Override] public function store(response_base $response): int { global $DB; $responsearr = $response->get_response_data(); $record = new \stdClass(); $record->prompt = $this->prompttext; $record->numberimages = $this->numimages; $record->quality = $this->quality; $record->aspectratio = $this->aspectratio; $record->style = $this->style; $record->sourceurl = $responsearr['sourceurl']; // Can be null. $record->revisedprompt = $responsearr['revisedprompt']; // Can be null. return $DB->insert_record($this->get_tablename(), $record); } } PK*\?o. . 7classes/aiactions/responses/response_generate_image.phpnu[. namespace core_ai\aiactions\responses; /** * Generate image action response class. * * Any method that processes an action must return an instance of this class. * * @package core_ai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class response_generate_image extends response_base { /** @var \stored_file|null The URL of the generated image. */ private ?\stored_file $draftfile = null; /** @var string|null The revised prompt generated by the AI. */ private ?string $revisedprompt = null; /** @var string|null The URL of the source image used to generate the image. */ private ?string $sourceurl = null; /** * Constructor. * * @param bool $success The success status of the action. * @param int $errorcode Error code. Must exist if success is false. * @param string $errormessage Error message. Must exist if success is false */ public function __construct( bool $success, int $errorcode = 0, string $errormessage = '', ) { parent::__construct( success: $success, actionname: 'generate_image', errorcode: $errorcode, errormessage: $errormessage, ); } #[\Override] public function set_response_data(array $response): void { $this->draftfile = $response['draftfile'] ?? null; $this->revisedprompt = $response['revisedprompt'] ?? null; $this->sourceurl = $response['sourceurl'] ?? null; $this->model = $response['model'] ?? null; } #[\Override] public function get_response_data(): array { return [ 'draftfile' => $this->draftfile, 'revisedprompt' => $this->revisedprompt, 'sourceurl' => $this->sourceurl, 'model' => $this->model, ]; } } PK*\I3  7classes/aiactions/responses/response_summarise_text.phpnu[. namespace core_ai\aiactions\responses; /** * Summarise text action response class. * * Any method that processes an action must return an instance of this class. * * @package core_ai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class response_summarise_text extends response_base { /** @var string|null A unique identifier for the chat completion, returned by the AI. */ private ?string $id = null; /** @var string|null This fingerprint represents the backend configuration that the model runs with. */ private ?string $fingerprint = null; /** @var string|null The contents of the generated message. */ private ?string $generatedcontent = null; /** @var string|null The reason the model stopped generating tokens. */ private ?string $finishreason = null; /** @var string|null Number of tokens in the prompt. */ private ?string $prompttokens = null; /** @var string|null Number of tokens in the generated completion. */ private ?string $completiontokens = null; /** * Constructor. * * @param bool $success The success status of the action. * @param int $errorcode Error code. Must exist if success is false. * @param string $errormessage Error message. Must exist if success is false */ public function __construct( bool $success, int $errorcode = 0, string $errormessage = '', ) { parent::__construct( success: $success, actionname: 'summarise_text', errorcode: $errorcode, errormessage: $errormessage, ); } #[\Override] public function set_response_data(array $response): void { $this->id = $response['id'] ?? null; $this->fingerprint = $response['fingerprint'] ?? null; $this->generatedcontent = $response['generatedcontent'] ?? null; $this->finishreason = $response['finishreason'] ?? null; $this->prompttokens = $response['prompttokens'] ?? null; $this->completiontokens = $response['completiontokens'] ?? null; $this->model = $response['model'] ?? null; } #[\Override] public function get_response_data(): array { return [ 'id' => $this->id, 'fingerprint' => $this->fingerprint, 'generatedcontent' => $this->generatedcontent, 'finishreason' => $this->finishreason, 'prompttokens' => $this->prompttokens, 'completiontokens' => $this->completiontokens, 'model' => $this->model, ]; } } PK*\-classes/aiactions/responses/response_base.phpnu[. namespace core_ai\aiactions\responses; use core\exception\coding_exception; /** * Action response base class. * Any method that processes an action must return an instance of this class. * * @package core_ai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ abstract class response_base { /** @var int The timestamp of when the response was created. */ private int $timecreated; /** * Constructor. * * @param bool $success The success status of the action. * @param string $actionname The name of the action that was processed. * @param int $errorcode Error code. Must exist if success is false. * @param string $errormessage Error message. Must exist if success is false * @param string $model The model used to generate the response. */ public function __construct( /** @var bool The success status of the action. */ private bool $success, /** @var string The name of the action that was processed. */ private string $actionname, /** @var int Error code. Must exist if status is error. */ private int $errorcode = 0, /** @var string Error message. Must exist if status is error */ private string $errormessage = '', /** @var string The model used to generate the response (if available). */ protected ?string $model = null, ) { $this->timecreated = \core\di::get(\core\clock::class)->time(); if (!$success && ($errorcode == 0 || empty($errormessage))) { throw new coding_exception('Error code and message must exist in an error response.'); } } /** * Set the response data returned by the AI provider. * * @param array $response The response data returned by the AI provider. */ abstract public function set_response_data(array $response): void; /** * Get the response data returned by the AI provider. * * @return array */ abstract public function get_response_data(): array; /** * Get the success status of the action. * * @return bool */ public function get_success(): bool { return $this->success; } /** * Get the timestamp of when the response was created. * * @return int */ public function get_timecreated(): int { return $this->timecreated; } /** * Get the name of the action that was processed. * * @return string */ public function get_actionname(): string { return $this->actionname; } /** * Get the error code. * * @return int */ public function get_errorcode(): int { return $this->errorcode; } /** * Get the error message. * * @return string */ public function get_errormessage(): string { return $this->errormessage; } /** * Get the model used to generate the response (if available). * * @return ?string */ public function get_model_used(): ?string { return $this->model; } } PK*\///  5classes/aiactions/responses/response_explain_text.phpnu[. namespace core_ai\aiactions\responses; /** * Explain text action response class. * * Any method that processes an action must return an instance of this class. * * @package core_ai * @copyright 2024 David Woloszyn * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class response_explain_text extends response_base { /** @var string|null A unique identifier for the chat completion, returned by the AI. */ private ?string $id = null; /** @var string|null This fingerprint represents the backend configuration that the model runs with. */ private ?string $fingerprint = null; /** @var string|null The contents of the generated message. */ private ?string $generatedcontent = null; /** @var string|null The reason the model stopped generating tokens. */ private ?string $finishreason = null; /** @var string|null Number of tokens in the prompt. */ private ?string $prompttokens = null; /** @var string|null Number of tokens in the generated completion. */ private ?string $completiontokens = null; /** * Constructor. * * @param bool $success The success status of the action. * @param int $errorcode Error code. Must exist if success is false. * @param string $errormessage Error message. Must exist if success is false */ public function __construct( bool $success, int $errorcode = 0, string $errormessage = '', ) { parent::__construct( success: $success, actionname: 'explain_text', errorcode: $errorcode, errormessage: $errormessage, ); } #[\Override] public function set_response_data(array $response): void { $this->id = $response['id'] ?? null; $this->fingerprint = $response['fingerprint'] ?? null; $this->generatedcontent = $response['generatedcontent'] ?? null; $this->finishreason = $response['finishreason'] ?? null; $this->prompttokens = $response['prompttokens'] ?? null; $this->completiontokens = $response['completiontokens'] ?? null; $this->model = $response['model'] ?? null; } #[\Override] public function get_response_data(): array { return [ 'id' => $this->id, 'fingerprint' => $this->fingerprint, 'generatedcontent' => $this->generatedcontent, 'finishreason' => $this->finishreason, 'prompttokens' => $this->prompttokens, 'completiontokens' => $this->completiontokens, 'model' => $this->model, ]; } } PK*\X{  6classes/aiactions/responses/response_generate_text.phpnu[. namespace core_ai\aiactions\responses; /** * Generate text action response class. * * Any method that processes an action must return an instance of this class. * * @package core_ai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class response_generate_text extends response_base { /** @var string|null A unique identifier for the chat completion, returned by the AI. */ private ?string $id = null; /** @var string|null This fingerprint represents the backend configuration that the model runs with. */ private ?string $fingerprint = null; /** @var string|null The contents of the generated message. */ private ?string $generatedcontent = null; /** @var string|null The reason the model stopped generating tokens. */ private ?string $finishreason = null; /** @var string|null Number of tokens in the prompt. */ private ?string $prompttokens = null; /** @var string|null Number of tokens in the generated completion. */ private ?string $completiontokens = null; /** * Constructor. * * @param bool $success The success status of the action. * @param int $errorcode Error code. Must exist if success is false. * @param string $errormessage Error message. Must exist if success is false */ public function __construct( bool $success, int $errorcode = 0, string $errormessage = '', ) { parent::__construct( success: $success, actionname: 'generate_text', errorcode: $errorcode, errormessage: $errormessage, ); } #[\Override] public function set_response_data(array $response): void { $this->id = $response['id'] ?? null; $this->fingerprint = $response['fingerprint'] ?? null; $this->generatedcontent = $response['generatedcontent'] ?? null; $this->finishreason = $response['finishreason'] ?? null; $this->prompttokens = $response['prompttokens'] ?? null; $this->completiontokens = $response['completiontokens'] ?? null; $this->model = $response['model'] ?? null; } #[\Override] public function get_response_data(): array { return [ 'id' => $this->id, 'fingerprint' => $this->fingerprint, 'generatedcontent' => $this->generatedcontent, 'finishreason' => $this->finishreason, 'prompttokens' => $this->prompttokens, 'completiontokens' => $this->completiontokens, 'model' => $this->model, ]; } } PK*\vWclasses/aiactions/base.phpnu[. namespace core_ai\aiactions; use core_ai\aiactions\responses\response_base; /** * Base Action class. * * @package core_ai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ abstract class base { /** @var int Timestamp the action object was created. */ protected readonly int $timecreated; /** * Constructor for the class. * * @param int $contextid The context ID where the action was created. */ public function __construct( /** @var int The context ID the action was created in */ protected int $contextid, ) { $this->timecreated = \core\di::get(\core\clock::class)->time(); } /** * Responsible for storing any action specific data in the database. * * @param response_base $response The response object to store. * @return int The id of the stored action. */ abstract public function store(response_base $response): int; /** * Get the basename of the class. * * This is used to generate the action name and description. * * @return string The basename of the class. */ public static function get_basename(): string { return basename(str_replace('\\', '/', static::class)); } /** * Get the action name. * * Defaults to the action name string. * * @return string */ public static function get_name(): string { $stringid = 'action_' . self::get_basename(); return get_string($stringid, 'core_ai'); } /** * Get the action description. * * Defaults to the action description string. * * @return string */ public static function get_description(): string { $stringid = 'action_' . self::get_basename() . '_desc'; return get_string($stringid, 'core_ai'); } /** * Get the system instruction for the action. * * @return string The system instruction for the action. */ public static function get_system_instruction(): string { $stringid = 'action_' . self::get_basename() . '_instruction'; // If the string doesn't exist, return an empty string. if (!get_string_manager()->string_exists($stringid, 'core_ai')) { return ''; } return get_string($stringid, 'core_ai'); } /** * Get a configuration option. * * @param string $name The name of the configuration option to get. * @return mixed The value of the configuration option. */ public function get_configuration(string $name): mixed { return $this->$name; } /** * Return the correct table name for the action. * * @return string The correct table name for the action. */ protected function get_tablename(): string { // Table name should always be in this format. return 'ai_action_' . $this->get_basename(); } /** * Get the class name of the response object. * * @return string The class name of the response object. */ public static function get_response_classname(): string { return responses::class . '\\response_' . self::get_basename(); } } PK*\TV8(templates/admin_action_settings.mustachenu[{{! 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 . }} {{! @template core_ai/admin_action_settings Template to display a headings for per provider actions. Context variables required for this template: * none Example context (json): { } }}

{{#str}}actionsettings, core_ai{{/str}}

{{#str}}actionsettings_desc, core_ai{{/str}}
PK*\6A{LLtemplates/policymodal.mustachenu[{{! 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 . }} {{! @template core_ai/policymodal Modal to allow acceptance of the AI Policy. To be used in combination with core_ai/policy ESM monly. Classes required for JS: * none Data attributes required for JS: * none Context variables required for this template: * elementid: The id of the element that will be used to create the modal. Example context (json): { "elementid": "exampleId" } }} {{< core/modal }} {{$title}} {{#str}} aiusagepolicy, core_ai{{/str}} {{/title}} {{$body}}
{{#str}} userpolicy, core_ai{{/str}}
{{/body}} {{$footer}} {{/footer}} {{/ core/modal }} PK*\Z@@templates/policyblock.mustachenu[{{! 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 . }} {{! @template core_ai/policyblock Template to display AI policy in the block drawer. Context variables required for this template: * none Example context (json): { } }}

{{#str}} aiusagepolicy, core_ai {{/str}}

{{#str}} userpolicy, core_ai {{/str}}
PK*\A_"__$templates/admin_noproviders.mustachenu[{{! 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 . }} {{! @template core_ai/admin_noproviders Template to display a message when there are no AI providers configured. Context variables required for this template: * providerurl - URL to the AI provider settings page. Example context (json): { "providerurl": "http://example.com" } }}
{{#str}}noproviders, core_ai, {{{providerurl}}}{{/str}}
PK*\,,%templates/admin_add_provider.mustachenu[{{! 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 . }} {{! @template core_ai/admin_add_provider Template to display a call to action to create new AI providers. Context variables required for this template: * addnewproviderurl - URL to the AI provider setup page. Example context (json): { "addnewproviderurl": "http://example.com" } }} PK*\7ą22(templates/admin_delete_provider.mustachenu[{{! 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 . }} {{! @template core_ai/admin_delete_provider Template to display a call render the delete button for a provider. Context variables required for this template: * id - provider instance id. * name - provider instance name. * provider - provider name. * delete-method - Webservice method name for delete. Example context (json): { "id": 1, "name": "Provider 1", "provider": "aiprovider_openai", "delete-method": "core_ai_delete_provider_instance" } }}  {{#str}}delete, core{{/str}} PK*\]]provider/openai/db/hooks.phpnu[. /** * Hook listener callbacks for the Open AI provider. * * @package aiprovider_openai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $callbacks = [ [ 'hook' => \core_ai\hook\after_ai_provider_form_hook::class, 'callback' => \aiprovider_openai\hook_listener::class . '::set_form_definition_for_aiprovider_openai', ], [ 'hook' => \core_ai\hook\after_ai_action_settings_form_hook::class, 'callback' => \aiprovider_openai\hook_listener::class . '::set_model_form_definition_for_aiprovider_openai', ], ]; PK*\]GDD-provider/openai/lang/en/aiprovider_openai.phpnu[. /** * Strings for component aiprovider_openai, language 'en'. * * @package aiprovider_openai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['action:explain_text:endpoint'] = 'API endpoint'; $string['action:explain_text:model'] = 'AI model'; $string['action:explain_text:model_help'] = 'The model used to explain the provided text.'; $string['action:explain_text:systeminstruction'] = 'System instruction'; $string['action:explain_text:systeminstruction_help'] = 'This instruction is sent to the AI model along with the user\'s prompt. Editing this instruction is not recommended unless absolutely required.'; $string['action:generate_image:endpoint'] = 'API endpoint'; $string['action:generate_image:model'] = 'AI model'; $string['action:generate_image:model_help'] = 'The model used to generate images from user prompts.'; $string['action:generate_text:endpoint'] = 'API endpoint'; $string['action:generate_text:model'] = 'AI model'; $string['action:generate_text:model_help'] = 'The model used to generate the text response.'; $string['action:generate_text:systeminstruction'] = 'System instruction'; $string['action:generate_text:systeminstruction_help'] = 'This instruction is sent to the AI model along with the user\'s prompt. Editing this instruction is not recommended unless absolutely required.'; $string['action:summarise_text:endpoint'] = 'API endpoint'; $string['action:summarise_text:model'] = 'AI model'; $string['action:summarise_text:model_help'] = 'The model used to summarise the provided text.'; $string['action:summarise_text:systeminstruction'] = 'System instruction'; $string['action:summarise_text:systeminstruction_help'] = 'This instruction is sent to the AI model along with the user\'s prompt. Editing this instruction is not recommended unless absolutely required.'; $string['apikey'] = 'OpenAI API key'; $string['apikey_help'] = 'Get a key from your OpenAI API keys.'; $string['custom_model_name'] = 'Custom model name'; $string['extraparams'] = 'Extra parameters'; $string['extraparams_help'] = 'Extra parameters can be configured here. We support JSON format. For example:
{
    "temperature": 0.5,
    "max_completion_tokens": 100
}
'; $string['invalidjson'] = 'Invalid JSON string'; $string['orgid'] = 'OpenAI organization ID'; $string['orgid_help'] = 'Get your OpenAI organization ID from your OpenAI account.'; $string['pluginname'] = 'OpenAI API provider'; $string['privacy:metadata'] = 'The OpenAI API provider plugin does not store any personal data.'; $string['privacy:metadata:aiprovider_openai:externalpurpose'] = 'This information is sent to the OpenAI API in order for a response to be generated. Your OpenAI account settings may change how OpenAI stores and retains this data. No user data is explicitly sent to OpenAI or stored in Moodle LMS by this plugin.'; $string['privacy:metadata:aiprovider_openai:model'] = 'The model used to generate the response.'; $string['privacy:metadata:aiprovider_openai:numberimages'] = 'When generating images the number of images used in the response.'; $string['privacy:metadata:aiprovider_openai:prompttext'] = 'The user entered text prompt used to generate the response.'; $string['privacy:metadata:aiprovider_openai:responseformat'] = 'The format of the response. When generating images.'; $string['settings'] = 'Settings'; $string['settings_frequency_penalty'] = 'frequency_penalty'; $string['settings_frequency_penalty_help'] = 'The frequency penalty adjusts how often words are repeated. The higher the penalty, the less repetitions in the generated text.'; $string['settings_help'] = 'Adjust the settings below to customise how requests are sent to OpenAI.'; $string['settings_max_completion_tokens'] = 'max_completion_tokens'; $string['settings_max_completion_tokens_help'] = 'The maximum number of tokens used in the generated text.'; $string['settings_presence_penalty'] = 'presence_penalty'; $string['settings_presence_penalty_help'] = 'The presence penalty encourages the model to use new words by increasing the likelihood of choosing words it hasn\'t used before. A higher value makes the generated text more diverse, while a lower value allows more repetition.'; $string['settings_top_p'] = 'top_p'; $string['settings_top_p_help'] = 'top_p (nucleus sampling) determines how many possible words to consider. A high value (e.g. 0.9) means the model looks at more words, which makes the generated text more diverse.'; // Deprecated since Moodle 5.0. $string['action:generate_image:model_desc'] = 'The model used to generate images from user prompts.'; $string['action:generate_text:model_desc'] = 'The model used to generate the text response.'; $string['action:generate_text:systeminstruction_desc'] = 'This instruction is sent to the AI model along with the user\'s prompt. Editing this instruction is not recommended unless absolutely required.'; $string['action:summarise_text:model_desc'] = 'The model used to summarise the provided text.'; $string['action:summarise_text:systeminstruction_desc'] = 'This instruction is sent to the AI model along with the user\'s prompt. Editing this instruction is not recommended unless absolutely required.'; $string['apikey_desc'] = 'Get a key from OpenAI Platform API keys.'; $string['enableglobalratelimit'] = 'Set site-wide rate limit'; $string['enableglobalratelimit_desc'] = 'Limit the number of requests that the OpenAI API provider can receive across the entire site every hour.'; $string['enableuserratelimit'] = 'Set user rate limit'; $string['enableuserratelimit_desc'] = 'Limit the number of requests each user can make to the OpenAI API provider every hour.'; $string['globalratelimit'] = 'Maximum number of site-wide requests'; $string['globalratelimit_desc'] = 'The number of site-wide requests allowed per hour.'; $string['orgid_desc'] = 'Get an OpenAI organization ID from your OpenAI Platform account.'; $string['userratelimit'] = 'Maximum number of requests per user'; $string['userratelimit_desc'] = 'The number of requests allowed per hour, per user.'; $string['settings_max_tokens'] = 'max_tokens'; $string['settings_max_tokens_help'] = 'The maximum number of tokens used in the generated text.'; PK*\:&provider/openai/lang/en/deprecated.txtnu[action:generate_image:model_desc,aiprovider_openai action:generate_text:model_desc,aiprovider_openai action:generate_text:systeminstruction_desc,aiprovider_openai action:summarise_text:model_desc,aiprovider_openai action:summarise_text:systeminstruction_desc,aiprovider_openai apikey_desc,aiprovider_openai enableglobalratelimit,aiprovider_openai enableglobalratelimit_desc,aiprovider_openai enableuserratelimit,aiprovider_openai enableuserratelimit_desc,aiprovider_openai globalratelimit,aiprovider_openai globalratelimit_desc,aiprovider_openai orgid_desc,aiprovider_openai userratelimit,aiprovider_openai userratelimit_desc,aiprovider_openai settings_max_tokens,aiprovider_openai settings_max_tokens_help,aiprovider_openai PK*\sA/DDprovider/openai/version.phpnu[. /** * Version information for aiprovider_openai. * * @package aiprovider_openai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'aiprovider_openai'; $plugin->version = 2025041400; $plugin->requires = 2025040800; $plugin->maturity = MATURITY_STABLE; PK*\( ;provider/openai/classes/form/action_generate_image_form.phpnu[. namespace aiprovider_openai\form; use aiprovider_openai\aimodel\openai_base; /** * Generate image action provider settings form. * * @package aiprovider_openai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class action_generate_image_form extends action_form { #[\Override] protected function definition(): void { parent::definition(); $mform = $this->_form; $this->add_model_fields(openai_base::MODEL_TYPE_IMAGE); // API endpoint. $mform->addElement( 'text', 'endpoint', get_string("action:{$this->actionname}:endpoint", 'aiprovider_openai'), 'maxlength="255" size="30"', ); $mform->setType('endpoint', PARAM_URL); $mform->addRule('endpoint', null, 'required', null, 'client'); $mform->setDefault('endpoint', $this->actionconfig['endpoint'] ?? 'https://api.openai.com/v1/images/generations'); if ($this->returnurl) { $mform->addElement('hidden', 'returnurl', $this->returnurl); $mform->setType('returnurl', PARAM_LOCALURL); } // Add the action class as a hidden field. $mform->addElement('hidden', 'action', $this->action); $mform->setType('action', PARAM_TEXT); // Add the provider class as a hidden field. $mform->addElement('hidden', 'provider', $this->providername); $mform->setType('provider', PARAM_TEXT); // Add the provider id as a hidden field. $mform->addElement('hidden', 'providerid', $this->providerid); $mform->setType('providerid', PARAM_INT); $this->set_data($this->actionconfig); } } PK*\=)k,provider/openai/classes/form/action_form.phpnu[. namespace aiprovider_openai\form; use aiprovider_openai\helper; use core_ai\form\action_settings_form; /** * Base action settings form for OpenAI provider. * * @package aiprovider_openai * @copyright 2025 Huong Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class action_form extends action_settings_form { /** * @var array Action configuration. */ protected array $actionconfig; /** * @var string|null Return URL. */ protected ?string $returnurl; /** * @var string Action name. */ protected string $actionname; /** * @var string Action class. */ protected string $action; /** * @var int Provider ID. */ protected int $providerid; /** * @var string Provider name. */ protected string $providername; #[\Override] protected function definition(): void { $mform = $this->_form; $this->actionconfig = $this->_customdata['actionconfig']['settings'] ?? []; $this->returnurl = $this->_customdata['returnurl'] ?? null; $this->actionname = $this->_customdata['actionname']; $this->action = $this->_customdata['action']; $this->providerid = $this->_customdata['providerid'] ?? 0; $this->providername = $this->_customdata['providername'] ?? 'aiprovider_openai'; $mform->addElement('header', 'generalsettingsheader', get_string('general', 'core')); } #[\Override] public function set_data($data): void { if (!empty($data['modelextraparams'])) { $data['modelextraparams'] = json_encode(json_decode($data['modelextraparams']), JSON_PRETTY_PRINT); } parent::set_data($data); } #[\Override] public function get_data(): ?\stdClass { $data = parent::get_data(); if ($data) { if (isset($data->modeltemplate)) { if ($data->modeltemplate === 'custom') { $data->model = $data->custommodel; } else { // Set the model to the selected model template. $data->model = $data->modeltemplate; // Cast settings to their intended types. if ($data->model === 'gpt-4o' || $data->model === 'o1') { if (isset($data->top_p)) { $data->top_p = floatval($data->top_p); } if (isset($data->max_completion_tokens)) { $data->max_completion_tokens = intval($data->max_completion_tokens); } if (isset($data->presence_penalty)) { $data->presence_penalty = floatval($data->presence_penalty); } if (isset($data->frequency_penalty)) { $data->frequency_penalty = floatval($data->frequency_penalty); } } } } // Unset the model template. unset($data->custommodel); unset($data->modeltemplate); // Unset any false-y values. $data = (object) array_filter((array) $data); } return $data; } #[\Override] public function validation($data, $files): array { $errors = parent::validation($data, $files); // Validate the extra parameters. if (!empty($data['modelextraparams'])) { json_decode($data['modelextraparams']); if (json_last_error() !== JSON_ERROR_NONE) { $errors['modelextraparams'] = get_string('invalidjson', 'aiprovider_openai'); } } // Validate the model. if ($data['modeltemplate'] === 'custom' && empty($data['custommodel'])) { $errors['custommodel'] = get_string('required'); } return $errors; } #[\Override] public function get_defaults(): array { $data = parent::get_defaults(); unset( $data['modeltemplate'], $data['custommodel'], $data['modelextraparams'], ); return $data; } /** * Add model fields to the form. * * @param int $modeltype Model type. */ protected function add_model_fields(int $modeltype): void { global $PAGE; $PAGE->requires->js_call_amd('aiprovider_openai/modelchooser', 'init'); $mform = $this->_form; $actionname = $this->actionname; // Action model to use. $mform->addElement( 'select', 'modeltemplate', get_string("action:{$this->actionname}:model", 'aiprovider_openai'), $this->get_model_list($modeltype), ['data-modelchooser-field' => 'selector'], ); $mform->setType('modeltemplate', PARAM_TEXT); $mform->addRule('modeltemplate', null, 'required', null, 'client'); if (!empty($this->actionconfig['model']) && (!array_key_exists($this->actionconfig['model'], $this->get_model_list($modeltype)) || !empty($this->actionconfig['modelextraparams']))) { $defaultmodel = 'custom'; } else if (empty($this->actionconfig['model'])) { $defaultmodel = ($actionname === 'generate_image') ? 'dall-e-3' : 'gpt-4o'; } else { $defaultmodel = $this->actionconfig['model']; } $mform->setDefault('modeltemplate', $defaultmodel); $mform->addHelpButton('modeltemplate', "action:{$this->actionname}:model", 'aiprovider_openai'); $mform->addElement('hidden', 'model', $defaultmodel); $mform->setType('model', PARAM_TEXT); $mform->addElement('text', 'custommodel', get_string('custom_model_name', 'aiprovider_openai')); $mform->setType('custommodel', PARAM_TEXT); $mform->setDefault('custommodel', $this->actionconfig['model'] ?? ''); $mform->hideIf('custommodel', 'modeltemplate', 'neq', 'custom'); $mform->registerNoSubmitButton('updateactionsettings'); $mform->addElement( 'submit', 'updateactionsettings', 'updateactionsettings', ['data-modelchooser-field' => 'updateButton', 'class' => 'd-none'] ); } /** * Get the list of models. * * @param int $modeltype Model type. * @return array List of models. */ protected function get_model_list(int $modeltype): array { $models = []; $models['custom'] = get_string('custom', 'core_form'); foreach (helper::get_model_classes() as $class) { $model = new $class(); if (in_array($modeltype, $model->model_type())) { $models[$model->get_model_name()] = $model->get_model_display_name(); } } return $models; } } PK*\lX :provider/openai/classes/form/action_generate_text_form.phpnu[. namespace aiprovider_openai\form; use aiprovider_openai\aimodel\openai_base; /** * Generate text action provider settings form. * * @package aiprovider_openai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class action_generate_text_form extends action_form { #[\Override] protected function definition(): void { parent::definition(); $mform = $this->_form; $this->add_model_fields(openai_base::MODEL_TYPE_TEXT); // API endpoint. $mform->addElement( 'text', 'endpoint', get_string("action:{$this->actionname}:endpoint", 'aiprovider_openai'), 'maxlength="255" size="30"', ); $mform->setType('endpoint', PARAM_URL); $mform->addRule('endpoint', null, 'required', null, 'client'); $mform->setDefault('endpoint', $actionconfig['endpoint'] ?? 'https://api.openai.com/v1/chat/completions'); // System Instructions. $mform->addElement( 'textarea', 'systeminstruction', get_string("action:{$this->actionname}:systeminstruction", 'aiprovider_openai'), 'wrap="virtual" rows="5" cols="20"', ); $mform->setType('systeminstruction', PARAM_TEXT); $mform->setDefault('systeminstruction', $actionconfig['systeminstruction'] ?? $this->action::get_system_instruction()); $mform->addHelpButton('systeminstruction', "action:{$this->actionname}:systeminstruction", 'aiprovider_openai'); if ($this->returnurl) { $mform->addElement('hidden', 'returnurl', $this->returnurl); $mform->setType('returnurl', PARAM_LOCALURL); } // Add the action class as a hidden field. $mform->addElement('hidden', 'action', $this->action); $mform->setType('action', PARAM_TEXT); // Add the provider class as a hidden field. $mform->addElement('hidden', 'provider', $this->providername); $mform->setType('provider', PARAM_TEXT); // Add the provider id as a hidden field. $mform->addElement('hidden', 'providerid', $this->providerid); $mform->setType('providerid', PARAM_INT); $this->set_data($this->actionconfig); } } PK*\ ,provider/openai/classes/privacy/provider.phpnu[. namespace aiprovider_openai\privacy; use core_privacy\local\metadata\collection; use core_privacy\local\request\approved_contextlist; use core_privacy\local\request\approved_userlist; use core_privacy\local\request\contextlist; use core_privacy\local\request\userlist; /** * Privacy provider implementation for OpenAI provider * * @package aiprovider_openai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @codeCoverageIgnore */ class provider implements \core_privacy\local\metadata\provider, \core_privacy\local\request\core_userlist_provider, \core_privacy\local\request\plugin\provider { #[\Override] public static function get_metadata(collection $collection): collection { $collection->add_external_location_link('aiprovider_openai', [ 'prompttext' => 'privacy:metadata:aiprovider_openai:prompttext', 'model' => 'privacy:metadata:aiprovider_openai:model', 'numberimages' => 'privacy:metadata:aiprovider_openai:numberimages', 'responseformat' => 'privacy:metadata:aiprovider_openai:responseformat', ], 'privacy:metadata:aiprovider_openai:externalpurpose'); return $collection; } #[\Override] public static function get_contexts_for_userid(int $userid): contextlist { return new contextlist(); } #[\Override] public static function get_users_in_context(userlist $userlist) { } #[\Override] public static function export_user_data(approved_contextlist $contextlist) { } #[\Override] public static function delete_data_for_all_users_in_context(\context $context) { } /** * Delete multiple users within a single context. * * @param approved_userlist $userlist The approved context and user information to delete information for. */ public static function delete_data_for_users(approved_userlist $userlist) { } #[\Override] public static function delete_data_for_user(approved_contextlist $contextlist) { } } PK*\Π2provider/openai/classes/process_generate_image.phpnu[. namespace aiprovider_openai; use core\http_client; use core_ai\ai_image; use GuzzleHttp\Psr7\Request; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; /** * Class process image generation. * * @package aiprovider_openai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class process_generate_image extends abstract_processor { /** @var int The number of images to generate dall-e-3 only supports 1. */ private int $numberimages = 1; /** @var string Response format: url or b64_json. */ private string $responseformat = 'url'; #[\Override] protected function query_ai_api(): array { $response = parent::query_ai_api(); // If the request was successful, save the URL to a file. if ($response['success']) { $fileobj = $this->url_to_file( $this->action->get_configuration('userid'), $response['sourceurl'] ); // Add the file to the response, so the calling placement can do whatever they want with it. $response['draftfile'] = $fileobj; } return $response; } /** * Convert the given aspect ratio to an image size * that is compatible with the OpenAI API. * * @param string $ratio The aspect ratio of the image. * @return string The size of the image. */ private function calculate_size(string $ratio): string { if ($ratio === 'square') { $size = '1024x1024'; } else if ($ratio === 'landscape') { $size = '1792x1024'; } else if ($ratio === 'portrait') { $size = '1024x1792'; } else { throw new \coding_exception('Invalid aspect ratio: ' . $ratio); } return $size; } #[\Override] protected function create_request_object(string $userid): RequestInterface { // Create the request object. $requestobj = new \stdClass(); $requestobj->model = $this->get_model(); $requestobj->user = $userid; $requestobj->prompt = $this->action->get_configuration('prompttext'); $requestobj->n = $this->numberimages; $requestobj->quality = $this->action->get_configuration('quality'); $requestobj->response_format = $this->responseformat; $requestobj->size = $this->calculate_size($this->action->get_configuration('aspectratio')); $requestobj->style = $this->action->get_configuration('style'); // Append the extra model settings. $modelsettings = $this->get_model_settings(); foreach ($modelsettings as $setting => $value) { $requestobj->$setting = $value; } return new Request( method: 'POST', uri: '', headers: [ 'Content-Type' => 'application/json', ], body: json_encode($requestobj), ); } #[\Override] protected function handle_api_success(ResponseInterface $response): array { $responsebody = $response->getBody(); $bodyobj = json_decode($responsebody->getContents()); return [ 'success' => true, 'sourceurl' => $bodyobj->data[0]->url, 'revisedprompt' => $bodyobj->data[0]->revised_prompt, 'model' => $this->get_model(), // There is no model in the response, use config. ]; } /** * Convert the url for the image to a file. * * Placements can't interact with the provider AI directly, * therefore we need to provide the image file in a format that can * be used by placements. So we use the file API. * * @param int $userid The user id. * @param string $url The URL to the image. * @return \stored_file The file object. */ private function url_to_file(int $userid, string $url): \stored_file { global $CFG; require_once("{$CFG->libdir}/filelib.php"); $parsedurl = parse_url($url, PHP_URL_PATH); // Parse the URL to get the path. $filename = basename($parsedurl); // Get the basename of the path. $client = \core\di::get(http_client::class); // Download the image and add the watermark. $tempdst = make_request_directory() . DIRECTORY_SEPARATOR . $filename; $client->get($url, [ 'sink' => $tempdst, 'timeout' => $CFG->repositorygetfiletimeout, ]); $image = new ai_image($tempdst); $image->add_watermark()->save(); // We put the file in the user draft area initially. // Placements (on behalf of the user) can then move it to the correct location. $fileinfo = new \stdClass(); $fileinfo->contextid = \context_user::instance($userid)->id; $fileinfo->filearea = 'draft'; $fileinfo->component = 'user'; $fileinfo->itemid = file_get_unused_draft_itemid(); $fileinfo->filepath = '/'; $fileinfo->filename = $filename; $fs = get_file_storage(); return $fs->create_file_from_string($fileinfo, file_get_contents($tempdst)); } } PK*\[*provider/openai/classes/aimodel/dalle3.phpnu[. namespace aiprovider_openai\aimodel; use core_ai\aimodel\base; /** * DALL-e-3 AI model. * * @package aiprovider_openai * @copyright 2025 Huong Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class dalle3 extends base implements openai_base { #[\Override] public function get_model_name(): string { return 'dall-e-3'; } #[\Override] public function get_model_display_name(): string { return 'DALL-e-3'; } #[\Override] public function has_model_settings(): bool { return false; } #[\Override] public function model_type(): array { return [self::MODEL_TYPE_IMAGE]; } } PK*\ /provider/openai/classes/aimodel/openai_base.phpnu[. namespace aiprovider_openai\aimodel; /** * OpenAI base AI model interface. * * @package aiprovider_openai * @copyright 2025 Huong Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ interface openai_base { /** @var int MODEL_TYPE_TEXT Text model type. */ public const MODEL_TYPE_TEXT = 1; /** @var int MODEL_TYPE_IMAGE Image model type. */ public const MODEL_TYPE_IMAGE = 2; /** * Get model types. * * @return array List of model types. */ public function model_type(): array; } PK*\c&provider/openai/classes/aimodel/o1.phpnu[. namespace aiprovider_openai\aimodel; use MoodleQuickForm; /** * O1 AI model. * * @package aiprovider_openai * @copyright 2025 Huong Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class o1 extends gpt4o { #[\Override] public function get_model_name(): string { return 'o1'; } #[\Override] public function get_model_display_name(): string { return 'O1'; } #[\Override] public function has_model_settings(): bool { return true; } #[\Override] public function add_model_settings(MoodleQuickForm $mform): void { $mform->addElement( 'text', 'max_completion_tokens', get_string('settings_max_completion_tokens', 'aiprovider_openai'), ); $mform->setType('max_completion_tokens', PARAM_INT); $mform->addHelpButton('max_completion_tokens', 'settings_max_completion_tokens', 'aiprovider_openai'); } #[\Override] public function model_type(): array { return [self::MODEL_TYPE_TEXT]; } } PK*\Zxt t )provider/openai/classes/aimodel/gpt4o.phpnu[. namespace aiprovider_openai\aimodel; use core_ai\aimodel\base; use MoodleQuickForm; /** * GPT-4o AI model. * * @package aiprovider_openai * @copyright 2025 Huong Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class gpt4o extends base implements openai_base { #[\Override] public function get_model_name(): string { return 'gpt-4o'; } #[\Override] public function get_model_display_name(): string { return 'GPT-4o'; } #[\Override] public function has_model_settings(): bool { return true; } #[\Override] public function add_model_settings(MoodleQuickForm $mform): void { $mform->addElement( 'text', 'top_p', get_string('settings_top_p', 'aiprovider_openai'), ); $mform->setType('top_p', PARAM_FLOAT); $mform->addHelpButton('top_p', 'settings_top_p', 'aiprovider_openai'); $mform->addElement( 'text', 'max_completion_tokens', get_string('settings_max_completion_tokens', 'aiprovider_openai'), ); $mform->setType('max_completion_tokens', PARAM_INT); $mform->addHelpButton('max_completion_tokens', 'settings_max_completion_tokens', 'aiprovider_openai'); $mform->addElement( 'text', 'frequency_penalty', get_string('settings_frequency_penalty', 'aiprovider_openai'), ); // This is a raw value because it can be a float from -2.0 to 2.0. $mform->setType('frequency_penalty', PARAM_RAW); $mform->addHelpButton('frequency_penalty', 'settings_frequency_penalty', 'aiprovider_openai'); $mform->addElement( 'text', 'presence_penalty', get_string('settings_presence_penalty', 'aiprovider_openai'), ); // This is a raw value because it can be a float from -2.0 to 2.0. $mform->setType('presence_penalty', PARAM_RAW); $mform->addHelpButton('presence_penalty', 'settings_presence_penalty', 'aiprovider_openai'); } #[\Override] public function model_type(): array { return [self::MODEL_TYPE_TEXT]; } } PK*\ l2provider/openai/classes/process_summarise_text.phpnu[. namespace aiprovider_openai; /** * Class process text summarisation. * * @package aiprovider_openai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class process_summarise_text extends process_generate_text { } PK*\2n)provider/openai/classes/hook_listener.phpnu[. namespace aiprovider_openai; use aiprovider_openai\model\base; use core_ai\hook\after_ai_action_settings_form_hook; use core_ai\hook\after_ai_provider_form_hook; /** * Hook listener for Open AI provider. * * @package aiprovider_openai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class hook_listener { /** * Hook listener for the Open AI instance setup form. * * @param after_ai_provider_form_hook $hook The hook to add to the AI instance setup. */ public static function set_form_definition_for_aiprovider_openai(after_ai_provider_form_hook $hook): void { if ($hook->plugin !== 'aiprovider_openai') { return; } $mform = $hook->mform; // Required setting to store OpenAI API key. $mform->addElement( 'passwordunmask', 'apikey', get_string('apikey', 'aiprovider_openai'), ['size' => 75], ); $mform->addHelpButton('apikey', 'apikey', 'aiprovider_openai'); $mform->addRule('apikey', get_string('required'), 'required', null, 'client'); // Setting to store OpenAI organization ID. $mform->addElement( 'text', 'orgid', get_string('orgid', 'aiprovider_openai'), ['size' => 25], ); $mform->setType('orgid', PARAM_TEXT); $mform->addHelpButton('orgid', 'orgid', 'aiprovider_openai'); } /** * Hook listener for the Open AI action settings form. * * @param after_ai_action_settings_form_hook $hook The hook to add to config action settings. */ public static function set_model_form_definition_for_aiprovider_openai(after_ai_action_settings_form_hook $hook): void { if ($hook->plugin !== 'aiprovider_openai') { return; } $mform = $hook->mform; if (isset($mform->_elementIndex['modeltemplate'])) { $model = $mform->getElementValue('modeltemplate'); if (is_array($model)) { $model = $model[0]; } if ($model == 'custom') { $mform->addElement('header', 'modelsettingsheader', get_string('settings', 'aiprovider_openai')); $settingshelp = \html_writer::tag('p', get_string('settings_help', 'aiprovider_openai')); $mform->addElement('html', $settingshelp); $mform->addElement( 'textarea', 'modelextraparams', get_string('extraparams', 'aiprovider_openai'), ['rows' => 5, 'cols' => 20], ); $mform->setType('modelextraparams', PARAM_TEXT); $mform->addElement('static', 'modelextraparams_help', null, get_string('extraparams_help', 'aiprovider_openai')); } else { $targetmodel = helper::get_model_class($model); if ($targetmodel) { if ($targetmodel->has_model_settings()) { $mform->addElement('header', 'modelsettingsheader', get_string('settings', 'aiprovider_openai')); $settingshelp = \html_writer::tag('p', get_string('settings_help', 'aiprovider_openai')); $mform->addElement('html', $settingshelp); $targetmodel->add_model_settings($mform); } } } } } } PK*\9[C"provider/openai/classes/helper.phpnu[. namespace aiprovider_openai; use core_ai\aimodel\base; /** * Helper class for the OpenAI provider. * * @package aiprovider_openai * @copyright 2025 Huong Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class helper { /** * Get all model classes. * * @return array Array of model classes. */ public static function get_model_classes(): array { $models = []; $modelclasses = \core_component::get_component_classes_in_namespace('aiprovider_openai', 'aimodel'); foreach ($modelclasses as $class => $path) { if (!class_exists($class) || !is_a($class, base::class, true)) { throw new \coding_exception("Model class not valid: {$class}"); } $models[] = $class; } return $models; } /** * Get model class by name. * * @param string $modelname Model name. * @return base|null */ public static function get_model_class(string $modelname): ?base { foreach (static::get_model_classes() as $classname) { $model = new $classname(); if ($model->get_model_name() === $modelname) { return $model; } } return null; } } PK*\n .provider/openai/classes/abstract_processor.phpnu[. namespace aiprovider_openai; use core\http_client; use core_ai\process_base; use GuzzleHttp\Exception\RequestException; use GuzzleHttp\Psr7\Uri; use GuzzleHttp\RequestOptions; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\UriInterface; /** * Class process text generation. * * @package aiprovider_openai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ abstract class abstract_processor extends process_base { /** * Get the endpoint URI. * * @return UriInterface */ protected function get_endpoint(): UriInterface { return new Uri($this->provider->actionconfig[$this->action::class]['settings']['endpoint']); } /** * Get the name of the model to use. * * @return string */ protected function get_model(): string { return $this->provider->actionconfig[$this->action::class]['settings']['model']; } /** * Get the model settings. * * @return array */ protected function get_model_settings(): array { $settings = $this->provider->actionconfig[$this->action::class]['settings']; if (!empty($settings['modelextraparams'])) { // Custom model settings. $params = json_decode($settings['modelextraparams'], true); foreach ($params as $key => $param) { $settings[$key] = $param; } } // Unset unnecessary settings. unset( $settings['model'], $settings['endpoint'], $settings['systeminstruction'], $settings['providerid'], $settings['modelextraparams'], ); return $settings; } /** * Get the system instructions. * * @return string */ protected function get_system_instruction(): string { return $this->action::get_system_instruction(); } /** * Create the request object to send to the OpenAI API. * * This object contains all the required parameters for the request. * * * * @param string $userid The user id. * @return RequestInterface The request object to send to the OpenAI API. */ abstract protected function create_request_object( string $userid, ): RequestInterface; /** * Handle a successful response from the external AI api. * * @param ResponseInterface $response The response object. * @return array The response. */ abstract protected function handle_api_success(ResponseInterface $response): array; #[\Override] protected function query_ai_api(): array { $request = $this->create_request_object( userid: $this->provider->generate_userid($this->action->get_configuration('userid')), ); $request = $this->provider->add_authentication_headers($request); $client = \core\di::get(http_client::class); try { // Call the external AI service. $response = $client->send($request, [ 'base_uri' => $this->get_endpoint(), RequestOptions::HTTP_ERRORS => false, ]); } catch (RequestException $e) { // Handle any exceptions. return [ 'success' => false, 'errorcode' => $e->getCode(), 'errormessage' => $e->getMessage(), ]; } // Double-check the response codes, in case of a non 200 that didn't throw an error. $status = $response->getStatusCode(); if ($status === 200) { return $this->handle_api_success($response); } else { return $this->handle_api_error($response); } } /** * Handle an error from the external AI api. * * @param ResponseInterface $response The response object. * @return array The error response. */ protected function handle_api_error(ResponseInterface $response): array { $responsearr = [ 'success' => false, 'errorcode' => $response->getStatusCode(), ]; $status = $response->getStatusCode(); if ($status >= 500 && $status < 600) { $responsearr['errormessage'] = $response->getReasonPhrase(); } else { $bodyobj = json_decode($response->getBody()->getContents()); $responsearr['errormessage'] = $bodyobj->error->message; } return $responsearr; } } PK*\r0__$provider/openai/classes/provider.phpnu[. namespace aiprovider_openai; use core_ai\form\action_settings_form; use Psr\Http\Message\RequestInterface; /** * Class provider. * * @package aiprovider_openai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider extends \core_ai\provider { /** * Get the list of actions that this provider supports. * * @return array An array of action class names. */ public static function get_action_list(): array { return [ \core_ai\aiactions\generate_text::class, \core_ai\aiactions\generate_image::class, \core_ai\aiactions\summarise_text::class, \core_ai\aiactions\explain_text::class, ]; } #[\Override] public function add_authentication_headers(RequestInterface $request): RequestInterface { if (isset($this->config['orgid'])) { return $request ->withAddedHeader('Authorization', "Bearer {$this->config['apikey']}") ->withAddedHeader('OpenAI-Organization', $this->config['orgid']); } else { return $request->withAddedHeader('Authorization', "Bearer {$this->config['apikey']}"); } } #[\Override] public static function get_action_settings( string $action, array $customdata = [], ): action_settings_form|bool { $actionname = substr($action, (strrpos($action, '\\') + 1)); $customdata['actionname'] = $actionname; $customdata['action'] = $action; if ($actionname === 'generate_text' || $actionname === 'summarise_text' || $actionname === 'explain_text') { return new form\action_generate_text_form(customdata: $customdata); } else if ($actionname === 'generate_image') { return new form\action_generate_image_form(customdata: $customdata); } return false; } #[\Override] public static function get_action_setting_defaults(string $action): array { $actionname = substr($action, (strrpos($action, '\\') + 1)); $customdata = [ 'actionname' => $actionname, 'action' => $action, 'providername' => 'aiprovider_openai', ]; if ($actionname === 'generate_text' || $actionname === 'summarise_text' || $actionname === 'explain_text') { $mform = new form\action_generate_text_form(customdata: $customdata); return $mform->get_defaults(); } else if ($actionname === 'generate_image') { $mform = new form\action_generate_image_form(customdata: $customdata); return $mform->get_defaults(); } return []; } /** * Check this provider has the minimal configuration to work. * * @return bool Return true if configured. */ public function is_provider_configured(): bool { return !empty($this->config['apikey']); } } PK*\cˑ0provider/openai/classes/process_explain_text.phpnu[. namespace aiprovider_openai; /** * Class process text explanation. * * @package aiprovider_openai * @copyright 2024 David Woloszyn * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class process_explain_text extends process_generate_text { } PK*\ 6provider/openai/classes/test/testcase_helper_trait.phpnu[. namespace aiprovider_openai\test; /** * Trait for test cases. * * @package aiprovider_openai * @copyright 2025 Huong Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ trait testcase_helper_trait { /** * Create the provider object. * * @param string $actionclass The action class to use. * @param array $actionconfig The action configuration to use. */ public function create_provider( string $actionclass, array $actionconfig = [], ): \core_ai\provider { $manager = \core\di::get(\core_ai\manager::class); $config = [ 'apikey' => '123', 'enableuserratelimit' => true, 'userratelimit' => 1, 'enableglobalratelimit' => true, 'globalratelimit' => 1, ]; $defaultactionconfig = [ $actionclass => [ 'settings' => [ 'model' => 'gpt-4o', 'endpoint' => "https://api.openai.com/v1/chat/completions", ], ], ]; foreach ($actionconfig as $key => $value) { $defaultactionconfig[$actionclass]['settings'][$key] = $value; } $provider = $manager->create_provider_instance( classname: '\aiprovider_openai\provider', name: 'dummy', config: $config, actionconfig: $defaultactionconfig, ); return $provider; } } PK*\$L; 1provider/openai/classes/process_generate_text.phpnu[. namespace aiprovider_openai; use GuzzleHttp\Psr7\Request; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; /** * Class process text generation. * * @package aiprovider_openai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class process_generate_text extends abstract_processor { #[\Override] protected function get_system_instruction(): string { return $this->provider->actionconfig[$this->action::class]['settings']['systeminstruction']; } #[\Override] protected function create_request_object(string $userid): RequestInterface { // Create the user object. $userobj = new \stdClass(); $userobj->role = 'user'; $userobj->content = $this->action->get_configuration('prompttext'); // Create the request object. $requestobj = new \stdClass(); $requestobj->model = $this->get_model(); $requestobj->user = $userid; // If there is a system string available, use it. $systeminstruction = $this->get_system_instruction(); if (!empty($systeminstruction)) { $systemobj = new \stdClass(); $systemobj->role = 'system'; $systemobj->content = $systeminstruction; $requestobj->messages = [$systemobj, $userobj]; } else { $requestobj->messages = [$userobj]; } // Append the extra model settings. $modelsettings = $this->get_model_settings(); foreach ($modelsettings as $setting => $value) { $requestobj->$setting = $value; } return new Request( method: 'POST', uri: '', headers: [ 'Content-Type' => 'application/json', ], body: json_encode($requestobj), ); } /** * Handle a successful response from the external AI api. * * @param ResponseInterface $response The response object. * @return array The response. */ protected function handle_api_success(ResponseInterface $response): array { $responsebody = $response->getBody(); $bodyobj = json_decode($responsebody->getContents()); return [ 'success' => true, 'id' => $bodyobj->id, 'fingerprint' => $bodyobj->system_fingerprint, 'generatedcontent' => $bodyobj->choices[0]->message->content, 'finishreason' => $bodyobj->choices[0]->finish_reason, 'prompttokens' => $bodyobj->usage->prompt_tokens, 'completiontokens' => $bodyobj->usage->completion_tokens, 'model' => $bodyobj->model ?? $this->get_model(), // Fallback to config model. ]; } } PK*\ۻ{(('provider/openai/amd/src/modelchooser.jsnu[// 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 . /** * AI provider model selection handler. * * @module aiprovider_openai/modelchooser * @copyright 2025 Huong Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ const Selectors = { fields: { selector: '[data-modelchooser-field="selector"]', updateButton: '[data-modelchooser-field="updateButton"]', }, }; /** * Initialise the AI provider chooser. */ export const init = () => { const modelSelector = document.querySelector(Selectors.fields.selector); if (modelSelector) { modelSelector.addEventListener('change', e => { modelSelector.options[e.target.selectedIndex].selected = true; const form = e.target.closest('form'); const updateButton = form.querySelector(Selectors.fields.updateButton); updateButton.click(); }); } }; PK*\_iKK1provider/openai/amd/build/modelchooser.min.js.mapnu[{"version":3,"file":"modelchooser.min.js","sources":["../src/modelchooser.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/ //\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * AI provider model selection handler.\n *\n * @module aiprovider_openai/modelchooser\n * @copyright 2025 Huong Nguyen \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nconst Selectors = {\n fields: {\n selector: '[data-modelchooser-field=\"selector\"]',\n updateButton: '[data-modelchooser-field=\"updateButton\"]',\n },\n};\n\n/**\n * Initialise the AI provider chooser.\n */\nexport const init = () => {\n const modelSelector = document.querySelector(Selectors.fields.selector);\n if (modelSelector) {\n modelSelector.addEventListener('change', e => {\n modelSelector.options[e.target.selectedIndex].selected = true;\n const form = e.target.closest('form');\n const updateButton = form.querySelector(Selectors.fields.updateButton);\n updateButton.click();\n });\n }\n};\n"],"names":["Selectors","selector","updateButton","modelSelector","document","querySelector","addEventListener","e","options","target","selectedIndex","selected","closest","click"],"mappings":";;;;;;;;MAsBMA,iBACM,CACJC,SAAU,uCACVC,aAAc,0DAOF,WACVC,cAAgBC,SAASC,cAAcL,iBAAiBC,UAC1DE,eACAA,cAAcG,iBAAiB,UAAUC,IACrCJ,cAAcK,QAAQD,EAAEE,OAAOC,eAAeC,UAAW,EAC5CJ,EAAEE,OAAOG,QAAQ,QACJP,cAAcL,iBAAiBE,cAC5CW"}PK*\PHUU-provider/openai/amd/build/modelchooser.min.jsnu[define("aiprovider_openai/modelchooser",["exports"],(function(_exports){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0; /** * AI provider model selection handler. * * @module aiprovider_openai/modelchooser * @copyright 2025 Huong Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ const Selectors_fields={selector:'[data-modelchooser-field="selector"]',updateButton:'[data-modelchooser-field="updateButton"]'};_exports.init=()=>{const modelSelector=document.querySelector(Selectors_fields.selector);modelSelector&&modelSelector.addEventListener("change",(e=>{modelSelector.options[e.target.selectedIndex].selected=!0;e.target.closest("form").querySelector(Selectors_fields.updateButton).click()}))}})); //# sourceMappingURL=modelchooser.min.js.mapPK*\u 8provider/openai/tests/fixtures/text_request_success.jsonnu[{ "id":"chatcmpl-9lkwPWOIiQEvI3nfcGofJcmS5lPYo", "object":"chat.completion", "created":1721168885, "model":"gpt-4o-2024-05-13", "choices":[ { "index":0, "message":{ "role":"assistant", "content":"Sure, here is some sample text in various styles to suit different testing needs:\n\n### Formal Business Email\nSubject: Proposal for Quarterly Marketing Strategy Meeting\n\nDear Ms. Johnson,\n\nI hope this message finds you well.\n\nI am writing to propose a meeting to discuss our marketing strategy for the upcoming quarter. Given our recent performance metrics, I believe it is essential to realign our objectives and explore new opportunities for growth.\n\nCould we schedule a meeting next Tuesday at 2 PM? Please let me know your availability or suggest an alternative time that suits you.\n\nThank you for your attention to this matter. I look forward to your response.\n\nBest regards,\n\nJohn Smith \nMarketing Manager \nABC Corporation \njohn.smith@abccorp.com \n(123) 456-7890\n\n---\n\n### Casual Text Message\nHey Emma,\n\nJust a heads up, a few of us are planning to get together this Friday evening at Joe's Place. Would love for you to join us! Let me know if you're free.\n\nCheers, \nAlex\n\n---\n\n### Technical Documentation\n**Function: calculateAverage**\n\n**Description:** \nThis function takes an array of numerical values as input and returns the average of those values.\n\n**Parameters:** \n- `numbers` (Array of Float): The input array containing numerical values.\n\n**Returns:** \n- `Float`: The average of the input values.\n\n**Example:**\n```python\ndef calculateAverage(numbers):\n if not numbers:\n return 0\n return sum(numbers) / len(numbers)\n\n# Example usage\ndata = [10, 20, 30, 40, 50]\naverage = calculateAverage(data)\nprint(f\"The average is: {average}\")\n```\n\n---\n\n### Narrative Paragraph\nThe sun dipped below the horizon, casting a warm golden glow over the rolling hills. Sarah stood still, taking in the breathtaking view, her heart filled with a mix of serenity and longing. As the first stars began to twinkle in the twilight sky, she knew that this moment, fleeting and beautiful, would stay with her forever—the end of one journey and the quiet beginning of another.\n\n---\n\n### Product Description\n**Portable Bluetooth Speaker**\n\nExperience top-quality sound on the go with our Portable Bluetooth Speaker. Lightweight and compact, this device offers crystal clear audio and robust bass. Its long-lasting battery ensures up to 12 hours of uninterrupted music, making it perfect for outdoor adventures, parties, and travel. With easy Bluetooth connectivity, you can pair it with any device in seconds and enjoy your favorite tunes anywhere, anytime.\n\n**Features:**\n- Up to 12 hours of playtime\n- Superior sound quality with deep bass\n- Lightweight and portable design\n- Quick and easy Bluetooth pairing\n- Durable, sleek exterior\n\n---\n\nFeel free to adjust the text according to your specific testing needs!" }, "logprobs":null, "finish_reason":"stop" } ], "usage":{ "prompt_tokens":11, "completion_tokens":568, "total_tokens":579 }, "system_fingerprint":"fp_c4e5b6fa31" } PK*\]>69provider/openai/tests/fixtures/image_request_success.jsonnu[{ "created": 1719140500, "data": [ { "revised_prompt": "An image that represents the concept of a 'test'. It could be a variety of things, such as a piece of paper with multiple-choice questions, a scientist in a lab conducting experiments, or a student at a desk studying for an upcoming exam. This image should use vivid colors and clear, detailed imagery to clearly represent the concept of 'test'.", "url": "https:\/\/oaidalleapiprodscus.blob.core.windows.net\/private\/org-EdXlYn9JmBUAo1tZ1QeRcOvg\/user-8GYNL27bMyzS3WcLtkUwREgd\/img-uPMRNi0R9lxPPJYOf4NZUmMY.png?st=2024-06-23T10%3A01%3A40Z&se=2024-06-23T12%3A01%3A40Z&sp=r&sv=2023-11-03&sr=b&rscd=inline&rsct=image\/png&skoid=6aaadede-4fb3-4698-a8f6-684d7786b067&sktid=a48cca56-e6da-484e-a814-9c849652bcb3&skt=2024-06-23T00%3A13%3A04Z&ske=2024-06-24T00%3A13%3A04Z&sks=b&skv=2023-11-03&sig=bmZqUqQ2QLrmQFSGxTRQoKRVf4C6VyYCZ0aA4Y5%2BGCs%3D" } ] } PK*\:Dr44'provider/openai/tests/fixtures/test.jpgnu[JFIFHHPhotoshop 3.08BIM(ICC_PROFILEappl scnrRGB XYZ acspAPPLappl-appl rXYZgXYZbXYZ0wtptDchadX,rTRCgTRCbTRCdesc=cprtAdscmXYZ tK>XYZ Zs&XYZ (W3XYZ Rsf32 B&lcurv3descCamera RGB ProfileCamera RGB Profilemluc enUS$esES,LdaDK4deDE,fiFI(frFU<itIT,rnlNL$noNO xptBR(JsvSE*jaJPkoKR2zhTW2zhCNKameran RGB-profiiliRGB-profil fr Kamera000 RGB 000000exOMvj_ RGB r_icϏPerfil RGB para CmaraRGB-kameraprofilRGB-Profil fr Kamerasvg: RGB cϏeNRGB-beskrivelse til KameraRGB-profiel CameratT| RGB \ |Perfil RGB de CmeraProfilo RGB FotocameraCamera RGB ProfileProfil RVB de l appareil-phototextCopyright 2003 Apple Computer Inc., all rights reserved.\ExifMM*  z(2i SONYDSC-H5HH2006:12:15 21:51:49"'0221  4 <D  L0100Ƞ  <% 2006:12:15 21:51:492006:12:15 21:51:49HCC }!1AQa"q2#BR$3br %&'()*456789:CDEFGHIJSTUVWXYZcdefghijstuvwxyz w!1AQaq"2B #3Rbr $4%&'()*56789:CDEFGHIJSTUVWXYZcdefghijstuvwxyz ?J0׌xǶAq_IݸhRzu^R|ϥ)E%}.]u]VMtˑ׮9Zk]_rjvO[StݖY7޷q$s\x\]Rz4OwNkk7fMI]]mC ~On WJRz=/߫oKmIFچQۧ^Wzw{G}UZl.g=+$Xtgӟߧ){]_6s^6oet䝝ᄋIO7~ΙGs뜞ו*z[qwzZ2k]'Vws-(<cϹ{Y$cmz5N7ݵgګ^S>\rx_댞5UgJھZ]N[~]/u{nxQ~x3n6Vufݬ~n^ݽnVx;rH<)5&嫌J͵q;iM^߫~Rz}=={+9M]K9iַ^n~oM=:) #>ӓۮO=knEtww5wV-4m~"C0z>g'%n].ZwvJWYkͶKZN/Ǟn\ɥ]wv+߫Oc}fV}SݢEsƢR+{՗~۱Wzn֫}^}7[_{3>x{þ&R<9xOmN٢I-'X4r i(yc IfGNsřSb`2bhJIF(u+TiFq9Zo1~l Gm|^ JǞ rJΊV48JJk7 *Oq4*atbԏ%Ju5JӍHEҦK[ >k>$.iͳl_ky~SIB QQJiKsu$߽]}O&o>uK] \ҮL+fQ C;c;Dn!H秈%.βIQM嘊jF']Ct{:ΕXҜ)%VNfW!|G.*L:<;bppY0W)ar):GVGO~~97;웺i7^׽ϯmWeݷ~r{܏ƪ/Y7~GFMߥmWg}ѧtֽׯ|`T..TUzK>8۳}RQqv9z~#3̽ӿWZHWZmm7Im[2yqׯ'>_^fٻWi^kn}w{-k{G=>\>-O;%v+zٴtzg֮s_+*4Zgmvܒ97'2{gw%+EO:H=_ҵoմZ4IDޝɡO|8OLg5&MnZoNm+[v}dܬvٷM]s$3g5$fTkivq묷׫O}UOyui.TgwrA]ުev֪tu7m;TZowT3^NMKZ4Z^VoevovMm8;q^FK 9ʡ9p2t_<]ƃ>g<%uzrdRr)~py7<-GRt b)QQOu)[>Glg=3_R~(%.Wmꖻk[hOZa2T3y'뎾e{^ۻ髶z?i}O[֯ox{1[I{'>#ڪ j:ͥ׃jHxi8&_bjK֎ox+ź|VYhiBpyQYkR,6tƜjܘ{6S_'79a2YÙL]Gsnu ͒s'ϯq뎆߼ݮvnɷf{kdw[+8i{Y]%;%}:׮zgWNVKg-'o[7q-n릋G]UMxsZr宊m-l[66`@ aSpp&6w[t^te:|U'JmFrfY7 bNk0Ζj0a!NxE>m#VEFmIҭ8TI#~ğ? ~[:G<1k^+x}>:Md4;[?,wn㲎Kq0\Lp`>ar<%:ASp:6Jq;+'d9Jt ucgEZ1yTRU+VUӼ;i^][ZxoKlu ^y 5Ksw5̎m dU!XQOd1l$|{}jpJ~zʔWS%%QZZ7|.&9VJ3Z!N5SR|mZ|WZxK[|a֡B狋 PZ_UR7&/<|{ˬO|%˻ML$\iv5 [ oi5;%E ^%`p\.G *3 N2u9ISVu$՜cExm᷇*?ԮJjvIBT^rr.iӡ-Bxs"bs̿88iьߕ7x'7ygipxD-Qk|7=GHAXyt'jcjJ kBrx7f?%Z\׷&ו,O*RB3Ԧ8Jj\N2Nx[YJ^K|Q4ءKY|1yZC3M$,ţw;xTwG[aڒIꨫrƜe~[;֩Rb^eXFc^SJͻ:jfJR Ks۷?=vZ%;woVW\muw^zTls=98&+Je{ZWf}:TZoz&sO_N{sVi[oT[}oE|B $qׯ$_T'd\tnV9o]u(Ȝ~v?nqQdVu>n߮Zj{zSrI3tCVһ4Ēkgu妶޻_KtGc&xg<^T]}VMidkNdZ}mi'M[#ߟϷA듼ey(ZOD";]ݮwս{3{r{όN{i֖v׮ɾw{roٹ^v5`c=220zO|yϧW{h螶I'g}ꮷI+.N rǷ?wj]_K>+v[Tߣ׫Xط^;r:'193霜n/K]ikm-/Қn~׮HO׶8?r;s4I<_mZoњ0}gǯ8y6oۯ3j׻սۏ{joUkւ:H|nwM={vto}oM4BPn.YTI۽_{KY4{]7wmn-};կ{j+W׺ݿF~~Ǿ@xn.F#IKxv.ncGu$mYjK*gJ  r59^#NvJf rOjQabjuToſkJ4oo?O~h)xukγ2IL`.xUBdm|*"URמ#Ӫ5:MJtU7<,۟RUJtUXRw^h(_c𖥩x?ç԰KЙ/o#>KG<^@ &TbXE3(bqP*uSVOޤڝԧ')p. ,˪dZN35U\P-RTzmGԧ)Po?^ O&se/y•l. =FH䌲lX)ԥON>dJZ+I]%քpbS2$*2dcpW)CjSUNHJ_)/|I}f:Rׅ\[=J643D]-˼]H.ZQ=?>b㱳氫 u|JR8RvT԰s:yJP/q>&ao 'dl8PUTO&'YA(ыjZ޹xzf[ZlF>&V@f㽷KI u8wpYJU'ZWT+TחNYJ0<*]o:&iS*~մM'Qһ\iuw[\g|H]v'o>r26ؤ94 4.,TinE*)lDSe&NQ3ZJR#p*|Jʜ);]UT+oEM~|m7ƞ) u˭>kbɥi3dm{w1_Ξ3d9v KkNqMF))^? ɸPêq*2#QjR|wIQYiBqȏlp1m׫vֽwWk=z{^<׾G^s'SwwNٽn;ۮkmCX sluߎhfVW6XMZz-}ZO_Ϲ8~r8皻m-J~-uf֪]-˦x$gӷ'T8ٸz;e'w}D+On{~y LzG{5woVO":T.(:cze]W7w[+%k4~ofvûN8<}s涋Y>s;ڋq]O_zmuO^ǁ^i;?zQ뙫ʹҺ\k[Wߚn~Wޫ\}9# u㩮I=mz5ӺڻMZukݽtKdӲ郎~3=lkeQKOUgӪ^f Ӄ~{jT%dVմwmr'{N]Z.@}G|wN+U{&uhu}^+m}=zG~7-:7t^_Vni[]Z[GuiTC>~(uM'AwkwXZ#ⳆI 6 #iU/|%^5xp \Y C4̳,4֯2\\~1ahՒLUxTisFJU#JQ=K xJ[S0YvW:Qe0V AV{ZI9ƟRdy~~#|dįχum'ƞ0\؝n-JmRhn\ ۳_WopF?V78ɰRq&ccNX\=\^59BK FMG_W5xge9eGjPȲܺjrQ<^2?sYBT?zHO==J=sTj~kh k1]i FHgȩ{Bo|=\= ZjiVU%8*jqnӜR+vY,CBj0SQP+ 8E*I^W=^; x:2w.B_ ɴeFU;}68OC)0שÞCc0Xl21f"NL®*3^rTzRdş<\Cqx,;.EdE*1ҡ8B:&[R%A|#gZχ>zm>)^GB{Yo LF:5(jԌ(Iڝ8˗].2G̅n6nV e14y!|9ө<-h{TaQo~ԣ=UJ92OE-O0.HCQӭz\m PkQ J-EP_|BsƝ}|J jLzy0-<4Z}wq\?gRqn*0`v#Stjnlno%XSsӥ˫hqiŸXJܖ; ?xU +^]&-NUӮ-4ZWrٕӭ .<5<hFU(SehԩWRITNQZm.^_1ƞ38L,7ibsU၅<]?mBdF.ubQ(Ν8Sk?%<=!kN!mWp)B6Ҋoh{yn?"`j\kvv?@J=z{W]zg$gf?;<ϚV+]WgEuͫUg'Qq;9+NTWiwo}tmkk=_E 8fZr(⣯Emuenaikh"9YcJw~imx+[tm٧\Νs98y)MGF׾kͽk$ڳk{w2fOS~C 팴ZWٵo[f=7}V4\ûNz{ Qnҍ-NJR[;5Jɴj僚hZr6@mQn8^W>Xt/nNM4zjݝn{{-;vi/;ji׿UZJi}7뻑t}9V(]unW]WokݛKMoہGN_Ǟ2MmyVu*;پ~^ɚуǨz{z,m]}tM+^dkmmk-;-n_zuQ=pzzVM-vܕwd޷^Gqm[꼴=?^ޭjjXuڣ8C(YP7cvW&Hq nsS8-d*帚fj:ʞ/-NoGcx/wq3Ͱi9a:8Ye+*< ђ-w=|^Z[3Hm-&Ia|aMnkOKlo(+u/ A,MD+oϣ,wfj\Wr3ݥ̥e/ŅGNq }<'§kkV޸7TqHe+O"Ws x{S1am8FNzUo|SI;.NWK8W!VU2\PshQXMFK:c?W/,i%[/K_Je]6ohBm3MԦnZY)6 a3!8f]B/3δp֔iW:pҚpº=ڃj p<&LaRk-/c 5j1URjQC5?^Q$5Z[}nO[$&xYm.#%ِ#m<\nǽV%R:7*:}9ƥK[ZPZYtiWJxR% ~8QQj ijݮʿֿxϏuVo2džu|{HbѴ-ئi`Hig3|?;ve᧖19NqSXZ5=&3 JJ/iRi9wo c62yKSXK .*hN)*qI]A9Y]0C[Sv\\Wq,͖?4*GT2< <5 .rݔȧ'dڒI.g+bkIS%)UJVqvQmI>Uꟶŝ;_|E߄t0既x_M=gqv-YZ%v0"%Gqaٜ*nؼVy&ؚ:r:pqU*Q})Ÿns~i!Vx 6&y~?uΥYPaaΥiRԕ9Jyf׌2źw5@dh|MKJW{ KDPcTϸ:pYq^UߝR^uKW{-)p pCd9F)EIR_ Nwԯ9ރ??ikPmuM^+{.'9LRq.Xܜ ~c_x,JUZ5z۩TrQwm$l r;!V )*8j80>o87ͤ윹ck~oư[ļ gj ^3VܛII3Oۻ^W?t*Z^{n޷~}znzt>KGTi4bwھ}DNN{~4׍z;>^׻wѳN5ggmV`A翯׽Ҿm]m4ֳ{Nv#+dGSWz,uV얝vm67oN]֯{}{Rx I?*\dҎJfݞO[M5{jշlc$;Ǟ;RZjItMwĵѯ=_U]Ii:OQsz{gv]㢿5յuսQ+kYe{-ݴ՝mBu['{ŸZk]ۭw7ufm8 v=ǿnt=s=$I׽][w%{'k﻾Wǎ?SӞO8pNVV֩|ZZjv.6N]Vy/47m`pqL{cfVvVVwV_N]˯OO;u׾8}z\vwV~_>u*|-][[}kqnmnKUi=WpIo'v^nvVPx>s\wj)$ҺR+6i5fMD~mv;릧ſ΁yqn!6%@s_xm[O>n:iY_Z\ƍJ U(MӼ,e/ǵϑio(牵 @?\xt;ָ?i\xvt&or.o O~-?x`|2p|gY. <*5jcFΌ0n\7?$!Z? ~|OX+fY6AY"\Ga2hdXn#K aZn5'<- |#ƾ+-N]:k@R^}ޫ}lɴ ye3Mq2ĘӖ`qNB1*j5:ժ*RiՍ1Q.Y=*yb+VԄZ*JNNTZ*N3a|i}akV:: Il.AC5 īe.eƘOW p4+הo*.N5jQg.k{ g$OPFPnpܹg|M?uK8_xGJJ[Z?Er.q y$kv"kyaQץXڸyM帪z]x(r.umSZZEÞƯSi*xxT9i질w.i'eJsɻH~ʫsG5~mxxOw&mGa.:Mʺ|P6P"(xXz?XU OoWQTڪ1U)ʝIxW5UQUG,=:=)ʕ:O 9^4Zom 6{ⷷ˔/įm4Cͤ h`nRy1~,1IiRҎ3kGYV~? xl9(JKv tOt;>;5'>0t[ķW!ӯlnmN r!HoGfq)ن/B7OJW('XP?q==.Lܟ&c~w^EO#(ԯ>zRQnKZL: ma|{jj:׌tK Q,Ǜk{z&9-$ID &'U)Uj +Nq0N)~MER`K5ХVʔqJ57OQU.J [_^w{W^SuCWZl^$uV҃ah:12;ISY PiC2v9#E673nO)mC1VjFeS>TFuRQ|ǯ^.ie{UzҾMӧq׮Gr8=yx/[.[&fuu$Bm#}?:ryWmn֛{=[;6=}LǷL`>96}/t[rM.M|ҿm/g8vn.EմagTնM >p2Nszӊwm{i~ȒgmV4z^nfJ?A7il|W&K4WT\{q>ܚRM2wz&~+]5$+k[~p` ^׊3K;^Wyފe5mזo8$l{^zU.vޭwzw7nNIqӿ5+6+Jmn)J^'~oqO;>'kJ;i;tݷ-E{k쓹.q#?3zVR ]lgeo[}u-}zz5]5b;O\x Dzǃ5ַQ|*nlZ2MjqfH`7%gu5%W3ԧ&]7Q啚V|oX Lh:nTùu (F|nݚ ~ƾ QMN}Z=&}[wҭ"uM>kˋxe3<5*P8Ix<:dydUqp[:9BS{ӣJ5)U{JkSQr?,,1&-ݼdJm 8z*ӋrjZN;)ihݵOh..߱#0r3q_(Q11չv8\%+^~k_|&Rm9e#)_W'J{'eAtM3c^EGO < M_kl,六 _5xY+]߭촻֚݊_^_| _ |?%WW\\4fV`qWz]} <V䋩Qʤ˫oOz~h֟ KZ왤> ^!ž#ͺN>%[K}^ Os_'3'縘br< N2*񋜰}X{ZUn$ڕJ0QRj?&rr>"&2:TqX]YhS&4rrڟ:⍎tZ [_^yڂ2lRHrBj9CZpi!`cZzj4⮣&S)7wk5%Mӧ,3UgNT7ng%vNO_Y x a&s$o*^IK!RW-WR-~Ζl+Z׵aj~Wa};g$ 5ů;a fv;,卤w*Hʦ:R_eZKYȽ2#'q.fQ–Fi>w(M򤠢*s༟:/3խ[JZ SR\%75)TSI%X'Ŀh< Zn}GԵ}Jᮦy U4cKu4q!Kğѕl,o\c!b*Unq9GF:JGXq9YU''C JJS[ *xz_,UY\)NSwjG:EѾ+_~$EnDŽ{o>,_Brh߈mXh$<Ejh)e?2d ѼN2I{RrѷZ崲̒#MSb ZI;bqYA.ZQ}?R7`y4I]O_eK壺n(;ݻ_K4l:^6~>i;^_kZ>Vw1oC?'=^[zѫۭ[Ӯz ׌\Q7;^+Vmm_7Kj n-}߳G8}i;ͻnvY^W݃z]j_zkf*L~Nj1FJM]uK[ꝝ[ u뭯Odrs'=N?LUgk6Mݾ]w뾩ݓwow{˸גzqc565[4wV+N^?wr3q˯5uݥm{ݶvu}]8k<; _{UnwQgY6dmݬktݾm}3?\/I'tޭk${_i{isv\ۯY4_.[2ZWmvV宺sN봝MM%z=Kmukgօ#㯮:ckIխܭw/@4^g=6ƬR8<TӵMw{.[ܤ;ٷn7~ksӌ{=Mc$)-UیZM-EФۺw~vv?|7XKIk0rqyW%<:xTNOoGkVʤdq4浾ot={E?|+4MJ/? ѵ;6^ 04@4_φ#T_yjꤠzT_6{虜JdArN~M˕MWBJzYo:O d\gyh>u9NP-u"Osf$,\, p2u(ԎRՠkJEIS6XRged7A^08GIՕ9Jxi:mZSu%MϚjTIqNgCb Ab`>Rvz'$JnѴ]5jkv&)VN3473ITOYE)u咳Z/e'/ݦeWW^G W)i{>cN{I#M7ŶzE+F,82lҥ')U˪(8ݾJ5a nSRKOy^/F" 2ӍJkENUV3Œ]Drj%|7g|g?WS ƞ3tCfs_.Fw柫i׺F-\qXIŇlb+ԡORIFtQnIsiv?܎0_G,~Wu,Zsνt(b0/OPJ5C.dޓ FKoŖگojmWIxSK3V1YvBI j:()JJr`Þa TA2QPT\R|39vEp Ux(SskVa5a?m=Lg,jWQjU%cۚ fx'׉OxWZuF.a_.\1,v (F!h6*VTaMkXz^qU֯VUq2F*GNITܛ# MjJ u=@+ןWMJVmGmu뵬ZW~Fݳ#q!T;3=j[;WnSj=9_j~=޷麿WWv֮M-7MMx[|2?CYIgmdZZϽyZZڽH['2rn_59YiMy edM_Gz_SENq{~GYGwtq|ϵ]";i˽wE<G$2J= ~=x<թrJI7{]o꺻[++=[GwE?O(W™4m: oQtDv>d.g|!A q1!WjTΩ>8!ҡGFPg&ײ-ɾGv?L1 ~J^ksbr}^pDB9lh3C1躏|e/iVwm]S_dq0-ZVmGs%6҉&r"9Ĩg&2],V oQ|?"^>¤(OjEJvl#С,pUX*bI3z5)S\JMՃsIma1i6V u 7F6.n$%G*YVW4Mi*+?<1wþ PVMnYԕ'4z;'gO|j~ Ǐ4 Ş+RG-oYcYa|U%#$C)x<Vڦ6Ɵ(n[OyL ֽ9VHzj5~2|Iٯ!xi:!U|(# Mʜj~\ɏo\(5'k]+Ͷb-m Ss4aeExgԥOь~*rsPqj1V?,iE9RjgWڪvӑ9JqIy+6 Ӳ7ſ x~/ӊ|]? V2!xi7IqejwRgq6at?^ӡ^*NpMFR=9Sϼ~$|5y6~fYqYЯJj1yF9d)SViL`|:xw_2MjPZ< TJHNv>$3.#*y[b8j|ю' Ztkre|Z3,s噾[Q/̰8\' K N'(JIS8Owfz7KMltN{ub9?^S2Wo[KD;^~݃V蝹ߥ^;?\*ҿ4t]>z]l[_;o} u8ޡMhO߯Qj}߿[=o-#v'OոK+y]~-mmߩ<җ,nm$kz'n{j[mH$ϡV}ϲVԶ-F익l͞^r}5 J+}Ok[ݚ9fzn__-b.\OqׁN}kUJ.Wi4~ֻjWuջi~[h\۩=;j\uwtwM,m$ޖ\׾W}{ytvOl?NJ]r+].fәGumrNd+QwVki6ַ.ms-S]֚>ڻY} |y?Ѷ벴tQgm{-fN1p{mJVvJ_յ]<5cn2ZƥKTm-Q{kuDҵ}]NƔmgz<%ʜW[I>Jm'm_}[z{3C1K=;_M]Sz'l{v޾D`־xwcVu_ Oi[x.5j ikg$W2XܻLSi'͸W8٦/eyCRTxk)B%ZM.#7s,>[S/PƬV]:_+TS2?3p/Vɩx>XE 9]bY^Mix X,/Q l4mO3)a<(St#P)Q j7QNtԥN0l7XTj՝]Ξ"jNYrqRWJZQ拌?9>;X|A#Am+7wsDs B[G|#K|q>QKf(, Bb4… ܭ^+RjG _8ƌ18lYj3 EJ/ my_+FN骉/߅֋5|<mG9tM_UMwT_zޟ]hWsLuHfhoh1\ۍq1p .{<. K SWqQu*_v ӊ+Wԭ]b3o*T _. 94%/u+O Z鷗߆ӛ]W񵷈u]RHi|A ψ,VHos#)<ʅ&'ujP)}K8a$ΝWW[K)Ud8¬MK4hXD!z0.aQJIũa>x<ҙ_fҮxO.<3iR?8\˧:n ҭmpVγY$~'8,VIZ3W]owok{u o~m;_vM{ٽS^{okiO}]_C?[z?My5&mRD/Vߓm}-?=ݒoY$Ųntzw]nץ?ϡQA=s=S>j髧fSv{Mٻ7]ov֚|%nu~[vkkvZ=[ktխ}x?F[WM8Yחg֛jՙ{u3gnQ>K'[-F3Z/[ﻵpy<׿ӞFR-ztMi[w}tk;+}U<B5wzy}l}}e7ުZo{&tI'>?ǜp/򿔖}W]ߝwmn{]MwFp϶yoL:.hdV]}5{ٍ}M+$iin[9$coӹׯa+;]NGʕmt_o$ <=vwOwmKTw}nS{[^W}uas}zssYN.ҺWM9z5gvRZګ]J*Ǽ3^$p2yWVU^p|*ZRK)νǞ5| j&~!uմ]*x5H.aצ?+Lտ#pٍ>y`kLa:Н* N8YU(PMe$f{9.qRɲq,ҾaK*f.P15*B\h(R(?n-cTxsĒOyE]z4XS xcE1RB:4M~N.UcVφb*'l%Iw,ci)SN>kbbqN'HbVe^TIs4rs/ RҼ_xTKRk :O}29нܷs(>7gEl> kO,Kt}Nl5e:*1TQM*uTh”=GxG"'yfXcn":XrC УZ5*)?=~A%=uGZ=miɴc=>ݖOhzݮ-_>}:<}3EnkjN$礷ko}5 'ۦ9[;dջi~gշ|^~^wԤx;Y4w{ڶ6t+[Zտ4PI>s'ѧ]Y;_^utۿ_k[Zuʝn?'ky?7֝SzKM^wٽfEw=Ok0\۴mKevmw9Zom;kԇ s׷o\cjwrjVݭͻ_дٵh]u{I8r8}ANҩE5n}vM>[w}[Mo}5lퟧF}u5%w'Y۷M,jE/]t-{.Mt= sgׯ~v%keJN[Gugv`ivkhWvvj[=u{n?qg8XI^J;h.g_ }5UцLS܂ U٫=}笥-v孑=]vݴwjDtOS׎=W3ZMY]-z]2W{ۣk"Ԡ{ o 4m,QH37{{ai`<+!rer~_Jp¦bƜN|ъ|ғm.V '6VXj կ^g`{j1iK97m9F_e(fOYx:׆+sZ?H^""q@tazY~xK=yR8(W0mNzTJNqp Fw>]Q G7x"u2ʝSWqgRXsR#Z2Z*7jpHNmdO:>5ާqI>PGe9/,5-rnK [9߄y ) XU}S5\𲕗R4k26ibrϞ; },NvixD#unm/"xw>1< MK~$3a1Ue_ iPnbxzP]I;~~4x3oowɭxCSӼWZnM+Zam yEҾ3FXlZ t䪺Pvn)&nT>[4UVxnʤrJ.RK[#"7!y1ut01 #yD9BtWiS唡MSkKNNm^.^e,LSIïk5QUl5u6)F_i'ykkm-yޫ]oVZze9}=*-+vݕ4nvdݷ}yА:dB:m.}>W7f߯K[w7NO\.$S oIi;W]o6;%]z-.Vz@'6z|z'%kךV['WUmmerr9_+JmڶiZk{.in=^+1mjWnܱ)IV׮O߲GoGg?z߄ .MgR&{wCoM"GU̱`rAu+*:թ9e-e^sY4?q_H a)ƏaT!?b̧/ig|,m)STz;z b_|2Cp|`e=a],X՞]`ֽ_2䖊=yKr?ۉyi WVN-)] !|"ӗx9'<K#iC.q\l],. evҌ_[s=}Zq!}%5sQ\JYF9_{;<>mkI__u9r-8~|N}9?E~+vJ2(|-s/>M{tA/׆{.#mrW?bێ~7n@:c Nk7~3~ kGcÃ><'E'w,gVw{P{vwZI *5&SmOM]kYL?L_)3Bao3q0 x @סH$fbE53 >I_"xgVUie{J]4pf?IRΛ"yz~g:V%k)E|g[ yaoϾ eǟ61q;{jNp q9'L>KJ̧)˒WT!Uj۩)I;~_5*.JRX;ܥbiSW%>ߴp}ygf)cGRAѦerCY@;0焋WIBooʒzZ}Z>34;,-or *YlwgF~ÿEj߳7)nqgz#}|*0O9[ρ9YfiO8ii9=-mO"aw m%ٵe/_߂A~m?~YA)%Aneɇ [Kx#0m )^Ŵ˖sy6# }ef+mj_q !dy&0_%R+ ^62Vana_EŦVW\~d݉CAivÝX[]u-ݣ%;K[7+#p[sI5lZqk5-%$RJmo͏kMWJtChhnMMm8Կ?.X| 74l#x=,b, ᑀ_-yu8Sʦ 6{EնIu Fs+?wu{ݽ>!j~~jxSc9REmS, +h-$.>VA*)#zS7_8v[fR%kg?(ZI'fY_% NQ}לV>|G(I]+y+ u/,Ie,IHi$rű )v H|蛞bkp+Ԟ\LeF̩eKIͮ[kϤ):2)SֆM]Bxs|mlH_P>?xP?}A'F3<_)\Tܟ[=ycջ=ӿ&éjGVeG:Y\g;+ H¤<{B\fK7eݛeUA˿~c~(kV:VW&G§>NA9Ӯz/Wd.M٫^zk&Í_it]WNO&7X㑟}19X;;ĭ4q:tW{ns2%-}Gw%41ivcu@"! #E[{ޏ>앓v ܘl)BP?5N9ԽJpR3Te62M$Zikm'ÅC})2m{,˵A T^zJ0ZWc+9%SmdKTkHe(aܝI*RS(Ԝ5bI{Wz;kΝ7Ѭz-rOE?f g we$,$liyZg{*/hyA/ze۷W(>$ۚ&qu=O/Nӫ Utۚ){B]G=7e~҅ѿ-}?XkjV{?i ?6u>oZ~ΧO ?-i Z7_m3_;G_(iZ~BkG L=}9vbuKM tJߪ]WgΒx:r/rrzc<<ոO'߯/mE/b޺6姖OdeOٶO=E׍4meYKR_*9k<c*i:34TI9]ҭ\Һ]߃MBۓT.ǥiu,L}hW,d.<϶}ggew),pX,^׃)rIMVWWMzr񰬢IEbĜY*/JWm!X>3\֨3k0,<|lX3DB!a|rC Ve;(+I9SN?{k;.*n.ғ(KU9e (ErZ{:i5%%fQĽ*=-\\y ]~#sPm* `wUN7nw+EsYZXVo IBNU$e*M-R CGχ4l+1kU|Ći$:z5{VJumZ(Nx|Vت1˳{4-9IßNxHxd|#_[Y=^X?u/.]~Zd? |g͙$,eu{N뾤SnWfݷw "u7uc֣>ؒ[4_~U{j8mj~AO}K ? Wt?>swEdX^U'!J-}??ˠׇߋI9!Ofxߞg৆ ռTH/h7~,,9M/W3Gh pZYb7g}[ƶމ]Ho9q-mwŶo[׮_Jwo'tmw#?,1A. namespace aiprovider_openai; use aiprovider_openai\test\testcase_helper_trait; use core_ai\aiactions\base; use core_ai\provider; use GuzzleHttp\Psr7\Response; /** * Test Generate text provider class for OpenAI provider methods. * * @package aiprovider_openai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \aiprovider_openai\provider * @covers \aiprovider_openai\process_generate_text * @covers \aiprovider_openai\abstract_processor */ final class process_generate_text_test extends \advanced_testcase { use testcase_helper_trait; /** @var string A successful response in JSON format. */ protected string $responsebodyjson; /** @var \core_ai\manager */ private $manager; /** @var provider The provider that will process the action. */ protected provider $provider; /** @var base The action to process. */ protected base $action; /** * Set up the test. */ protected function setUp(): void { parent::setUp(); $this->resetAfterTest(); // Load a response body from a file. $this->responsebodyjson = file_get_contents(self::get_fixture_path('aiprovider_openai', 'text_request_success.json')); $this->manager = \core\di::get(\core_ai\manager::class); $this->provider = $this->create_provider( actionclass: \core_ai\aiactions\generate_text::class, actionconfig: [ 'systeminstruction' => get_string('action_generate_text_instruction', 'core_ai'), ], ); $this->create_action(); } /** * Create the action object. * @param int $userid The user id to use in the action. */ private function create_action(int $userid = 1): void { $this->action = new \core_ai\aiactions\generate_text( contextid: 1, userid: $userid, prompttext: 'This is a test prompt', ); } /** * Test create_request_object */ public function test_create_request_object(): void { $processor = new process_generate_text($this->provider, $this->action); // We're working with a private method here, so we need to use reflection. $method = new \ReflectionMethod($processor, 'create_request_object'); $request = $method->invoke($processor, 1); $body = (object) json_decode($request->getBody()->getContents()); $this->assertEquals('This is a test prompt', $body->messages[1]->content); $this->assertEquals('user', $body->messages[1]->role); } /** * Test create_request_object with extra model settings. */ public function test_create_request_object_with_model_settings(): void { $this->provider = $this->create_provider( actionclass: \core_ai\aiactions\generate_text::class, actionconfig: [ 'systeminstruction' => get_string('action_generate_text_instruction', 'core_ai'), 'temperature' => '0.5', 'max_completion_tokens' => '100', ], ); $processor = new process_generate_text($this->provider, $this->action); // We're working with a private method here, so we need to use reflection. $method = new \ReflectionMethod($processor, 'create_request_object'); $request = $method->invoke($processor, 1); $body = (object) json_decode($request->getBody()->getContents()); $this->assertEquals('gpt-4o', $body->model); $this->assertEquals('0.5', $body->temperature); $this->assertEquals('100', $body->max_completion_tokens); $this->provider = $this->create_provider( actionclass: \core_ai\aiactions\generate_text::class, actionconfig: [ 'model' => 'my-custom-gpt', 'systeminstruction' => get_string('action_generate_text_instruction', 'core_ai'), 'modelextraparams' => '{"temperature": 0.5,"max_completion_tokens": 100}', ], ); $processor = new process_generate_text($this->provider, $this->action); // We're working with a private method here, so we need to use reflection. $method = new \ReflectionMethod($processor, 'create_request_object'); $request = $method->invoke($processor, 1); $body = (object) json_decode($request->getBody()->getContents()); $this->assertEquals('my-custom-gpt', $body->model); $this->assertEquals('0.5', $body->temperature); $this->assertEquals('100', $body->max_completion_tokens); } /** * Test the API error response handler method. */ public function test_handle_api_error(): void { $responses = [ 500 => new Response(500, ['Content-Type' => 'application/json']), 503 => new Response(503, ['Content-Type' => 'application/json']), 401 => new Response( 401, ['Content-Type' => 'application/json'], json_encode(['error' => ['message' => 'Invalid Authentication']]), ), 404 => new Response( 404, ['Content-Type' => 'application/json'], json_encode(['error' => ['message' => 'You must be a member of an organization to use the API']]), ), 429 => new Response( 429, ['Content-Type' => 'application/json'], json_encode(['error' => ['message' => 'Rate limit reached for requests']]), ), ]; $processor = new process_generate_text($this->provider, $this->action); $method = new \ReflectionMethod($processor, 'handle_api_error'); foreach ($responses as $status => $response) { $result = $method->invoke($processor, $response); $this->assertEquals($status, $result['errorcode']); if ($status == 500) { $this->assertEquals('Internal Server Error', $result['errormessage']); } else if ($status == 503) { $this->assertEquals('Service Unavailable', $result['errormessage']); } else { $this->assertStringContainsString($response->getBody()->getContents(), $result['errormessage']); } } } /** * Test the API success response handler method. */ public function test_handle_api_success(): void { $response = new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, ); // We're testing a private method, so we need to setup reflector magic. $processor = new process_generate_text($this->provider, $this->action); $method = new \ReflectionMethod($processor, 'handle_api_success'); $result = $method->invoke($processor, $response); $this->assertTrue($result['success']); $this->assertEquals('chatcmpl-9lkwPWOIiQEvI3nfcGofJcmS5lPYo', $result['id']); $this->assertEquals('fp_c4e5b6fa31', $result['fingerprint']); $this->assertStringContainsString('Sure, here is some sample text', $result['generatedcontent']); $this->assertEquals('stop', $result['finishreason']); $this->assertEquals('11', $result['prompttokens']); $this->assertEquals('568', $result['completiontokens']); $this->assertEquals('gpt-4o-2024-05-13', $result['model']); } /** * Test query_ai_api for a successful call. */ public function test_query_ai_api_success(): void { // Mock the http client to return a successful response. ['mock' => $mock] = $this->get_mocked_http_client(); // The response from OpenAI. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $processor = new process_generate_text($this->provider, $this->action); $method = new \ReflectionMethod($processor, 'query_ai_api'); $result = $method->invoke($processor); $this->assertTrue($result['success']); $this->assertEquals('chatcmpl-9lkwPWOIiQEvI3nfcGofJcmS5lPYo', $result['id']); $this->assertEquals('fp_c4e5b6fa31', $result['fingerprint']); $this->assertStringContainsString('Sure, here is some sample text', $result['generatedcontent']); $this->assertEquals('stop', $result['finishreason']); $this->assertEquals('11', $result['prompttokens']); $this->assertEquals('568', $result['completiontokens']); $this->assertEquals('gpt-4o-2024-05-13', $result['model']); } /** * Test prepare_response success. */ public function test_prepare_response_success(): void { $processor = new process_generate_text($this->provider, $this->action); // We're working with a private method here, so we need to use reflection. $method = new \ReflectionMethod($processor, 'prepare_response'); $response = [ 'success' => true, 'id' => 'chatcmpl-9lkwPWOIiQEvI3nfcGofJcmS5lPYo', 'fingerprint' => 'fp_c4e5b6fa31', 'generatedcontent' => 'Sure, here is some sample text', 'finishreason' => 'stop', 'prompttokens' => '11', 'completiontokens' => '568', 'model' => 'gpt-4o', ]; $result = $method->invoke($processor, $response); $this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result); $this->assertTrue($result->get_success()); $this->assertEquals('generate_text', $result->get_actionname()); $this->assertEquals($response['success'], $result->get_success()); $this->assertEquals($response['generatedcontent'], $result->get_response_data()['generatedcontent']); $this->assertEquals($response['model'], $result->get_response_data()['model']); } /** * Test prepare_response error. */ public function test_prepare_response_error(): void { $processor = new process_generate_text($this->provider, $this->action); // We're working with a private method here, so we need to use reflection. $method = new \ReflectionMethod($processor, 'prepare_response'); $response = [ 'success' => false, 'errorcode' => 500, 'errormessage' => 'Internal server error.', ]; $result = $method->invoke($processor, $response); $this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result); $this->assertFalse($result->get_success()); $this->assertEquals('generate_text', $result->get_actionname()); $this->assertEquals($response['errorcode'], $result->get_errorcode()); $this->assertEquals($response['errormessage'], $result->get_errormessage()); } /** * Test process method. */ public function test_process(): void { // Log in user. $this->setUser($this->getDataGenerator()->create_user()); // Mock the http client to return a successful response. ['mock' => $mock] = $this->get_mocked_http_client(); // The response from OpenAI. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $processor = new process_generate_text($this->provider, $this->action); $result = $processor->process(); $this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result); $this->assertTrue($result->get_success()); $this->assertEquals('generate_text', $result->get_actionname()); } /** * Test process method with error. */ public function test_process_error(): void { // Log in user. $this->setUser($this->getDataGenerator()->create_user()); // Mock the http client to return a successful response. ['mock' => $mock] = $this->get_mocked_http_client(); // The response from OpenAI. $mock->append(new Response( 401, ['Content-Type' => 'application/json'], json_encode(['error' => ['message' => 'Invalid Authentication']]), )); $processor = new process_generate_text($this->provider, $this->action); $result = $processor->process(); $this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result); $this->assertFalse($result->get_success()); $this->assertEquals('generate_text', $result->get_actionname()); $this->assertEquals(401, $result->get_errorcode()); $this->assertEquals('Invalid Authentication', $result->get_errormessage()); } /** * Test process method with user rate limiter. */ public function test_process_with_user_rate_limiter(): void { // Create users. $user1 = $this->getDataGenerator()->create_user(); $user2 = $this->getDataGenerator()->create_user(); // Log in user1. $this->setUser($user1); // Mock clock. $clock = $this->mock_clock_with_frozen(); // Set the user rate limiter. $config = [ 'apikey' => '123', 'enableuserratelimit' => true, 'userratelimit' => 1, ]; $provider = $this->manager->create_provider_instance( classname: '\aiprovider_openai\provider', name: 'dummy', config: $config, actionconfig: [ \core_ai\aiactions\generate_text::class => [ 'settings' => [ 'model' => 'gpt-4o', 'endpoint' => "https://api.openai.com/v1/chat/completions", 'systeminstruction' => get_string('action_generate_text_instruction', 'core_ai'), ], ], ], ); // Mock the http client to return a successful response. ['mock' => $mock] = $this->get_mocked_http_client(); // Case 1: User rate limit has not been reached. $this->create_action($user1->id); // The response from OpenAI. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $processor = new process_generate_text($this->provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); // Case 2: User rate limit has been reached. $clock->bump(HOURSECS - 10); // The response from OpenAI. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $this->create_action($user1->id); $processor = new process_generate_text($provider, $this->action); $result = $processor->process(); $this->assertEquals(429, $result->get_errorcode()); $this->assertEquals('User rate limit exceeded', $result->get_errormessage()); $this->assertFalse($result->get_success()); // Case 3: User rate limit has not been reached for a different user. // Log in user2. $this->setUser($user2); $this->create_action($user2->id); // The response from OpenAI. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $processor = new process_generate_text($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); // Case 4: Time window has passed, user rate limit should be reset. $clock->bump(11); // Log in user1. $this->setUser($user1); // The response from OpenAI. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $this->provider = $this->create_provider(\core_ai\aiactions\generate_text::class); $this->create_action($user1->id); $processor = new process_generate_text($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); } /** * Test process method with global rate limiter. */ public function test_process_with_global_rate_limiter(): void { // Create users. $user1 = $this->getDataGenerator()->create_user(); $user2 = $this->getDataGenerator()->create_user(); // Log in user1. $this->setUser($user1); // Mock clock. $clock = $this->mock_clock_with_frozen(); // Set the global rate limiter. $config = [ 'apikey' => '123', 'enableglobalratelimit' => true, 'globalratelimit' => 1, ]; $provider = $this->manager->create_provider_instance( classname: '\aiprovider_openai\provider', name: 'dummy', config: $config, actionconfig: [ \core_ai\aiactions\generate_text::class => [ 'settings' => [ 'model' => 'gpt-4o', 'endpoint' => "https://api.openai.com/v1/chat/completions", 'systeminstruction' => get_string('action_generate_text_instruction', 'core_ai'), ], ], ], ); // Mock the http client to return a successful response. ['mock' => $mock] = $this->get_mocked_http_client(); // Case 1: Global rate limit has not been reached. $this->create_action($user1->id); // The response from OpenAI. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $processor = new process_generate_text($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); // Case 2: Global rate limit has been reached. $clock->bump(HOURSECS - 10); // The response from OpenAI. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $this->create_action($user1->id); $processor = new process_generate_text($provider, $this->action); $result = $processor->process(); $this->assertEquals(429, $result->get_errorcode()); $this->assertEquals('Global rate limit exceeded', $result->get_errormessage()); $this->assertFalse($result->get_success()); // Case 3: Global rate limit has been reached for a different user too. // Log in user2. $this->setUser($user2); $this->create_action($user2->id); // The response from OpenAI. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $processor = new process_generate_text($provider, $this->action); $result = $processor->process(); $this->assertFalse($result->get_success()); // Case 4: Time window has passed, global rate limit should be reset. $clock->bump(11); // Log in user1. $this->setUser($user1); // The response from OpenAI. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $this->provider = $this->create_provider(\core_ai\aiactions\generate_text::class); $this->create_action($user1->id); $processor = new process_generate_text($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); } } PK*\ll5provider/openai/tests/process_generate_image_test.phpnu[. namespace aiprovider_openai; use aiprovider_openai\test\testcase_helper_trait; use core_ai\aiactions\base; use core_ai\provider; use GuzzleHttp\Psr7\Response; /** * Test response_base OpenAI provider methods. * * @package aiprovider_openai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \aiprovider_openai\provider * @covers \aiprovider_openai\process_generate_image * @covers \aiprovider_openai\abstract_processor */ final class process_generate_image_test extends \advanced_testcase { use testcase_helper_trait; /** @var string A successful response in JSON format. */ protected string $responsebodyjson; /** @var \core_ai\manager */ private $manager; /** @var provider The provider that will process the action. */ protected provider $provider; /** @var base The action to process. */ protected base $action; /** * Set up the test. */ protected function setUp(): void { parent::setUp(); $this->resetAfterTest(); // Load a response body from a file. $this->responsebodyjson = file_get_contents(self::get_fixture_path('aiprovider_openai', 'image_request_success.json')); $this->manager = \core\di::get(\core_ai\manager::class); $this->provider = $this->create_provider( actionclass: \core_ai\aiactions\generate_image::class, actionconfig: [ 'model' => 'dall-e-3', ], ); $this->create_action(); } /** * Create the action object. * @param int $userid The user id to use in the action. */ private function create_action(int $userid = 1): void { $this->action = new \core_ai\aiactions\generate_image( contextid: 1, userid: $userid, prompttext: 'This is a test prompt', quality: 'hd', aspectratio: 'square', numimages: 1, style: 'vivid', ); } /** * Test calculate_size. */ public function test_calculate_size(): void { $processor = new process_generate_image($this->provider, $this->action); // We're working with a private method here, so we need to use reflection. $method = new \ReflectionMethod($processor, 'calculate_size'); $ratio = 'square'; $size = $method->invoke($processor, $ratio); $this->assertEquals('1024x1024', $size); $ratio = 'portrait'; $size = $method->invoke($processor, $ratio); $this->assertEquals('1024x1792', $size); $ratio = 'landscape'; $size = $method->invoke($processor, $ratio); $this->assertEquals('1792x1024', $size); } /** * Test create_request_object */ public function test_create_request_object(): void { $processor = new process_generate_image($this->provider, $this->action); // We're working with a private method here, so we need to use reflection. $method = new \ReflectionMethod($processor, 'create_request_object'); $request = $method->invoke($processor, 1); $requestdata = (object) json_decode($request->getBody()->getContents()); $this->assertEquals('This is a test prompt', $requestdata->prompt); $this->assertEquals('dall-e-3', $requestdata->model); $this->assertEquals('1', $requestdata->n); $this->assertEquals('hd', $requestdata->quality); $this->assertEquals('url', $requestdata->response_format); $this->assertEquals('1024x1024', $requestdata->size); } /** * Test create_request_object with extra model settings. */ public function test_create_request_object_with_model_settings(): void { $this->provider = $this->create_provider( actionclass: \core_ai\aiactions\generate_image::class, actionconfig: [ 'model' => 'dall-e-3', 'temperature' => '0.5', 'max_completion_tokens' => '100', ], ); $processor = new process_generate_image($this->provider, $this->action); // We're working with a private method here, so we need to use reflection. $method = new \ReflectionMethod($processor, 'create_request_object'); $request = $method->invoke($processor, 1); $body = (object) json_decode($request->getBody()->getContents()); $this->assertEquals('dall-e-3', $body->model); $this->assertEquals('0.5', $body->temperature); $this->assertEquals('100', $body->max_completion_tokens); $this->provider = $this->create_provider( actionclass: \core_ai\aiactions\generate_image::class, actionconfig: [ 'model' => 'my-custom-gpt', 'modelextraparams' => '{"temperature": 0.5,"max_completion_tokens": 100}', ], ); $processor = new process_generate_image($this->provider, $this->action); // We're working with a private method here, so we need to use reflection. $method = new \ReflectionMethod($processor, 'create_request_object'); $request = $method->invoke($processor, 1); $body = (object) json_decode($request->getBody()->getContents()); $this->assertEquals('my-custom-gpt', $body->model); $this->assertEquals('0.5', $body->temperature); $this->assertEquals('100', $body->max_completion_tokens); } /** * Test the API error response handler method. */ public function test_handle_api_error(): void { $responses = [ 500 => new Response(500, ['Content-Type' => 'application/json']), 503 => new Response(503, ['Content-Type' => 'application/json']), 401 => new Response(401, ['Content-Type' => 'application/json'], '{"error": {"message": "Invalid Authentication"}}'), 404 => new Response(404, ['Content-Type' => 'application/json'], '{"error": {"message": "You must be a member of an organization to use the API"}}'), 429 => new Response(429, ['Content-Type' => 'application/json'], '{"error": {"message": "Rate limit reached for requests"}}'), ]; $processor = new process_generate_image($this->provider, $this->action); $method = new \ReflectionMethod($processor, 'handle_api_error'); foreach ($responses as $status => $response) { $result = $method->invoke($processor, $response); $this->assertEquals($status, $result['errorcode']); if ($status == 500) { $this->assertEquals('Internal Server Error', $result['errormessage']); } else if ($status == 503) { $this->assertEquals('Service Unavailable', $result['errormessage']); } else { $this->assertStringContainsString($response->getBody()->getContents(), $result['errormessage']); } } } /** * Test the API success response handler method. */ public function test_handle_api_success(): void { $response = new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, ); // We're testing a private method, so we need to setup reflector magic. $processor = new process_generate_image($this->provider, $this->action); $method = new \ReflectionMethod($processor, 'handle_api_success'); $result = $method->invoke($processor, $response); $this->stringContains('An image that represents the concept of a \'test\'.', $result['revisedprompt']); $this->stringContains('oaidalleapiprodscus.blob.core.windows.net', $result['sourceurl']); $this->assertEquals('dall-e-3', $result['model']); } /** * Test query_ai_api for a successful call. */ public function test_query_ai_api_success(): void { // Mock the http client to return a successful response. ['mock' => $mock] = $this->get_mocked_http_client(); // The response from OpenAI. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $mock->append(new Response( 200, ['Content-Type' => 'image/jpeg'], \GuzzleHttp\Psr7\Utils::streamFor(fopen( self::get_fixture_path('aiprovider_openai', 'test.jpg'), 'r', )), )); $this->setAdminUser(); $processor = new process_generate_image($this->provider, $this->action); $method = new \ReflectionMethod($processor, 'query_ai_api'); $result = $method->invoke($processor); $this->stringContains('An image that represents the concept of a \'test\'.', $result['revisedprompt']); $this->stringContains('oaidalleapiprodscus.blob.core.windows.net', $result['sourceurl']); $this->assertEquals('dall-e-3', $result['model']); } /** * Test prepare_response success. */ public function test_prepare_response_success(): void { $processor = new process_generate_image($this->provider, $this->action); // We're working with a private method here, so we need to use reflection. $method = new \ReflectionMethod($processor, 'prepare_response'); $response = [ 'success' => true, 'revisedprompt' => 'An image that represents the concept of a \'test\'.', 'imageurl' => 'oaidalleapiprodscus.blob.core.windows.net', 'model' => 'dall-e-3', ]; $result = $method->invoke($processor, $response); $this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result); $this->assertTrue($result->get_success()); $this->assertEquals('generate_image', $result->get_actionname()); $this->assertEquals($response['success'], $result->get_success()); $this->assertEquals($response['revisedprompt'], $result->get_response_data()['revisedprompt']); $this->assertEquals($response['model'], $result->get_response_data()['model']); } /** * Test prepare_response error. */ public function test_prepare_response_error(): void { $processor = new process_generate_image($this->provider, $this->action); // We're working with a private method here, so we need to use reflection. $method = new \ReflectionMethod($processor, 'prepare_response'); $response = [ 'success' => false, 'errorcode' => 500, 'errormessage' => 'Internal server error.', ]; $result = $method->invoke($processor, $response); $this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result); $this->assertFalse($result->get_success()); $this->assertEquals('generate_image', $result->get_actionname()); $this->assertEquals($response['errorcode'], $result->get_errorcode()); $this->assertEquals($response['errormessage'], $result->get_errormessage()); } /** * Test url_to_file. */ public function test_url_to_file(): void { // Log in user. $this->setUser($this->getDataGenerator()->create_user()); $processor = new process_generate_image($this->provider, $this->action); // We're working with a private method here, so we need to use reflection. $method = new \ReflectionMethod($processor, 'url_to_file'); $contextid = 1; $url = $this->getExternalTestFileUrl('/test.jpg', false); $filenobj = $method->invoke($processor, $contextid, $url); $this->assertEquals('test.jpg', $filenobj->get_filename()); } /** * Test process. */ public function test_process(): void { // Log in user. $this->setUser($this->getDataGenerator()->create_user()); // Mock the http client to return a successful response. ['mock' => $mock] = $this->get_mocked_http_client(); $url = 'https://example.com/test.jpg'; // The response from OpenAI. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], json_encode([ 'created' => 1719140500, 'data' => [ (object) [ 'revised_prompt' => 'An image that represents the concept of a \'test\'.', 'url' => $url, ], ], ]), )); // The image downloaded from the server successfully. $mock->append(new Response( 200, ['Content-Type' => 'image/jpeg'], \GuzzleHttp\Psr7\Utils::streamFor(fopen(self::get_fixture_path('aiprovider_openai', 'test.jpg'), 'r')), )); // Create a request object. $contextid = 1; $userid = 1; $prompttext = 'This is a test prompt'; $aspectratio = 'square'; $quality = 'hd'; $numimages = 1; $style = 'vivid'; $this->action = new \core_ai\aiactions\generate_image( contextid: $contextid, userid: $userid, prompttext: $prompttext, quality: $quality, aspectratio: $aspectratio, numimages: $numimages, style: $style, ); $processor = new process_generate_image($this->provider, $this->action); $result = $processor->process(); $this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result); $this->assertTrue($result->get_success()); $this->assertEquals('generate_image', $result->get_actionname()); $this->assertEquals('An image that represents the concept of a \'test\'.', $result->get_response_data()['revisedprompt']); $this->assertEquals($url, $result->get_response_data()['sourceurl']); } /** * Test process method with error. */ public function test_process_error(): void { // Log in user. $this->setUser($this->getDataGenerator()->create_user()); // Mock the http client to return a successful response. ['mock' => $mock] = $this->get_mocked_http_client(); // The response from OpenAI. $mock->append(new Response( 401, ['Content-Type' => 'application/json'], json_encode(['error' => ['message' => 'Invalid Authentication']]), )); $processor = new process_generate_image($this->provider, $this->action); $result = $processor->process(); $this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result); $this->assertFalse($result->get_success()); $this->assertEquals('generate_image', $result->get_actionname()); $this->assertEquals(401, $result->get_errorcode()); $this->assertEquals('Invalid Authentication', $result->get_errormessage()); } /** * Test process method with user rate limiter. */ public function test_process_with_user_rate_limiter(): void { // Create users. $user1 = $this->getDataGenerator()->create_user(); $user2 = $this->getDataGenerator()->create_user(); // Log in user1. $this->setUser($user1); // Mock clock. $clock = $this->mock_clock_with_frozen(); // Set the user rate limiter. $config = [ 'apikey' => '123', 'enableuserratelimit' => true, 'userratelimit' => 1, ]; $provider = $this->manager->create_provider_instance( classname: '\aiprovider_openai\provider', name: 'dummy', config: $config, actionconfig: [ \core_ai\aiactions\generate_image::class => [ 'settings' => [ 'model' => 'dall-e-3', 'endpoint' => "https://api.openai.com/v1/chat/completions", ], ], ], ); // Mock the http client to return a successful response. ['mock' => $mock] = $this->get_mocked_http_client(); $url = 'https://example.com/test.jpg'; // Case 1: User rate limit has not been reached. $this->create_action($user1->id); // The response from OpenAI. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], json_encode([ 'created' => 1719140500, 'data' => [ (object) [ 'revised_prompt' => 'An image that represents the concept of a \'test\'.', 'url' => $url, ], ], ]), )); // The image downloaded from the server successfully. $mock->append(new Response( 200, ['Content-Type' => 'image/jpeg'], \GuzzleHttp\Psr7\Utils::streamFor(fopen(self::get_fixture_path('aiprovider_openai', 'test.jpg'), 'r')), )); $processor = new process_generate_image($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); // Case 2: User rate limit has been reached. $clock->bump(HOURSECS - 10); // The response from OpenAI. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], json_encode([ 'created' => 1719140500, 'data' => [ (object) [ 'revised_prompt' => 'An image that represents the concept of a \'test\'.', 'url' => $url, ], ], ]), )); // The image downloaded from the server successfully. $mock->append(new Response( 200, ['Content-Type' => 'image/jpeg'], \GuzzleHttp\Psr7\Utils::streamFor(fopen(self::get_fixture_path('aiprovider_openai', 'test.jpg'), 'r')), )); $this->create_action($user1->id); $processor = new process_generate_image($provider, $this->action); $result = $processor->process(); $this->assertEquals(429, $result->get_errorcode()); $this->assertEquals('User rate limit exceeded', $result->get_errormessage()); $this->assertFalse($result->get_success()); // Case 3: User rate limit has not been reached for a different user. // Log in user2. $this->setUser($user2); $this->create_action($user2->id); // The response from OpenAI. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], json_encode([ 'created' => 1719140500, 'data' => [ (object) [ 'revised_prompt' => 'An image that represents the concept of a \'test\'.', 'url' => $url, ], ], ]), )); // The image downloaded from the server successfully. $mock->append(new Response( 200, ['Content-Type' => 'image/jpeg'], \GuzzleHttp\Psr7\Utils::streamFor(fopen(self::get_fixture_path('aiprovider_openai', 'test.jpg'), 'r')), )); $processor = new process_generate_image($this->provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); // Case 4: Time window has passed, user rate limit should be reset. $clock->bump(11); // Log in user1. $this->setUser($user1); // The response from OpenAI. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], json_encode([ 'created' => 1719140500, 'data' => [ (object) [ 'revised_prompt' => 'An image that represents the concept of a \'test\'.', 'url' => $url, ], ], ]), )); // The image downloaded from the server successfully. $mock->append(new Response( 200, ['Content-Type' => 'image/jpeg'], \GuzzleHttp\Psr7\Utils::streamFor(fopen(self::get_fixture_path('aiprovider_openai', 'test.jpg'), 'r')), )); $this->create_action($user1->id); $processor = new process_generate_image($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); } /** * Test process method with global rate limiter. */ public function test_process_with_global_rate_limiter(): void { // Create users. $user1 = $this->getDataGenerator()->create_user(); $user2 = $this->getDataGenerator()->create_user(); // Log in user1. $this->setUser($user1); // Mock clock. $clock = $this->mock_clock_with_frozen(); // Set the global rate limiter. $config = [ 'apikey' => '123', 'enableglobalratelimit' => true, 'globalratelimit' => 1, ]; $provider = $this->manager->create_provider_instance( classname: '\aiprovider_openai\provider', name: 'dummy', config: $config, actionconfig: [ \core_ai\aiactions\generate_image::class => [ 'settings' => [ 'model' => 'dall-e-3', 'endpoint' => "https://api.openai.com/v1/chat/completions", ], ], ], ); // Mock the http client to return a successful response. ['mock' => $mock] = $this->get_mocked_http_client(); $url = 'https://example.com/test.jpg'; // Case 1: Global rate limit has not been reached. $this->create_action($user1->id); // The response from OpenAI. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], json_encode([ 'created' => 1719140500, 'data' => [ (object) [ 'revised_prompt' => 'An image that represents the concept of a \'test\'.', 'url' => $url, ], ], ]), )); // The image downloaded from the server successfully. $mock->append(new Response( 200, ['Content-Type' => 'image/jpeg'], \GuzzleHttp\Psr7\Utils::streamFor(fopen(self::get_fixture_path('aiprovider_openai', 'test.jpg'), 'r')), )); $processor = new process_generate_image($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); // Case 2: Global rate limit has been reached. $clock->bump(HOURSECS - 10); // The response from OpenAI. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], json_encode([ 'created' => 1719140500, 'data' => [ (object) [ 'revised_prompt' => 'An image that represents the concept of a \'test\'.', 'url' => $url, ], ], ]), )); // The image downloaded from the server successfully. $mock->append(new Response( 200, ['Content-Type' => 'image/jpeg'], \GuzzleHttp\Psr7\Utils::streamFor(fopen(self::get_fixture_path('aiprovider_openai', 'test.jpg'), 'r')), )); $this->provider = $this->create_provider(\core_ai\aiactions\generate_image::class); $this->create_action($user1->id); $processor = new process_generate_image($provider, $this->action); $result = $processor->process(); $this->assertEquals(429, $result->get_errorcode()); $this->assertEquals('Global rate limit exceeded', $result->get_errormessage()); $this->assertFalse($result->get_success()); // Case 3: Global rate limit has been reached for a different user too. // Log in user2. $this->setUser($user2); $this->provider = $this->create_provider(\core_ai\aiactions\generate_image::class); $this->create_action($user2->id); // The response from OpenAI. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], json_encode([ 'created' => 1719140500, 'data' => [ (object) [ 'revised_prompt' => 'An image that represents the concept of a \'test\'.', 'url' => $url, ], ], ]), )); // The image downloaded from the server successfully. $mock->append(new Response( 200, ['Content-Type' => 'image/jpeg'], \GuzzleHttp\Psr7\Utils::streamFor(fopen(self::get_fixture_path('aiprovider_openai', 'test.jpg'), 'r')), )); $processor = new process_generate_image($provider, $this->action); $result = $processor->process(); $this->assertFalse($result->get_success()); // Case 4: Time window has passed, global rate limit should be reset. $clock->bump(11); // Log in user1. $this->setUser($user1); // The response from OpenAI. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], json_encode([ 'created' => 1719140500, 'data' => [ (object) [ 'revised_prompt' => 'An image that represents the concept of a \'test\'.', 'url' => $url, ], ], ]), )); // The image downloaded from the server successfully. $mock->append(new Response( 200, ['Content-Type' => 'image/jpeg'], \GuzzleHttp\Psr7\Utils::streamFor(fopen(self::get_fixture_path('aiprovider_openai', 'test.jpg'), 'r')), )); $this->provider = $this->create_provider(\core_ai\aiactions\generate_image::class); $this->create_action($user1->id); $processor = new process_generate_image($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); } } PK*\C_PP5provider/openai/tests/process_summarise_text_test.phpnu[. namespace aiprovider_openai; use aiprovider_openai\test\testcase_helper_trait; use core_ai\aiactions\base; use core_ai\provider; use GuzzleHttp\Psr7\Response; /** * Test Generate text provider class for OpenAI provider methods. * * @package aiprovider_openai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \aiprovider_openai\provider * @covers \aiprovider_openai\process_summarise_text * @covers \aiprovider_openai\abstract_processor */ final class process_summarise_text_test extends \advanced_testcase { use testcase_helper_trait; /** @var string A successful response in JSON format. */ protected string $responsebodyjson; /** @var \core_ai\manager */ private $manager; /** @var provider The provider that will process the action. */ protected provider $provider; /** @var base The action to process. */ protected base $action; /** * Set up the test. */ protected function setUp(): void { parent::setUp(); $this->resetAfterTest(); // Load a response body from a file. $this->responsebodyjson = file_get_contents(self::get_fixture_path('aiprovider_openai', 'text_request_success.json')); $this->manager = \core\di::get(\core_ai\manager::class); $this->provider = $this->create_provider( actionclass: \core_ai\aiactions\summarise_text::class, actionconfig: [ 'systeminstruction' => get_string('action_summarise_text_instruction', 'core_ai'), ], ); $this->create_action(); } /** * Create the action object. * @param int $userid The user id to use in the action. */ private function create_action(int $userid = 1): void { $this->action = new \core_ai\aiactions\summarise_text( contextid: 1, userid: $userid, prompttext: 'This is a test prompt', ); } /** * Test create_request_object */ public function test_create_request_object(): void { $processor = new process_summarise_text($this->provider, $this->action); // We're working with a private method here, so we need to use reflection. $method = new \ReflectionMethod($processor, 'create_request_object'); $request = $method->invoke($processor, 1); $body = (object) json_decode($request->getBody()->getContents()); $this->assertEquals('system', $body->messages[0]->role); $this->assertEquals(get_string('action_summarise_text_instruction', 'core_ai'), $body->messages[0]->content); $this->assertEquals('This is a test prompt', $body->messages[1]->content); $this->assertEquals('user', $body->messages[1]->role); } /** * Test create_request_object with extra model settings. */ public function test_create_request_object_with_model_settings(): void { $this->provider = $this->create_provider( actionclass: \core_ai\aiactions\summarise_text::class, actionconfig: [ 'systeminstruction' => get_string('action_summarise_text_instruction', 'core_ai'), 'temperature' => '0.5', 'max_completion_tokens' => '100', ], ); $processor = new process_summarise_text($this->provider, $this->action); // We're working with a private method here, so we need to use reflection. $method = new \ReflectionMethod($processor, 'create_request_object'); $request = $method->invoke($processor, 1); $body = (object) json_decode($request->getBody()->getContents()); $this->assertEquals('gpt-4o', $body->model); $this->assertEquals('0.5', $body->temperature); $this->assertEquals('100', $body->max_completion_tokens); $this->provider = $this->create_provider( actionclass: \core_ai\aiactions\summarise_text::class, actionconfig: [ 'model' => 'my-custom-gpt', 'systeminstruction' => get_string('action_summarise_text_instruction', 'core_ai'), 'modelextraparams' => '{"temperature": 0.5,"max_completion_tokens": 100}', ], ); $processor = new process_summarise_text($this->provider, $this->action); // We're working with a private method here, so we need to use reflection. $method = new \ReflectionMethod($processor, 'create_request_object'); $request = $method->invoke($processor, 1); $body = (object) json_decode($request->getBody()->getContents()); $this->assertEquals('my-custom-gpt', $body->model); $this->assertEquals('0.5', $body->temperature); $this->assertEquals('100', $body->max_completion_tokens); } /** * Test the API error response handler method. * */ public function test_handle_api_error(): void { $responses = [ 500 => new Response(500, ['Content-Type' => 'application/json']), 503 => new Response(503, ['Content-Type' => 'application/json']), 401 => new Response(401, ['Content-Type' => 'application/json'], '{"error": {"message": "Invalid Authentication"}}'), 404 => new Response(404, ['Content-Type' => 'application/json'], '{"error": {"message": "You must be a member of an organization to use the API"}}'), 429 => new Response(429, ['Content-Type' => 'application/json'], '{"error": {"message": "Rate limit reached for requests"}}'), ]; $processor = new process_summarise_text($this->provider, $this->action); $method = new \ReflectionMethod($processor, 'handle_api_error'); foreach ($responses as $status => $response) { $result = $method->invoke($processor, $response); $this->assertEquals($status, $result['errorcode']); if ($status == 500) { $this->assertEquals('Internal Server Error', $result['errormessage']); } else if ($status == 503) { $this->assertEquals('Service Unavailable', $result['errormessage']); } else { $this->assertStringContainsString($response->getBody()->getContents(), $result['errormessage']); } } } /** * Test the API success response handler method. */ public function test_handle_api_success(): void { $response = new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, ); // We're testing a private method, so we need to set up reflector magic. $processor = new process_summarise_text($this->provider, $this->action); $method = new \ReflectionMethod($processor, 'handle_api_success'); $result = $method->invoke($processor, $response); $this->assertTrue($result['success']); $this->assertEquals('chatcmpl-9lkwPWOIiQEvI3nfcGofJcmS5lPYo', $result['id']); $this->assertEquals('fp_c4e5b6fa31', $result['fingerprint']); $this->assertStringContainsString('Sure, here is some sample text', $result['generatedcontent']); $this->assertEquals('stop', $result['finishreason']); $this->assertEquals('11', $result['prompttokens']); $this->assertEquals('568', $result['completiontokens']); $this->assertEquals('gpt-4o-2024-05-13', $result['model']); } /** * Test query_ai_api for a successful call. */ public function test_query_ai_api_success(): void { // Mock the http client to return a successful response. ['mock' => $mock] = $this->get_mocked_http_client(); // The response from OpenAI. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $processor = new process_summarise_text($this->provider, $this->action); $method = new \ReflectionMethod($processor, 'query_ai_api'); $result = $method->invoke($processor); $this->assertTrue($result['success']); $this->assertEquals('chatcmpl-9lkwPWOIiQEvI3nfcGofJcmS5lPYo', $result['id']); $this->assertEquals('fp_c4e5b6fa31', $result['fingerprint']); $this->assertStringContainsString('Sure, here is some sample text', $result['generatedcontent']); $this->assertEquals('stop', $result['finishreason']); $this->assertEquals('11', $result['prompttokens']); $this->assertEquals('568', $result['completiontokens']); $this->assertEquals('gpt-4o-2024-05-13', $result['model']); } /** * Test prepare_response success. */ public function test_prepare_response_success(): void { $processor = new process_summarise_text($this->provider, $this->action); // We're working with a private method here, so we need to use reflection. $method = new \ReflectionMethod($processor, 'prepare_response'); $response = [ 'success' => true, 'id' => 'chatcmpl-9lkwPWOIiQEvI3nfcGofJcmS5lPYo', 'fingerprint' => 'fp_c4e5b6fa31', 'generatedcontent' => 'Sure, here is some sample text', 'finishreason' => 'stop', 'prompttokens' => '11', 'completiontokens' => '568', 'model' => 'gpt-4o', ]; $result = $method->invoke($processor, $response); $this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result); $this->assertTrue($result->get_success()); $this->assertEquals('summarise_text', $result->get_actionname()); $this->assertEquals($response['success'], $result->get_success()); $this->assertEquals($response['generatedcontent'], $result->get_response_data()['generatedcontent']); $this->assertEquals($response['model'], $result->get_response_data()['model']); } /** * Test prepare_response error. */ public function test_prepare_response_error(): void { $processor = new process_summarise_text($this->provider, $this->action); // We're working with a private method here, so we need to use reflection. $method = new \ReflectionMethod($processor, 'prepare_response'); $response = [ 'success' => false, 'errorcode' => 500, 'errormessage' => 'Internal server error.', ]; $result = $method->invoke($processor, $response); $this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result); $this->assertFalse($result->get_success()); $this->assertEquals('summarise_text', $result->get_actionname()); $this->assertEquals($response['errorcode'], $result->get_errorcode()); $this->assertEquals($response['errormessage'], $result->get_errormessage()); } /** * Test process method. */ public function test_process(): void { // Log in user. $this->setUser($this->getDataGenerator()->create_user()); // Mock the http client to return a successful response. ['mock' => $mock] = $this->get_mocked_http_client(); // The response from OpenAI. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $processor = new process_summarise_text($this->provider, $this->action); $result = $processor->process(); $this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result); $this->assertTrue($result->get_success()); $this->assertEquals('summarise_text', $result->get_actionname()); } /** * Test process method with error. */ public function test_process_error(): void { // Log in user. $this->setUser($this->getDataGenerator()->create_user()); // Mock the http client to return a successful response. ['mock' => $mock] = $this->get_mocked_http_client(); // The response from OpenAI. $mock->append(new Response( 401, ['Content-Type' => 'application/json'], json_encode(['error' => ['message' => 'Invalid Authentication']]), )); $processor = new process_summarise_text($this->provider, $this->action); $result = $processor->process(); $this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result); $this->assertFalse($result->get_success()); $this->assertEquals('summarise_text', $result->get_actionname()); $this->assertEquals(401, $result->get_errorcode()); $this->assertEquals('Invalid Authentication', $result->get_errormessage()); } /** * Test process method with user rate limiter. */ public function test_process_with_user_rate_limiter(): void { // Create users. $user1 = $this->getDataGenerator()->create_user(); $user2 = $this->getDataGenerator()->create_user(); // Log in user1. $this->setUser($user1); // Mock clock. $clock = $this->mock_clock_with_frozen(); // Set the user rate limiter. $config = [ 'apikey' => '123', 'enableuserratelimit' => true, 'userratelimit' => 1, ]; $provider = $this->manager->create_provider_instance( classname: '\aiprovider_openai\provider', name: 'dummy', config: $config, actionconfig: [ \core_ai\aiactions\summarise_text::class => [ 'settings' => [ 'model' => 'gpt-4o', 'endpoint' => "https://api.openai.com/v1/chat/completions", 'systeminstruction' => get_string('action_summarise_text_instruction', 'core_ai'), ], ], ], ); // Mock the http client to return a successful response. ['mock' => $mock] = $this->get_mocked_http_client(); // Case 1: User rate limit has not been reached. $this->create_action($user1->id); // The response from OpenAI. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $processor = new process_summarise_text($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); // Case 2: User rate limit has been reached. $clock->bump(HOURSECS - 10); // The response from OpenAI. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $this->create_action($user1->id); $processor = new process_summarise_text($provider, $this->action); $result = $processor->process(); $this->assertEquals(429, $result->get_errorcode()); $this->assertEquals('User rate limit exceeded', $result->get_errormessage()); $this->assertFalse($result->get_success()); // Case 3: User rate limit has not been reached for a different user. // Log in user2. $this->setUser($user2); $this->create_action($user2->id); // The response from OpenAI. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $processor = new process_summarise_text($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); // Case 4: Time window has passed, user rate limit should be reset. $clock->bump(11); // Log in user1. $this->setUser($user1); // The response from OpenAI. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $this->create_action($user1->id); $processor = new process_summarise_text($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); } /** * Test process method with global rate limiter. */ public function test_process_with_global_rate_limiter(): void { // Create users. $user1 = $this->getDataGenerator()->create_user(); $user2 = $this->getDataGenerator()->create_user(); // Log in user1. $this->setUser($user1); // Mock clock. $clock = $this->mock_clock_with_frozen(); // Set the global rate limiter. $config = [ 'apikey' => '123', 'enableglobalratelimit' => true, 'globalratelimit' => 1, ]; $provider = $this->manager->create_provider_instance( classname: '\aiprovider_openai\provider', name: 'dummy', config: $config, actionconfig: [ \core_ai\aiactions\summarise_text::class => [ 'settings' => [ 'model' => 'gpt-4o', 'endpoint' => "https://api.openai.com/v1/chat/completions", 'systeminstruction' => get_string('action_summarise_text_instruction', 'core_ai'), ], ], ], ); // Mock the http client to return a successful response. ['mock' => $mock] = $this->get_mocked_http_client(); // Case 1: Global rate limit has not been reached. $this->create_action($user1->id); // The response from OpenAI. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $processor = new process_summarise_text($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); // Case 2: Global rate limit has been reached. $clock->bump(HOURSECS - 10); // The response from OpenAI. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $this->create_action($user1->id); $processor = new process_summarise_text($provider, $this->action); $result = $processor->process(); $this->assertEquals(429, $result->get_errorcode()); $this->assertEquals('Global rate limit exceeded', $result->get_errormessage()); $this->assertFalse($result->get_success()); // Case 3: Global rate limit has been reached for a different user too. // Log in user2. $this->setUser($user2); $this->create_action($user2->id); // The response from OpenAI. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $processor = new process_summarise_text($provider, $this->action); $result = $processor->process(); $this->assertFalse($result->get_success()); // Case 4: Time window has passed, global rate limit should be reset. $clock->bump(11); // Log in user1. $this->setUser($user1); // The response from OpenAI. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $this->create_action($user1->id); $processor = new process_summarise_text($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); } } PK*\'provider/openai/tests/provider_test.phpnu[. namespace aiprovider_openai; /** * Test OpenAI provider methods. * * @package aiprovider_openai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \aiprovider_openai\provider */ final class provider_test extends \advanced_testcase { /** @var \core_ai\manager */ private $manager; /** @var \core_ai\provider */ private $provider; /** * Overriding setUp() function to always reset after tests. */ public function setUp(): void { parent::setUp(); $this->resetAfterTest(); // Create the provider instance. $this->manager = \core\di::get(\core_ai\manager::class); $this->provider = $this->manager->create_provider_instance( classname: '\aiprovider_openai\provider', name: 'dummy', ); } /** * Test get_action_list */ public function test_get_action_list(): void { $actionlist = $this->provider->get_action_list(); $this->assertIsArray($actionlist); $this->assertCount(4, $actionlist); $this->assertContains(\core_ai\aiactions\generate_text::class, $actionlist); $this->assertContains(\core_ai\aiactions\generate_image::class, $actionlist); $this->assertContains(\core_ai\aiactions\summarise_text::class, $actionlist); $this->assertContains(\core_ai\aiactions\explain_text::class, $actionlist); } /** * Test generate_userid. */ public function test_generate_userid(): void { $userid = $this->provider->generate_userid(1); // Assert that the generated userid is a string of proper length. $this->assertIsString($userid); $this->assertEquals(64, strlen($userid)); } /** * Test is_request_allowed. */ public function test_is_request_allowed(): void { // Create the provider instance. $config = [ 'enableuserratelimit' => true, 'userratelimit' => 3, 'enableglobalratelimit' => true, 'globalratelimit' => 5, ]; $provider = $this->manager->create_provider_instance( classname: '\aiprovider_openai\provider', name: 'dummy', config: $config, ); $contextid = 1; $userid = 1; $prompttext = 'This is a test prompt'; $aspectratio = 'square'; $quality = 'hd'; $numimages = 1; $style = 'vivid'; $action = new \core_ai\aiactions\generate_image( contextid: $contextid, userid: $userid, prompttext: $prompttext, quality: $quality, aspectratio: $aspectratio, numimages: $numimages, style: $style, ); // Make 3 requests, all should be allowed. for ($i = 0; $i < 3; $i++) { $this->assertTrue($provider->is_request_allowed($action)); } // The 4th request for the same user should be denied. $result = $provider->is_request_allowed($action); $this->assertFalse($result['success']); $this->assertEquals('User rate limit exceeded', $result['errormessage']); // Change user id to make a request for a different user, should pass (4 requests for global rate). $action = new \core_ai\aiactions\generate_image( contextid: $contextid, userid: 2, prompttext: $prompttext, quality: $quality, aspectratio: $aspectratio, numimages: $numimages, style: $style, ); $this->assertTrue($provider->is_request_allowed($action)); // Make a 5th request for the global rate limit, it should be allowed. $this->assertTrue($provider->is_request_allowed($action)); // The 6th request should be denied. $result = $provider->is_request_allowed($action); $this->assertFalse($result['success']); $this->assertEquals('Global rate limit exceeded', $result['errormessage']); } /** * Test is_provider_configured. */ public function test_is_provider_configured(): void { // No configured values. $this->assertFalse($this->provider->is_provider_configured()); // Properly configured values. $updatedprovider = $this->manager->update_provider_instance( provider: $this->provider, config: ['apikey' => '123'], ); $this->assertTrue($updatedprovider->is_provider_configured()); } } PK*\cWWprovider/ollama/db/hooks.phpnu[. /** * Hook listener callbacks for the Ollama Provider. * * @package aiprovider_ollama * @copyright 2025 Huong Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $callbacks = [ [ 'hook' => \core_ai\hook\after_ai_provider_form_hook::class, 'callback' => \aiprovider_ollama\hook_listener::class . '::set_form_definition_for_aiprovider_ollama', ], [ 'hook' => \core_ai\hook\after_ai_action_settings_form_hook::class, 'callback' => \aiprovider_ollama\hook_listener::class . '::set_model_form_definition_for_aiprovider_ollama', ], ]; PK*\\-provider/ollama/lang/en/aiprovider_ollama.phpnu[. /** * Strings for component aiprovider_ollama, language 'en'. * * @package aiprovider_ollama * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['action:explain_text:model'] = 'Text explanation model'; $string['action:explain_text:model_help'] = 'The model used to explain the provided text.'; $string['action:explain_text:systeminstruction'] = 'System instruction'; $string['action:explain_text:systeminstruction_help'] = 'This instruction is sent to the AI model along with the user\'s prompt. Editing this instruction is not recommended unless absolutely required.'; $string['action:generate_text:model'] = 'Text generation model'; $string['action:generate_text:model_help'] = 'The model used to generate the text response.'; $string['action:generate_text:systeminstruction'] = 'System instruction'; $string['action:generate_text:systeminstruction_help'] = 'This instruction is sent to the AI model along with the user\'s prompt. Editing this instruction is not recommended unless absolutely required.'; $string['action:summarise_text:model'] = 'Text summarisation model'; $string['action:summarise_text:model_help'] = 'The model used to summarise the provided text.'; $string['action:summarise_text:systeminstruction'] = 'System instruction'; $string['action:summarise_text:systeminstruction_help'] = 'This instruction is sent to the AI model along with the user\'s prompt. Editing this instruction is not recommended unless absolutely required.'; $string['custom_model_name'] = 'Custom model name'; $string['enablebasicauth'] = 'Enable basic authentication'; $string['enablebasicauth_help'] = 'Enable basic authentication for the Ollama API provider.'; $string['endpoint'] = 'API endpoint'; $string['endpoint_help'] = 'The API endpoint for the Ollama API server.'; $string['extraparams'] = 'Extra parameters'; $string['extraparams_help'] = 'Extra parameters can be configured here. We support JSON format. For example:
{
    "temperature": 0.5,
    "max_tokens": 100
}
'; $string['invalidjson'] = 'Invalid JSON string'; $string['password'] = 'Password'; $string['password_help'] = 'The password used for basic authentication.'; $string['pluginname'] = 'Ollama API provider'; $string['privacy:metadata'] = 'The Ollama API provider plugin does not store any personal data.'; $string['privacy:metadata:aiprovider_ollama:externalpurpose'] = 'This information is sent to the Ollama API in order for a response to be generated. Your Ollama account settings may change how Ollama stores and retains this data. No user data is explicitly sent to Ollama or stored in Moodle LMS by this plugin.'; $string['privacy:metadata:aiprovider_ollama:model'] = 'The model used to generate the response.'; $string['privacy:metadata:aiprovider_ollama:prompttext'] = 'The user entered text prompt used to generate the response.'; $string['settings'] = 'Settings'; $string['settings_help'] = 'Adjust the settings below to customise how requests are sent to Ollama.'; $string['settings_mirostat'] = 'Mirostat'; $string['settings_mirostat_help'] = 'Mirostat is a neural text decoding algorithm for controlling perplexity. 0 = disabled, 1 = Mirostat, 2 = Mirostat 2.0. (Default: 0)'; $string['settings_seed'] = 'seed'; $string['settings_seed_help'] = 'Sets the random number seed to use for generation. Setting this to a specific number will make the model generate the same text for the same prompt. (Default: 0)'; $string['settings_temperature'] = 'temperature'; $string['settings_temperature_help'] = 'Temperature influences whether the output is more random and creative or more predictable. Increasing the temperature will make the model answer more creatively. (Default: 0.8)'; $string['settings_top_k'] = 'top_k'; $string['settings_top_k_help'] = 'Reduces the probability of generating nonsense. A higher value (e.g. 100) will give more diverse answers, while a lower value (e.g. 10) will be more conservative. (Default: 40)'; $string['settings_top_p'] = 'top_p'; $string['settings_top_p_help'] = 'Works together with top-k. A higher value (e.g. 0.95) will lead to more diverse text, while a lower value (e.g. 0.5) will generate more focused and conservative text. (Default: 0.9)'; $string['username'] = 'Username'; $string['username_help'] = 'The username used for basic authentication.'; PK*\|xDDprovider/ollama/version.phpnu[. /** * Version information for aiprovider_ollama. * * @package aiprovider_ollama * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'aiprovider_ollama'; $plugin->version = 2025041400; $plugin->requires = 2025040800; $plugin->maturity = MATURITY_STABLE; PK*\Gx  ,provider/ollama/classes/form/action_form.phpnu[. namespace aiprovider_ollama\form; use aiprovider_ollama\helper; use core_ai\form\action_settings_form; /** * Base action settings form for Ollama provider. * * @package aiprovider_ollama * @copyright 2025 Huong Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class action_form extends action_settings_form { /** * @var array Action configuration. */ protected array $actionconfig; /** * @var string|null Return URL. */ protected ?string $returnurl; /** * @var string Action name. */ protected string $actionname; /** * @var string Action class. */ protected string $action; /** * @var int Provider ID. */ protected int $providerid; /** * @var string Provider name. */ protected string $providername; #[\Override] protected function definition(): void { $mform = $this->_form; $this->actionconfig = $this->_customdata['actionconfig']['settings'] ?? []; $this->returnurl = $this->_customdata['returnurl'] ?? null; $this->actionname = $this->_customdata['actionname']; $this->action = $this->_customdata['action']; $this->providerid = $this->_customdata['providerid'] ?? 0; $this->providername = $this->_customdata['providername'] ?? 'aiprovider_ollama'; $mform->addElement('header', 'generalsettingsheader', get_string('general', 'core')); } #[\Override] public function set_data($data): void { if (!empty($data['modelextraparams'])) { $data['modelextraparams'] = json_encode(json_decode($data['modelextraparams']), JSON_PRETTY_PRINT); } parent::set_data($data); } #[\Override] public function get_data(): ?\stdClass { $data = parent::get_data(); if ($data) { if (isset($data->modeltemplate)) { if ($data->modeltemplate === 'custom') { $data->model = $data->custommodel; } else { // Set the model to the selected model template. $data->model = $data->modeltemplate; } } // Unset the model template. unset($data->custommodel); unset($data->modeltemplate); // Unset any false-y values. $data = (object) array_filter((array) $data); } return $data; } #[\Override] public function validation($data, $files): array { $errors = parent::validation($data, $files); // Validate the extra parameters. if (!empty($data['modelextraparams'])) { json_decode($data['modelextraparams']); if (json_last_error() !== JSON_ERROR_NONE) { $errors['modelextraparams'] = get_string('invalidjson', 'aiprovider_ollama'); } } // Validate the model. if ($data['modeltemplate'] === 'custom' && empty($data['custommodel'])) { $errors['custommodel'] = get_string('required'); } return $errors; } #[\Override] public function get_defaults(): array { $data = parent::get_defaults(); unset( $data['modeltemplate'], $data['custommodel'], $data['modelextraparams'], ); return $data; } /** * Add model fields to the form. * * @param int $modeltype Model type. */ protected function add_model_fields(int $modeltype): void { global $PAGE; $PAGE->requires->js_call_amd('aiprovider_ollama/modelchooser', 'init'); $mform = $this->_form; // Action model to use. $mform->addElement( 'select', 'modeltemplate', get_string("action:{$this->actionname}:model", 'aiprovider_ollama'), $this->get_model_list($modeltype), ['data-modelchooser-field' => 'selector'], ); $mform->setType('modeltemplate', PARAM_TEXT); $mform->addRule('modeltemplate', null, 'required', null, 'client'); if (!empty($this->actionconfig['model']) && (!array_key_exists($this->actionconfig['model'], $this->get_model_list($modeltype)) || !empty($this->actionconfig['modelextraparams']))) { $defaultmodel = 'custom'; } else { $defaultmodel = $this->actionconfig['model'] ?? 'llama3.3'; } $mform->setDefault('modeltemplate', $defaultmodel); $mform->addHelpButton('modeltemplate', "action:{$this->actionname}:model", 'aiprovider_ollama'); $mform->addElement('hidden', 'model', $this->actionconfig['model'] ?? 'llama3.3'); $mform->setType('model', PARAM_TEXT); $mform->addElement('text', 'custommodel', get_string('custom_model_name', 'aiprovider_ollama')); $mform->setType('custommodel', PARAM_TEXT); $mform->setDefault('custommodel', $this->actionconfig['model'] ?? ''); $mform->hideIf('custommodel', 'modeltemplate', 'neq', 'custom'); $mform->registerNoSubmitButton('updateactionsettings'); $mform->addElement( 'submit', 'updateactionsettings', 'updateactionsettings', ['data-modelchooser-field' => 'updateButton', 'class' => 'd-none'] ); } /** * Get the list of models. * * @param int $modeltype Model type. * @return array List of models. */ protected function get_model_list(int $modeltype): array { $models = []; $models['custom'] = get_string('custom', 'core_form'); foreach (helper::get_model_classes() as $class) { $model = new $class(); if ($model->model_type() == $modeltype) { $models[$model->get_model_name()] = $model->get_model_display_name(); } } return $models; } } PK*\L% :provider/ollama/classes/form/action_generate_text_form.phpnu[. namespace aiprovider_ollama\form; use aiprovider_ollama\aimodel\ollama_base; /** * Base action settings form for Ollama provider. * * @package aiprovider_ollama * @copyright 2025 Huong Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class action_generate_text_form extends action_form { #[\Override] protected function definition(): void { parent::definition(); $mform = $this->_form; $this->add_model_fields(ollama_base::MODEL_TYPE_TEXT); // System Instructions. $mform->addElement( 'textarea', 'systeminstruction', get_string("action:{$this->actionname}:systeminstruction", 'aiprovider_ollama'), 'wrap="virtual" rows="5" cols="20"', ); $mform->setType('systeminstruction', PARAM_TEXT); $mform->setDefault('systeminstruction', $actionconfig['systeminstruction'] ?? $this->action::get_system_instruction()); $mform->addHelpButton('systeminstruction', "action:{$this->actionname}:systeminstruction", 'aiprovider_ollama'); if ($this->returnurl) { $mform->addElement('hidden', 'returnurl', $this->returnurl); $mform->setType('returnurl', PARAM_LOCALURL); } // Add the action class as a hidden field. $mform->addElement('hidden', 'action', $this->action); $mform->setType('action', PARAM_TEXT); // Add the provider class as a hidden field. $mform->addElement('hidden', 'provider', $this->providername); $mform->setType('provider', PARAM_TEXT); // Add the provider id as a hidden field. $mform->addElement('hidden', 'providerid', $this->providerid); $mform->setType('providerid', PARAM_INT); $this->set_data($this->actionconfig); } } PK*\@[F F ,provider/ollama/classes/privacy/provider.phpnu[. namespace aiprovider_ollama\privacy; use core_privacy\local\metadata\collection; use core_privacy\local\request\approved_contextlist; use core_privacy\local\request\approved_userlist; use core_privacy\local\request\contextlist; use core_privacy\local\request\userlist; /** * Privacy provider implementation for Ollama provider * * @package aiprovider_ollama * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @codeCoverageIgnore */ class provider implements \core_privacy\local\metadata\provider, \core_privacy\local\request\core_userlist_provider, \core_privacy\local\request\plugin\provider { #[\Override] public static function get_metadata(collection $collection): collection { $collection->add_external_location_link('aiprovider_ollama', [ 'prompttext' => 'privacy:metadata:aiprovider_ollama:prompttext', 'model' => 'privacy:metadata:aiprovider_ollama:model', ], 'privacy:metadata:aiprovider_ollama:externalpurpose'); return $collection; } #[\Override] public static function get_contexts_for_userid(int $userid): contextlist { return new contextlist(); } #[\Override] public static function get_users_in_context(userlist $userlist) { } #[\Override] public static function export_user_data(approved_contextlist $contextlist) { } #[\Override] public static function delete_data_for_all_users_in_context(\context $context) { } /** * Delete multiple users within a single context. * * @param approved_userlist $userlist The approved context and user information to delete information for. */ public static function delete_data_for_users(approved_userlist $userlist) { } #[\Override] public static function delete_data_for_user(approved_contextlist $contextlist) { } } PK*\SYY Y +provider/ollama/classes/aimodel/llama33.phpnu[. namespace aiprovider_ollama\aimodel; use core_ai\aimodel\base; use MoodleQuickForm; /** * Llama 3.3 AI model. * * @package aiprovider_ollama * @copyright 2025 Huong Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class llama33 extends base implements ollama_base { #[\Override] public function get_model_name(): string { return 'llama3.3'; } #[\Override] public function get_model_display_name(): string { return 'Llama 3.3'; } #[\Override] public function has_model_settings(): bool { return true; } #[\Override] public function add_model_settings(MoodleQuickForm $mform): void { $mform->addElement( 'text', 'mirostat', get_string('settings_mirostat', 'aiprovider_ollama'), ); $mform->setType('mirostat', PARAM_INT); $mform->addHelpButton('mirostat', 'settings_mirostat', 'aiprovider_ollama'); $mform->addElement( 'text', 'temperature', get_string('settings_temperature', 'aiprovider_ollama'), ); $mform->setType('temperature', PARAM_FLOAT); $mform->addHelpButton('temperature', 'settings_temperature', 'aiprovider_ollama'); $mform->addElement( 'text', 'seed', get_string('settings_seed', 'aiprovider_ollama'), ); $mform->setType('seed', PARAM_INT); $mform->addHelpButton('seed', 'settings_seed', 'aiprovider_ollama'); $mform->addElement( 'text', 'top_k', get_string('settings_top_k', 'aiprovider_ollama'), ); $mform->setType('top_k', PARAM_FLOAT); $mform->addHelpButton('top_k', 'settings_top_k', 'aiprovider_ollama'); $mform->addElement( 'text', 'top_p', get_string('settings_top_p', 'aiprovider_ollama'), ); $mform->setType('top_p', PARAM_FLOAT); $mform->addHelpButton('top_p', 'settings_top_p', 'aiprovider_ollama'); } #[\Override] public function model_type(): int { return self::MODEL_TYPE_TEXT; } } PK*\Zo/provider/ollama/classes/aimodel/ollama_base.phpnu[. namespace aiprovider_ollama\aimodel; /** * Ollama base AI model interface. * * @package aiprovider_ollama * @copyright 2025 Huong Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ interface ollama_base { /** @var int MODEL_TYPE_TEXT Text model type. */ public const MODEL_TYPE_TEXT = 1; /** @var int MODEL_TYPE_IMAGE Image model type. */ public const MODEL_TYPE_IMAGE = 2; /** * Get model type. * * @return int Model type. */ public function model_type(): int; } PK*\2provider/ollama/classes/process_summarise_text.phpnu[. namespace aiprovider_ollama; /** * Class process text summarisation. * * @package aiprovider_ollama * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class process_summarise_text extends process_generate_text { #[\Override] protected function get_system_instruction(): string { return $this->provider->actionconfig[$this->action::class]['settings']['systeminstruction']; } } PK*\Nf##)provider/ollama/classes/hook_listener.phpnu[. namespace aiprovider_ollama; use core_ai\hook\after_ai_action_settings_form_hook; use core_ai\hook\after_ai_provider_form_hook; /** * Hook listener for Ollama Provider. * * @package aiprovider_ollama * @copyright 2025 Huong Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class hook_listener { /** * Hook listener for the Ollama instance setup form. * * @param after_ai_provider_form_hook $hook The hook to add to the AI instance setup. */ public static function set_form_definition_for_aiprovider_ollama(after_ai_provider_form_hook $hook): void { if ($hook->plugin !== 'aiprovider_ollama') { return; } $mform = $hook->mform; // Setting to store Ollama endpoint URL. $mform->addElement( 'text', 'endpoint', get_string('endpoint', 'aiprovider_ollama'), ['size' => 25], ); $mform->setType('endpoint', PARAM_URL); $mform->addHelpButton('endpoint', 'endpoint', 'aiprovider_ollama'); $mform->addRule('endpoint', get_string('required'), 'required', null, 'client'); $mform->setDefault('endpoint', 'http://localhost:11434'); // Checkbox to enable basic auth settings. $mform->addElement( 'checkbox', 'enablebasicauth', get_string('enablebasicauth', 'aiprovider_ollama') ); $mform->setType('enablebasicauth', PARAM_INT); $mform->addHelpButton('enablebasicauth', 'enablebasicauth', 'aiprovider_ollama'); $mform->setDefault('enablebasicauth', 0); // Username for basic auth. $mform->addElement( 'text', 'username', get_string('username', 'aiprovider_ollama'), ); $mform->setType('username', PARAM_TEXT); $mform->addHelpButton('username', 'username', 'aiprovider_ollama'); $mform->hideIf('username', 'enablebasicauth', 'notchecked'); // Password for basic auth. // Username for basic auth. $mform->addElement( 'passwordunmask', 'password', get_string('password', 'aiprovider_ollama'), ); $mform->setType('password', PARAM_TEXT); $mform->addHelpButton('password', 'password', 'aiprovider_ollama'); $mform->hideIf('password', 'enablebasicauth', 'notchecked'); } /** * Hook listener for the Ollama action settings form. * * @param after_ai_action_settings_form_hook $hook The hook to add to config action settings. */ public static function set_model_form_definition_for_aiprovider_ollama(after_ai_action_settings_form_hook $hook): void { if ($hook->plugin !== 'aiprovider_ollama') { return; } $mform = $hook->mform; if (isset($mform->_elementIndex['modeltemplate'])) { $model = $mform->getElementValue('modeltemplate'); if (is_array($model)) { $model = $model[0]; } if ($model == 'custom') { $mform->addElement('header', 'modelsettingsheader', get_string('settings', 'aiprovider_ollama')); $settingshelp = \html_writer::tag('p', get_string('settings_help', 'aiprovider_ollama')); $mform->addElement('html', $settingshelp); $mform->addElement( 'textarea', 'modelextraparams', get_string('extraparams', 'aiprovider_ollama'), ['rows' => 5, 'cols' => 20], ); $mform->setType('modelextraparams', PARAM_TEXT); $mform->addElement('static', 'modelextraparams_help', null, get_string('extraparams_help', 'aiprovider_ollama')); } else { $targetmodel = helper::get_model_class($model); if ($targetmodel) { if ($targetmodel->has_model_settings()) { $mform->addElement('header', 'modelsettingsheader', get_string('settings', 'aiprovider_ollama')); $settingshelp = \html_writer::tag('p', get_string('settings_help', 'aiprovider_ollama')); $mform->addElement('html', $settingshelp); $targetmodel->add_model_settings($mform); } } } } } } PK*\."provider/ollama/classes/helper.phpnu[. namespace aiprovider_ollama; use core_ai\aimodel\base; /** * Helper class for the Ollama provider. * * @package aiprovider_ollama * @copyright 2025 Huong Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class helper { /** * Get all model classes. * * @return array Array of model classes. */ public static function get_model_classes(): array { $models = []; $modelclasses = \core_component::get_component_classes_in_namespace('aiprovider_ollama', 'aimodel'); foreach ($modelclasses as $class => $path) { if (!class_exists($class) || !is_a($class, base::class, true)) { throw new \coding_exception("Model class not valid: {$class}"); } $models[] = $class; } return $models; } /** * Get model class by name. * * @param string $modelname Model name. * @return base|null */ public static function get_model_class(string $modelname): ?base { foreach (static::get_model_classes() as $classname) { $model = new $classname(); if ($model->get_model_name() === $modelname) { return $model; } } return null; } } PK*\hUU.provider/ollama/classes/abstract_processor.phpnu[. namespace aiprovider_ollama; use core\http_client; use core_ai\process_base; use GuzzleHttp\Exception\RequestException; use GuzzleHttp\Psr7\Uri; use GuzzleHttp\RequestOptions; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\UriInterface; /** * Class process text generation. * * @package aiprovider_ollama * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ abstract class abstract_processor extends process_base { /** * Get the endpoint URI. * * @return UriInterface */ protected function get_endpoint(): UriInterface { $url = rtrim($this->provider->config['endpoint'], '/') . '/api/generate'; return new Uri($url); } /** * Get the name of the model to use. * * @return string */ protected function get_model(): string { return $this->provider->actionconfig[$this->action::class]['settings']['model']; } /** * Get the model settings. * * @return array */ protected function get_model_settings(): array { $settings = $this->provider->actionconfig[$this->action::class]['settings']; if (!empty($settings['modelextraparams'])) { // Custom model settings. $params = json_decode($settings['modelextraparams'], true); foreach ($params as $key => $param) { $settings[$key] = $param; } } // Unset unnecessary settings. unset( $settings['model'], $settings['systeminstruction'], $settings['providerid'], $settings['modelextraparams'], ); return $settings; } /** * Get the system instructions. * * @return string */ protected function get_system_instruction(): string { return $this->action::get_system_instruction(); } /** * Create the request object to send to the Ollama API. * This object contains all the required parameters for the request. * * @param string $userid The user id. * @return RequestInterface The request object to send to the Ollama API. */ abstract protected function create_request_object(string $userid): RequestInterface; /** * Handle a successful response from the external AI api. * * @param ResponseInterface $response The response object. * @return array The response. */ abstract protected function handle_api_success(ResponseInterface $response): array; #[\Override] protected function query_ai_api(): array { $request = $this->create_request_object($this->provider->generate_userid($this->action->get_configuration('userid'))); $request = $this->provider->add_authentication_headers($request); $client = \core\di::get(http_client::class); try { // Call the external AI service. $response = $client->send($request, [ 'base_uri' => $this->get_endpoint(), RequestOptions::HTTP_ERRORS => false, ]); } catch (RequestException $e) { // Handle any exceptions. return [ 'success' => false, 'errorcode' => $e->getCode(), 'errormessage' => $e->getMessage(), ]; } // Double-check the response codes, in case of a non 200 that didn't throw an error. $status = $response->getStatusCode(); if ($status === 200) { return $this->handle_api_success($response); } else { return $this->handle_api_error($response); } } /** * Handle an error from the external AI api. * * @param ResponseInterface $response The response object. * @return array The error response. */ protected function handle_api_error(ResponseInterface $response): array { $responsearr = [ 'success' => false, 'errorcode' => $response->getStatusCode(), ]; $status = $response->getStatusCode(); if ($status >= 500 && $status < 600) { $responsearr['errormessage'] = $response->getReasonPhrase(); } else { $bodyobj = json_decode($response->getBody()->getContents()); $responsearr['errormessage'] = $bodyobj->error->message; } return $responsearr; } } PK*\iH/5 5 $provider/ollama/classes/provider.phpnu[. namespace aiprovider_ollama; use core_ai\form\action_settings_form; use Psr\Http\Message\RequestInterface; /** * Class provider. * * @package aiprovider_ollama * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider extends \core_ai\provider { #[\Override] public static function get_action_list(): array { return [ \core_ai\aiactions\generate_text::class, \core_ai\aiactions\summarise_text::class, \core_ai\aiactions\explain_text::class, ]; } #[\Override] public static function get_action_settings( string $action, array $customdata = [], ): action_settings_form|bool { $actionname = substr($action, (strrpos($action, '\\') + 1)); $customdata['actionname'] = $actionname; $customdata['action'] = $action; $customdata['providername'] = 'aiprovider_ollama'; if ($actionname === 'generate_text' || $actionname === 'summarise_text' || $actionname === 'explain_text') { return new form\action_generate_text_form(customdata: $customdata); } return false; } #[\Override] public function add_authentication_headers(RequestInterface $request): RequestInterface { if (empty($this->config['basicauthenabled'])) { return $request; } else { // Add the Authorization header for basic auth. $authheader = 'Basic ' . base64_encode($this->config['username'] . ':' . $this->config['password']); return $request->withAddedHeader('Authorization', $authheader); } } #[\Override] public static function get_action_setting_defaults(string $action): array { $actionname = substr($action, (strrpos($action, '\\') + 1)); $customdata = [ 'actionname' => $actionname, 'action' => $action, 'providername' => 'aiprovider_ollama', ]; if ($actionname === 'generate_text' || $actionname === 'summarise_text' || $actionname === 'explain_text') { $mform = new form\action_generate_text_form(customdata: $customdata); return $mform->get_defaults(); } return []; } #[\Override] public function is_provider_configured(): bool { return !empty($this->config['endpoint']); } } PK*\e0provider/ollama/classes/process_explain_text.phpnu[. namespace aiprovider_ollama; /** * Class process text explanation. * * @package aiprovider_ollama * @copyright 2025 Huong Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class process_explain_text extends process_generate_text { } PK*\Ҙ< < 1provider/ollama/classes/process_generate_text.phpnu[. namespace aiprovider_ollama; use GuzzleHttp\Psr7\Request; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; /** * Class process text generation. * * @package aiprovider_ollama * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class process_generate_text extends abstract_processor { #[\Override] protected function get_system_instruction(): string { return $this->provider->actionconfig[$this->action::class]['settings']['systeminstruction']; } #[\Override] protected function create_request_object(string $userid): RequestInterface { // Create the request object. $requestobj = new \stdClass(); $requestobj->model = $this->get_model(); $requestobj->stream = false; $requestobj->prompt = $this->action->get_configuration('prompttext'); $requestobj->user = $userid; $requestobj->options = new \stdClass(); // If there is a system string available, use it. $systeminstruction = $this->get_system_instruction(); if (!empty($systeminstruction)) { $requestobj->system = $systeminstruction; } // Append the extra model settings. $modelsettings = $this->get_model_settings(); foreach ($modelsettings as $setting => $value) { $requestobj->options->$setting = $value; } return new Request( method: 'POST', uri: '', body: json_encode($requestobj), headers: [ 'Content-Type' => 'application/json', ], ); } /** * Handle a successful response from the external AI api. * * @param ResponseInterface $response The response object. * @return array The response. */ protected function handle_api_success(ResponseInterface $response): array { $responsebody = $response->getBody(); $bodyobj = json_decode($responsebody->getContents()); return [ 'success' => true, 'generatedcontent' => $bodyobj->response, 'finishreason' => $bodyobj->done_reason, 'prompttokens' => $bodyobj->prompt_eval_count, 'completiontokens' => $bodyobj->eval_count, 'model' => $bodyobj->model ?? $this->get_model(), // Fallback to config model. ]; } } PK*\=(('provider/ollama/amd/src/modelchooser.jsnu[// 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 . /** * AI provider model selection handler. * * @module aiprovider_ollama/modelchooser * @copyright 2025 Huong Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ const Selectors = { fields: { selector: '[data-modelchooser-field="selector"]', updateButton: '[data-modelchooser-field="updateButton"]', }, }; /** * Initialise the AI provider chooser. */ export const init = () => { const modelSelector = document.querySelector(Selectors.fields.selector); if (modelSelector) { modelSelector.addEventListener('change', e => { modelSelector.options[e.target.selectedIndex].selected = true; const form = e.target.closest('form'); const updateButton = form.querySelector(Selectors.fields.updateButton); updateButton.click(); }); } }; PK*\0F2@KK1provider/ollama/amd/build/modelchooser.min.js.mapnu[{"version":3,"file":"modelchooser.min.js","sources":["../src/modelchooser.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/ //\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * AI provider model selection handler.\n *\n * @module aiprovider_ollama/modelchooser\n * @copyright 2025 Huong Nguyen \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nconst Selectors = {\n fields: {\n selector: '[data-modelchooser-field=\"selector\"]',\n updateButton: '[data-modelchooser-field=\"updateButton\"]',\n },\n};\n\n/**\n * Initialise the AI provider chooser.\n */\nexport const init = () => {\n const modelSelector = document.querySelector(Selectors.fields.selector);\n if (modelSelector) {\n modelSelector.addEventListener('change', e => {\n modelSelector.options[e.target.selectedIndex].selected = true;\n const form = e.target.closest('form');\n const updateButton = form.querySelector(Selectors.fields.updateButton);\n updateButton.click();\n });\n }\n};\n"],"names":["Selectors","selector","updateButton","modelSelector","document","querySelector","addEventListener","e","options","target","selectedIndex","selected","closest","click"],"mappings":";;;;;;;;MAsBMA,iBACM,CACJC,SAAU,uCACVC,aAAc,0DAOF,WACVC,cAAgBC,SAASC,cAAcL,iBAAiBC,UAC1DE,eACAA,cAAcG,iBAAiB,UAAUC,IACrCJ,cAAcK,QAAQD,EAAEE,OAAOC,eAAeC,UAAW,EAC5CJ,EAAEE,OAAOG,QAAQ,QACJP,cAAcL,iBAAiBE,cAC5CW"}PK*\&bzUU-provider/ollama/amd/build/modelchooser.min.jsnu[define("aiprovider_ollama/modelchooser",["exports"],(function(_exports){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0; /** * AI provider model selection handler. * * @module aiprovider_ollama/modelchooser * @copyright 2025 Huong Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ const Selectors_fields={selector:'[data-modelchooser-field="selector"]',updateButton:'[data-modelchooser-field="updateButton"]'};_exports.init=()=>{const modelSelector=document.querySelector(Selectors_fields.selector);modelSelector&&modelSelector.addEventListener("change",(e=>{modelSelector.options[e.target.selectedIndex].selected=!0;e.target.closest("form").querySelector(Selectors_fields.updateButton).click()}))}})); //# sourceMappingURL=modelchooser.min.js.mapPK*\nu  8provider/ollama/tests/fixtures/text_request_success.jsonnu[{ "model":"mistral", "created_at":"2024-09-09T01:54:37.479368Z", "response":" World War II was a global conflict that lasted from 1939 to 1945. It involved the majority of the world's countries—organized into two opposing military alliances: the Allies and the Axis powers. The main spark for the war was Adolf Hitler's invasion of Poland, which led to declarations of war on Germany by Britain and France. The war concluded with the unconditional surrender of the Axis powers. It resulted in 70-85 million fatalities, making it one of the deadliest conflicts in human history.", "done":true, "done_reason":"stop", "context":[ 3, 29473, 1763, 1390, 6324, 1032, 3013, 3555, 1245, 1040, 2956, 29491, 4372, 4406, 1117, 1066, 9038, 3013, 3586, 1124, 1420, 2927, 29491, 10590, 1935, 3046, 12150, 8998, 781, 1028, 29508, 29491, 5339, 1040, 14828, 1065, 11603, 3013, 1633, 6256, 781, 1028, 29518, 29491, 3146, 1227, 3792, 1475, 2484, 4022, 1989, 15526, 29493, 4424, 1067, 1510, 29493, 1210, 25242, 16555, 29491, 781, 781, 6006, 29473, 29550, 29502, 3853, 1124, 1043, 29495, 29518, 4, 1027, 4072, 4041, 4485, 1171, 1032, 4294, 11086, 1137, 24121, 1245, 29473, 29508, 29542, 29538, 29542, 1066, 29473, 29508, 29542, 29549, 29550, 29491, 1429, 6058, 1040, 8525, 1070, 1040, 2294, 29510, 29481, 6548, 29589, 9742, 2100, 1546, 1757, 6561, 1056, 6237, 1312, 21672, 29515, 1040, 2450, 1265, 1072, 1040, 1098, 13426, 12518, 29491, 1183, 2959, 14891, 1122, 1040, 2264, 1171, 2732, 5977, 15624, 29510, 29481, 19561, 1070, 18580, 29493, 1458, 4931, 1066, 18995, 1465, 1070, 2264, 1124, 8061, 1254, 10942, 1072, 5611, 29491, 1183, 2264, 17257, 1163, 1040, 1289, 1981, 3350, 26652, 1070, 1040, 1098, 13426, 12518, 29491, 1429, 17728, 1065, 29473, 29555, 29502, 29501, 29551, 29550, 4609, 20942, 1986, 29493, 3260, 1146, 1392, 1070, 1040, 4462, 18835, 24897, 1065, 3698, 4108, 29491 ], "total_duration":63199305763, "load_duration":28276495, "prompt_eval_count":75, "prompt_eval_duration":6918955000, "eval_count":123, "eval_duration":56249258000 } PK*\]+OO4provider/ollama/tests/process_generate_text_test.phpnu[. namespace aiprovider_ollama; use core_ai\aiactions\base; use core_ai\provider; use GuzzleHttp\Psr7\Response; defined('MOODLE_INTERNAL') || die(); require_once(__DIR__ . '/testcase_helper_trait.php'); /** * Test Generate text provider class for Ollama provider methods. * * @package aiprovider_ollama * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \aiprovider_ollama\provider * @covers \aiprovider_ollama\process_generate_text * @covers \aiprovider_ollama\abstract_processor */ final class process_generate_text_test extends \advanced_testcase { use testcase_helper_trait; /** @var string A successful response in JSON format. */ protected string $responsebodyjson; /** @var \core_ai\manager AI Manager. */ private $manager; /** @var provider The provider that will process the action. */ protected provider $provider; /** @var base The action to process. */ protected base $action; /** * Set up the test. */ protected function setUp(): void { parent::setUp(); $this->resetAfterTest(); // Load a response body from a file. $this->responsebodyjson = file_get_contents(self::get_fixture_path('aiprovider_ollama', 'text_request_success.json')); $this->manager = \core\di::get(\core_ai\manager::class); $this->provider = $this->create_provider( actionclass: \core_ai\aiactions\generate_text::class, actionconfig: [ 'systeminstruction' => get_string('action_generate_text_instruction', 'core_ai'), ], ); $this->create_action(); } /** * Create the action object. * @param int $userid The user id to use in the action. */ private function create_action(int $userid = 1): void { $this->action = new \core_ai\aiactions\generate_text( contextid: 1, userid: $userid, prompttext: 'This is a test prompt', ); } /** * Test create_request_object */ public function test_create_request_object(): void { $processor = new process_generate_text($this->provider, $this->action); // We're working with a private method here, so we need to use reflection. $method = new \ReflectionMethod($processor, 'create_request_object'); $request = $method->invoke($processor, 1); $body = (object) json_decode($request->getBody()->getContents()); $this->assertEquals('This is a test prompt', $body->prompt); $this->assertEquals('llama3.2', $body->model); } /** * Test create_request_object with extra model settings. */ public function test_create_request_object_with_model_settings(): void { $this->provider = $this->create_provider( actionclass: \core_ai\aiactions\generate_text::class, actionconfig: [ 'systeminstruction' => get_string('action_generate_text_instruction', 'core_ai'), 'temperature' => '0.5', 'mirostat' => '1', 'seed' => '50', ], ); $processor = new process_generate_text($this->provider, $this->action); // We're working with a private method here, so we need to use reflection. $method = new \ReflectionMethod($processor, 'create_request_object'); $request = $method->invoke($processor, 1); $body = (object) json_decode($request->getBody()->getContents()); $this->assertEquals('llama3.2', $body->model); $this->assertEquals('0.5', $body->options->temperature); $this->assertEquals('1', $body->options->mirostat); $this->assertEquals('50', $body->options->seed); $this->provider = $this->create_provider( actionclass: \core_ai\aiactions\generate_text::class, actionconfig: [ 'model' => 'my-custom-ollama', 'systeminstruction' => get_string('action_generate_text_instruction', 'core_ai'), 'modelextraparams' => '{"temperature": 0.5,"mirostat": 1,"seed": "50"}', ], ); $processor = new process_generate_text($this->provider, $this->action); // We're working with a private method here, so we need to use reflection. $method = new \ReflectionMethod($processor, 'create_request_object'); $request = $method->invoke($processor, 1); $body = (object) json_decode($request->getBody()->getContents()); $this->assertEquals('my-custom-ollama', $body->model); $this->assertEquals('0.5', $body->options->temperature); $this->assertEquals('1', $body->options->mirostat); $this->assertEquals('50', $body->options->seed); } /** * Test the API error response handler method. */ public function test_handle_api_error(): void { $responses = [ 500 => new Response(500, ['Content-Type' => 'application/json']), 503 => new Response(503, ['Content-Type' => 'application/json']), 401 => new Response( 401, ['Content-Type' => 'application/json'], json_encode(['error' => ['message' => 'Invalid Authentication']]), ), 404 => new Response( 404, ['Content-Type' => 'application/json'], json_encode(['error' => ['message' => 'You must be a member of an organization to use the API']]), ), 429 => new Response( 429, ['Content-Type' => 'application/json'], json_encode(['error' => ['message' => 'Rate limit reached for requests']]), ), ]; $processor = new process_generate_text($this->provider, $this->action); $method = new \ReflectionMethod($processor, 'handle_api_error'); foreach ($responses as $status => $response) { $result = $method->invoke($processor, $response); $this->assertEquals($status, $result['errorcode']); if ($status == 500) { $this->assertEquals('Internal Server Error', $result['errormessage']); } else if ($status == 503) { $this->assertEquals('Service Unavailable', $result['errormessage']); } else { $this->assertStringContainsString($response->getBody()->getContents(), $result['errormessage']); } } } /** * Test the API success response handler method. */ public function test_handle_api_success(): void { $response = new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, ); // We're testing a private method, so we need to setup reflector magic. $processor = new process_generate_text($this->provider, $this->action); $method = new \ReflectionMethod($processor, 'handle_api_success'); $result = $method->invoke($processor, $response); $this->assertTrue($result['success']); $this->assertStringContainsString('World War II was a global conflict', $result['generatedcontent']); $this->assertEquals('stop', $result['finishreason']); $this->assertEquals('75', $result['prompttokens']); $this->assertEquals('123', $result['completiontokens']); } /** * Test query_ai_api for a successful call. */ public function test_query_ai_api_success(): void { // Mock the http client to return a successful response. ['mock' => $mock] = $this->get_mocked_http_client(); // The response from Ollama. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $processor = new process_generate_text($this->provider, $this->action); $method = new \ReflectionMethod($processor, 'query_ai_api'); $result = $method->invoke($processor); $this->assertTrue($result['success']); $this->assertStringContainsString('World War II was a global conflict', $result['generatedcontent']); $this->assertEquals('stop', $result['finishreason']); $this->assertEquals('75', $result['prompttokens']); $this->assertEquals('123', $result['completiontokens']); } /** * Test prepare_response success. */ public function test_prepare_response_success(): void { $processor = new process_generate_text($this->provider, $this->action); // We're working with a private method here, so we need to use reflection. $method = new \ReflectionMethod($processor, 'prepare_response'); $response = [ 'success' => true, 'id' => 'chatcmpl-9lkwPWOIiQEvI3nfcGofJcmS5lPYo', 'fingerprint' => 'fp_c4e5b6fa31', 'generatedcontent' => 'Sure, here is some sample text', 'finishreason' => 'stop', 'prompttokens' => '11', 'completiontokens' => '568', ]; $result = $method->invoke($processor, $response); $this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result); $this->assertTrue($result->get_success()); $this->assertEquals('generate_text', $result->get_actionname()); $this->assertEquals($response['success'], $result->get_success()); $this->assertEquals($response['generatedcontent'], $result->get_response_data()['generatedcontent']); } /** * Test prepare_response error. */ public function test_prepare_response_error(): void { $processor = new process_generate_text($this->provider, $this->action); // We're working with a private method here, so we need to use reflection. $method = new \ReflectionMethod($processor, 'prepare_response'); $response = [ 'success' => false, 'errorcode' => 500, 'errormessage' => 'Internal server error.', ]; $result = $method->invoke($processor, $response); $this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result); $this->assertFalse($result->get_success()); $this->assertEquals('generate_text', $result->get_actionname()); $this->assertEquals($response['errorcode'], $result->get_errorcode()); $this->assertEquals($response['errormessage'], $result->get_errormessage()); } /** * Test process method. */ public function test_process(): void { $this->resetAfterTest(); // Log in user. $this->setUser($this->getDataGenerator()->create_user()); // Mock the http client to return a successful response. ['mock' => $mock] = $this->get_mocked_http_client(); // The response from Ollama. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $processor = new process_generate_text($this->provider, $this->action); $result = $processor->process(); $this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result); $this->assertTrue($result->get_success()); $this->assertEquals('generate_text', $result->get_actionname()); } /** * Test process method with error. */ public function test_process_error(): void { $this->resetAfterTest(); // Log in user. $this->setUser($this->getDataGenerator()->create_user()); // Mock the http client to return a successful response. ['mock' => $mock] = $this->get_mocked_http_client(); // The response from Ollama. $mock->append(new Response( 401, ['Content-Type' => 'application/json'], json_encode(['error' => ['message' => 'Invalid Authentication']]), )); $processor = new process_generate_text($this->provider, $this->action); $result = $processor->process(); $this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result); $this->assertFalse($result->get_success()); $this->assertEquals('generate_text', $result->get_actionname()); $this->assertEquals(401, $result->get_errorcode()); $this->assertEquals('Invalid Authentication', $result->get_errormessage()); } /** * Test process method with user rate limiter. */ public function test_process_with_user_rate_limiter(): void { $this->resetAfterTest(); // Create users. $user1 = $this->getDataGenerator()->create_user(); $user2 = $this->getDataGenerator()->create_user(); // Log in user1. $this->setUser($user1); // Mock clock. $clock = $this->mock_clock_with_frozen(); // Set the user rate limiter. $config = [ 'enableuserratelimit' => true, 'userratelimit' => 1, 'endpoint' => "http://localhost:11434/", ]; $provider = $this->manager->create_provider_instance( classname: '\aiprovider_ollama\provider', name: 'dummy', config: $config, actionconfig: [ \core_ai\aiactions\generate_text::class => [ 'settings' => [ 'model' => 'llama3.2', 'systeminstruction' => get_string('action_generate_text_instruction', 'core_ai'), ], ], ], ); // Mock the http client to return a successful response. ['mock' => $mock] = $this->get_mocked_http_client(); // Case 1: User rate limit has not been reached. $this->create_action($user1->id); // The response from Ollama. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $processor = new process_generate_text($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); // Case 2: User rate limit has been reached. $clock->bump(HOURSECS - 10); // The response from Ollama. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $this->create_action($user1->id); $processor = new process_generate_text($provider, $this->action); $result = $processor->process(); $this->assertEquals(429, $result->get_errorcode()); $this->assertEquals('User rate limit exceeded', $result->get_errormessage()); $this->assertFalse($result->get_success()); // Case 3: User rate limit has not been reached for a different user. // Log in user2. $this->setUser($user2); $this->create_action($user2->id); // The response from Ollama. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $processor = new process_generate_text($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); // Case 4: Time window has passed, user rate limit should be reset. $clock->bump(11); // Log in user1. $this->setUser($user1); // The response from Ollama. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $this->create_action($user1->id); $processor = new process_generate_text($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); } /** * Test process method with global rate limiter. */ public function test_process_with_global_rate_limiter(): void { $this->resetAfterTest(); // Create users. $user1 = $this->getDataGenerator()->create_user(); $user2 = $this->getDataGenerator()->create_user(); // Log in user1. $this->setUser($user1); // Mock clock. $clock = $this->mock_clock_with_frozen(); // Set the global rate limiter. $config = [ 'enableglobalratelimit' => true, 'globalratelimit' => 1, 'endpoint' => "http://localhost:11434/", ]; $provider = $this->manager->create_provider_instance( classname: '\aiprovider_ollama\provider', name: 'dummy', config: $config, actionconfig: [ \core_ai\aiactions\generate_text::class => [ 'settings' => [ 'model' => 'llama3.2', 'systeminstruction' => get_string('action_generate_text_instruction', 'core_ai'), ], ], ], ); // Mock the http client to return a successful response. ['mock' => $mock] = $this->get_mocked_http_client(); // Case 1: Global rate limit has not been reached. $this->create_action($user1->id); // The response from Ollama. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $processor = new process_generate_text($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); // Case 2: Global rate limit has been reached. $clock->bump(HOURSECS - 10); // The response from Ollama. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $this->create_action($user1->id); $processor = new process_generate_text($provider, $this->action); $result = $processor->process(); $this->assertEquals(429, $result->get_errorcode()); $this->assertEquals('Global rate limit exceeded', $result->get_errormessage()); $this->assertFalse($result->get_success()); // Case 3: Global rate limit has been reached for a different user too. // Log in user2. $this->setUser($user2); $this->create_action($user2->id); // The response from Ollama. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $processor = new process_generate_text($provider, $this->action); $result = $processor->process(); $this->assertFalse($result->get_success()); // Case 4: Time window has passed, global rate limit should be reset. $clock->bump(11); // Log in user1. $this->setUser($user1); // The response from Ollama. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $this->create_action($user1->id); $processor = new process_generate_text($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); } } PK*\[[/provider/ollama/tests/testcase_helper_trait.phpnu[. namespace aiprovider_ollama; /** * Trait for test cases. * * @package aiprovider_ollama * @copyright 2025 Huong Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ trait testcase_helper_trait { /** * Create the provider object. * * @param string $actionclass The action class to use. * @param array $actionconfig The action configuration to use. */ public function create_provider( string $actionclass, array $actionconfig = [], ): \core_ai\provider { $manager = \core\di::get(\core_ai\manager::class); $config = [ 'enableuserratelimit' => true, 'userratelimit' => 1, 'enableglobalratelimit' => true, 'globalratelimit' => 1, 'endpoint' => "http://localhost:11434/", ]; $defaultactionconfig = [ $actionclass => [ 'settings' => [ 'model' => 'llama3.2', ], ], ]; foreach ($actionconfig as $key => $value) { $defaultactionconfig[$actionclass]['settings'][$key] = $value; } $provider = $manager->create_provider_instance( classname: '\aiprovider_ollama\provider', name: 'dummy', config: $config, actionconfig: $defaultactionconfig, ); return $provider; } } PK*\lvNvN5provider/ollama/tests/process_summarise_text_test.phpnu[. namespace aiprovider_ollama; use core_ai\aiactions\base; use core_ai\provider; use GuzzleHttp\Psr7\Response; defined('MOODLE_INTERNAL') || die(); require_once(__DIR__ . '/testcase_helper_trait.php'); /** * Test Generate text provider class for Ollama provider methods. * * @package aiprovider_ollama * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \aiprovider_ollama\provider * @covers \aiprovider_ollama\process_summarise_text * @covers \aiprovider_ollama\abstract_processor */ final class process_summarise_text_test extends \advanced_testcase { use testcase_helper_trait; /** @var string A successful response in JSON format. */ protected string $responsebodyjson; /** @var \core_ai\manager AI Manager. */ private $manager; /** @var provider The provider that will process the action. */ protected provider $provider; /** @var base The action to process. */ protected base $action; /** * Set up the test. */ protected function setUp(): void { parent::setUp(); $this->resetAfterTest(); // Load a response body from a file. $this->responsebodyjson = file_get_contents(self::get_fixture_path('aiprovider_ollama', 'text_request_success.json')); $this->manager = \core\di::get(\core_ai\manager::class); $this->provider = $this->create_provider( actionclass: \core_ai\aiactions\summarise_text::class, actionconfig: [ 'systeminstruction' => get_string('action_summarise_text_instruction', 'core_ai'), ], ); $this->create_action(); } /** * Create the action object. * @param int $userid The user id to use in the action. */ private function create_action(int $userid = 1): void { $this->action = new \core_ai\aiactions\summarise_text( contextid: 1, userid: $userid, prompttext: 'This is a test prompt', ); } /** * Test create_request_object */ public function test_create_request_object(): void { $processor = new process_summarise_text($this->provider, $this->action); // We're working with a private method here, so we need to use reflection. $method = new \ReflectionMethod($processor, 'create_request_object'); $request = $method->invoke($processor, 1); $body = (object) json_decode($request->getBody()->getContents()); $this->assertEquals('This is a test prompt', $body->prompt); $this->assertEquals('llama3.2', $body->model); } /** * Test create_request_object with extra model settings. */ public function test_create_request_object_with_model_settings(): void { $this->provider = $this->create_provider( actionclass: \core_ai\aiactions\summarise_text::class, actionconfig: [ 'systeminstruction' => get_string('action_summarise_text_instruction', 'core_ai'), 'temperature' => '0.5', 'mirostat' => '1', 'seed' => '50', ], ); $processor = new process_summarise_text($this->provider, $this->action); // We're working with a private method here, so we need to use reflection. $method = new \ReflectionMethod($processor, 'create_request_object'); $request = $method->invoke($processor, 1); $body = (object) json_decode($request->getBody()->getContents()); $this->assertEquals('llama3.2', $body->model); $this->assertEquals('0.5', $body->options->temperature); $this->assertEquals('1', $body->options->mirostat); $this->assertEquals('50', $body->options->seed); $this->provider = $this->create_provider( actionclass: \core_ai\aiactions\summarise_text::class, actionconfig: [ 'model' => 'my-custom-ollama', 'systeminstruction' => get_string('action_summarise_text_instruction', 'core_ai'), 'modelextraparams' => '{"temperature": 0.5,"mirostat": 1,"seed": "50"}', ], ); $processor = new process_summarise_text($this->provider, $this->action); // We're working with a private method here, so we need to use reflection. $method = new \ReflectionMethod($processor, 'create_request_object'); $request = $method->invoke($processor, 1); $body = (object) json_decode($request->getBody()->getContents()); $this->assertEquals('my-custom-ollama', $body->model); $this->assertEquals('0.5', $body->options->temperature); $this->assertEquals('1', $body->options->mirostat); $this->assertEquals('50', $body->options->seed); } /** * Test the API error response handler method. * */ public function test_handle_api_error(): void { $responses = [ 500 => new Response(500, ['Content-Type' => 'application/json']), 503 => new Response(503, ['Content-Type' => 'application/json']), 401 => new Response(401, ['Content-Type' => 'application/json'], '{"error": {"message": "Invalid Authentication"}}'), 404 => new Response(404, ['Content-Type' => 'application/json'], '{"error": {"message": "You must be a member of an organization to use the API"}}'), 429 => new Response(429, ['Content-Type' => 'application/json'], '{"error": {"message": "Rate limit reached for requests"}}'), ]; $processor = new process_summarise_text($this->provider, $this->action); $method = new \ReflectionMethod($processor, 'handle_api_error'); foreach ($responses as $status => $response) { $result = $method->invoke($processor, $response); $this->assertEquals($status, $result['errorcode']); if ($status == 500) { $this->assertEquals('Internal Server Error', $result['errormessage']); } else if ($status == 503) { $this->assertEquals('Service Unavailable', $result['errormessage']); } else { $this->assertStringContainsString($response->getBody()->getContents(), $result['errormessage']); } } } /** * Test the API success response handler method. */ public function test_handle_api_success(): void { $response = new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, ); // We're testing a private method, so we need to set up reflector magic. $processor = new process_summarise_text($this->provider, $this->action); $method = new \ReflectionMethod($processor, 'handle_api_success'); $result = $method->invoke($processor, $response); $this->assertTrue($result['success']); $this->assertStringContainsString('World War II was a global conflict', $result['generatedcontent']); $this->assertEquals('stop', $result['finishreason']); $this->assertEquals('75', $result['prompttokens']); $this->assertEquals('123', $result['completiontokens']); } /** * Test query_ai_api for a successful call. */ public function test_query_ai_api_success(): void { // Mock the http client to return a successful response. ['mock' => $mock] = $this->get_mocked_http_client(); // The response from Ollama. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $processor = new process_summarise_text($this->provider, $this->action); $method = new \ReflectionMethod($processor, 'query_ai_api'); $result = $method->invoke($processor); $this->assertTrue($result['success']); $this->assertStringContainsString('World War II was a global conflict', $result['generatedcontent']); $this->assertEquals('stop', $result['finishreason']); $this->assertEquals('75', $result['prompttokens']); $this->assertEquals('123', $result['completiontokens']); } /** * Test prepare_response success. */ public function test_prepare_response_success(): void { $processor = new process_summarise_text($this->provider, $this->action); // We're working with a private method here, so we need to use reflection. $method = new \ReflectionMethod($processor, 'prepare_response'); $response = [ 'success' => true, 'id' => 'chatcmpl-9lkwPWOIiQEvI3nfcGofJcmS5lPYo', 'fingerprint' => 'fp_c4e5b6fa31', 'generatedcontent' => 'Sure, here is some sample text', 'finishreason' => 'stop', 'prompttokens' => '11', 'completiontokens' => '568', ]; $result = $method->invoke($processor, $response); $this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result); $this->assertTrue($result->get_success()); $this->assertEquals('summarise_text', $result->get_actionname()); $this->assertEquals($response['success'], $result->get_success()); $this->assertEquals($response['generatedcontent'], $result->get_response_data()['generatedcontent']); } /** * Test prepare_response error. */ public function test_prepare_response_error(): void { $processor = new process_summarise_text($this->provider, $this->action); // We're working with a private method here, so we need to use reflection. $method = new \ReflectionMethod($processor, 'prepare_response'); $response = [ 'success' => false, 'errorcode' => 500, 'errormessage' => 'Internal server error.', ]; $result = $method->invoke($processor, $response); $this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result); $this->assertFalse($result->get_success()); $this->assertEquals('summarise_text', $result->get_actionname()); $this->assertEquals($response['errorcode'], $result->get_errorcode()); $this->assertEquals($response['errormessage'], $result->get_errormessage()); } /** * Test process method. */ public function test_process(): void { $this->resetAfterTest(); // Log in user. $this->setUser($this->getDataGenerator()->create_user()); // Mock the http client to return a successful response. ['mock' => $mock] = $this->get_mocked_http_client(); // The response from Ollama. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $processor = new process_summarise_text($this->provider, $this->action); $result = $processor->process(); $this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result); $this->assertTrue($result->get_success()); $this->assertEquals('summarise_text', $result->get_actionname()); } /** * Test process method with error. */ public function test_process_error(): void { $this->resetAfterTest(); // Log in user. $this->setUser($this->getDataGenerator()->create_user()); // Mock the http client to return a successful response. ['mock' => $mock] = $this->get_mocked_http_client(); // The response from Ollama. $mock->append(new Response( 401, ['Content-Type' => 'application/json'], json_encode(['error' => ['message' => 'Invalid Authentication']]), )); $processor = new process_summarise_text($this->provider, $this->action); $result = $processor->process(); $this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result); $this->assertFalse($result->get_success()); $this->assertEquals('summarise_text', $result->get_actionname()); $this->assertEquals(401, $result->get_errorcode()); $this->assertEquals('Invalid Authentication', $result->get_errormessage()); } /** * Test process method with user rate limiter. */ public function test_process_with_user_rate_limiter(): void { $this->resetAfterTest(); // Create users. $user1 = $this->getDataGenerator()->create_user(); $user2 = $this->getDataGenerator()->create_user(); // Log in user1. $this->setUser($user1); // Mock clock. $clock = $this->mock_clock_with_frozen(); // Set the user rate limiter. $config = [ 'enableuserratelimit' => true, 'userratelimit' => 1, 'endpoint' => "http://localhost:11434/", ]; $provider = $this->manager->create_provider_instance( classname: '\aiprovider_ollama\provider', name: 'dummy', config: $config, actionconfig: [ \core_ai\aiactions\summarise_text::class => [ 'settings' => [ 'model' => 'llama3.2', 'systeminstruction' => get_string('action_summarise_text_instruction', 'core_ai'), ], ], ], ); // Mock the http client to return a successful response. ['mock' => $mock] = $this->get_mocked_http_client(); // Case 1: User rate limit has not been reached. $this->create_action($user1->id); // The response from Ollama. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $processor = new process_summarise_text($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); // Case 2: User rate limit has been reached. $clock->bump(HOURSECS - 10); // The response from Ollama. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $this->create_action($user1->id); $processor = new process_summarise_text($provider, $this->action); $result = $processor->process(); $this->assertEquals(429, $result->get_errorcode()); $this->assertEquals('User rate limit exceeded', $result->get_errormessage()); $this->assertFalse($result->get_success()); // Case 3: User rate limit has not been reached for a different user. // Log in user2. $this->setUser($user2); $this->create_action($user2->id); // The response from Ollama. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $processor = new process_summarise_text($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); // Case 4: Time window has passed, user rate limit should be reset. $clock->bump(11); // Log in user1. $this->setUser($user1); // The response from Ollama. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $this->create_action($user1->id); $processor = new process_summarise_text($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); } /** * Test process method with global rate limiter. */ public function test_process_with_global_rate_limiter(): void { $this->resetAfterTest(); // Create users. $user1 = $this->getDataGenerator()->create_user(); $user2 = $this->getDataGenerator()->create_user(); // Log in user1. $this->setUser($user1); // Mock clock. $clock = $this->mock_clock_with_frozen(); // Set the global rate limiter. $config = [ 'enableglobalratelimit' => true, 'globalratelimit' => 1, 'endpoint' => "http://localhost:11434/", ]; $provider = $this->manager->create_provider_instance( classname: '\aiprovider_ollama\provider', name: 'dummy', config: $config, actionconfig: [ \core_ai\aiactions\summarise_text::class => [ 'settings' => [ 'model' => 'llama3.2', 'systeminstruction' => get_string('action_summarise_text_instruction', 'core_ai'), ], ], ], ); // Mock the http client to return a successful response. ['mock' => $mock] = $this->get_mocked_http_client(); // Case 1: Global rate limit has not been reached. $this->create_action($user1->id); // The response from Ollama. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $processor = new process_summarise_text($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); // Case 2: Global rate limit has been reached. $clock->bump(HOURSECS - 10); // The response from Ollama. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $this->create_action($user1->id); $processor = new process_summarise_text($provider, $this->action); $result = $processor->process(); $this->assertEquals(429, $result->get_errorcode()); $this->assertEquals('Global rate limit exceeded', $result->get_errormessage()); $this->assertFalse($result->get_success()); // Case 3: Global rate limit has been reached for a different user too. // Log in user2. $this->setUser($user2); $this->create_action($user2->id); // The response from Ollama. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $processor = new process_summarise_text($provider, $this->action); $result = $processor->process(); $this->assertFalse($result->get_success()); // Case 4: Time window has passed, global rate limit should be reset. $clock->bump(11); // Log in user1. $this->setUser($user1); // The response from Ollama. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $this->create_action($user1->id); $processor = new process_summarise_text($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); } } PK*\ds$'provider/ollama/tests/provider_test.phpnu[. namespace aiprovider_ollama; /** * Test Ollama provider methods. * * @package aiprovider_ollama * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \aiprovider_ollama\provider */ final class provider_test extends \advanced_testcase { /** @var \core_ai\manager */ private $manager; /** @var \core_ai\provider */ private $provider; /** * Overriding setUp() function to always reset after tests. */ public function setUp(): void { parent::setUp(); $this->resetAfterTest(); // Create the provider instance. $this->manager = \core\di::get(\core_ai\manager::class); $config = ['data' => 'goeshere']; $this->provider = $this->manager->create_provider_instance( classname: '\aiprovider_ollama\provider', name: 'dummy', config: $config, ); } /** * Test get_action_list */ public function test_get_action_list(): void { $actionlist = $this->provider->get_action_list(); $this->assertIsArray($actionlist); $this->assertCount(3, $actionlist); $this->assertContains(\core_ai\aiactions\generate_text::class, $actionlist); $this->assertContains(\core_ai\aiactions\summarise_text::class, $actionlist); $this->assertContains(\core_ai\aiactions\explain_text::class, $actionlist); } /** * Test is_request_allowed. */ public function test_is_request_allowed(): void { // Create the provider instance. $config = [ 'enableuserratelimit' => true, 'userratelimit' => 3, 'enableglobalratelimit' => true, 'globalratelimit' => 5, ]; $provider = $this->manager->create_provider_instance( classname: '\aiprovider_ollama\provider', name: 'dummy', config: $config, ); $contextid = 1; $userid = 1; $prompttext = 'This is a test prompt'; $action = new \core_ai\aiactions\generate_text( contextid: $contextid, userid: $userid, prompttext: $prompttext, ); // Make 3 requests, all should be allowed. for ($i = 0; $i < 3; $i++) { $this->assertTrue($provider->is_request_allowed($action)); } // The 4th request for the same user should be denied. $result = $provider->is_request_allowed($action); $this->assertFalse($result['success']); $this->assertEquals('User rate limit exceeded', $result['errormessage']); // Change user id to make a request for a different user, should pass (4 requests for global rate). $action = new \core_ai\aiactions\generate_text( contextid: $contextid, userid: 2, prompttext: $prompttext, ); $this->assertTrue($provider->is_request_allowed($action)); // Make a 5th request for the global rate limit, it should be allowed. $this->assertTrue($provider->is_request_allowed($action)); // The 6th request should be denied. $result = $provider->is_request_allowed($action); $this->assertFalse($result['success']); $this->assertEquals('Global rate limit exceeded', $result['errormessage']); } } PK*\A+N+N3provider/ollama/tests/process_explain_text_test.phpnu[. namespace aiprovider_ollama; use core_ai\aiactions\base; use core_ai\provider; use GuzzleHttp\Psr7\Response; defined('MOODLE_INTERNAL') || die(); require_once(__DIR__ . '/testcase_helper_trait.php'); /** * Test Explain text provider class for Ollama provider methods. * * @package aiprovider_ollama * @copyright 2025 Huong Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \aiprovider_ollama\provider * @covers \aiprovider_ollama\process_explain_text * @covers \aiprovider_ollama\abstract_processor */ final class process_explain_text_test extends \advanced_testcase { use testcase_helper_trait; /** @var string A successful response in JSON format. */ protected string $responsebodyjson; /** @var \core_ai\manager AI Manager. */ private $manager; /** @var provider The provider that will process the action. */ protected provider $provider; /** @var base The action to process. */ protected base $action; /** * Set up the test. */ protected function setUp(): void { parent::setUp(); $this->resetAfterTest(); // Load a response body from a file. $this->responsebodyjson = file_get_contents(self::get_fixture_path('aiprovider_ollama', 'text_request_success.json')); $this->manager = \core\di::get(\core_ai\manager::class); $this->provider = $this->create_provider( actionclass: \core_ai\aiactions\explain_text::class, actionconfig: [ 'systeminstruction' => get_string('action_explain_text_instruction', 'core_ai'), ], ); $this->create_action(); } /** * Create the action object. * @param int $userid The user id to use in the action. */ private function create_action(int $userid = 1): void { $this->action = new \core_ai\aiactions\explain_text( contextid: 1, userid: $userid, prompttext: 'This is a test prompt', ); } /** * Test create_request_object */ public function test_create_request_object(): void { $processor = new process_explain_text($this->provider, $this->action); // We're working with a private method here, so we need to use reflection. $method = new \ReflectionMethod($processor, 'create_request_object'); $request = $method->invoke($processor, 1); $body = (object) json_decode($request->getBody()->getContents()); $this->assertEquals('This is a test prompt', $body->prompt); $this->assertEquals('llama3.2', $body->model); } /** * Test create_request_object with extra model settings. */ public function test_create_request_object_with_model_settings(): void { $this->provider = $this->create_provider( actionclass: \core_ai\aiactions\explain_text::class, actionconfig: [ 'systeminstruction' => get_string('action_explain_text_instruction', 'core_ai'), 'temperature' => '0.5', 'mirostat' => '1', 'seed' => '50', ], ); $processor = new process_explain_text($this->provider, $this->action); // We're working with a private method here, so we need to use reflection. $method = new \ReflectionMethod($processor, 'create_request_object'); $request = $method->invoke($processor, 1); $body = (object) json_decode($request->getBody()->getContents()); $this->assertEquals('llama3.2', $body->model); $this->assertEquals('0.5', $body->options->temperature); $this->assertEquals('1', $body->options->mirostat); $this->assertEquals('50', $body->options->seed); $this->provider = $this->create_provider( actionclass: \core_ai\aiactions\explain_text::class, actionconfig: [ 'model' => 'my-custom-ollama', 'systeminstruction' => get_string('action_explain_text_instruction', 'core_ai'), 'modelextraparams' => '{"temperature": 0.5,"mirostat": 1,"seed": "50"}', ], ); $processor = new process_explain_text($this->provider, $this->action); // We're working with a private method here, so we need to use reflection. $method = new \ReflectionMethod($processor, 'create_request_object'); $request = $method->invoke($processor, 1); $body = (object) json_decode($request->getBody()->getContents()); $this->assertEquals('my-custom-ollama', $body->model); $this->assertEquals('0.5', $body->options->temperature); $this->assertEquals('1', $body->options->mirostat); $this->assertEquals('50', $body->options->seed); } /** * Test the API error response handler method. * */ public function test_handle_api_error(): void { $responses = [ 500 => new Response(500, ['Content-Type' => 'application/json']), 503 => new Response(503, ['Content-Type' => 'application/json']), 401 => new Response(401, ['Content-Type' => 'application/json'], '{"error": {"message": "Invalid Authentication"}}'), 404 => new Response(404, ['Content-Type' => 'application/json'], '{"error": {"message": "You must be a member of an organization to use the API"}}'), 429 => new Response(429, ['Content-Type' => 'application/json'], '{"error": {"message": "Rate limit reached for requests"}}'), ]; $processor = new process_explain_text($this->provider, $this->action); $method = new \ReflectionMethod($processor, 'handle_api_error'); foreach ($responses as $status => $response) { $result = $method->invoke($processor, $response); $this->assertEquals($status, $result['errorcode']); if ($status == 500) { $this->assertEquals('Internal Server Error', $result['errormessage']); } else if ($status == 503) { $this->assertEquals('Service Unavailable', $result['errormessage']); } else { $this->assertStringContainsString($response->getBody()->getContents(), $result['errormessage']); } } } /** * Test the API success response handler method. */ public function test_handle_api_success(): void { $response = new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, ); // We're testing a private method, so we need to set up reflector magic. $processor = new process_explain_text($this->provider, $this->action); $method = new \ReflectionMethod($processor, 'handle_api_success'); $result = $method->invoke($processor, $response); $this->assertTrue($result['success']); $this->assertStringContainsString('World War II was a global conflict', $result['generatedcontent']); $this->assertEquals('stop', $result['finishreason']); $this->assertEquals('75', $result['prompttokens']); $this->assertEquals('123', $result['completiontokens']); } /** * Test query_ai_api for a successful call. */ public function test_query_ai_api_success(): void { // Mock the http client to return a successful response. ['mock' => $mock] = $this->get_mocked_http_client(); // The response from Ollama. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $processor = new process_explain_text($this->provider, $this->action); $method = new \ReflectionMethod($processor, 'query_ai_api'); $result = $method->invoke($processor); $this->assertTrue($result['success']); $this->assertStringContainsString('World War II was a global conflict', $result['generatedcontent']); $this->assertEquals('stop', $result['finishreason']); $this->assertEquals('75', $result['prompttokens']); $this->assertEquals('123', $result['completiontokens']); } /** * Test prepare_response success. */ public function test_prepare_response_success(): void { $processor = new process_explain_text($this->provider, $this->action); // We're working with a private method here, so we need to use reflection. $method = new \ReflectionMethod($processor, 'prepare_response'); $response = [ 'success' => true, 'id' => 'chatcmpl-9lkwPWOIiQEvI3nfcGofJcmS5lPYo', 'fingerprint' => 'fp_c4e5b6fa31', 'generatedcontent' => 'Sure, here is some sample text', 'finishreason' => 'stop', 'prompttokens' => '11', 'completiontokens' => '568', ]; $result = $method->invoke($processor, $response); $this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result); $this->assertTrue($result->get_success()); $this->assertEquals('explain_text', $result->get_actionname()); $this->assertEquals($response['success'], $result->get_success()); $this->assertEquals($response['generatedcontent'], $result->get_response_data()['generatedcontent']); } /** * Test prepare_response error. */ public function test_prepare_response_error(): void { $processor = new process_explain_text($this->provider, $this->action); // We're working with a private method here, so we need to use reflection. $method = new \ReflectionMethod($processor, 'prepare_response'); $response = [ 'success' => false, 'errorcode' => 500, 'errormessage' => 'Internal server error.', ]; $result = $method->invoke($processor, $response); $this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result); $this->assertFalse($result->get_success()); $this->assertEquals('explain_text', $result->get_actionname()); $this->assertEquals($response['errorcode'], $result->get_errorcode()); $this->assertEquals($response['errormessage'], $result->get_errormessage()); } /** * Test process method. */ public function test_process(): void { $this->resetAfterTest(); // Log in user. $this->setUser($this->getDataGenerator()->create_user()); // Mock the http client to return a successful response. ['mock' => $mock] = $this->get_mocked_http_client(); // The response from Ollama. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $processor = new process_explain_text($this->provider, $this->action); $result = $processor->process(); $this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result); $this->assertTrue($result->get_success()); $this->assertEquals('explain_text', $result->get_actionname()); } /** * Test process method with error. */ public function test_process_error(): void { $this->resetAfterTest(); // Log in user. $this->setUser($this->getDataGenerator()->create_user()); // Mock the http client to return a successful response. ['mock' => $mock] = $this->get_mocked_http_client(); // The response from Ollama. $mock->append(new Response( 401, ['Content-Type' => 'application/json'], json_encode(['error' => ['message' => 'Invalid Authentication']]), )); $processor = new process_explain_text($this->provider, $this->action); $result = $processor->process(); $this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result); $this->assertFalse($result->get_success()); $this->assertEquals('explain_text', $result->get_actionname()); $this->assertEquals(401, $result->get_errorcode()); $this->assertEquals('Invalid Authentication', $result->get_errormessage()); } /** * Test process method with user rate limiter. */ public function test_process_with_user_rate_limiter(): void { $this->resetAfterTest(); // Create users. $user1 = $this->getDataGenerator()->create_user(); $user2 = $this->getDataGenerator()->create_user(); // Log in user1. $this->setUser($user1); // Mock clock. $clock = $this->mock_clock_with_frozen(); // Set the user rate limiter. $config = [ 'enableuserratelimit' => true, 'userratelimit' => 1, 'endpoint' => "http://localhost:11434/", ]; $provider = $this->manager->create_provider_instance( classname: '\aiprovider_ollama\provider', name: 'dummy', config: $config, actionconfig: [ \core_ai\aiactions\explain_text::class => [ 'settings' => [ 'model' => 'llama3.2', 'systeminstruction' => get_string('action_explain_text_instruction', 'core_ai'), ], ], ], ); // Mock the http client to return a successful response. ['mock' => $mock] = $this->get_mocked_http_client(); // Case 1: User rate limit has not been reached. $this->create_action($user1->id); // The response from Ollama. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $processor = new process_explain_text($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); // Case 2: User rate limit has been reached. $clock->bump(HOURSECS - 10); // The response from Ollama. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $this->create_action($user1->id); $processor = new process_explain_text($provider, $this->action); $result = $processor->process(); $this->assertEquals(429, $result->get_errorcode()); $this->assertEquals('User rate limit exceeded', $result->get_errormessage()); $this->assertFalse($result->get_success()); // Case 3: User rate limit has not been reached for a different user. // Log in user2. $this->setUser($user2); $this->create_action($user2->id); // The response from Ollama. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $processor = new process_explain_text($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); // Case 4: Time window has passed, user rate limit should be reset. $clock->bump(11); // Log in user1. $this->setUser($user1); // The response from Ollama. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $this->create_action($user1->id); $processor = new process_explain_text($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); } /** * Test process method with global rate limiter. */ public function test_process_with_global_rate_limiter(): void { $this->resetAfterTest(); // Create users. $user1 = $this->getDataGenerator()->create_user(); $user2 = $this->getDataGenerator()->create_user(); // Log in user1. $this->setUser($user1); // Mock clock. $clock = $this->mock_clock_with_frozen(); // Set the global rate limiter. $config = [ 'enableglobalratelimit' => true, 'globalratelimit' => 1, 'endpoint' => "http://localhost:11434/", ]; $provider = $this->manager->create_provider_instance( classname: '\aiprovider_ollama\provider', name: 'dummy', config: $config, actionconfig: [ \core_ai\aiactions\explain_text::class => [ 'settings' => [ 'model' => 'llama3.2', 'systeminstruction' => get_string('action_explain_text_instruction', 'core_ai'), ], ], ], ); // Mock the http client to return a successful response. ['mock' => $mock] = $this->get_mocked_http_client(); // Case 1: Global rate limit has not been reached. $this->create_action($user1->id); // The response from Ollama. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $processor = new process_explain_text($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); // Case 2: Global rate limit has been reached. $clock->bump(HOURSECS - 10); // The response from Ollama. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $this->create_action($user1->id); $processor = new process_explain_text($provider, $this->action); $result = $processor->process(); $this->assertEquals(429, $result->get_errorcode()); $this->assertEquals('Global rate limit exceeded', $result->get_errormessage()); $this->assertFalse($result->get_success()); // Case 3: Global rate limit has been reached for a different user too. // Log in user2. $this->setUser($user2); $this->create_action($user2->id); // The response from Ollama. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $processor = new process_explain_text($provider, $this->action); $result = $processor->process(); $this->assertFalse($result->get_success()); // Case 4: Time window has passed, global rate limit should be reset. $clock->bump(11); // Log in user1. $this->setUser($user1); // The response from Ollama. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $this->create_action($user1->id); $processor = new process_explain_text($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); } } PK*\Vdprovider/azureai/db/hooks.phpnu[. /** * Hook listener callbacks for the Azure AI provider. * * @package aiprovider_azureai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $callbacks = [ [ 'hook' => \core_ai\hook\after_ai_provider_form_hook::class, 'callback' => \aiprovider_azureai\hook_listener::class . '::set_form_definition_for_aiprovider_azureai', ], ]; PK*\6͛_'provider/azureai/lang/en/deprecated.txtnu[action_apiversion,aiprovider_azureai action_deployment,aiprovider_azureai action_deployment_desc,aiprovider_azureai action_systeminstruction,aiprovider_azureai action_systeminstruction_desc,aiprovider_azureai apikey_desc,aiprovider_azureai deployment,aiprovider_azureai deployment_desc,aiprovider_azureai enableglobalratelimit,aiprovider_azureai enableglobalratelimit_desc,aiprovider_azureai enableuserratelimit,aiprovider_azureai enableuserratelimit_desc,aiprovider_azureai endpoint_desc,aiprovider_azureai globalratelimit,aiprovider_azureai globalratelimit_desc,aiprovider_azureai userratelimit,aiprovider_azureai userratelimit_desc,aiprovider_azureai PK*\M l/provider/azureai/lang/en/aiprovider_azureai.phpnu[. /** * Strings for component aiprovider_azureai, language 'en'. * * @package aiprovider_azureai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['action:explain_text:apiversion'] = 'API version'; $string['action:explain_text:deployment'] = 'Deployment ID'; $string['action:explain_text:deployment_help'] = 'The deployment ID that relates to the API endpoint the provider uses for this action.'; $string['action:explain_text:systeminstruction'] = 'System instruction'; $string['action:explain_text:systeminstruction_help'] = 'This instruction is sent to the AI model along with the user\'s prompt. Editing this instruction is not recommended unless absolutely required.'; $string['action:generate_image:apiversion'] = 'API version'; $string['action:generate_image:deployment'] = 'Deployment ID'; $string['action:generate_image:deployment_help'] = 'The deployment ID that relates to the API endpoint the provider uses for this action.'; $string['action:generate_text:apiversion'] = 'API version'; $string['action:generate_text:deployment'] = 'Deployment ID'; $string['action:generate_text:deployment_help'] = 'The deployment ID that relates to the API endpoint the provider uses for this action.'; $string['action:generate_text:systeminstruction'] = 'System instruction'; $string['action:generate_text:systeminstruction_help'] = 'This instruction is sent to the AI model along with the user\'s prompt. Editing this instruction is not recommended unless absolutely required.'; $string['action:summarise_text:apiversion'] = 'API version'; $string['action:summarise_text:deployment'] = 'Deployment ID'; $string['action:summarise_text:deployment_help'] = 'The deployment ID that relates to the API endpoint the provider uses for this action.'; $string['action:summarise_text:systeminstruction'] = 'System instruction'; $string['action:summarise_text:systeminstruction_help'] = 'This instruction is sent to the AI model along with the user\'s prompt. Editing this instruction is not recommended unless absolutely required.'; $string['apikey'] = 'Azure AI API key'; $string['apikey_help'] = 'Enter your Azure AI API key.'; $string['endpoint'] = 'Azure AI API endpoint'; $string['endpoint_help'] = 'Enter the endpoint URL for your Azure AI API in the following format: https://YOUR_RESOURCE_NAME.openai.azure.com'; $string['pluginname'] = 'Azure AI API provider'; $string['privacy:metadata'] = 'The Azure AI API provider plugin does not store any personal data.'; $string['privacy:metadata:aiprovider_azureai:externalpurpose'] = 'This information is sent to the Azure API in order for a response to be generated. Your Azure AI account settings may change how Microsoft stores and retains this data. No user data is explicitly sent to Microsoft or stored in Moodle LMS by this plugin.'; $string['privacy:metadata:aiprovider_azureai:model'] = 'The model used to generate the response.'; $string['privacy:metadata:aiprovider_azureai:numberimages'] = 'When generating images the number of images used in the response.'; $string['privacy:metadata:aiprovider_azureai:prompttext'] = 'The user entered text prompt used to generate the response.'; $string['privacy:metadata:aiprovider_azureai:responseformat'] = 'The format of the response when generating images.'; // Deprecated since Moodle 5.0. $string['action_apiversion'] = 'API version'; $string['action_deployment'] = 'Deployment ID'; $string['action_deployment_desc'] = 'The deployment ID that relates to the API endpoint the provider uses for this action.'; $string['action_systeminstruction'] = 'System instruction'; $string['action_systeminstruction_desc'] = 'This instruction is sent to the AI model along with the user\'s prompt. Editing this instruction is not recommended unless absolutely required.'; $string['apikey_desc'] = 'Enter your Azure AI API key.'; $string['deployment'] = 'Azure AI API deployment name'; $string['deployment_desc'] = 'Enter the deployment name for your Azure AI API.'; $string['enableglobalratelimit'] = 'Set site-wide rate limit'; $string['enableglobalratelimit_desc'] = 'Limit the number of requests that the Azure AI API provider can receive across the entire site every hour.'; $string['enableuserratelimit'] = 'Set user rate limit'; $string['enableuserratelimit_desc'] = 'Limit the number of requests each user can make to the Azure AI API provider every hour.'; $string['endpoint_desc'] = 'Enter the endpoint URL for your Azure AI API in the following format: https://YOUR_RESOURCE_NAME.openai.azure.com'; $string['globalratelimit'] = 'Maximum number of site-wide requests'; $string['globalratelimit_desc'] = 'The number of site-wide requests allowed per hour.'; $string['userratelimit'] = 'Maximum number of requests per user'; $string['userratelimit_desc'] = 'The number of requests allowed per hour, per user.'; PK*\!cNGGprovider/azureai/version.phpnu[. /** * Version information for aiprovider_azureai. * * @package aiprovider_azureai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'aiprovider_azureai'; $plugin->version = 2025041400; $plugin->requires = 2025040800; $plugin->maturity = MATURITY_STABLE; PK*\{Z Z <provider/azureai/classes/form/action_generate_image_form.phpnu[. namespace aiprovider_azureai\form; use core_ai\form\action_settings_form; /** * Generate image action provider settings form. * * @package aiprovider_azureai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class action_generate_image_form extends action_settings_form { #[\Override] protected function definition() { $mform = $this->_form; $actionconfig = $this->_customdata['actionconfig']['settings'] ?? []; $returnurl = $this->_customdata['returnurl'] ?? null; $actionname = $this->_customdata['actionname']; $action = $this->_customdata['action']; $providerid = $this->_customdata['providerid'] ?? 0; // Add API deployment name. $mform->addElement( 'text', 'deployment', get_string("action:{$actionname}:deployment", 'aiprovider_azureai'), 'maxlength="255" size="20"', ); $mform->setType('deployment', PARAM_ALPHANUMEXT); $mform->addRule('deployment', null, 'required', null, 'client'); $mform->setDefault('deployment', $actionconfig['deployment'] ?? ''); $mform->addHelpButton('deployment', "action:{$actionname}:deployment", 'aiprovider_azureai'); // Add API version. $mform->addElement( 'text', 'apiversion', get_string("action:{$actionname}:apiversion", 'aiprovider_azureai'), 'maxlength="255" size="30"', ); $mform->setType('apiversion', PARAM_ALPHANUMEXT); $mform->addRule('apiversion', null, 'required', null, 'client'); $mform->setDefault('apiversion', $actionconfig['apiversion'] ?? '2024-06-01'); if ($returnurl) { $mform->addElement('hidden', 'returnurl', $returnurl); $mform->setType('returnurl', PARAM_LOCALURL); } // Add the action class as a hidden field. $mform->addElement('hidden', 'action', $action); $mform->setType('action', PARAM_TEXT); // Add the provider class as a hidden field. $mform->addElement('hidden', 'provider', 'aiprovider_azureai'); $mform->setType('provider', PARAM_TEXT); // Add the provider id as a hidden field. $mform->addElement('hidden', 'providerid', $providerid); $mform->setType('providerid', PARAM_INT); $this->set_data($actionconfig); } } PK*\.t6;provider/azureai/classes/form/action_generate_text_form.phpnu[. namespace aiprovider_azureai\form; use core_ai\form\action_settings_form; /** * Generate text action provider settings form. * * @package aiprovider_azureai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class action_generate_text_form extends action_settings_form { #[\Override] protected function definition() { $mform = $this->_form; $actionconfig = $this->_customdata['actionconfig']['settings'] ?? []; $returnurl = $this->_customdata['returnurl'] ?? null; $actionname = $this->_customdata['actionname']; $action = $this->_customdata['action']; $providerid = $this->_customdata['providerid'] ?? 0; // Add API deployment name. $mform->addElement( 'text', 'deployment', get_string("action:{$actionname}:deployment", 'aiprovider_azureai'), 'maxlength="255" size="20"', ); $mform->setType('deployment', PARAM_ALPHANUMEXT); $mform->addRule('deployment', null, 'required', null, 'client'); $mform->setDefault('deployment', $actionconfig['deployment'] ?? ''); $mform->addHelpButton('deployment', "action:{$actionname}:deployment", 'aiprovider_azureai'); // Add API version. $mform->addElement( 'text', 'apiversion', get_string("action:{$actionname}:apiversion", 'aiprovider_azureai'), 'maxlength="255" size="30"', ); $mform->setType('apiversion', PARAM_ALPHANUMEXT); $mform->addRule('apiversion', null, 'required', null, 'client'); $mform->setDefault('apiversion', $actionconfig['apiversion'] ?? '2024-06-01'); // System Instructions. $mform->addElement( 'textarea', 'systeminstruction', get_string("action:{$actionname}:systeminstruction", 'aiprovider_azureai'), 'wrap="virtual" rows="5" cols="20"', ); $mform->setType('systeminstruction', PARAM_TEXT); $mform->setDefault('systeminstruction', $actionconfig['systeminstruction'] ?? $action::get_system_instruction()); $mform->addHelpButton('systeminstruction', "action:{$actionname}:systeminstruction", 'aiprovider_azureai'); if ($returnurl) { $mform->addElement('hidden', 'returnurl', $returnurl); $mform->setType('returnurl', PARAM_LOCALURL); } // Add the action class as a hidden field. $mform->addElement('hidden', 'action', $action); $mform->setType('action', PARAM_TEXT); // Add the provider class as a hidden field. $mform->addElement('hidden', 'provider', 'aiprovider_azureai'); $mform->setType('provider', PARAM_TEXT); // Add the provider id as a hidden field. $mform->addElement('hidden', 'providerid', $providerid); $mform->setType('providerid', PARAM_INT); $this->set_data($actionconfig); } } PK*\ūA  -provider/azureai/classes/privacy/provider.phpnu[. namespace aiprovider_azureai\privacy; use core_privacy\local\metadata\collection; use core_privacy\local\request\approved_contextlist; use core_privacy\local\request\approved_userlist; use core_privacy\local\request\contextlist; use core_privacy\local\request\userlist; /** * Privacy Subsystem for Azure AI provider implementing null_provider. * * @package aiprovider_azureai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @codeCoverageIgnore */ class provider implements \core_privacy\local\metadata\provider, \core_privacy\local\request\core_userlist_provider, \core_privacy\local\request\plugin\provider { #[\Override] public static function get_metadata(collection $collection): collection { $collection->add_external_location_link('aiprovider_azureai', [ 'prompttext' => 'privacy:metadata:aiprovider_azureai:prompttext', 'model' => 'privacy:metadata:aiprovider_azureai:model', 'numberimages' => 'privacy:metadata:aiprovider_azureai:numberimages', 'responseformat' => 'privacy:metadata:aiprovider_azureai:responseformat', ], 'privacy:metadata:aiprovider_azureai:externalpurpose'); return $collection; } #[\Override] public static function get_contexts_for_userid(int $userid): contextlist { return new contextlist(); } #[\Override] public static function get_users_in_context(userlist $userlist) { } #[\Override] public static function export_user_data(approved_contextlist $contextlist) { } #[\Override] public static function delete_data_for_all_users_in_context(\context $context) { } /** * Delete multiple users within a single context. * * @param approved_userlist $userlist The approved context and user information to delete information for. */ public static function delete_data_for_users(approved_userlist $userlist) { } #[\Override] public static function delete_data_for_user(approved_contextlist $contextlist) { } } PK*\hІ3provider/azureai/classes/process_generate_image.phpnu[. namespace aiprovider_azureai; use core\http_client; use core_ai\ai_image; use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Uri; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\UriInterface; /** * Class process image generation. * * @package aiprovider_azureai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class process_generate_image extends abstract_processor { /** @var int The number of images to generate dall-e-3 only supports 1 */ private int $numberimages = 1; #[\Override] protected function get_endpoint(): UriInterface { $url = rtrim($this->provider->config['endpoint'], '/') . '/openai/deployments/' . $this->get_deployment_name() . '/images/generations?api-version=' . $this->get_api_version(); return new Uri($url); } #[\Override] protected function query_ai_api(): array { $response = parent::query_ai_api(); // If the request was successful, save the URL to a file. if ($response['success']) { $fileobj = $this->url_to_file( $this->action->get_configuration('userid'), $response['sourceurl'] ); // Add the file to the response, so the calling placement can do whatever they want with it. $response['draftfile'] = $fileobj; } return $response; } /** * Convert the given aspect ratio to an image size * that is compatible with the azureai API. * * @param string $ratio The aspect ratio of the image. * @return string The size of the image. * @throws \coding_exception */ private function calculate_size(string $ratio): string { if ($ratio === 'square') { $size = '1024x1024'; } else if ($ratio === 'landscape') { $size = '1792x1024'; } else if ($ratio === 'portrait') { $size = '1024x1792'; } else { throw new \coding_exception('Invalid aspect ratio: ' . $ratio); } return $size; } #[\Override] protected function create_request_object(string $userid): RequestInterface { return new Request( method: 'POST', uri: '', headers: [ 'Content-Type' => 'application/json', ], body: json_encode((object) [ 'prompt' => $this->action->get_configuration('prompttext'), 'n' => $this->numberimages, 'quality' => $this->action->get_configuration('quality'), 'size' => $this->calculate_size($this->action->get_configuration('aspectratio')), 'style' => $this->action->get_configuration('style'), 'user' => $userid, ]), ); } #[\Override] protected function handle_api_success(ResponseInterface $response): array { $responsebody = $response->getBody(); $bodyobj = json_decode($responsebody->getContents()); return [ 'success' => true, 'sourceurl' => $bodyobj->data[0]->url, 'revisedprompt' => $bodyobj->data[0]->revised_prompt, ]; } /** * Convert the url for the image to a file. * * Placements can't interact with the provider AI directly, * therefore we need to provide the image file in a format that can * be used by placements. So we use the file API. * * @param int $userid The user id. * @param string $url The URL to the image. * @return \stored_file The file object. */ private function url_to_file(int $userid, string $url): \stored_file { global $CFG; require_once("{$CFG->libdir}/filelib.php"); // Azure AI doesn't always return unique file names, but does return unique URLS. // Therefore, some processing is needed to get a unique filename. $parsedurl = parse_url($url, PHP_URL_PATH); // Parse the URL to get the path. $fileext = pathinfo($parsedurl, PATHINFO_EXTENSION); // Get the file extension. $filename = substr(hash('sha512', ($url . $userid)), 0, 16) . '.' . $fileext; $client = \core\di::get(http_client::class); // Download the image and add the watermark. $downloadtmpdir = make_request_directory(); $tempdst = $downloadtmpdir . $filename; $client->get($url, [ 'sink' => $tempdst, 'timeout' => $CFG->repositorygetfiletimeout, ]); $image = new ai_image($tempdst); $image->add_watermark()->save(); // We put the file in the user draft area initially. // Placements (on behalf of the user) can then move it to the correct location. $fileinfo = new \stdClass(); $fileinfo->contextid = \context_user::instance($userid)->id; $fileinfo->filearea = 'draft'; $fileinfo->component = 'user'; $fileinfo->itemid = file_get_unused_draft_itemid(); $fileinfo->filepath = '/'; $fileinfo->filename = $filename; $fs = get_file_storage(); return $fs->create_file_from_string($fileinfo, file_get_contents($tempdst)); } } PK*\3provider/azureai/classes/process_summarise_text.phpnu[. namespace aiprovider_azureai; /** * Class process text summarisation. * * @package aiprovider_azureai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class process_summarise_text extends process_generate_text { } PK*\in*provider/azureai/classes/hook_listener.phpnu[. namespace aiprovider_azureai; use core_ai\hook\after_ai_provider_form_hook; /** * Hook listener for Azure AI provider. * * @package aiprovider_azureai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class hook_listener { /** * Hook listener for the Azure AI instance setup form. * * @param after_ai_provider_form_hook $hook The hook to add to the AI instance setup. */ public static function set_form_definition_for_aiprovider_azureai(after_ai_provider_form_hook $hook): void { if ($hook->plugin !== 'aiprovider_azureai') { return; } $mform = $hook->mform; // Required setting to store azureai API key. $mform->addElement( 'passwordunmask', 'apikey', get_string('apikey', 'aiprovider_azureai'), ['size' => 75], ); $mform->addHelpButton('apikey', 'apikey', 'aiprovider_azureai'); $mform->addRule('apikey', get_string('required'), 'required', null, 'client'); // Setting to store AzureAI endpoint URL. $mform->addElement( 'text', 'endpoint', get_string('endpoint', 'aiprovider_azureai'), ['size' => 25], ); $mform->setType('endpoint', PARAM_URL); $mform->addHelpButton('endpoint', 'endpoint', 'aiprovider_azureai'); $mform->addRule('endpoint', get_string('required'), 'required', null, 'client'); } } PK*\7/provider/azureai/classes/abstract_processor.phpnu[. namespace aiprovider_azureai; use core\http_client; use core_ai\process_base; use GuzzleHttp\Exception\RequestException; use GuzzleHttp\RequestOptions; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\UriInterface; /** * Class process text generation. * * @package aiprovider_azureai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ abstract class abstract_processor extends process_base { /** * Get the endpoint URI. * * @return UriInterface */ abstract protected function get_endpoint(): UriInterface; /** * Get the system instructions. * * @return string */ protected function get_system_instruction(): string { return $this->action::get_system_instruction(); } /** * Get the deployment name. * * @return string */ protected function get_deployment_name(): string { return $this->provider->actionconfig[$this->action::class]['settings']['deployment']; } /** * Get the api version to use. * * @return string */ protected function get_api_version(): string { return $this->provider->actionconfig[$this->action::class]['settings']['apiversion']; } /** * Create the request object to send to the OpenAI API. * * This object contains all the required parameters for the request. * * @param string $userid The user id. * @return RequestInterface The request object to send to the OpenAI API. */ abstract protected function create_request_object( string $userid, ): RequestInterface; /** * Handle a successful response from the external AI api. * * @param ResponseInterface $response The response object. * @return array The response. */ abstract protected function handle_api_success(ResponseInterface $response): array; #[\Override] protected function query_ai_api(): array { $request = $this->create_request_object( userid: $this->provider->generate_userid($this->action->get_configuration('userid')), ); $request = $this->provider->add_authentication_headers($request); $client = \core\di::get(http_client::class); try { // Call the external AI service. $response = $client->send($request, [ 'base_uri' => $this->get_endpoint(), RequestOptions::HTTP_ERRORS => false, ]); } catch (RequestException $e) { // Handle any exceptions. return [ 'success' => false, 'errorcode' => $e->getCode(), 'errormessage' => $e->getMessage(), ]; } // Double-check the response codes, in case of a non 200 that didn't throw an error. $status = $response->getStatusCode(); if ($status === 200) { return $this->handle_api_success($response); } else { return $this->handle_api_error($response); } } /** * Handle an error from the external AI api. * * @param ResponseInterface $response The response object. * @return array The error response. */ protected function handle_api_error(ResponseInterface $response): array { $responsearr = [ 'success' => false, 'errorcode' => $response->getStatusCode(), ]; $status = $response->getStatusCode(); if ($status >= 500 && $status < 600) { $responsearr['errormessage'] = $response->getReasonPhrase(); } else { $bodyobj = json_decode($response->getBody()->getContents()); $responsearr['errormessage'] = $bodyobj->error->message; } return $responsearr; } } PK*\nF F %provider/azureai/classes/provider.phpnu[. namespace aiprovider_azureai; use core_ai\form\action_settings_form; use Psr\Http\Message\RequestInterface; /** * Class provider. * * @package aiprovider_azureai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider extends \core_ai\provider { /** * Get the list of actions that this provider supports. * * @return array An array of action class names. */ public static function get_action_list(): array { return [ \core_ai\aiactions\generate_text::class, \core_ai\aiactions\generate_image::class, \core_ai\aiactions\summarise_text::class, \core_ai\aiactions\explain_text::class, ]; } #[\Override] public function add_authentication_headers(RequestInterface $request): RequestInterface { return $request ->withAddedHeader('api-key', $this->config['apikey']); } #[\Override] public static function get_action_settings( string $action, array $customdata = [], ): action_settings_form|bool { $actionname = substr($action, (strrpos($action, '\\') + 1)); $customdata['actionname'] = $actionname; $customdata['action'] = $action; if ($actionname === 'generate_text' || $actionname === 'summarise_text' || $actionname === 'explain_text') { return new form\action_generate_text_form(customdata: $customdata); } else if ($actionname === 'generate_image') { return new form\action_generate_image_form(customdata: $customdata); } return false; } #[\Override] public static function get_action_setting_defaults(string $action): array { $actionname = substr($action, (strrpos($action, '\\') + 1)); $customdata = [ 'actionname' => $actionname, 'action' => $action, ]; if ($actionname === 'generate_text' || $actionname === 'summarise_text' || $actionname === 'explain_text') { $mform = new form\action_generate_text_form(customdata: $customdata); return $mform->get_defaults(); } else if ($actionname === 'generate_image') { $mform = new form\action_generate_image_form(customdata: $customdata); return $mform->get_defaults(); } return []; } /** * Check this provider has the minimal configuration to work. * * @return bool Return true if configured. */ public function is_provider_configured(): bool { return !empty($this->config['apikey']) && !empty($this->config['endpoint']); } } PK*\mR*1provider/azureai/classes/process_explain_text.phpnu[. namespace aiprovider_azureai; /** * Class process text explanation. * * @package aiprovider_azureai * @copyright 2024 David Woloszyn * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class process_explain_text extends process_generate_text { } PK*\)2provider/azureai/classes/process_generate_text.phpnu[. namespace aiprovider_azureai; use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Uri; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\UriInterface; /** * Class process text generation. * * @package aiprovider_azureai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class process_generate_text extends abstract_processor { #[\Override] protected function get_endpoint(): UriInterface { $url = rtrim($this->provider->config['endpoint'], '/') . '/openai/deployments/' . $this->get_deployment_name() . '/chat/completions?api-version=' . $this->get_api_version(); return new Uri($url); } #[\Override] protected function get_system_instruction(): string { return $this->provider->actionconfig[$this->action::class]['settings']['systeminstruction']; } #[\Override] protected function create_request_object(string $userid): RequestInterface { // Create the user object. $userobj = new \stdClass(); $userobj->role = 'user'; $userobj->content = $this->action->get_configuration('prompttext'); // Create the request object. $requestobj = new \stdClass(); $requestobj->user = $userid; // If there is a system string available, use it. $systeminstruction = $this->get_system_instruction(); if (!empty($systeminstruction)) { $systemobj = new \stdClass(); $systemobj->role = 'system'; $systemobj->content = $systeminstruction; $requestobj->messages = [$systemobj, $userobj]; } else { $requestobj->messages = [$userobj]; } return new Request( method: 'POST', uri: '', headers: [ 'Content-Type' => 'application/json', ], body: json_encode($requestobj), ); } /** * Handle a successful response from the external AI api. * * @param ResponseInterface $response The response object. * @return array The response. */ protected function handle_api_success(ResponseInterface $response): array { $responsebody = $response->getBody(); $bodyobj = json_decode($responsebody->getContents()); return [ 'success' => true, 'id' => $bodyobj->id, 'fingerprint' => $bodyobj->system_fingerprint, 'generatedcontent' => $bodyobj->choices[0]->message->content, 'finishreason' => $bodyobj->choices[0]->finish_reason, 'prompttokens' => $bodyobj->usage->prompt_tokens, 'completiontokens' => $bodyobj->usage->completion_tokens, 'model' => $bodyobj->model, ]; } } PK*\(9provider/azureai/tests/fixtures/text_request_success.jsonnu[{ "choices":[ { "content_filter_results":{ "hate":{ "filtered":false, "severity":"safe" }, "self_harm":{ "filtered":false, "severity":"safe" }, "sexual":{ "filtered":false, "severity":"safe" }, "violence":{ "filtered":false, "severity":"safe" } }, "finish_reason":"stop", "index":0, "logprobs":null, "message":{ "content":"Sure, I'm here to help! How can I assist you today?", "role":"assistant" } } ], "created":1721897889, "id":"chatcmpl-9ooaXlMSUIhOkd2pfxKBgpipMynkX", "model":"gpt-4o-2024-05-13", "object":"chat.completion", "prompt_filter_results":[ { "prompt_index":0, "content_filter_results":{ "hate":{ "filtered":false, "severity":"safe" }, "jailbreak":{ "filtered":false, "detected":false }, "self_harm":{ "filtered":false, "severity":"safe" }, "sexual":{ "filtered":false, "severity":"safe" }, "violence":{ "filtered":false, "severity":"safe" } } } ], "system_fingerprint":"fp_abc28019ad", "usage":{ "completion_tokens":14, "prompt_tokens":12, "total_tokens":26 } } PK*\]>6:provider/azureai/tests/fixtures/image_request_success.jsonnu[{ "created": 1719140500, "data": [ { "revised_prompt": "An image that represents the concept of a 'test'. It could be a variety of things, such as a piece of paper with multiple-choice questions, a scientist in a lab conducting experiments, or a student at a desk studying for an upcoming exam. This image should use vivid colors and clear, detailed imagery to clearly represent the concept of 'test'.", "url": "https:\/\/oaidalleapiprodscus.blob.core.windows.net\/private\/org-EdXlYn9JmBUAo1tZ1QeRcOvg\/user-8GYNL27bMyzS3WcLtkUwREgd\/img-uPMRNi0R9lxPPJYOf4NZUmMY.png?st=2024-06-23T10%3A01%3A40Z&se=2024-06-23T12%3A01%3A40Z&sp=r&sv=2023-11-03&sr=b&rscd=inline&rsct=image\/png&skoid=6aaadede-4fb3-4698-a8f6-684d7786b067&sktid=a48cca56-e6da-484e-a814-9c849652bcb3&skt=2024-06-23T00%3A13%3A04Z&ske=2024-06-24T00%3A13%3A04Z&sks=b&skv=2023-11-03&sig=bmZqUqQ2QLrmQFSGxTRQoKRVf4C6VyYCZ0aA4Y5%2BGCs%3D" } ] } PK*\:Dr44(provider/azureai/tests/fixtures/test.jpgnu[JFIFHHPhotoshop 3.08BIM(ICC_PROFILEappl scnrRGB XYZ acspAPPLappl-appl rXYZgXYZbXYZ0wtptDchadX,rTRCgTRCbTRCdesc=cprtAdscmXYZ tK>XYZ Zs&XYZ (W3XYZ Rsf32 B&lcurv3descCamera RGB ProfileCamera RGB Profilemluc enUS$esES,LdaDK4deDE,fiFI(frFU<itIT,rnlNL$noNO xptBR(JsvSE*jaJPkoKR2zhTW2zhCNKameran RGB-profiiliRGB-profil fr Kamera000 RGB 000000exOMvj_ RGB r_icϏPerfil RGB para CmaraRGB-kameraprofilRGB-Profil fr Kamerasvg: RGB cϏeNRGB-beskrivelse til KameraRGB-profiel CameratT| RGB \ |Perfil RGB de CmeraProfilo RGB FotocameraCamera RGB ProfileProfil RVB de l appareil-phototextCopyright 2003 Apple Computer Inc., all rights reserved.\ExifMM*  z(2i SONYDSC-H5HH2006:12:15 21:51:49"'0221  4 <D  L0100Ƞ  <% 2006:12:15 21:51:492006:12:15 21:51:49HCC }!1AQa"q2#BR$3br %&'()*456789:CDEFGHIJSTUVWXYZcdefghijstuvwxyz w!1AQaq"2B #3Rbr $4%&'()*56789:CDEFGHIJSTUVWXYZcdefghijstuvwxyz ?J0׌xǶAq_IݸhRzu^R|ϥ)E%}.]u]VMtˑ׮9Zk]_rjvO[StݖY7޷q$s\x\]Rz4OwNkk7fMI]]mC ~On WJRz=/߫oKmIFچQۧ^Wzw{G}UZl.g=+$Xtgӟߧ){]_6s^6oet䝝ᄋIO7~ΙGs뜞ו*z[qwzZ2k]'Vws-(<cϹ{Y$cmz5N7ݵgګ^S>\rx_댞5UgJھZ]N[~]/u{nxQ~x3n6Vufݬ~n^ݽnVx;rH<)5&嫌J͵q;iM^߫~Rz}=={+9M]K9iַ^n~oM=:) #>ӓۮO=knEtww5wV-4m~"C0z>g'%n].ZwvJWYkͶKZN/Ǟn\ɥ]wv+߫Oc}fV}SݢEsƢR+{՗~۱Wzn֫}^}7[_{3>x{þ&R<9xOmN٢I-'X4r i(yc IfGNsřSb`2bhJIF(u+TiFq9Zo1~l Gm|^ JǞ rJΊV48JJk7 *Oq4*atbԏ%Ju5JӍHEҦK[ >k>$.iͳl_ky~SIB QQJiKsu$߽]}O&o>uK] \ҮL+fQ C;c;Dn!H秈%.βIQM嘊jF']Ct{:ΕXҜ)%VNfW!|G.*L:<;bppY0W)ar):GVGO~~97;웺i7^׽ϯmWeݷ~r{܏ƪ/Y7~GFMߥmWg}ѧtֽׯ|`T..TUzK>8۳}RQqv9z~#3̽ӿWZHWZmm7Im[2yqׯ'>_^fٻWi^kn}w{-k{G=>\>-O;%v+zٴtzg֮s_+*4Zgmvܒ97'2{gw%+EO:H=_ҵoմZ4IDޝɡO|8OLg5&MnZoNm+[v}dܬvٷM]s$3g5$fTkivq묷׫O}UOyui.TgwrA]ުev֪tu7m;TZowT3^NMKZ4Z^VoevovMm8;q^FK 9ʡ9p2t_<]ƃ>g<%uzrdRr)~py7<-GRt b)QQOu)[>Glg=3_R~(%.Wmꖻk[hOZa2T3y'뎾e{^ۻ髶z?i}O[֯ox{1[I{'>#ڪ j:ͥ׃jHxi8&_bjK֎ox+ź|VYhiBpyQYkR,6tƜjܘ{6S_'79a2YÙL]Gsnu ͒s'ϯq뎆߼ݮvnɷf{kdw[+8i{Y]%;%}:׮zgWNVKg-'o[7q-n릋G]UMxsZr宊m-l[66`@ aSpp&6w[t^te:|U'JmFrfY7 bNk0Ζj0a!NxE>m#VEFmIҭ8TI#~ğ? ~[:G<1k^+x}>:Md4;[?,wn㲎Kq0\Lp`>ar<%:ASp:6Jq;+'d9Jt ucgEZ1yTRU+VUӼ;i^][ZxoKlu ^y 5Ksw5̎m dU!XQOd1l$|{}jpJ~zʔWS%%QZZ7|.&9VJ3Z!N5SR|mZ|WZxK[|a֡B狋 PZ_UR7&/<|{ˬO|%˻ML$\iv5 [ oi5;%E ^%`p\.G *3 N2u9ISVu$՜cExm᷇*?ԮJjvIBT^rr.iӡ-Bxs"bs̿88iьߕ7x'7ygipxD-Qk|7=GHAXyt'jcjJ kBrx7f?%Z\׷&ו,O*RB3Ԧ8Jj\N2Nx[YJ^K|Q4ءKY|1yZC3M$,ţw;xTwG[aڒIꨫrƜe~[;֩Rb^eXFc^SJͻ:jfJR Ks۷?=vZ%;woVW\muw^zTls=98&+Je{ZWf}:TZoz&sO_N{sVi[oT[}oE|B $qׯ$_T'd\tnV9o]u(Ȝ~v?nqQdVu>n߮Zj{zSrI3tCVһ4Ēkgu妶޻_KtGc&xg<^T]}VMidkNdZ}mi'M[#ߟϷA듼ey(ZOD";]ݮwս{3{r{όN{i֖v׮ɾw{roٹ^v5`c=220zO|yϧW{h螶I'g}ꮷI+.N rǷ?wj]_K>+v[Tߣ׫Xط^;r:'193霜n/K]ikm-/Қn~׮HO׶8?r;s4I<_mZoњ0}gǯ8y6oۯ3j׻սۏ{joUkւ:H|nwM={vto}oM4BPn.YTI۽_{KY4{]7wmn-};կ{j+W׺ݿF~~Ǿ@xn.F#IKxv.ncGu$mYjK*gJ  r59^#NvJf rOjQabjuToſkJ4oo?O~h)xukγ2IL`.xUBdm|*"URמ#Ӫ5:MJtU7<,۟RUJtUXRw^h(_c𖥩x?ç԰KЙ/o#>KG<^@ &TbXE3(bqP*uSVOޤڝԧ')p. ,˪dZN35U\P-RTzmGԧ)Po?^ O&se/y•l. =FH䌲lX)ԥON>dJZ+I]%քpbS2$*2dcpW)CjSUNHJ_)/|I}f:Rׅ\[=J643D]-˼]H.ZQ=?>b㱳氫 u|JR8RvT԰s:yJP/q>&ao 'dl8PUTO&'YA(ыjZ޹xzf[ZlF>&V@f㽷KI u8wpYJU'ZWT+TחNYJ0<*]o:&iS*~մM'Qһ\iuw[\g|H]v'o>r26ؤ94 4.,TinE*)lDSe&NQ3ZJR#p*|Jʜ);]UT+oEM~|m7ƞ) u˭>kbɥi3dm{w1_Ξ3d9v KkNqMF))^? ɸPêq*2#QjR|wIQYiBqȏlp1m׫vֽwWk=z{^<׾G^s'SwwNٽn;ۮkmCX sluߎhfVW6XMZz-}ZO_Ϲ8~r8皻m-J~-uf֪]-˦x$gӷ'T8ٸz;e'w}D+On{~y LzG{5woVO":T.(:cze]W7w[+%k4~ofvûN8<}s涋Y>s;ڋq]O_zmuO^ǁ^i;?zQ뙫ʹҺ\k[Wߚn~Wޫ\}9# u㩮I=mz5ӺڻMZukݽtKdӲ郎~3=lkeQKOUgӪ^f Ӄ~{jT%dVմwmr'{N]Z.@}G|wN+U{&uhu}^+m}=zG~7-:7t^_Vni[]Z[GuiTC>~(uM'AwkwXZ#ⳆI 6 #iU/|%^5xp \Y C4̳,4֯2\\~1ahՒLUxTisFJU#JQ=K xJ[S0YvW:Qe0V AV{ZI9ƟRdy~~#|dįχum'ƞ0\؝n-JmRhn\ ۳_WopF?V78ɰRq&ccNX\=\^59BK FMG_W5xge9eGjPȲܺjrQ<^2?sYBT?zHO==J=sTj~kh k1]i FHgȩ{Bo|=\= ZjiVU%8*jqnӜR+vY,CBj0SQP+ 8E*I^W=^; x:2w.B_ ɴeFU;}68OC)0שÞCc0Xl21f"NL®*3^rTzRdş<\Cqx,;.EdE*1ҡ8B:&[R%A|#gZχ>zm>)^GB{Yo LF:5(jԌ(Iڝ8˗].2G̅n6nV e14y!|9ө<-h{TaQo~ԣ=UJ92OE-O0.HCQӭz\m PkQ J-EP_|BsƝ}|J jLzy0-<4Z}wq\?gRqn*0`v#Stjnlno%XSsӥ˫hqiŸXJܖ; ?xU +^]&-NUӮ-4ZWrٕӭ .<5<hFU(SehԩWRITNQZm.^_1ƞ38L,7ibsU၅<]?mBdF.ubQ(Ν8Sk?%<=!kN!mWp)B6Ҋoh{yn?"`j\kvv?@J=z{W]zg$gf?;<ϚV+]WgEuͫUg'Qq;9+NTWiwo}tmkk=_E 8fZr(⣯Emuenaikh"9YcJw~imx+[tm٧\Νs98y)MGF׾kͽk$ڳk{w2fOS~C 팴ZWٵo[f=7}V4\ûNz{ Qnҍ-NJR[;5Jɴj僚hZr6@mQn8^W>Xt/nNM4zjݝn{{-;vi/;ji׿UZJi}7뻑t}9V(]unW]WokݛKMoہGN_Ǟ2MmyVu*;پ~^ɚуǨz{z,m]}tM+^dkmmk-;-n_zuQ=pzzVM-vܕwd޷^Gqm[꼴=?^ޭjjXuڣ8C(YP7cvW&Hq nsS8-d*帚fj:ʞ/-NoGcx/wq3Ͱi9a:8Ye+*< ђ-w=|^Z[3Hm-&Ia|aMnkOKlo(+u/ A,MD+oϣ,wfj\Wr3ݥ̥e/ŅGNq }<'§kkV޸7TqHe+O"Ws x{S1am8FNzUo|SI;.NWK8W!VU2\PshQXMFK:c?W/,i%[/K_Je]6ohBm3MԦnZY)6 a3!8f]B/3δp֔iW:pҚpº=ڃj p<&LaRk-/c 5j1URjQC5?^Q$5Z[}nO[$&xYm.#%ِ#m<\nǽV%R:7*:}9ƥK[ZPZYtiWJxR% ~8QQj ijݮʿֿxϏuVo2džu|{HbѴ-ئi`Hig3|?;ve᧖19NqSXZ5=&3 JJ/iRi9wo c62yKSXK .*hN)*qI]A9Y]0C[Sv\\Wq,͖?4*GT2< <5 .rݔȧ'dڒI.g+bkIS%)UJVqvQmI>Uꟶŝ;_|E߄t0既x_M=gqv-YZ%v0"%Gqaٜ*nؼVy&ؚ:r:pqU*Q})Ÿns~i!Vx 6&y~?uΥYPaaΥiRԕ9Jyf׌2źw5@dh|MKJW{ KDPcTϸ:pYq^UߝR^uKW{-)p pCd9F)EIR_ Nwԯ9ރ??ikPmuM^+{.'9LRq.Xܜ ~c_x,JUZ5z۩TrQwm$l r;!V )*8j80>o87ͤ윹ck~oư[ļ gj ^3VܛII3Oۻ^W?t*Z^{n޷~}znzt>KGTi4bwھ}DNN{~4׍z;>^׻wѳN5ggmV`A翯׽Ҿm]m4ֳ{Nv#+dGSWz,uV얝vm67oN]֯{}{Rx I?*\dҎJfݞO[M5{jշlc$;Ǟ;RZjItMwĵѯ=_U]Ii:OQsz{gv]㢿5յuսQ+kYe{-ݴ՝mBu['{ŸZk]ۭw7ufm8 v=ǿnt=s=$I׽][w%{'k﻾Wǎ?SӞO8pNVV֩|ZZjv.6N]Vy/47m`pqL{cfVvVVwV_N]˯OO;u׾8}z\vwV~_>u*|-][[}kqnmnKUi=WpIo'v^nvVPx>s\wj)$ҺR+6i5fMD~mv;릧ſ΁yqn!6%@s_xm[O>n:iY_Z\ƍJ U(MӼ,e/ǵϑio(牵 @?\xt;ָ?i\xvt&or.o O~-?x`|2p|gY. <*5jcFΌ0n\7?$!Z? ~|OX+fY6AY"\Ga2hdXn#K aZn5'<- |#ƾ+-N]:k@R^}ޫ}lɴ ye3Mq2ĘӖ`qNB1*j5:ժ*RiՍ1Q.Y=*yb+VԄZ*JNNTZ*N3a|i}akV:: Il.AC5 īe.eƘOW p4+הo*.N5jQg.k{ g$OPFPnpܹg|M?uK8_xGJJ[Z?Er.q y$kv"kyaQץXڸyM帪z]x(r.umSZZEÞƯSi*xxT9i질w.i'eJsɻH~ʫsG5~mxxOw&mGa.:Mʺ|P6P"(xXz?XU OoWQTڪ1U)ʝIxW5UQUG,=:=)ʕ:O 9^4Zom 6{ⷷ˔/įm4Cͤ h`nRy1~,1IiRҎ3kGYV~? xl9(JKv tOt;>;5'>0t[ķW!ӯlnmN r!HoGfq)ن/B7OJW('XP?q==.Lܟ&c~w^EO#(ԯ>zRQnKZL: ma|{jj:׌tK Q,Ǜk{z&9-$ID &'U)Uj +Nq0N)~MER`K5ХVʔqJ57OQU.J [_^w{W^SuCWZl^$uV҃ah:12;ISY PiC2v9#E673nO)mC1VjFeS>TFuRQ|ǯ^.ie{UzҾMӧq׮Gr8=yx/[.[&fuu$Bm#}?:ryWmn֛{=[;6=}LǷL`>96}/t[rM.M|ҿm/g8vn.EմagTնM >p2Nszӊwm{i~ȒgmV4z^nfJ?A7il|W&K4WT\{q>ܚRM2wz&~+]5$+k[~p` ^׊3K;^Wyފe5mזo8$l{^zU.vޭwzw7nNIqӿ5+6+Jmn)J^'~oqO;>'kJ;i;tݷ-E{k쓹.q#?3zVR ]lgeo[}u-}zz5]5b;O\x Dzǃ5ַQ|*nlZ2MjqfH`7%gu5%W3ԧ&]7Q啚V|oX Lh:nTùu (F|nݚ ~ƾ QMN}Z=&}[wҭ"uM>kˋxe3<5*P8Ix<:dydUqp[:9BS{ӣJ5)U{JkSQr?,,1&-ݼdJm 8z*ӋrjZN;)ihݵOh..߱#0r3q_(Q11չv8\%+^~k_|&Rm9e#)_W'J{'eAtM3c^EGO < M_kl,六 _5xY+]߭촻֚݊_^_| _ |?%WW\\4fV`qWz]} <V䋩Qʤ˫oOz~h֟ KZ왤> ^!ž#ͺN>%[K}^ Os_'3'縘br< N2*񋜰}X{ZUn$ڕJ0QRj?&rr>"&2:TqX]YhS&4rrڟ:⍎tZ [_^yڂ2lRHrBj9CZpi!`cZzj4⮣&S)7wk5%Mӧ,3UgNT7ng%vNO_Y x a&s$o*^IK!RW-WR-~Ζl+Z׵aj~Wa};g$ 5ů;a fv;,卤w*Hʦ:R_eZKYȽ2#'q.fQ–Fi>w(M򤠢*s༟:/3խ[JZ SR\%75)TSI%X'Ŀh< Zn}GԵ}Jᮦy U4cKu4q!Kğѕl,o\c!b*Unq9GF:JGXq9YU''C JJS[ *xz_,UY\)NSwjG:EѾ+_~$EnDŽ{o>,_Brh߈mXh$<Ejh)e?2d ѼN2I{RrѷZ崲̒#MSb ZI;bqYA.ZQ}?R7`y4I]O_eK壺n(;ݻ_K4l:^6~>i;^_kZ>Vw1oC?'=^[zѫۭ[Ӯz ׌\Q7;^+Vmm_7Kj n-}߳G8}i;ͻnvY^W݃z]j_zkf*L~Nj1FJM]uK[ꝝ[ u뭯Odrs'=N?LUgk6Mݾ]w뾩ݓwow{˸גzqc565[4wV+N^?wr3q˯5uݥm{ݶvu}]8k<; _{UnwQgY6dmݬktݾm}3?\/I'tޭk${_i{isv\ۯY4_.[2ZWmvV宺sN봝MM%z=Kmukgօ#㯮:ckIխܭw/@4^g=6ƬR8<TӵMw{.[ܤ;ٷn7~ksӌ{=Mc$)-UیZM-EФۺw~vv?|7XKIk0rqyW%<:xTNOoGkVʤdq4浾ot={E?|+4MJ/? ѵ;6^ 04@4_φ#T_yjꤠzT_6{虜JdArN~M˕MWBJzYo:O d\gyh>u9NP-u"Osf$,\, p2u(ԎRՠkJEIS6XRged7A^08GIՕ9Jxi:mZSu%MϚjTIqNgCb Ab`>Rvz'$JnѴ]5jkv&)VN3473ITOYE)u咳Z/e'/ݦeWW^G W)i{>cN{I#M7ŶzE+F,82lҥ')U˪(8ݾJ5a nSRKOy^/F" 2ӍJkENUV3Œ]Drj%|7g|g?WS ƞ3tCfs_.Fw柫i׺F-\qXIŇlb+ԡORIFtQnIsiv?܎0_G,~Wu,Zsνt(b0/OPJ5C.dޓ FKoŖگojmWIxSK3V1YvBI j:()JJr`Þa TA2QPT\R|39vEp Ux(SskVa5a?m=Lg,jWQjU%cۚ fx'׉OxWZuF.a_.\1,v (F!h6*VTaMkXz^qU֯VUq2F*GNITܛ# MjJ u=@+ןWMJVmGmu뵬ZW~Fݳ#q!T;3=j[;WnSj=9_j~=޷麿WWv֮M-7MMx[|2?CYIgmdZZϽyZZڽH['2rn_59YiMy edM_Gz_SENq{~GYGwtq|ϵ]";i˽wE<G$2J= ~=x<թrJI7{]o꺻[++=[GwE?O(W™4m: oQtDv>d.g|!A q1!WjTΩ>8!ҡGFPg&ײ-ɾGv?L1 ~J^ksbr}^pDB9lh3C1躏|e/iVwm]S_dq0-ZVmGs%6҉&r"9Ĩg&2],V oQ|?"^>¤(OjEJvl#С,pUX*bI3z5)S\JMՃsIma1i6V u 7F6.n$%G*YVW4Mi*+?<1wþ PVMnYԕ'4z;'gO|j~ Ǐ4 Ş+RG-oYcYa|U%#$C)x<Vڦ6Ɵ(n[OyL ֽ9VHzj5~2|Iٯ!xi:!U|(# Mʜj~\ɏo\(5'k]+Ͷb-m Ss4aeExgԥOь~*rsPqj1V?,iE9RjgWڪvӑ9JqIy+6 Ӳ7ſ x~/ӊ|]? V2!xi7IqejwRgq6at?^ӡ^*NpMFR=9Sϼ~$|5y6~fYqYЯJj1yF9d)SViL`|:xw_2MjPZ< TJHNv>$3.#*y[b8j|ю' Ztkre|Z3,s噾[Q/̰8\' K N'(JIS8Owfz7KMltN{ub9?^S2Wo[KD;^~݃V蝹ߥ^;?\*ҿ4t]>z]l[_;o} u8ޡMhO߯Qj}߿[=o-#v'OոK+y]~-mmߩ<җ,nm$kz'n{j[mH$ϡV}ϲVԶ-F익l͞^r}5 J+}Ok[ݚ9fzn__-b.\OqׁN}kUJ.Wi4~ֻjWuջi~[h\۩=;j\uwtwM,m$ޖ\׾W}{ytvOl?NJ]r+].fәGumrNd+QwVki6ַ.ms-S]֚>ڻY} |y?Ѷ벴tQgm{-fN1p{mJVvJ_յ]<5cn2ZƥKTm-Q{kuDҵ}]NƔmgz<%ʜW[I>Jm'm_}[z{3C1K=;_M]Sz'l{v޾D`־xwcVu_ Oi[x.5j ikg$W2XܻLSi'͸W8٦/eyCRTxk)B%ZM.#7s,>[S/PƬV]:_+TS2?3p/Vɩx>XE 9]bY^Mix X,/Q l4mO3)a<(St#P)Q j7QNtԥN0l7XTj՝]Ξ"jNYrqRWJZQ拌?9>;X|A#Am+7wsDs B[G|#K|q>QKf(, Bb4… ܭ^+RjG _8ƌ18lYj3 EJ/ my_+FN骉/߅֋5|<mG9tM_UMwT_zޟ]hWsLuHfhoh1\ۍq1p .{<. K SWqQu*_v ӊ+Wԭ]b3o*T _. 94%/u+O Z鷗߆ӛ]W񵷈u]RHi|A ψ,VHos#)<ʅ&'ujP)}K8a$ΝWW[K)Ud8¬MK4hXD!z0.aQJIũa>x<ҙ_fҮxO.<3iR?8\˧:n ҭmpVγY$~'8,VIZ3W]owok{u o~m;_vM{ٽS^{okiO}]_C?[z?My5&mRD/Vߓm}-?=ݒoY$Ųntzw]nץ?ϡQA=s=S>j髧fSv{Mٻ7]ov֚|%nu~[vkkvZ=[ktխ}x?F[WM8Yחg֛jՙ{u3gnQ>K'[-F3Z/[ﻵpy<׿ӞFR-ztMi[w}tk;+}U<B5wzy}l}}e7ުZo{&tI'>?ǜp/򿔖}W]ߝwmn{]MwFp϶yoL:.hdV]}5{ٍ}M+$iin[9$coӹׯa+;]NGʕmt_o$ <=vwOwmKTw}nS{[^W}uas}zssYN.ҺWM9z5gvRZګ]J*Ǽ3^$p2yWVU^p|*ZRK)νǞ5| j&~!uմ]*x5H.aצ?+Lտ#pٍ>y`kLa:Н* N8YU(PMe$f{9.qRɲq,ҾaK*f.P15*B\h(R(?n-cTxsĒOyE]z4XS xcE1RB:4M~N.UcVφb*'l%Iw,ci)SN>kbbqN'HbVe^TIs4rs/ RҼ_xTKRk :O}29нܷs(>7gEl> kO,Kt}Nl5e:*1TQM*uTh”=GxG"'yfXcn":XrC УZ5*)?=~A%=uGZ=miɴc=>ݖOhzݮ-_>}:<}3EnkjN$礷ko}5 'ۦ9[;dջi~gշ|^~^wԤx;Y4w{ڶ6t+[Zտ4PI>s'ѧ]Y;_^utۿ_k[Zuʝn?'ky?7֝SzKM^wٽfEw=Ok0\۴mKevmw9Zom;kԇ s׷o\cjwrjVݭͻ_дٵh]u{I8r8}ANҩE5n}vM>[w}[Mo}5lퟧF}u5%w'Y۷M,jE/]t-{.Mt= sgׯ~v%keJN[Gugv`ivkhWvvj[=u{n?qg8XI^J;h.g_ }5UцLS܂ U٫=}笥-v孑=]vݴwjDtOS׎=W3ZMY]-z]2W{ۣk"Ԡ{ o 4m,QH37{{ai`<+!rer~_Jp¦bƜN|ъ|ғm.V '6VXj կ^g`{j1iK97m9F_e(fOYx:׆+sZ?H^""q@tazY~xK=yR8(W0mNzTJNqp Fw>]Q G7x"u2ʝSWqgRXsR#Z2Z*7jpHNmdO:>5ާqI>PGe9/,5-rnK [9߄y ) XU}S5\𲕗R4k26ibrϞ; },NvixD#unm/"xw>1< MK~$3a1Ue_ iPnbxzP]I;~~4x3oowɭxCSӼWZnM+Zam yEҾ3FXlZ t䪺Pvn)&nT>[4UVxnʤrJ.RK[#"7!y1ut01 #yD9BtWiS唡MSkKNNm^.^e,LSIïk5QUl5u6)F_i'ykkm-yޫ]oVZze9}=*-+vݕ4nvdݷ}yА:dB:m.}>W7f߯K[w7NO\.$S oIi;W]o6;%]z-.Vz@'6z|z'%kךV['WUmmerr9_+JmڶiZk{.in=^+1mjWnܱ)IV׮O߲GoGg?z߄ .MgR&{wCoM"GU̱`rAu+*:թ9e-e^sY4?q_H a)ƏaT!?b̧/ig|,m)STz;z b_|2Cp|`e=a],X՞]`ֽ_2䖊=yKr?ۉyi WVN-)] !|"ӗx9'<K#iC.q\l],. evҌ_[s=}Zq!}%5sQ\JYF9_{;<>mkI__u9r-8~|N}9?E~+vJ2(|-s/>M{tA/׆{.#mrW?bێ~7n@:c Nk7~3~ kGcÃ><'E'w,gVw{P{vwZI *5&SmOM]kYL?L_)3Bao3q0 x @סH$fbE53 >I_"xgVUie{J]4pf?IRΛ"yz~g:V%k)E|g[ yaoϾ eǟ61q;{jNp q9'L>KJ̧)˒WT!Uj۩)I;~_5*.JRX;ܥbiSW%>ߴp}ygf)cGRAѦerCY@;0焋WIBooʒzZ}Z>34;,-or *YlwgF~ÿEj߳7)nqgz#}|*0O9[ρ9YfiO8ii9=-mO"aw m%ٵe/_߂A~m?~YA)%Aneɇ [Kx#0m )^Ŵ˖sy6# }ef+mj_q !dy&0_%R+ ^62Vana_EŦVW\~d݉CAivÝX[]u-ݣ%;K[7+#p[sI5lZqk5-%$RJmo͏kMWJtChhnMMm8Կ?.X| 74l#x=,b, ᑀ_-yu8Sʦ 6{EնIu Fs+?wu{ݽ>!j~~jxSc9REmS, +h-$.>VA*)#zS7_8v[fR%kg?(ZI'fY_% NQ}לV>|G(I]+y+ u/,Ie,IHi$rű )v H|蛞bkp+Ԟ\LeF̩eKIͮ[kϤ):2)SֆM]Bxs|mlH_P>?xP?}A'F3<_)\Tܟ[=ycջ=ӿ&éjGVeG:Y\g;+ H¤<{B\fK7eݛeUA˿~c~(kV:VW&G§>NA9Ӯz/Wd.M٫^zk&Í_it]WNO&7X㑟}19X;;ĭ4q:tW{ns2%-}Gw%41ivcu@"! #E[{ޏ>앓v ܘl)BP?5N9ԽJpR3Te62M$Zikm'ÅC})2m{,˵A T^zJ0ZWc+9%SmdKTkHe(aܝI*RS(Ԝ5bI{Wz;kΝ7Ѭz-rOE?f g we$,$liyZg{*/hyA/ze۷W(>$ۚ&qu=O/Nӫ Utۚ){B]G=7e~҅ѿ-}?XkjV{?i ?6u>oZ~ΧO ?-i Z7_m3_;G_(iZ~BkG L=}9vbuKM tJߪ]WgΒx:r/rrzc<<ոO'߯/mE/b޺6姖OdeOٶO=E׍4meYKR_*9k<c*i:34TI9]ҭ\Һ]߃MBۓT.ǥiu,L}hW,d.<϶}ggew),pX,^׃)rIMVWWMzr񰬢IEbĜY*/JWm!X>3\֨3k0,<|lX3DB!a|rC Ve;(+I9SN?{k;.*n.ғ(KU9e (ErZ{:i5%%fQĽ*=-\\y ]~#sPm* `wUN7nw+EsYZXVo IBNU$e*M-R CGχ4l+1kU|Ći$:z5{VJumZ(Nx|Vت1˳{4-9IßNxHxd|#_[Y=^X?u/.]~Zd? |g͙$,eu{N뾤SnWfݷw "u7uc֣>ؒ[4_~U{j8mj~AO}K ? Wt?>swEdX^U'!J-}??ˠׇߋI9!Ofxߞg৆ ռTH/h7~,,9M/W3Gh pZYb7g}[ƶމ]Ho9q-mwŶo[׮_Jwo'tmw#?,1A. namespace aiprovider_azureai; use core_ai\aiactions\base; use core_ai\manager; use GuzzleHttp\Psr7\Response; /** * Test Generate text provider class for azureai provider methods. * * @package aiprovider_azureai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \aiprovider_azureai\provider * @covers \aiprovider_azureai\process_generate_text * @covers \aiprovider_azureai\abstract_processor */ final class process_generate_text_test extends \advanced_testcase { /** @var string A successful response in JSON format. */ protected string $responsebodyjson; /** @var manager $manager */ private manager $manager; /** @var provider The provider that will process the action. */ protected provider $provider; /** @var base The action to process. */ protected base $action; /** * Set up the test. */ protected function setUp(): void { parent::setUp(); $this->resetAfterTest(); // Load a response body from a file. $this->responsebodyjson = file_get_contents(self::get_fixture_path('aiprovider_azureai', 'text_request_success.json')); $this->create_provider(); $this->create_action(); } /** * Create the provider object. */ private function create_provider(): void { $this->manager = \core\di::get(\core_ai\manager::class); $config = [ 'apikey' => '123', 'endpoint' => 'https://api.example.com', 'enableuserratelimit' => true, 'userratelimit' => 1, 'enableglobalratelimit' => true, 'globalratelimit' => 1, ]; $this->provider = $this->manager->create_provider_instance( classname: '\aiprovider_azureai\provider', name: 'dummy', config: $config, ); } /** * Create the action object. * * @param int $userid The user id to use in the action. */ private function create_action(int $userid = 1): void { $this->action = new \core_ai\aiactions\generate_text( contextid: 1, userid: $userid, prompttext: 'This is a test prompt', ); } /** * Test create_request_object */ public function test_create_request_object(): void { $processor = new process_generate_text($this->provider, $this->action); // We're working with a private method here, so we need to use reflection. $method = new \ReflectionMethod($processor, 'create_request_object'); $request = $method->invoke($processor, 1); $body = (object) json_decode($request->getBody()->getContents()); $this->assertEquals('This is a test prompt', $body->messages[1]->content); $this->assertEquals('user', $body->messages[1]->role); } /** * Test the API error response handler method. */ public function test_handle_api_error(): void { $responses = [ 500 => new Response(500, ['Content-Type' => 'application/json']), 503 => new Response(503, ['Content-Type' => 'application/json']), 401 => new Response( 401, ['Content-Type' => 'application/json'], json_encode(['error' => ['message' => 'Invalid Authentication']]), ), 404 => new Response( 404, ['Content-Type' => 'application/json'], json_encode(['error' => ['message' => 'You must be a member of an organization to use the API']]), ), 429 => new Response( 429, ['Content-Type' => 'application/json'], json_encode(['error' => ['message' => 'Rate limit reached for requests']]), ), ]; $processor = new process_generate_text($this->provider, $this->action); $method = new \ReflectionMethod($processor, 'handle_api_error'); foreach ($responses as $status => $response) { $result = $method->invoke($processor, $response); $this->assertEquals($status, $result['errorcode']); if ($status == 500) { $this->assertEquals('Internal Server Error', $result['errormessage']); } else if ($status == 503) { $this->assertEquals('Service Unavailable', $result['errormessage']); } else { $this->assertStringContainsString($response->getBody()->getContents(), $result['errormessage']); } } } /** * Test the API success response handler method. */ public function test_handle_api_success(): void { $response = new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson ); // We're testing a private method, so we need to setup reflector magic. $processor = new process_generate_text($this->provider, $this->action); $method = new \ReflectionMethod($processor, 'handle_api_success'); $result = $method->invoke($processor, $response); $this->assertTrue($result['success']); $this->assertEquals('chatcmpl-9ooaXlMSUIhOkd2pfxKBgpipMynkX', $result['id']); $this->assertEquals('fp_abc28019ad', $result['fingerprint']); $this->assertStringContainsString('Sure, I\'m here to help', $result['generatedcontent']); $this->assertEquals('stop', $result['finishreason']); $this->assertEquals('12', $result['prompttokens']); $this->assertEquals('14', $result['completiontokens']); $this->assertEquals('gpt-4o-2024-05-13', $result['model']); } /** * Test query_ai_api for a successful call. */ public function test_query_ai_api_success(): void { // Mock the http client to return a successful response. ['mock' => $mock] = $this->get_mocked_http_client(); // The response from Azure AI. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $processor = new process_generate_text($this->provider, $this->action); $method = new \ReflectionMethod($processor, 'query_ai_api'); $result = $method->invoke($processor); $this->assertTrue($result['success']); $this->assertEquals('chatcmpl-9ooaXlMSUIhOkd2pfxKBgpipMynkX', $result['id']); $this->assertEquals('fp_abc28019ad', $result['fingerprint']); $this->assertStringContainsString('Sure, I\'m here to help', $result['generatedcontent']); $this->assertEquals('stop', $result['finishreason']); $this->assertEquals('12', $result['prompttokens']); $this->assertEquals('14', $result['completiontokens']); $this->assertEquals('gpt-4o-2024-05-13', $result['model']); } /** * Test prepare_response success. */ public function test_prepare_response_success(): void { $processor = new process_generate_text($this->provider, $this->action); // We're working with a private method here, so we need to use reflection. $method = new \ReflectionMethod($processor, 'prepare_response'); $response = [ 'success' => true, 'id' => 'chatcmpl-9lkwPWOIiQEvI3nfcGofJcmS5lPYo', 'fingerprint' => 'fp_c4e5b6fa31', 'generatedcontent' => 'Sure, here is some sample text', 'finishreason' => 'stop', 'prompttokens' => '11', 'completiontokens' => '14', 'model' => 'gpt-4o', ]; $result = $method->invoke($processor, $response); $this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result); $this->assertTrue($result->get_success()); $this->assertEquals('generate_text', $result->get_actionname()); $this->assertEquals($response['success'], $result->get_success()); $this->assertEquals($response['generatedcontent'], $result->get_response_data()['generatedcontent']); $this->assertEquals($response['model'], $result->get_response_data()['model']); } /** * Test prepare_response error. */ public function test_prepare_response_error(): void { $processor = new process_generate_text($this->provider, $this->action); // We're working with a private method here, so we need to use reflection. $method = new \ReflectionMethod($processor, 'prepare_response'); $response = [ 'success' => false, 'errorcode' => 500, 'errormessage' => 'Internal server error.', ]; $result = $method->invoke($processor, $response); $this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result); $this->assertFalse($result->get_success()); $this->assertEquals('generate_text', $result->get_actionname()); $this->assertEquals($response['errorcode'], $result->get_errorcode()); $this->assertEquals($response['errormessage'], $result->get_errormessage()); } /** * Test process method. */ public function test_process(): void { // Log in user. $this->setUser($this->getDataGenerator()->create_user()); // Mock the http client to return a successful response. ['mock' => $mock] = $this->get_mocked_http_client(); // The response from Azure AI. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $processor = new process_generate_text($this->provider, $this->action); $result = $processor->process(); $this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result); $this->assertTrue($result->get_success()); $this->assertEquals('generate_text', $result->get_actionname()); } /** * Test process method with error. */ public function test_process_error(): void { // Log in user. $this->setUser($this->getDataGenerator()->create_user()); // Mock the http client to return an usuccessful response. ['mock' => $mock] = $this->get_mocked_http_client(); // The response from Azure AI. $mock->append(new Response( 401, ['Content-Type' => 'application/json'], json_encode(['error' => ['message' => 'Invalid Authentication']]), )); $processor = new process_generate_text($this->provider, $this->action); $result = $processor->process(); $this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result); $this->assertFalse($result->get_success()); $this->assertEquals('generate_text', $result->get_actionname()); $this->assertEquals(401, $result->get_errorcode()); $this->assertEquals('Invalid Authentication', $result->get_errormessage()); } /** * Test process method with user rate limiter. */ public function test_process_with_user_rate_limiter(): void { // Create users. $user1 = $this->getDataGenerator()->create_user(); $user2 = $this->getDataGenerator()->create_user(); // Log in user1. $this->setUser($user1); // Mock clock. $clock = $this->mock_clock_with_frozen(); // Set the user rate limiter. $config = [ 'apikey' => '123', 'endpoint' => 'https://api.example.com', 'enableuserratelimit' => true, 'userratelimit' => 1, ]; $actionconfig = [ 'core_ai\\aiactions\\generate_text' => [ 'enabled' => true, 'settings' => [ 'deployment' => 'test', 'apiversion' => '2024-06-01', 'systeminstruction' => '', ], ], ]; $provider = $this->manager->create_provider_instance( classname: provider::class, name: 'dummy', config: $config, ); $provider = $this->manager->update_provider_instance( provider: $provider, actionconfig: $actionconfig, ); // Mock the http client to return a successful response. ['mock' => $mock] = $this->get_mocked_http_client(); // Case 1: User rate limit has not been reached. $this->create_action($user1->id); // The response from Azure AI. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $processor = new process_generate_text($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); // Case 2: User rate limit has been reached. $clock->bump(HOURSECS - 10); // The response from Azure AI. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $this->create_action($user1->id); $processor = new process_generate_text($provider, $this->action); $result = $processor->process(); $this->assertEquals(429, $result->get_errorcode()); $this->assertEquals('User rate limit exceeded', $result->get_errormessage()); $this->assertFalse($result->get_success()); // Case 3: User rate limit has not been reached for a different user. // Log in user2. $this->setUser($user2); $this->create_action($user2->id); // The response from Azure AI. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $processor = new process_generate_text($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); // Case 4: Time window has passed, user rate limit should be reset. $clock->bump(11); // Log in user1. $this->setUser($user1); // The response from Azure AI. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $this->create_action($user1->id); $processor = new process_generate_text($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); } /** * Test process method with global rate limiter. */ public function test_process_with_global_rate_limiter(): void { // Create users. $user1 = $this->getDataGenerator()->create_user(); $user2 = $this->getDataGenerator()->create_user(); // Log in user1. $this->setUser($user1); // Mock clock. $clock = $this->mock_clock_with_frozen(); // Set the global rate limiter. $config = [ 'apikey' => '123', 'endpoint' => 'https://api.example.com', 'enableglobalratelimit' => true, 'globalratelimit' => 1, ]; $actionconfig = [ 'core_ai\\aiactions\\generate_text' => [ 'enabled' => true, 'settings' => [ 'deployment' => 'test', 'apiversion' => '2024-06-01', 'systeminstruction' => '', ], ], ]; $provider = $this->manager->create_provider_instance( classname: provider::class, name: 'dummy', config: $config, ); $provider = $this->manager->update_provider_instance( provider: $provider, actionconfig: $actionconfig, ); // Mock the http client to return a successful response. ['mock' => $mock] = $this->get_mocked_http_client(); // Case 1: Global rate limit has not been reached. $this->create_action($user1->id); // The response from Azure AI. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $processor = new process_generate_text($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); // Case 2: Global rate limit has been reached. $clock->bump(HOURSECS - 10); // The response from Azure AI. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $this->create_action($user1->id); $processor = new process_generate_text($provider, $this->action); $result = $processor->process(); $this->assertEquals(429, $result->get_errorcode()); $this->assertEquals('Global rate limit exceeded', $result->get_errormessage()); $this->assertFalse($result->get_success()); // Case 3: Global rate limit has been reached for a different user too. // Log in user2. $this->setUser($user2); $this->create_action($user2->id); // The response from Azure AI. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $processor = new process_generate_text($provider, $this->action); $result = $processor->process(); $this->assertFalse($result->get_success()); // Case 4: Time window has passed, global rate limit should be reset. $clock->bump(11); // Log in user1. $this->setUser($user1); // The response from Azure AI. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $this->create_action($user1->id); $processor = new process_generate_text($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); } } PK*\Vpdd6provider/azureai/tests/process_generate_image_test.phpnu[. namespace aiprovider_azureai; use core_ai\aiactions\base; use core_ai\manager; use GuzzleHttp\Psr7\Response; /** * Test response_base Azure AI provider methods. * * @package aiprovider_azureai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \aiprovider_azureai\process_generate_image */ final class process_generate_image_test extends \advanced_testcase { /** @var string A successful response in JSON format. */ protected string $responsebodyjson; /** @var manager $manager */ private manager $manager; /** @var provider The provider that will process the action. */ protected provider $provider; /** @var base The action to process. */ protected base $action; /** * Set up the test. */ protected function setUp(): void { parent::setUp(); $this->resetAfterTest(); // Load a response body from a file. $this->responsebodyjson = file_get_contents(self::get_fixture_path('aiprovider_azureai', 'image_request_success.json')); $this->create_provider(); $this->create_action(); } /** * Create the provider object. */ private function create_provider(): void { $this->manager = \core\di::get(\core_ai\manager::class); $config = [ 'apikey' => '123', 'endpoint' => 'https://api.example.com', 'enableuserratelimit' => true, 'userratelimit' => 1, 'enableglobalratelimit' => true, 'globalratelimit' => 1, ]; $this->provider = $this->manager->create_provider_instance( classname: '\aiprovider_azureai\provider', name: 'dummy', config: $config, ); } /** * Create the action object. * * @param int $userid The user id to use in the action. */ private function create_action(int $userid = 1): void { $this->action = new \core_ai\aiactions\generate_image( contextid: 1, userid: $userid, prompttext: 'This is a test prompt', quality: 'hd', aspectratio: 'square', numimages: 1, style: 'vivid', ); } /** * Test calculate_size. */ public function test_calculate_size(): void { $processor = new process_generate_image($this->provider, $this->action); // We're working with a private method here, so we need to use reflection. $method = new \ReflectionMethod($processor, 'calculate_size'); $ratio = 'square'; $size = $method->invoke($processor, $ratio); $this->assertEquals('1024x1024', $size); $ratio = 'portrait'; $size = $method->invoke($processor, $ratio); $this->assertEquals('1024x1792', $size); $ratio = 'landscape'; $size = $method->invoke($processor, $ratio); $this->assertEquals('1792x1024', $size); } /** * Test create_request_object */ public function test_create_request_object(): void { $processor = new process_generate_image($this->provider, $this->action); // We're working with a private method here, so we need to use reflection. $method = new \ReflectionMethod($processor, 'create_request_object'); $request = $method->invoke($processor, 1); $requestdata = (object) json_decode($request->getBody()->getContents()); $this->assertEquals('This is a test prompt', $requestdata->prompt); $this->assertEquals('1', $requestdata->n); $this->assertEquals('hd', $requestdata->quality); $this->assertEquals('1024x1024', $requestdata->size); } /** * Test the API error response handler method. */ public function test_handle_api_error(): void { $responses = [ 500 => new Response(500, ['Content-Type' => 'application/json']), 503 => new Response(503, ['Content-Type' => 'application/json']), 401 => new Response(401, ['Content-Type' => 'application/json'], '{"error": {"message": "Invalid Authentication"}}'), 404 => new Response(404, ['Content-Type' => 'application/json'], '{"error": {"message": "You must be a member of an organization to use the API"}}'), 429 => new Response(429, ['Content-Type' => 'application/json'], '{"error": {"message": "Rate limit reached for requests"}}'), ]; $processor = new process_generate_image($this->provider, $this->action); $method = new \ReflectionMethod($processor, 'handle_api_error'); foreach ($responses as $status => $response) { $result = $method->invoke($processor, $response); $this->assertEquals($status, $result['errorcode']); if ($status == 500) { $this->assertEquals('Internal Server Error', $result['errormessage']); } else if ($status == 503) { $this->assertEquals('Service Unavailable', $result['errormessage']); } else { $this->assertStringContainsString($response->getBody()->getContents(), $result['errormessage']); } } } /** * Test the API success response handler method. */ public function test_handle_api_success(): void { $response = new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson ); // We're testing a private method, so we need to setup reflector magic. $processor = new process_generate_image($this->provider, $this->action); $method = new \ReflectionMethod($processor, 'handle_api_success'); $result = $method->invoke($processor, $response); $this->stringContains('An image that represents the concept of a \'test\'.', $result['revisedprompt']); $this->stringContains('oaidalleapiprodscus.blob.core.windows.net', $result['sourceurl']); } /** * Test query_ai_api for a successful call. */ public function test_query_ai_api_success(): void { // Mock the http client to return a successful response. ['mock' => $mock] = $this->get_mocked_http_client(); // The response from Azure AI. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $mock->append(new Response( 200, ['Content-Type' => 'image/jpeg'], \GuzzleHttp\Psr7\Utils::streamFor(fopen( self::get_fixture_path('aiprovider_azureai', 'test.jpg'), 'r', )), )); $this->setAdminUser(); $processor = new process_generate_image($this->provider, $this->action); $method = new \ReflectionMethod($processor, 'query_ai_api'); $result = $method->invoke($processor); $this->stringContains('An image that represents the concept of a \'test\'.', $result['revisedprompt']); $this->stringContains('oaidalleapiprodscus.blob.core.windows.net', $result['sourceurl']); } /** * Test prepare_response success. */ public function test_prepare_response_success(): void { $processor = new process_generate_image($this->provider, $this->action); // We're working with a private method here, so we need to use reflection. $method = new \ReflectionMethod($processor, 'prepare_response'); $response = [ 'success' => true, 'revisedprompt' => 'An image that represents the concept of a \'test\'.', 'imageurl' => 'oaidalleapiprodscus.blob.core.windows.net', ]; $result = $method->invoke($processor, $response); $this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result); $this->assertTrue($result->get_success()); $this->assertEquals('generate_image', $result->get_actionname()); $this->assertEquals($response['success'], $result->get_success()); $this->assertEquals($response['revisedprompt'], $result->get_response_data()['revisedprompt']); } /** * Test prepare_response error. */ public function test_prepare_response_error(): void { $processor = new process_generate_image($this->provider, $this->action); // We're working with a private method here, so we need to use reflection. $method = new \ReflectionMethod($processor, 'prepare_response'); $response = [ 'success' => false, 'errorcode' => 500, 'errormessage' => 'Internal server error.', ]; $result = $method->invoke($processor, $response); $this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result); $this->assertFalse($result->get_success()); $this->assertEquals('generate_image', $result->get_actionname()); $this->assertEquals($response['errorcode'], $result->get_errorcode()); $this->assertEquals($response['errormessage'], $result->get_errormessage()); } /** * Test url_to_file. */ public function test_url_to_file(): void { // Log in user. $this->setUser($this->getDataGenerator()->create_user()); $processor = new process_generate_image($this->provider, $this->action); // We're working with a private method here, so we need to use reflection. $method = new \ReflectionMethod($processor, 'url_to_file'); $contextid = 1; $url = $this->getExternalTestFileUrl('/test.jpg', false); $fileobj = $method->invoke($processor, $contextid, $url); $this->assertEquals('user', $fileobj->get_component()); $this->assertEquals('draft', $fileobj->get_filearea()); } /** * Test process. */ public function test_process(): void { // Log in user. $this->setUser($this->getDataGenerator()->create_user()); // Mock the http client to return a successful response. ['mock' => $mock] = $this->get_mocked_http_client(); $url = 'https://example.com/test.jpg'; // The response from Azure AI. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], json_encode([ 'created' => 1719140500, 'data' => [ (object) [ 'revised_prompt' => 'An image that represents the concept of a \'test\'.', 'url' => $url, ], ], ]), )); // The image downloaded from the server successfully. $mock->append(new Response( 200, ['Content-Type' => 'image/jpeg'], \GuzzleHttp\Psr7\Utils::streamFor(fopen(self::get_fixture_path('aiprovider_azureai', 'test.jpg'), 'r')), )); // Create a request object. $contextid = 1; $userid = 1; $prompttext = 'This is a test prompt'; $aspectratio = 'square'; $quality = 'hd'; $numimages = 1; $style = 'vivid'; $this->action = new \core_ai\aiactions\generate_image( contextid: $contextid, userid: $userid, prompttext: $prompttext, quality: $quality, aspectratio: $aspectratio, numimages: $numimages, style: $style, ); $processor = new process_generate_image($this->provider, $this->action); $result = $processor->process(); $this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result); $this->assertTrue($result->get_success()); $this->assertEquals('generate_image', $result->get_actionname()); $this->assertEquals('An image that represents the concept of a \'test\'.', $result->get_response_data()['revisedprompt']); $this->assertEquals($url, $result->get_response_data()['sourceurl']); } /** * Test process method with error. */ public function test_process_error(): void { // Log in user. $this->setUser($this->getDataGenerator()->create_user()); // Mock the http client to return a successful response. ['mock' => $mock] = $this->get_mocked_http_client(); // The response from Azure AI. $mock->append(new Response( 401, ['Content-Type' => 'application/json'], json_encode(['error' => ['message' => 'Invalid Authentication']]), )); $processor = new process_generate_image($this->provider, $this->action); $result = $processor->process(); $this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result); $this->assertFalse($result->get_success()); $this->assertEquals('generate_image', $result->get_actionname()); $this->assertEquals(401, $result->get_errorcode()); $this->assertEquals('Invalid Authentication', $result->get_errormessage()); } /** * Test process method with user rate limiter. */ public function test_process_with_user_rate_limiter(): void { // Create users. $user1 = $this->getDataGenerator()->create_user(); $user2 = $this->getDataGenerator()->create_user(); // Log in user1. $this->setUser($user1); // Mock clock. $clock = $this->mock_clock_with_frozen(); // Set the user rate limiter. $config = [ 'apikey' => '123', 'endpoint' => 'https://api.example.com', 'enableuserratelimit' => true, 'userratelimit' => 1, ]; $actionconfig = [ 'core_ai\\aiactions\\generate_image' => [ 'enabled' => true, 'settings' => [ 'deployment' => 'test', 'apiversion' => '2024-06-01', ], ], ]; $provider = $this->manager->create_provider_instance( classname: provider::class, name: 'dummy', config: $config, ); $provider = $this->manager->update_provider_instance( provider: $provider, actionconfig: $actionconfig, ); // Mock the http client to return a successful response. ['mock' => $mock] = $this->get_mocked_http_client(); $url = 'https://example.com/test.jpg'; // Case 1: User rate limit has not been reached. $this->create_action($user1->id); // The response from Azure AI. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], json_encode([ 'created' => 1719140500, 'data' => [ (object) [ 'revised_prompt' => 'An image that represents the concept of a \'test\'.', 'url' => $url, ], ], ]), )); // The image downloaded from the server successfully. $mock->append(new Response( 200, ['Content-Type' => 'image/jpeg'], \GuzzleHttp\Psr7\Utils::streamFor(fopen(self::get_fixture_path('aiprovider_azureai', 'test.jpg'), 'r')), )); $processor = new process_generate_image($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); // Case 2: User rate limit has been reached. $clock->bump(HOURSECS - 10); // The response from Azure AI. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], json_encode([ 'created' => 1719140500, 'data' => [ (object) [ 'revised_prompt' => 'An image that represents the concept of a \'test\'.', 'url' => $url, ], ], ]), )); // The image downloaded from the server successfully. $mock->append(new Response( 200, ['Content-Type' => 'image/jpeg'], \GuzzleHttp\Psr7\Utils::streamFor(fopen(self::get_fixture_path('aiprovider_azureai', 'test.jpg'), 'r')), )); $this->create_action($user1->id); $processor = new process_generate_image($provider, $this->action); $result = $processor->process(); $this->assertEquals(429, $result->get_errorcode()); $this->assertEquals('User rate limit exceeded', $result->get_errormessage()); $this->assertFalse($result->get_success()); // Case 3: User rate limit has not been reached for a different user. // Log in user2. $this->setUser($user2); $this->create_action($user2->id); // The response from Azure AI. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], json_encode([ 'created' => 1719140500, 'data' => [ (object) [ 'revised_prompt' => 'An image that represents the concept of a \'test\'.', 'url' => $url, ], ], ]), )); // The image downloaded from the server successfully. $mock->append(new Response( 200, ['Content-Type' => 'image/jpeg'], \GuzzleHttp\Psr7\Utils::streamFor(fopen(self::get_fixture_path('aiprovider_azureai', 'test.jpg'), 'r')), )); $processor = new process_generate_image($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); // Case 4: Time window has passed, user rate limit should be reset. $clock->bump(11); // Log in user1. $this->setUser($user1); // The response from Azure AI. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], json_encode([ 'created' => 1719140500, 'data' => [ (object) [ 'revised_prompt' => 'An image that represents the concept of a \'test\'.', 'url' => $url, ], ], ]), )); // The image downloaded from the server successfully. $mock->append(new Response( 200, ['Content-Type' => 'image/jpeg'], \GuzzleHttp\Psr7\Utils::streamFor(fopen(self::get_fixture_path('aiprovider_azureai', 'test.jpg'), 'r')), )); $this->create_action($user1->id); $processor = new process_generate_image($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); } /** * Test process method with global rate limiter. */ public function test_process_with_global_rate_limiter(): void { // Create users. $user1 = $this->getDataGenerator()->create_user(); $user2 = $this->getDataGenerator()->create_user(); // Log in user1. $this->setUser($user1); // Mock clock. $clock = $this->mock_clock_with_frozen(); // Set the global rate limiter. $config = [ 'apikey' => '123', 'endpoint' => 'https://api.example.com', 'enableglobalratelimit' => true, 'globalratelimit' => 1, ]; $actionconfig = [ 'core_ai\\aiactions\\generate_image' => [ 'enabled' => true, 'settings' => [ 'deployment' => 'test', 'apiversion' => '2024-06-01', ], ], ]; $provider = $this->manager->create_provider_instance( classname: provider::class, name: 'dummy', config: $config, ); $provider = $this->manager->update_provider_instance( provider: $provider, actionconfig: $actionconfig, ); // Mock the http client to return a successful response. ['mock' => $mock] = $this->get_mocked_http_client(); $url = 'https://example.com/test.jpg'; // Case 1: Global rate limit has not been reached. $this->create_action($user1->id); // The response from Azure AI. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], json_encode([ 'created' => 1719140500, 'data' => [ (object) [ 'revised_prompt' => 'An image that represents the concept of a \'test\'.', 'url' => $url, ], ], ]), )); // The image downloaded from the server successfully. $mock->append(new Response( 200, ['Content-Type' => 'image/jpeg'], \GuzzleHttp\Psr7\Utils::streamFor(fopen(self::get_fixture_path('aiprovider_azureai', 'test.jpg'), 'r')), )); $processor = new process_generate_image($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); // Case 2: Global rate limit has been reached. $clock->bump(HOURSECS - 10); // The response from Azure AI. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], json_encode([ 'created' => 1719140500, 'data' => [ (object) [ 'revised_prompt' => 'An image that represents the concept of a \'test\'.', 'url' => $url, ], ], ]), )); // The image downloaded from the server successfully. $mock->append(new Response( 200, ['Content-Type' => 'image/jpeg'], \GuzzleHttp\Psr7\Utils::streamFor(fopen(self::get_fixture_path('aiprovider_azureai', 'test.jpg'), 'r')), )); $this->create_action($user1->id); $processor = new process_generate_image($provider, $this->action); $result = $processor->process(); $this->assertEquals(429, $result->get_errorcode()); $this->assertEquals('Global rate limit exceeded', $result->get_errormessage()); $this->assertFalse($result->get_success()); // Case 3: Global rate limit has been reached for a different user too. // Log in user2. $this->setUser($user2); $this->create_action($user2->id); // The response from Azure AI. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], json_encode([ 'created' => 1719140500, 'data' => [ (object) [ 'revised_prompt' => 'An image that represents the concept of a \'test\'.', 'url' => $url, ], ], ]), )); // The image downloaded from the server successfully. $mock->append(new Response( 200, ['Content-Type' => 'image/jpeg'], \GuzzleHttp\Psr7\Utils::streamFor(fopen(self::get_fixture_path('aiprovider_azureai', 'test.jpg'), 'r')), )); $processor = new process_generate_image($provider, $this->action); $result = $processor->process(); $this->assertFalse($result->get_success()); // Case 4: Time window has passed, global rate limit should be reset. $clock->bump(11); // Log in user1. $this->setUser($user1); // The response from Azure AI. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], json_encode([ 'created' => 1719140500, 'data' => [ (object) [ 'revised_prompt' => 'An image that represents the concept of a \'test\'.', 'url' => $url, ], ], ]), )); // The image downloaded from the server successfully. $mock->append(new Response( 200, ['Content-Type' => 'image/jpeg'], \GuzzleHttp\Psr7\Utils::streamFor(fopen(self::get_fixture_path('aiprovider_azureai', 'test.jpg'), 'r')), )); $this->create_action($user1->id); $processor = new process_generate_image($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); } } PK*\F JJ6provider/azureai/tests/process_summarise_text_test.phpnu[. namespace aiprovider_azureai; use core_ai\aiactions\base; use core_ai\manager; use GuzzleHttp\Psr7\Response; /** * Test Generate text provider class for Azure AI provider methods. * * @package aiprovider_azureai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \aiprovider_azureai\provider * @covers \aiprovider_azureai\process_summarise_text * @covers \aiprovider_azureai\abstract_processor */ final class process_summarise_text_test extends \advanced_testcase { /** @var string A successful response in JSON format. */ protected string $responsebodyjson; /** @var manager $manager */ private manager $manager; /** @var provider The provider that will process the action. */ protected provider $provider; /** @var base The action to process. */ protected base $action; /** * Set up the test. */ protected function setUp(): void { parent::setUp(); $this->resetAfterTest(); // Load a response body from a file. $this->responsebodyjson = file_get_contents(self::get_fixture_path('aiprovider_azureai', 'text_request_success.json')); $this->create_provider(); $this->create_action(); } /** * Create the provider object. */ private function create_provider(): void { $this->manager = \core\di::get(\core_ai\manager::class); $config = [ 'apikey' => '123', 'endpoint' => 'https://api.example.com', 'enableuserratelimit' => true, 'userratelimit' => 1, 'enableglobalratelimit' => true, 'globalratelimit' => 1, ]; $this->provider = $this->manager->create_provider_instance( classname: '\aiprovider_azureai\provider', name: 'dummy', config: $config, ); } /** * Create the action object. * * @param int $userid The user id to use in the action. */ private function create_action(int $userid = 1): void { $this->action = new \core_ai\aiactions\summarise_text( contextid: 1, userid: $userid, prompttext: 'This is a test prompt', ); } /** * Test create_request_object */ public function test_create_request_object(): void { $processor = new process_summarise_text($this->provider, $this->action); // We're working with a private method here, so we need to use reflection. $method = new \ReflectionMethod($processor, 'create_request_object'); $request = $method->invoke($processor, 1); $body = (object) json_decode($request->getBody()->getContents()); $this->assertEquals('system', $body->messages[0]->role); $this->assertEquals(get_string('action_summarise_text_instruction', 'core_ai'), $body->messages[0]->content); $this->assertEquals('This is a test prompt', $body->messages[1]->content); $this->assertEquals('user', $body->messages[1]->role); } /** * Test the API error response handler method. */ public function test_handle_api_error(): void { $responses = [ 500 => new Response(500, ['Content-Type' => 'application/json']), 503 => new Response(503, ['Content-Type' => 'application/json']), 401 => new Response(401, ['Content-Type' => 'application/json'], '{"error": {"message": "Invalid Authentication"}}'), 404 => new Response(404, ['Content-Type' => 'application/json'], '{"error": {"message": "You must be a member of an organization to use the API"}}'), 429 => new Response(429, ['Content-Type' => 'application/json'], '{"error": {"message": "Rate limit reached for requests"}}'), ]; $processor = new process_summarise_text($this->provider, $this->action); $method = new \ReflectionMethod($processor, 'handle_api_error'); foreach ($responses as $status => $response) { $result = $method->invoke($processor, $response); $this->assertEquals($status, $result['errorcode']); if ($status == 500) { $this->assertEquals('Internal Server Error', $result['errormessage']); } else if ($status == 503) { $this->assertEquals('Service Unavailable', $result['errormessage']); } else { $this->assertStringContainsString($response->getBody()->getContents(), $result['errormessage']); } } } /** * Test the API success response handler method. */ public function test_handle_api_success(): void { $response = new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson ); // We're testing a private method, so we need to set up reflector magic. $processor = new process_summarise_text($this->provider, $this->action); $method = new \ReflectionMethod($processor, 'handle_api_success'); $result = $method->invoke($processor, $response); $this->assertTrue($result['success']); $this->assertEquals('chatcmpl-9ooaXlMSUIhOkd2pfxKBgpipMynkX', $result['id']); $this->assertEquals('fp_abc28019ad', $result['fingerprint']); $this->assertStringContainsString('Sure, I\'m here to help!', $result['generatedcontent']); $this->assertEquals('stop', $result['finishreason']); $this->assertEquals('12', $result['prompttokens']); $this->assertEquals('14', $result['completiontokens']); $this->assertEquals('gpt-4o-2024-05-13', $result['model']); } /** * Test query_ai_api for a successful call. */ public function test_query_ai_api_success(): void { // Mock the http client to return a successful response. ['mock' => $mock] = $this->get_mocked_http_client(); // The response from Azure AI. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $processor = new process_summarise_text($this->provider, $this->action); $method = new \ReflectionMethod($processor, 'query_ai_api'); $result = $method->invoke($processor); $this->assertTrue($result['success']); $this->assertEquals('chatcmpl-9ooaXlMSUIhOkd2pfxKBgpipMynkX', $result['id']); $this->assertEquals('fp_abc28019ad', $result['fingerprint']); $this->assertStringContainsString('Sure, I\'m here to help!', $result['generatedcontent']); $this->assertEquals('stop', $result['finishreason']); $this->assertEquals('12', $result['prompttokens']); $this->assertEquals('14', $result['completiontokens']); $this->assertEquals('gpt-4o-2024-05-13', $result['model']); } /** * Test prepare_response success. */ public function test_prepare_response_success(): void { $processor = new process_summarise_text($this->provider, $this->action); // We're working with a private method here, so we need to use reflection. $method = new \ReflectionMethod($processor, 'prepare_response'); $response = [ 'success' => true, 'id' => 'chatcmpl-9lkwPWOIiQEvI3nfcGofJcmS5lPYo', 'fingerprint' => 'fp_c4e5b6fa31', 'generatedcontent' => 'Sure, here is some sample text', 'finishreason' => 'stop', 'prompttokens' => '11', 'completiontokens' => '568', 'model' => 'gpt-4o', ]; $result = $method->invoke($processor, $response); $this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result); $this->assertTrue($result->get_success()); $this->assertEquals('summarise_text', $result->get_actionname()); $this->assertEquals($response['success'], $result->get_success()); $this->assertEquals($response['generatedcontent'], $result->get_response_data()['generatedcontent']); $this->assertEquals($response['model'], $result->get_response_data()['model']); } /** * Test prepare_response error. */ public function test_prepare_response_error(): void { $processor = new process_summarise_text($this->provider, $this->action); // We're working with a private method here, so we need to use reflection. $method = new \ReflectionMethod($processor, 'prepare_response'); $response = [ 'success' => false, 'errorcode' => 500, 'errormessage' => 'Internal server error.', ]; $result = $method->invoke($processor, $response); $this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result); $this->assertFalse($result->get_success()); $this->assertEquals('summarise_text', $result->get_actionname()); $this->assertEquals($response['errorcode'], $result->get_errorcode()); $this->assertEquals($response['errormessage'], $result->get_errormessage()); } /** * Test process method. */ public function test_process(): void { // Log in user. $this->setUser($this->getDataGenerator()->create_user()); // Mock the http client to return a successful response. ['mock' => $mock] = $this->get_mocked_http_client(); // The response from Azure AI. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $processor = new process_summarise_text($this->provider, $this->action); $result = $processor->process(); $this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result); $this->assertTrue($result->get_success()); $this->assertEquals('summarise_text', $result->get_actionname()); } /** * Test process method with error. */ public function test_process_error(): void { // Log in user. $this->setUser($this->getDataGenerator()->create_user()); // Mock the http client to return an unsuccessful response. ['mock' => $mock] = $this->get_mocked_http_client(); // The response from AzureAI. $mock->append(new Response( 401, ['Content-Type' => 'application/json'], json_encode(['error' => ['message' => 'Invalid Authentication']]), )); $processor = new process_summarise_text($this->provider, $this->action); $result = $processor->process(); $this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result); $this->assertFalse($result->get_success()); $this->assertEquals('summarise_text', $result->get_actionname()); $this->assertEquals(401, $result->get_errorcode()); $this->assertEquals('Invalid Authentication', $result->get_errormessage()); } /** * Test process method with user rate limiter. */ public function test_process_with_user_rate_limiter(): void { // Create users. $user1 = $this->getDataGenerator()->create_user(); $user2 = $this->getDataGenerator()->create_user(); // Log in user1. $this->setUser($user1); // Mock clock. $clock = $this->mock_clock_with_frozen(); // Set the user rate limiter. $config = [ 'apikey' => '123', 'endpoint' => 'https://api.example.com', 'enableuserratelimit' => true, 'userratelimit' => 1, ]; $actionconfig = [ 'core_ai\\aiactions\\summarise_text' => [ 'enabled' => true, 'settings' => [ 'deployment' => 'test', 'apiversion' => '2024-06-01', 'systeminstruction' => '', ], ], ]; $provider = $this->manager->create_provider_instance( classname: provider::class, name: 'dummy', config: $config, ); $provider = $this->manager->update_provider_instance( provider: $provider, actionconfig: $actionconfig, ); // Mock the http client to return a successful response. ['mock' => $mock] = $this->get_mocked_http_client(); // Case 1: User rate limit has not been reached. $this->create_action($user1->id); // The response from Azure I. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $processor = new process_summarise_text($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); // Case 2: User rate limit has been reached. $clock->bump(HOURSECS - 10); // The response from Azure AI. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $this->create_action($user1->id); $processor = new process_summarise_text($provider, $this->action); $result = $processor->process(); $this->assertEquals(429, $result->get_errorcode()); $this->assertEquals('User rate limit exceeded', $result->get_errormessage()); $this->assertFalse($result->get_success()); // Case 3: User rate limit has not been reached for a different user. // Log in user2. $this->setUser($user2); $this->create_action($user2->id); // The response from Azure AI. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $processor = new process_summarise_text($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); // Case 4: Time window has passed, user rate limit should be reset. $clock->bump(11); // Log in user1. $this->setUser($user1); // The response from Azure AI. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $this->create_action($user1->id); $processor = new process_summarise_text($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); } /** * Test process method with global rate limiter. */ public function test_process_with_global_rate_limiter(): void { $this->resetAfterTest(); // Create users. $user1 = $this->getDataGenerator()->create_user(); $user2 = $this->getDataGenerator()->create_user(); // Log in user1. $this->setUser($user1); // Mock clock. $clock = $this->mock_clock_with_frozen(); // Set the global rate limiter. $config = [ 'apikey' => '123', 'endpoint' => 'https://api.example.com', 'enableglobalratelimit' => true, 'globalratelimit' => 1, ]; $actionconfig = [ 'core_ai\\aiactions\\summarise_text' => [ 'enabled' => true, 'settings' => [ 'deployment' => 'test', 'apiversion' => '2024-06-01', 'systeminstruction' => '', ], ], ]; $provider = $this->manager->create_provider_instance( classname: provider::class, name: 'dummy', config: $config, ); $provider = $this->manager->update_provider_instance( provider: $provider, actionconfig: $actionconfig, ); // Mock the http client to return a successful response. ['mock' => $mock] = $this->get_mocked_http_client(); // Case 1: Global rate limit has not been reached. $this->create_action($user1->id); // The response from Azure AI. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $processor = new process_summarise_text($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); // Case 2: Global rate limit has been reached. $clock->bump(HOURSECS - 10); // The response from Azure AI. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $this->create_action($user1->id); $processor = new process_summarise_text($provider, $this->action); $result = $processor->process(); $this->assertEquals(429, $result->get_errorcode()); $this->assertEquals('Global rate limit exceeded', $result->get_errormessage()); $this->assertFalse($result->get_success()); // Case 3: Global rate limit has been reached for a different user too. // Log in user2. $this->setUser($user2); $this->create_action($user2->id); // The response from Azure AI. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $processor = new process_summarise_text($provider, $this->action); $result = $processor->process(); $this->assertFalse($result->get_success()); // Case 4: Time window has passed, global rate limit should be reset. $clock->bump(11); // Log in user1. $this->setUser($user1); // The response from Azure AI. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $this->create_action($user1->id); $processor = new process_summarise_text($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); } } PK*\2ê(provider/azureai/tests/provider_test.phpnu[. namespace aiprovider_azureai; use core_ai\manager; /** * Test Azure AI provider methods. * * @package aiprovider_azureai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \aiprovider_azureai\provider */ final class provider_test extends \advanced_testcase { /** @var manager $manager */ private manager $manager; /** @var provider $provider */ private provider $provider; /** * Overriding setUp() function to always reset after tests. */ public function setUp(): void { parent::setUp(); $this->resetAfterTest(); // Create the provider instance. $this->manager = \core\di::get(\core_ai\manager::class); $config = ['data' => 'goeshere']; $this->provider = $this->manager->create_provider_instance( classname: '\aiprovider_azureai\provider', name: 'dummy', config: $config, ); } /** * Test get_action_list */ public function test_get_action_list(): void { $actionlist = $this->provider->get_action_list(); $this->assertIsArray($actionlist); $this->assertEquals(4, count($actionlist)); $this->assertContains(\core_ai\aiactions\generate_text::class, $actionlist); $this->assertContains(\core_ai\aiactions\generate_image::class, $actionlist); $this->assertContains(\core_ai\aiactions\summarise_text::class, $actionlist); $this->assertContains(\core_ai\aiactions\explain_text::class, $actionlist); } /** * Test generate_userid. */ public function test_generate_userid(): void { $userid = $this->provider->generate_userid(1); // Assert that the generated userid is a string of proper length. $this->assertIsString($userid); $this->assertEquals(64, strlen($userid)); } /** * Test is_request_allowed. */ public function test_is_request_allowed(): void { // Create the provider instance. $config = [ 'enableuserratelimit' => true, 'userratelimit' => 3, 'enableglobalratelimit' => true, 'globalratelimit' => 5, ]; /** @var provider $provider */ $provider = $this->manager->create_provider_instance( classname: provider::class, name: 'dummy', config: $config, ); $contextid = 1; $userid = 1; $prompttext = 'This is a test prompt'; $aspectratio = 'square'; $quality = 'hd'; $numimages = 1; $style = 'vivid'; $action = new \core_ai\aiactions\generate_image( contextid: $contextid, userid: $userid, prompttext: $prompttext, quality: $quality, aspectratio: $aspectratio, numimages: $numimages, style: $style ); // Make 3 requests, all should be allowed. for ($i = 0; $i < 3; $i++) { $this->assertTrue($provider->is_request_allowed($action)); } // The 4th request for the same user should be denied. $result = $provider->is_request_allowed($action); $this->assertFalse($result['success']); $this->assertEquals('User rate limit exceeded', $result['errormessage']); // Change user id to make a request for a different user, should pass (4 requests for global rate). $action = new \core_ai\aiactions\generate_image( contextid: $contextid, userid: 2, prompttext: $prompttext, quality: $quality, aspectratio: $aspectratio, numimages: $numimages, style: $style); $this->assertTrue($provider->is_request_allowed($action)); // Make a 5th request for the global rate limit, it should be allowed. $this->assertTrue($provider->is_request_allowed($action)); // The 6th request should be denied. $result = $provider->is_request_allowed($action); $this->assertFalse($result['success']); $this->assertEquals('Global rate limit exceeded', $result['errormessage']); } /** * Test is_provider_configured. */ public function test_is_provider_configured(): void { // No configured values. $this->assertFalse($this->provider->is_provider_configured()); // Partially configured values. $updatedprovider = $this->manager->update_provider_instance( provider: $this->provider, config: ['apikey' => '123'], ); $this->assertFalse($updatedprovider->is_provider_configured()); // Properly configured values. $updatedprovider = $this->manager->update_provider_instance( provider: $this->provider, config: [ 'apikey' => '123', 'endpoint' => 'abc', ], ); $this->assertTrue($updatedprovider->is_provider_configured()); } } PK*\ffpolicy_acceptance_report.phpnu[. /** * Display AI policy acceptance report. * * @package core_ai * @copyright 2024 David Woloszyn * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ use core_reportbuilder\system_report_factory; use core_ai\reportbuilder\local\systemreports\policy_acceptance; require(__DIR__ . '/../config.php'); require_once($CFG->libdir . '/adminlib.php'); admin_externalpage_setup('aipolicyacceptancereport'); // Set up the page. $systemcontext = context_system::instance(); $pageurl = new moodle_url($CFG->wwwroot . '/ai/policy_acceptance_report.php'); $PAGE->set_url($pageurl); $PAGE->set_context($systemcontext); $PAGE->set_pagelayout('report'); $PAGE->set_primary_active_tab('siteadminnode'); echo $OUTPUT->header(); $report = system_report_factory::create(policy_acceptance::class, $systemcontext); echo $OUTPUT->heading($report::get_name()); echo $report->output(); echo $OUTPUT->footer(); PK*\'amd/src/providerchooser.jsnu[// 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 . /** * AI provider selection handler. * * @module core_ai/providerchooser * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ const Selectors = { fields: { selector: '[data-aiproviderchooser-field="selector"]', updateButton: '[data-aiproviderchooser-field="updateButton"]', }, }; /** * Initialise the AI provider chooser. */ export const init = () => { document.querySelector(Selectors.fields.selector)?.addEventListener('change', e => { const form = e.target.closest('form'); const updateButton = form.querySelector(Selectors.fields.updateButton); const url = new URL(form.action); form.action = url.toString(); updateButton.click(); }); }; PK*\o`amd/src/policymodal.jsnu[// 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 . import Modal from 'core/modal'; import Policy from './policy'; import CustomEvents from 'core/custom_interaction_events'; /** * The Javascript module to handle the policy modal. * * @module core_ai/policymodal * @copyright Andrew Lyons * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ export default class PolicyModal extends Modal { static TYPE = 'core_ai/policymodal'; static TEMPLATE = 'core_ai/policymodal'; /** * Configure the modal. * * @param {object} modalConfig The modal configuration. */ configure(modalConfig) { super.configure({ ...modalConfig, show: true, removeOnClose: true, }); this.context = modalConfig.context; this.setXlarge(); } /** * Set the modal to be extra large. */ setXlarge() { this.getModal().addClass('modal-xl'); } /** * Handle click events within the policy modal. */ registerEventListeners() { super.registerEventListeners(); this.registerCloseOnSave(); this.registerCloseOnCancel(); this.getModal().on(CustomEvents.events.activate, this.getActionSelector('save'), (e) => { e.preventDefault(); Policy.acceptPolicy(); }); } } PK*\X*B  -amd/src/aiprovider_action_management_table.jsnu[// 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 . import PluginManagementTable from 'core_admin/plugin_management_table'; import {call as fetchMany} from 'core/ajax'; let watching = false; /** * Handles setting plugin state for the AI provider management table. * * @module core_ai/aiprovider_action_management_table * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ export default class extends PluginManagementTable { /** * Constructor for the class. * * @param {int} providerid The provider id. */ constructor(providerid) { super(); // Call the parent constructor, so inherited properties and methods initialize properly. this.providerid = providerid; // Store provider id as an instance field. } /** * Initialise an instance of the class. * * @param {int} providerid The provider id. */ static init(providerid) { if (watching) { return; } watching = true; new this(providerid); } /** * Set the plugin state (enabled or disabled). * * @param {string} methodname The web service to call. * @param {string} plugin The name of the plugin and action to set the state for. * @param {number} state The state to set. * @returns {Promise} */ setPluginState(methodname, plugin, state) { const providerid = this.providerid; return fetchMany([{ methodname, args: { plugin, state, providerid, }, }])[0]; } } PK*\F""/amd/src/aiprovider_instance_management_table.jsnu[// 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 . import PluginManagementTable from 'core_admin/plugin_management_table'; import {call as fetchMany} from 'core/ajax'; import {refreshTableContent} from 'core_table/dynamic'; import Pending from 'core/pending'; import {fetchNotifications} from 'core/notification'; import {prefetchStrings} from 'core/prefetch'; import {getString} from 'core/str'; import DeleteCancelModal from 'core/modal_delete_cancel'; import ModalEvents from 'core/modal_events'; let watching = false; /** * Handles setting plugin state for the AI provider management table. * * @module core_ai/aiprovider_instance_management_table * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ export default class extends PluginManagementTable { constructor() { super(); this.addClickHandler(this.handleDelete); } /** * Initialise an instance of the class. * * This is just a way of making it easier to initialise an instance of the class from PHP. */ static init() { if (watching) { return; } prefetchStrings('core_ai', [ 'providerinstancedelete', 'providerinstancedeleteconfirm', ]); watching = true; new this(); } /** * Call the delete service. * * @param {string} methodname The web service to call * @param {number} providerid The provider id. * @return {Promise} The promise. */ deleteProvider(methodname, providerid) { return fetchMany([{ methodname, args: { providerid, }, }])[0]; } /** * Handle delete. * * @param {HTMLElement} tableRoot * @param {Event} e */ async handleDelete(tableRoot, e) { const deleteElement = e.target.closest('[data-delete-method]'); if (deleteElement) { e.preventDefault(); const providerId = e.target.dataset.id; const deleteMethod = e.target.dataset.deleteMethod; const bodyParams = { provider: e.target.dataset.provider, name: e.target.dataset.name, }; const modal = await DeleteCancelModal.create({ title: getString('providerinstancedelete', 'core_ai'), body: getString('providerinstancedeleteconfirm', 'core_ai', bodyParams), show: true, removeOnClose: true, }); // Handle delete event. modal.getRoot().on(ModalEvents.delete, async(e) => { e.preventDefault(); const pendingPromise = new Pending('core_table/dynamic:deleteProvider'); await this.deleteProvider(deleteMethod, providerId); // Reload the table, so we get the updated list of providers, and any messages. await Promise.all([ refreshTableContent(tableRoot), fetchNotifications(), ]); modal.destroy(); pendingPromise.resolve(); }); } } } PK*\svamd/src/repository.jsnu[// 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 . /** * AI Subsystem policy functions. * * @module core_ai/repository * @copyright Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @since 4.5 */ import Ajax from 'core/ajax'; /** * Get the policy status for a user. * * @param {Number} userid The user ID. * @return {Promise} The policy status. */ export const getPolicyStatus = (userid) => Ajax.call([{ methodname: 'core_ai_get_policy_status', args: {userid}, }])[0]; /** * Set the policy status for a user. * * @param {Number} contextId The context ID. * @return {Promise} Promise resolved with the policy set status. */ export const setPolicyStatus = (contextId) => Ajax.call([{ methodname: 'core_ai_set_policy_status', args: {contextid: contextId}, }])[0]; PK*\Qk k amd/src/helper.jsnu[// 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 . /** * The helper module or AI Subsystem. * * @module core_ai/helper * @copyright 2024 Huong Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ export default class AIHelper { /** * Replace double line breaks with
and with

for paragraphs. * This is to handle the difference in response from the AI to what is expected by the editor. * * @param {String} text The text to replace. * @returns {String} */ static replaceLineBreaks(text) { // Replace double line breaks with

for paragraphs const textWithParagraphs = text.replace(/\n{2,}|\r\n/g, '

'); // Replace remaining single line breaks with
tags const textWithBreaks = textWithParagraphs.replace(/\n/g, '
'); // Add opening and closing

tags to wrap the entire content return `

${textWithBreaks}

`; } /** * Replace markdown formatting. * Even when asked not to, AI models will sometimes return markdown. * * @param {String} text The text to replace. * @returns {String} */ static replaceMarkdown(text) { // Replace markdown bold formatting HTML equivalent. const textWithMarkdown = text.replace(/\*\*(.*?)\*\*/g, '$1'); return textWithMarkdown; } /** * Format the response provided by the AI model. * * @param {String} text The text to format. * @returns {String} */ static formatResponse(text) { let formattedText = this.replaceLineBreaks(text) ; formattedText = this.replaceMarkdown(formattedText); return formattedText; } } PK*\j##amd/src/policy.jsnu[// 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 . import {getPolicyStatus, setPolicyStatus} from "./repository"; /** * The Javascript module to handle the policy acceptance. * * @module core_ai/policy * @copyright 2024 Andrew Lyons * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ export default class { static #policyAcceptedFor = {}; static preconfigurePolicyState(userid, state) { if (!this.#policyAcceptedFor.hasOwnProperty(userid)) { this.#policyAcceptedFor[userid] = state; } } /** * Get the policy status for a user. * * @param {Number} userid The user ID. * @return {Promise} The policy status. */ static async getPolicyStatus(userid) { if (this.#policyAcceptedFor[userid]) { return this.#policyAcceptedFor[userid]; } const accepted = await getPolicyStatus(userid); this.#policyAcceptedFor[userid] = accepted.status; return accepted.status; } static acceptPolicy() { this.#policyAcceptedFor[M.cfg.userId] = true; return setPolicyStatus(M.cfg.contextid); } } PK*\󮀹amd/build/repository.min.jsnu[define("core_ai/repository",["exports","core/ajax"],(function(_exports,_ajax){var obj; /** * AI Subsystem policy functions. * * @module core_ai/repository * @copyright Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @since 4.5 */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.setPolicyStatus=_exports.getPolicyStatus=void 0,_ajax=(obj=_ajax)&&obj.__esModule?obj:{default:obj};_exports.getPolicyStatus=userid=>_ajax.default.call([{methodname:"core_ai_get_policy_status",args:{userid:userid}}])[0];_exports.setPolicyStatus=contextId=>_ajax.default.call([{methodname:"core_ai_set_policy_status",args:{contextid:contextId}}])[0]})); //# sourceMappingURL=repository.min.js.mapPK*\Fee3amd/build/aiprovider_action_management_table.min.jsnu[define("core_ai/aiprovider_action_management_table",["exports","core_admin/plugin_management_table","core/ajax"],(function(_exports,_plugin_management_table,_ajax){var obj;Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_plugin_management_table=(obj=_plugin_management_table)&&obj.__esModule?obj:{default:obj};let watching=!1; /** * Handles setting plugin state for the AI provider management table. * * @module core_ai/aiprovider_action_management_table * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */class _default extends _plugin_management_table.default{constructor(providerid){super(),this.providerid=providerid}static init(providerid){watching||(watching=!0,new this(providerid))}setPluginState(methodname,plugin,state){const providerid=this.providerid;return(0,_ajax.call)([{methodname:methodname,args:{plugin:plugin,state:state,providerid:providerid}}])[0]}}return _exports.default=_default,_exports.default})); //# sourceMappingURL=aiprovider_action_management_table.min.js.mapPK*\SFd d 5amd/build/aiprovider_instance_management_table.min.jsnu[define("core_ai/aiprovider_instance_management_table",["exports","core_admin/plugin_management_table","core/ajax","core_table/dynamic","core/pending","core/notification","core/prefetch","core/str","core/modal_delete_cancel","core/modal_events"],(function(_exports,_plugin_management_table,_ajax,_dynamic,_pending,_notification,_prefetch,_str,_modal_delete_cancel,_modal_events){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_plugin_management_table=_interopRequireDefault(_plugin_management_table),_pending=_interopRequireDefault(_pending),_modal_delete_cancel=_interopRequireDefault(_modal_delete_cancel),_modal_events=_interopRequireDefault(_modal_events);let watching=!1; /** * Handles setting plugin state for the AI provider management table. * * @module core_ai/aiprovider_instance_management_table * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */class _default extends _plugin_management_table.default{constructor(){super(),this.addClickHandler(this.handleDelete)}static init(){watching||((0,_prefetch.prefetchStrings)("core_ai",["providerinstancedelete","providerinstancedeleteconfirm"]),watching=!0,new this)}deleteProvider(methodname,providerid){return(0,_ajax.call)([{methodname:methodname,args:{providerid:providerid}}])[0]}async handleDelete(tableRoot,e){if(e.target.closest("[data-delete-method]")){e.preventDefault();const providerId=e.target.dataset.id,deleteMethod=e.target.dataset.deleteMethod,bodyParams={provider:e.target.dataset.provider,name:e.target.dataset.name},modal=await _modal_delete_cancel.default.create({title:(0,_str.getString)("providerinstancedelete","core_ai"),body:(0,_str.getString)("providerinstancedeleteconfirm","core_ai",bodyParams),show:!0,removeOnClose:!0});modal.getRoot().on(_modal_events.default.delete,(async e=>{e.preventDefault();const pendingPromise=new _pending.default("core_table/dynamic:deleteProvider");await this.deleteProvider(deleteMethod,providerId),await Promise.all([(0,_dynamic.refreshTableContent)(tableRoot),(0,_notification.fetchNotifications)()]),modal.destroy(),pendingPromise.resolve()}))}}}return _exports.default=_default,_exports.default})); //# sourceMappingURL=aiprovider_instance_management_table.min.js.mapPK*\>ppamd/build/repository.min.js.mapnu[{"version":3,"file":"repository.min.js","sources":["../src/repository.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * AI Subsystem policy functions.\n *\n * @module core_ai/repository\n * @copyright Matt Porritt \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n * @since 4.5\n */\n\nimport Ajax from 'core/ajax';\n\n/**\n * Get the policy status for a user.\n *\n * @param {Number} userid The user ID.\n * @return {Promise} The policy status.\n */\nexport const getPolicyStatus = (userid) => Ajax.call([{\n methodname: 'core_ai_get_policy_status',\n args: {userid},\n}])[0];\n\n/**\n * Set the policy status for a user.\n *\n * @param {Number} contextId The context ID.\n * @return {Promise} Promise resolved with the policy set status.\n */\nexport const setPolicyStatus = (contextId) => Ajax.call([{\n methodname: 'core_ai_set_policy_status',\n args: {contextid: contextId},\n}])[0];\n"],"names":["userid","Ajax","call","methodname","args","contextId","contextid"],"mappings":";;;;;;;;mMAgCgCA,QAAWC,cAAKC,KAAK,CAAC,CAClDC,WAAY,4BACZC,KAAM,CAACJ,OAAAA,WACP,4BAQ4BK,WAAcJ,cAAKC,KAAK,CAAC,CACrDC,WAAY,4BACZC,KAAM,CAACE,UAAWD,cAClB"}PK*\Ec amd/build/policymodal.min.js.mapnu[{"version":3,"file":"policymodal.min.js","sources":["../src/policymodal.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\nimport Modal from 'core/modal';\nimport Policy from './policy';\nimport CustomEvents from 'core/custom_interaction_events';\n\n/**\n * The Javascript module to handle the policy modal.\n *\n * @module core_ai/policymodal\n * @copyright Andrew Lyons \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nexport default class PolicyModal extends Modal {\n static TYPE = 'core_ai/policymodal';\n static TEMPLATE = 'core_ai/policymodal';\n\n /**\n * Configure the modal.\n *\n * @param {object} modalConfig The modal configuration.\n */\n configure(modalConfig) {\n super.configure({\n ...modalConfig,\n show: true,\n removeOnClose: true,\n });\n\n this.context = modalConfig.context;\n this.setXlarge();\n }\n\n /**\n * Set the modal to be extra large.\n */\n setXlarge() {\n this.getModal().addClass('modal-xl');\n }\n\n /**\n * Handle click events within the policy modal.\n */\n registerEventListeners() {\n super.registerEventListeners();\n this.registerCloseOnSave();\n this.registerCloseOnCancel();\n\n this.getModal().on(CustomEvents.events.activate, this.getActionSelector('save'), (e) => {\n e.preventDefault();\n Policy.acceptPolicy();\n });\n }\n}\n"],"names":["PolicyModal","Modal","configure","modalConfig","show","removeOnClose","context","setXlarge","getModal","addClass","registerEventListeners","registerCloseOnSave","registerCloseOnCancel","on","CustomEvents","events","activate","this","getActionSelector","e","preventDefault","acceptPolicy"],"mappings":";;;;;;;uPA0BqBA,oBAAoBC,eASrCC,UAAUC,mBACAD,UAAU,IACTC,YACHC,MAAM,EACNC,eAAe,SAGdC,QAAUH,YAAYG,aACtBC,YAMTA,iBACSC,WAAWC,SAAS,YAM7BC,+BACUA,8BACDC,2BACAC,6BAEAJ,WAAWK,GAAGC,mCAAaC,OAAOC,SAAUC,KAAKC,kBAAkB,SAAUC,IAC9EA,EAAEC,iCACKC,uEArCErB,mBACH,uCADGA,uBAEC"}PK*\S6_F F 7amd/build/aiprovider_action_management_table.min.js.mapnu[{"version":3,"file":"aiprovider_action_management_table.min.js","sources":["../src/aiprovider_action_management_table.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/ //\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\nimport PluginManagementTable from 'core_admin/plugin_management_table';\nimport {call as fetchMany} from 'core/ajax';\n\nlet watching = false;\n\n/**\n * Handles setting plugin state for the AI provider management table.\n *\n * @module core_ai/aiprovider_action_management_table\n * @copyright 2024 Matt Porritt \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nexport default class extends PluginManagementTable {\n\n /**\n * Constructor for the class.\n *\n * @param {int} providerid The provider id.\n */\n constructor(providerid) {\n super(); // Call the parent constructor, so inherited properties and methods initialize properly.\n this.providerid = providerid; // Store provider id as an instance field.\n }\n\n /**\n * Initialise an instance of the class.\n *\n * @param {int} providerid The provider id.\n */\n static init(providerid) {\n if (watching) {\n return;\n }\n watching = true;\n new this(providerid);\n }\n\n /**\n * Set the plugin state (enabled or disabled).\n *\n * @param {string} methodname The web service to call.\n * @param {string} plugin The name of the plugin and action to set the state for.\n * @param {number} state The state to set.\n * @returns {Promise}\n */\n setPluginState(methodname, plugin, state) {\n const providerid = this.providerid;\n return fetchMany([{\n methodname,\n args: {\n plugin,\n state,\n providerid,\n },\n }])[0];\n }\n\n}\n"],"names":["watching","PluginManagementTable","constructor","providerid","this","setPluginState","methodname","plugin","state","args"],"mappings":"0VAiBIA,UAAW;;;;;;;4BAScC,iCAOzBC,YAAYC,yBAEHA,WAAaA,uBAQVA,YACJH,WAGJA,UAAW,MACPI,KAAKD,aAWbE,eAAeC,WAAYC,OAAQC,aACzBL,WAAaC,KAAKD,kBACjB,cAAU,CAAC,CACdG,WAAAA,WACAG,KAAM,CACFF,OAAAA,OACAC,MAAAA,MACAL,WAAAA,eAEJ"}PK*\c:66amd/build/policymodal.min.jsnu[define("core_ai/policymodal",["exports","core/modal","./policy","core/custom_interaction_events"],(function(_exports,_modal,_policy,_custom_interaction_events){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}function _defineProperty(obj,key,value){return key in obj?Object.defineProperty(obj,key,{value:value,enumerable:!0,configurable:!0,writable:!0}):obj[key]=value,obj} /** * The Javascript module to handle the policy modal. * * @module core_ai/policymodal * @copyright Andrew Lyons * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_modal=_interopRequireDefault(_modal),_policy=_interopRequireDefault(_policy),_custom_interaction_events=_interopRequireDefault(_custom_interaction_events);class PolicyModal extends _modal.default{configure(modalConfig){super.configure({...modalConfig,show:!0,removeOnClose:!0}),this.context=modalConfig.context,this.setXlarge()}setXlarge(){this.getModal().addClass("modal-xl")}registerEventListeners(){super.registerEventListeners(),this.registerCloseOnSave(),this.registerCloseOnCancel(),this.getModal().on(_custom_interaction_events.default.events.activate,this.getActionSelector("save"),(e=>{e.preventDefault(),_policy.default.acceptPolicy()}))}}return _exports.default=PolicyModal,_defineProperty(PolicyModal,"TYPE","core_ai/policymodal"),_defineProperty(PolicyModal,"TEMPLATE","core_ai/policymodal"),_exports.default})); //# sourceMappingURL=policymodal.min.js.mapPK*\< amd/build/providerchooser.min.jsnu[define("core_ai/providerchooser",["exports"],(function(_exports){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0; /** * AI provider selection handler. * * @module core_ai/providerchooser * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ const Selectors_fields={selector:'[data-aiproviderchooser-field="selector"]',updateButton:'[data-aiproviderchooser-field="updateButton"]'};_exports.init=()=>{var _document$querySelect;null===(_document$querySelect=document.querySelector(Selectors_fields.selector))||void 0===_document$querySelect||_document$querySelect.addEventListener("change",(e=>{const form=e.target.closest("form"),updateButton=form.querySelector(Selectors_fields.updateButton),url=new URL(form.action);form.action=url.toString(),updateButton.click()}))}})); //# sourceMappingURL=providerchooser.min.js.mapPK*\>Ƃ2 2 amd/build/helper.min.js.mapnu[{"version":3,"file":"helper.min.js","sources":["../src/helper.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * The helper module or AI Subsystem.\n *\n * @module core_ai/helper\n * @copyright 2024 Huong Nguyen \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nexport default class AIHelper {\n /**\n * Replace double line breaks with
and with

for paragraphs.\n * This is to handle the difference in response from the AI to what is expected by the editor.\n *\n * @param {String} text The text to replace.\n * @returns {String}\n */\n static replaceLineBreaks(text) {\n // Replace double line breaks with

for paragraphs\n const textWithParagraphs = text.replace(/\\n{2,}|\\r\\n/g, '

');\n\n // Replace remaining single line breaks with
tags\n const textWithBreaks = textWithParagraphs.replace(/\\n/g, '
');\n\n // Add opening and closing

tags to wrap the entire content\n return `

${textWithBreaks}

`;\n }\n\n /**\n * Replace markdown formatting.\n * Even when asked not to, AI models will sometimes return markdown.\n *\n * @param {String} text The text to replace.\n * @returns {String}\n */\n static replaceMarkdown(text) {\n // Replace markdown bold formatting HTML equivalent.\n const textWithMarkdown = text.replace(/\\*\\*(.*?)\\*\\*/g, '$1');\n\n return textWithMarkdown;\n }\n\n /**\n * Format the response provided by the AI model.\n *\n * @param {String} text The text to format.\n * @returns {String}\n */\n static formatResponse(text) {\n let formattedText = this.replaceLineBreaks(text) ;\n formattedText = this.replaceMarkdown(formattedText);\n\n return formattedText;\n }\n}\n"],"names":["text","textWithBreaks","replace","formattedText","this","replaceLineBreaks","replaceMarkdown"],"mappings":";;;;;;;;+BA8B6BA,YAKfC,eAHqBD,KAAKE,QAAQ,eAAgB,cAGdA,QAAQ,MAAO,4BAG5CD,8CAUMD,aAEMA,KAAKE,QAAQ,iBAAkB,6CAWtCF,UACdG,cAAgBC,KAAKC,kBAAkBL,aAC3CG,cAAgBC,KAAKE,gBAAgBH,eAE9BA"}PK*\7]IIamd/build/helper.min.jsnu[define("core_ai/helper",["exports"],(function(_exports){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0;return _exports.default= /** * The helper module or AI Subsystem. * * @module core_ai/helper * @copyright 2024 Huong Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class{static replaceLineBreaks(text){const textWithBreaks=text.replace(/\n{2,}|\r\n/g,"

").replace(/\n/g,"
");return"

".concat(textWithBreaks,"

")}static replaceMarkdown(text){return text.replace(/\*\*(.*?)\*\*/g,"$1")}static formatResponse(text){let formattedText=this.replaceLineBreaks(text);return formattedText=this.replaceMarkdown(formattedText),formattedText}},_exports.default})); //# sourceMappingURL=helper.min.js.mapPK*\T9amd/build/aiprovider_instance_management_table.min.js.mapnu[{"version":3,"file":"aiprovider_instance_management_table.min.js","sources":["../src/aiprovider_instance_management_table.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\nimport PluginManagementTable from 'core_admin/plugin_management_table';\nimport {call as fetchMany} from 'core/ajax';\nimport {refreshTableContent} from 'core_table/dynamic';\nimport Pending from 'core/pending';\nimport {fetchNotifications} from 'core/notification';\nimport {prefetchStrings} from 'core/prefetch';\nimport {getString} from 'core/str';\nimport DeleteCancelModal from 'core/modal_delete_cancel';\nimport ModalEvents from 'core/modal_events';\n\nlet watching = false;\n\n/**\n * Handles setting plugin state for the AI provider management table.\n *\n * @module core_ai/aiprovider_instance_management_table\n * @copyright 2024 Matt Porritt \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nexport default class extends PluginManagementTable {\n constructor() {\n super();\n this.addClickHandler(this.handleDelete);\n }\n\n /**\n * Initialise an instance of the class.\n *\n * This is just a way of making it easier to initialise an instance of the class from PHP.\n */\n static init() {\n if (watching) {\n return;\n }\n\n prefetchStrings('core_ai', [\n 'providerinstancedelete',\n 'providerinstancedeleteconfirm',\n ]);\n\n watching = true;\n new this();\n }\n\n /**\n * Call the delete service.\n *\n * @param {string} methodname The web service to call\n * @param {number} providerid The provider id.\n * @return {Promise} The promise.\n */\n deleteProvider(methodname, providerid) {\n return fetchMany([{\n methodname,\n args: {\n providerid,\n },\n }])[0];\n }\n\n /**\n * Handle delete.\n *\n * @param {HTMLElement} tableRoot\n * @param {Event} e\n */\n async handleDelete(tableRoot, e) {\n const deleteElement = e.target.closest('[data-delete-method]');\n if (deleteElement) {\n e.preventDefault();\n const providerId = e.target.dataset.id;\n const deleteMethod = e.target.dataset.deleteMethod;\n const bodyParams = {\n provider: e.target.dataset.provider,\n name: e.target.dataset.name,\n };\n const modal = await DeleteCancelModal.create({\n title: getString('providerinstancedelete', 'core_ai'),\n body: getString('providerinstancedeleteconfirm', 'core_ai', bodyParams),\n show: true,\n removeOnClose: true,\n });\n\n // Handle delete event.\n modal.getRoot().on(ModalEvents.delete, async(e) => {\n e.preventDefault();\n const pendingPromise = new Pending('core_table/dynamic:deleteProvider');\n await this.deleteProvider(deleteMethod, providerId);\n // Reload the table, so we get the updated list of providers, and any messages.\n await Promise.all([\n refreshTableContent(tableRoot),\n fetchNotifications(),\n ]);\n modal.destroy();\n pendingPromise.resolve();\n });\n }\n }\n}\n"],"names":["watching","PluginManagementTable","constructor","addClickHandler","this","handleDelete","deleteProvider","methodname","providerid","args","tableRoot","e","target","closest","preventDefault","providerId","dataset","id","deleteMethod","bodyParams","provider","name","modal","DeleteCancelModal","create","title","body","show","removeOnClose","getRoot","on","ModalEvents","delete","async","pendingPromise","Pending","Promise","all","destroy","resolve"],"mappings":"0wBAyBIA,UAAW;;;;;;;4BAScC,iCACzBC,2BAESC,gBAAgBC,KAAKC,4BAStBL,yCAIY,UAAW,CACvB,yBACA,kCAGJA,UAAW,MACPI,MAURE,eAAeC,WAAYC,mBAChB,cAAU,CAAC,CACdD,WAAAA,WACAE,KAAM,CACFD,WAAAA,eAEJ,sBASWE,UAAWC,MACJA,EAAEC,OAAOC,QAAQ,wBACpB,CACfF,EAAEG,uBACIC,WAAaJ,EAAEC,OAAOI,QAAQC,GAC9BC,aAAeP,EAAEC,OAAOI,QAAQE,aAChCC,WAAa,CACfC,SAAUT,EAAEC,OAAOI,QAAQI,SAC3BC,KAAMV,EAAEC,OAAOI,QAAQK,MAErBC,YAAcC,6BAAkBC,OAAO,CACzCC,OAAO,kBAAU,yBAA0B,WAC3CC,MAAM,kBAAU,gCAAiC,UAAWP,YAC5DQ,MAAM,EACNC,eAAe,IAInBN,MAAMO,UAAUC,GAAGC,sBAAYC,QAAQC,MAAAA,IACnCtB,EAAEG,uBACIoB,eAAiB,IAAIC,iBAAQ,2CAC7B/B,KAAKE,eAAeY,aAAcH,kBAElCqB,QAAQC,IAAI,EACd,gCAAoB3B,YACpB,wCAEJY,MAAMgB,UACNJ,eAAeK"}PK*\7  amd/build/policy.min.jsnu[define("core_ai/policy",["exports","./repository"],(function(_exports,_repository){function _classStaticPrivateFieldSpecGet(receiver,classConstructor,descriptor){return function(receiver,classConstructor){if(receiver!==classConstructor)throw new TypeError("Private static access of wrong provenance")}(receiver,classConstructor),function(descriptor,action){if(void 0===descriptor)throw new TypeError("attempted to "+action+" private static field before its declaration")}(descriptor,"get"),function(receiver,descriptor){if(descriptor.get)return descriptor.get.call(receiver);return descriptor.value} /** * The Javascript module to handle the policy acceptance. * * @module core_ai/policy * @copyright 2024 Andrew Lyons * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */(receiver,descriptor)}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0;class _class{static preconfigurePolicyState(userid,state){_classStaticPrivateFieldSpecGet(this,_class,_policyAcceptedFor).hasOwnProperty(userid)||(_classStaticPrivateFieldSpecGet(this,_class,_policyAcceptedFor)[userid]=state)}static async getPolicyStatus(userid){if(_classStaticPrivateFieldSpecGet(this,_class,_policyAcceptedFor)[userid])return _classStaticPrivateFieldSpecGet(this,_class,_policyAcceptedFor)[userid];const accepted=await(0,_repository.getPolicyStatus)(userid);return _classStaticPrivateFieldSpecGet(this,_class,_policyAcceptedFor)[userid]=accepted.status,accepted.status}static acceptPolicy(){return _classStaticPrivateFieldSpecGet(this,_class,_policyAcceptedFor)[M.cfg.userId]=!0,(0,_repository.setPolicyStatus)(M.cfg.contextid)}}_exports.default=_class;var _policyAcceptedFor={writable:!0,value:{}};return _exports.default})); //# sourceMappingURL=policy.min.js.mapPK*\o \$amd/build/providerchooser.min.js.mapnu[{"version":3,"file":"providerchooser.min.js","sources":["../src/providerchooser.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/ //\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * AI provider selection handler.\n *\n * @module core_ai/providerchooser\n * @copyright 2024 Matt Porritt \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nconst Selectors = {\n fields: {\n selector: '[data-aiproviderchooser-field=\"selector\"]',\n updateButton: '[data-aiproviderchooser-field=\"updateButton\"]',\n },\n};\n\n/**\n * Initialise the AI provider chooser.\n */\nexport const init = () => {\n document.querySelector(Selectors.fields.selector)?.addEventListener('change', e => {\n const form = e.target.closest('form');\n const updateButton = form.querySelector(Selectors.fields.updateButton);\n const url = new URL(form.action);\n\n form.action = url.toString();\n updateButton.click();\n });\n};\n"],"names":["Selectors","selector","updateButton","document","querySelector","addEventListener","e","form","target","closest","url","URL","action","toString","click"],"mappings":";;;;;;;;MAsBMA,iBACM,CACJC,SAAU,4CACVC,aAAc,+DAOF,6DAChBC,SAASC,cAAcJ,iBAAiBC,kEAAWI,iBAAiB,UAAUC,UACpEC,KAAOD,EAAEE,OAAOC,QAAQ,QACxBP,aAAeK,KAAKH,cAAcJ,iBAAiBE,cACnDQ,IAAM,IAAIC,IAAIJ,KAAKK,QAEzBL,KAAKK,OAASF,IAAIG,WAClBX,aAAaY"}PK*\`vk  amd/build/policy.min.js.mapnu[{"version":3,"file":"policy.min.js","sources":["../src/policy.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\nimport {getPolicyStatus, setPolicyStatus} from \"./repository\";\n\n/**\n * The Javascript module to handle the policy acceptance.\n *\n * @module core_ai/policy\n * @copyright 2024 Andrew Lyons \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nexport default class {\n static #policyAcceptedFor = {};\n\n static preconfigurePolicyState(userid, state) {\n if (!this.#policyAcceptedFor.hasOwnProperty(userid)) {\n this.#policyAcceptedFor[userid] = state;\n }\n }\n\n /**\n * Get the policy status for a user.\n *\n * @param {Number} userid The user ID.\n * @return {Promise} The policy status.\n */\n static async getPolicyStatus(userid) {\n if (this.#policyAcceptedFor[userid]) {\n return this.#policyAcceptedFor[userid];\n }\n\n const accepted = await getPolicyStatus(userid);\n\n this.#policyAcceptedFor[userid] = accepted.status;\n\n return accepted.status;\n }\n\n static acceptPolicy() {\n this.#policyAcceptedFor[M.cfg.userId] = true;\n\n return setPolicyStatus(M.cfg.contextid);\n }\n}\n"],"names":["userid","state","_classStaticPrivateFieldSpecGet","hasOwnProperty","accepted","status","M","cfg","userId","contextid"],"mappings":";;;;;;;uJA2BmCA,OAAQC,OAC9BC,gEAAwBC,eAAeH,0EAChBA,QAAUC,oCAUbD,WACrBE,gEAAwBF,eACjBE,gEAAwBF,cAG7BI,eAAiB,+BAAgBJ,+EAEfA,QAAUI,SAASC,OAEpCD,SAASC,oGAIQC,EAAEC,IAAIC,SAAU,GAEjC,+BAAgBF,EAAEC,IAAIE,8EA7BL"}PK*\# UPGRADING.mdnu[# core_ai (subsystem) Upgrade notes ## 5.0 ### Added - - A new hook, `\core_ai\hook\after_ai_action_settings_form_hook`, has been introduced. It will allows AI provider plugins to add additional form elements for action settings configuration. For more information see [MDL-82980](https://tracker.moodle.org/browse/MDL-82980) - - AI provider plugins that want to implement `pre-defined models` and display additional settings for models must now extend the `\core_ai\aimodel\base` class. For more information see [MDL-82980](https://tracker.moodle.org/browse/MDL-82980) ### Changed - - The `\core_ai\form\action_settings_form` class has been updated to automatically include action buttons such as Save and Cancel. - AI provider plugins should update their form classes by removing the `$this->add_action_buttons();` call, as it is no longer required. For more information see [MDL-82980](https://tracker.moodle.org/browse/MDL-82980) ### Deprecated - The ai_provider_management_table has been refactored to inherit from flexible_table instead of plugin_management_table. As a result the methods get_plugintype and get_action_url are now unused and have been deprecated in the class. For more information see [MDL-82922](https://tracker.moodle.org/browse/MDL-82922) PK*\SI_tests/fixtures/black.pngnu[PNG  IHDR":9 pHYs.#.#x?vIDATx nH@`՗IENDB`PK*\Pbbtests/fixtures/grey.pngnu[PNG  IHDR":9 pHYs.#.#x?vIDATx ot BBydxHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH/`DIENDB`PK*\!Gbbtests/fixtures/white.pngnu[PNG  IHDR":9 pHYs.#.#x?vIDATx w> BBydxHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH! ٪IENDB`PK*\Ytests/generator/lib.phpnu[. /** * AI data generator for tests. * * @package core_ai * @copyright 2024 David Woloszyn * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class core_ai_generator extends component_generator_base { /** * Creates AI action registry records. * * @param array $data */ public function create_ai_actions(array $data): void { global $DB; if (!isset($data['actionname'])) { throw new Exception('\'ai actions\' requires the field \'actionname\' to be specified'); } if (!isset($data['success'])) { throw new Exception('\'ai actions\' requires the field \'success\' to be specified'); } if (!isset($data['userid'])) { throw new Exception('\'ai actions\' requires the field \'user\' to be specified'); } if (!isset($data['contextid'])) { throw new Exception('\'ai actions\' requires the field \'contextid\' to be specified'); } if (!isset($data['provider'])) { throw new Exception('\'ai actions\' requires the field \'provider\' to be specified'); } // Create the child action record. $child = new stdClass(); $child->prompt = 'Prompt text'; // Generate image actions need to be structured differently. if ($data['actionname'] === 'generate_image') { $child->numberimages = 1; $child->quality = 'hd'; $child->aspectratio = 'landscape'; $child->style = 'vivid'; $child->sourceurl = 'http://localhost/yourimage'; $child->revisedprompt = 'Revised prompt'; } else { // Generate text (and variants). $child->generatedcontent = 'Your generated content'; $child->prompttokens = $data['prompttokens'] ?? 111; $child->completiontoken = $data['completiontokens'] ?? 222; } // Simulate an error. if ($data['success'] == 0) { $data['errorcode'] = 403; $data['errormessage'] = 'Forbidden'; // Unset some values that won't be present with an error. unset($child->generatedcontent); unset($child->revisedprompt); unset($child->prompttokens); unset($child->completiontoken); } $childid = $DB->insert_record("ai_action_{$data['actionname']}", $child); // Finalise some fields before inserting. $data['actionid'] = $childid; $data['timecreated'] = time(); $data['timecompleted'] = time() + 1; $DB->insert_record('ai_action_register', $data); } /** * Creates AI provider instance. * * @param array $data * @return void */ public function create_ai_provider(array $data) { $manager = \core\di::get(\core_ai\manager::class); $classname = $data['provider'] . '\\' . 'provider'; $name = $data['name']; $enabled = $data['enabled']; unset($data['provider'], $data['name'], $data['enabled']); $manager->create_provider_instance( classname: $classname, name: $name, enabled: $enabled, config: $data, ); } } PK*\)mHH+tests/generator/behat_core_ai_generator.phpnu[. /** * Behat generator for AI. * * @package core_ai * @copyright 2024 David Woloszyn * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class behat_core_ai_generator extends behat_generator_base { /** * Get the list of creatable entities for core_ai. * * @return array */ protected function get_creatable_entities(): array { return [ 'ai actions' => [ 'singular' => 'ai action', 'datagenerator' => 'ai_actions', 'required' => [ 'actionname', 'success', 'user', 'contextid', 'provider', ], 'switchids' => [ 'user' => 'userid', ], ], 'ai providers' => [ 'singular' => 'ai provider', 'datagenerator' => 'ai_provider', 'required' => [ 'provider', 'name', 'enabled', ], ], ]; } } PK*\H P P &tests/external/provider_order_test.phpnu[. namespace core_ai\external; /** * Test provider order external api calls. * * @package core_ai * @copyright Meirza * @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); } } PK*\bߝtests/external/policy_test.phpnu[. namespace core_ai\external; /** * Test policy external api calls. * * @package core_ai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \core_ai\external\set_policy_status * @covers \core_ai\external\get_policy_status */ final class policy_test extends \advanced_testcase { /** * Test get policy failure. */ public function test_get_policy_failure(): void { $this->resetAfterTest(); // Create a test user. $user = $this->getDataGenerator()->create_user(); $this->setUser($user->id); $_POST['sesskey'] = sesskey(); $params = [ 'userid' => $user->id, ]; $result = \core_external\external_api::call_external_function( 'core_ai_get_policy_status', $params, ); $this->assertFalse($result['error']); $this->assertFalse($result['data']['status']); } /** * Test get policy success. */ public function test_get_policy_success(): void { $this->resetAfterTest(); global $DB; // Create a test user. $user = $this->getDataGenerator()->create_user(); $this->setUser($user->id); // Get system context. $context = \core\context\system::instance(); // Manually add record to the database. $record = new \stdClass(); $record->userid = $user->id; $record->contextid = $context->id; $record->timeaccepted = time(); $DB->insert_record('ai_policy_register', $record); $_POST['sesskey'] = sesskey(); $params = [ 'userid' => $user->id, ]; $result = \core_external\external_api::call_external_function( 'core_ai_get_policy_status', $params ); $this->assertFalse($result['error']); $this->assertTrue($result['data']['status']); } /** * Test set policy. */ public function test_set_policy(): void { $this->resetAfterTest(); // Create a test user. $user = $this->getDataGenerator()->create_user(); $this->setUser($user->id); // Get system context. $context = \context_system::instance(); $_POST['sesskey'] = sesskey(); $params = [ 'contextid' => $context->id, ]; $result = \core_external\external_api::call_external_function( 'core_ai_set_policy_status', $params ); $this->assertFalse($result['error']); $this->assertTrue($result['data']['success']); } /** * Test load_many_for_cache. */ public function test_user_policy_caching(): void { global $DB; $this->resetAfterTest(); $user1 = $this->getDataGenerator()->create_user(); $user2 = $this->getDataGenerator()->create_user(); // Manually add records to the database. $record1 = new \stdClass(); $record1->userid = $user1->id; $record1->contextid = 1; $record1->timeaccepted = time(); $record2 = new \stdClass(); $record2->userid = $user2->id; $record2->contextid = 1; $record2->timeaccepted = time(); $DB->insert_records('ai_policy_register', [ $record1, $record2, ]); $policycache = \cache::make('core', 'ai_policy'); $this->assertNotEmpty($policycache->get_many([$user1->id, $user2->id])); } } PK*\tOBtests/rate_limiter_test.phpnu[. namespace core_ai; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->libdir . '/testing/classes/incrementing_clock.php'); /** * Test ai subsystem rate limiter methods. * * @package core_ai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \core_ai\rate_limiter */ final class rate_limiter_test extends \advanced_testcase { /** * Set up before tests. */ protected function setUp(): void { parent::setUp(); $this->resetAfterTest(true); } /** * Test global rate limit is enforced correctly. */ public function test_global_rate_limit(): void { $ratelimiter = \core\di::get(rate_limiter::class); $component = 'testcomponent'; $ratelimit = 5; // Make 5 requests, all should be allowed. for ($i = 0; $i < 5; $i++) { $this->assertTrue($ratelimiter->check_global_rate_limit($component, $ratelimit)); } // The 6th request should be denied. $this->assertFalse($ratelimiter->check_global_rate_limit($component, $ratelimit)); } /** * Test user rate limit is enforced correctly. */ public function test_user_rate_limit(): void { $ratelimiter = \core\di::get(rate_limiter::class); $component = 'testcomponent'; $ratelimit = 3; $userid = 12345; // Make 3 requests for the user, all should be allowed. for ($i = 0; $i < 3; $i++) { $this->assertTrue($ratelimiter->check_user_rate_limit($component, $ratelimit, $userid)); } // The 4th request should be denied. $this->assertFalse($ratelimiter->check_user_rate_limit($component, $ratelimit, $userid)); } /** * Test global rate limit resets after time window. */ public function test_global_rate_limit_reset(): void { $clock = $this->mock_clock_with_incrementing(0); $ratelimiter = \core\di::get(rate_limiter::class); $component = 'testcomponent'; $ratelimit = 3; // Make 3 requests, all should be allowed. for ($i = 0; $i < 3; $i++) { $this->assertTrue($ratelimiter->check_global_rate_limit($component, $ratelimit)); } // Simulate moving time forward by TIME_WINDOW to reset the rate limit. $clock->set_to(rate_limiter::TIME_WINDOW + 1); // The next request should be allowed again. $this->assertTrue($ratelimiter->check_global_rate_limit($component, $ratelimit)); } /** * Test user rate limit resets after time window. */ public function test_user_rate_limit_reset(): void { $clock = $this->mock_clock_with_incrementing(0); $ratelimiter = \core\di::get(rate_limiter::class); $component = 'testcomponent'; $ratelimit = 3; $userid = 12345; // Make 3 requests for the user, all should be allowed. for ($i = 0; $i < 3; $i++) { $this->assertTrue($ratelimiter->check_user_rate_limit($component, $ratelimit, $userid)); } // Simulate moving time forward by TIME_WINDOW to reset the rate limit. $clock->set_to(rate_limiter::TIME_WINDOW + 1); // The next user request should be allowed again. $this->assertTrue($ratelimiter->check_user_rate_limit($component, $ratelimit, $userid)); } } PK*\&& tests/provider/provider_test.phpnu[. namespace core_ai\provider; use core_ai\aiactions\generate_image; use core_ai\aiactions\generate_text; use core_ai\aiactions\responses\response_generate_image; use core_ai\aiactions\responses\response_generate_text; use core_ai\aiactions\responses\response_summarise_text; use core_ai\aiactions\summarise_text; use core_ai\manager; use core_ai\privacy\provider; use core_privacy\local\request\approved_contextlist; use core_privacy\local\request\approved_userlist; use core_privacy\local\request\writer; use core_privacy\tests\request\content_writer; /** * Unit tests for \core_ai\provider * * @package core_ai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \core_ai\provider */ final class provider_test extends \advanced_testcase { /** @var \core_ai\manager */ private $manager; /** @var \core_ai\provider */ private $provider; /** * Overriding setUp() function to always reset after tests. */ public function setUp(): void { parent::setUp(); $this->resetAfterTest(); // Create the provider instance. $this->manager = \core\di::get(\core_ai\manager::class); $config = ['data' => 'goeshere']; $this->provider = $this->manager->create_provider_instance( classname: '\aiprovider_openai\provider', name: 'dummy', config: $config, ); } /** * Test get_contexts_for_userid(). */ public function test_get_contexts_for_userid(): void { $user1 = $this->getDataGenerator()->create_user(); $user2 = $this->getDataGenerator()->create_user(); $course1 = $this->getDataGenerator()->create_course(); $course2 = $this->getDataGenerator()->create_course(); $course1context = \context_course::instance($course1->id); $course2context = \context_course::instance($course2->id); $syscontext = \context_system::instance(); // AI policy. // Set the user policy. manager::user_policy_accepted($user1->id, $course1context->id); // Retrieve the user1's context ids. $contextids = provider::get_contexts_for_userid($user1->id); $this->assertNotEmpty($contextids->get_contextids()); $this->assertCount(1, $contextids->get_contextids()); $this->assertTrue(in_array($course1context->id, $contextids->get_contextids())); // Retrieve the user2's context ids. $contextids = provider::get_contexts_for_userid($user2->id); $this->assertEmpty($contextids->get_contextids()); // AI generate text. $action = new generate_text( contextid: $course2context->id, userid: $user1->id, prompttext: 'This is a test prompt', ); $body = [ 'id' => 'chatcmpl-123', 'fingerprint' => 'fp_44709d6fcb', 'generatedcontent' => 'This is the generated content', 'finishreason' => 'stop', 'prompttokens' => 9, 'completiontokens' => 12, 'model' => 'gpt-4o', ]; $actionresponse = new response_generate_text( success: true, ); $actionresponse->set_response_data($body); $method = new \ReflectionMethod($this->manager, 'store_action_result'); $method->invoke($this->manager, $this->provider, $action, $actionresponse); // Retrieve the user1's context ids. $contextids = provider::get_contexts_for_userid($user1->id); $this->assertNotEmpty($contextids->get_contextids()); $this->assertCount(2, $contextids->get_contextids()); $this->assertTrue(in_array($course1context->id, $contextids->get_contextids())); $this->assertTrue(in_array($course2context->id, $contextids->get_contextids())); // AI generate image. $action = new generate_image( contextid: $syscontext->id, userid: $user2->id, prompttext: 'This is a test prompt', quality: 'hd', aspectratio: 'square', numimages: 1, style: 'vivid', ); $body = [ 'revisedprompt' => 'This is a revised prompt', 'sourceurl' => 'https://example.com/image.png', 'model' => 'dall-e-3', ]; $actionresponse = new response_generate_image( success: true, ); $actionresponse->set_response_data($body); $method = new \ReflectionMethod($this->manager, 'store_action_result'); $method->invoke($this->manager, $this->provider, $action, $actionresponse); // Retrieve the user1's context ids. $contextids = provider::get_contexts_for_userid($user1->id); $this->assertNotEmpty($contextids->get_contextids()); $this->assertCount(2, $contextids->get_contextids()); $this->assertTrue(in_array($course1context->id, $contextids->get_contextids())); $this->assertTrue(in_array($course2context->id, $contextids->get_contextids())); // Retrieve the user2's context ids. $contextids = provider::get_contexts_for_userid($user2->id); $this->assertNotEmpty($contextids->get_contextids()); $this->assertCount(1, $contextids->get_contextids()); $this->assertFalse(in_array($course2context->id, $contextids->get_contextids())); $this->assertTrue(in_array($syscontext->id, $contextids->get_contextids())); // AI summarise text. $action = new summarise_text( contextid: $course2context->id, userid: $user2->id, prompttext: 'This is a test prompt', ); $body = [ 'id' => 'chatcmpl-123', 'fingerprint' => 'fp_44709d6fcb', 'generatedcontent' => 'This is the generated content', 'finishreason' => 'stop', 'prompttokens' => 9, 'completiontokens' => 12, 'model' => 'gpt-4o', ]; $actionresponse = new response_generate_text( success: true, ); $actionresponse->set_response_data($body); $method = new \ReflectionMethod($this->manager, 'store_action_result'); $method->invoke($this->manager, $this->provider, $action, $actionresponse); // Retrieve the user2's context ids. $contextids = provider::get_contexts_for_userid($user2->id); $this->assertNotEmpty($contextids->get_contextids()); $this->assertCount(2, $contextids->get_contextids()); $this->assertTrue(in_array($course2context->id, $contextids->get_contextids())); $this->assertTrue(in_array($syscontext->id, $contextids->get_contextids())); } /** * Test export_user_data() for AI policy. */ public function test_export_user_data_for_policy(): void { $user = $this->getDataGenerator()->create_user(); $course = $this->getDataGenerator()->create_course(); $coursecontext = \context_course::instance($course->id); // Set the user policy. manager::user_policy_accepted($user->id, $coursecontext->id); // Retrieve the user's context ids. $contextlist = provider::get_contexts_for_userid($user->id); $approvedcontextlist = new approved_contextlist($user, 'core_ai', $contextlist->get_contextids()); provider::export_user_data($approvedcontextlist); foreach ($contextlist as $context) { /** @var content_writer $writer */ $writer = writer::with_context($context); $this->assertTrue($writer->has_any_data()); $subcontexts = [ get_string('ai', 'core_ai'), ]; $name = 'policy'; $data = $writer->get_related_data($subcontexts, $name); $this->assertEquals($coursecontext->id, $data->contextid); } } /** * Test export_user_data() for generate text. */ public function test_export_user_data_for_generate_text(): void { $user = $this->getDataGenerator()->create_user(); $course1 = $this->getDataGenerator()->create_course(); $course2 = $this->getDataGenerator()->create_course(); $course1context = \context_course::instance($course1->id); $course2context = \context_course::instance($course2->id); $clock = $this->mock_clock_with_frozen(); $timecreated1 = $clock->time(); $action = new generate_text( contextid: $course1context->id, userid: $user->id, prompttext: 'This is a test prompt 1', ); $body = [ 'id' => 'chatcmpl-123', 'fingerprint' => 'fp_44709d6fcb', 'generatedcontent' => 'This is the generated content 1', 'finishreason' => 'stop', 'prompttokens' => 9, 'completiontokens' => 12, 'model' => 'gpt-4o', ]; $actionresponse = new response_generate_text( success: true, ); $actionresponse->set_response_data($body); $method = new \ReflectionMethod($this->manager, 'store_action_result'); $method->invoke($this->manager, $this->provider, $action, $actionresponse); $clock->bump(2); $timecreated2 = $clock->time(); $action = new generate_text( contextid: $course2context->id, userid: $user->id, prompttext: 'This is a test prompt 2', ); $body = [ 'id' => 'chatcmpl-123', 'fingerprint' => 'fp_44709d6fcb', 'generatedcontent' => 'This is the generated content 2', 'finishreason' => 'stop', 'prompttokens' => 10, 'completiontokens' => 15, 'model' => 'gpt-4o', ]; $actionresponse = new response_generate_text( success: true, ); $actionresponse->set_response_data($body); $method->invoke($this->manager, $this->provider, $action, $actionresponse); // Retrieve the user's context ids. $contextlist = provider::get_contexts_for_userid($user->id); $approvedcontextlist = new approved_contextlist($user, 'core_ai', $contextlist->get_contextids()); provider::export_user_data($approvedcontextlist); foreach ($contextlist as $context) { /** @var content_writer $writer */ $writer = writer::with_context($context); $this->assertTrue($writer->has_any_data()); if ($context->instanceid == $course1context->instanceid) { $subcontexts = [ get_string('ai', 'core_ai'), get_string('action_generate_text', 'core_ai'), date('c', $timecreated1), ]; $name = 'action_generate_text'; $data = $writer->get_related_data($subcontexts, $name); $this->assertEquals('generate_text', $data->actionname); $this->assertEquals($course1context->id, $data->contextid); $this->assertEquals('This is a test prompt 1', $data->prompt); $this->assertEquals('This is the generated content 1', $data->generatedcontent); $this->assertEquals('9', $data->prompttokens); $this->assertEquals('12', $data->completiontoken); $this->assertEquals(get_string('yes'), $data->success); $this->assertEquals('gpt-4o', $data->model); } if ($context->instanceid == $course2context->instanceid) { $subcontexts = [ get_string('ai', 'core_ai'), get_string('action_generate_text', 'core_ai'), date('c', $timecreated2), ]; $name = 'action_generate_text'; $data = $writer->get_related_data($subcontexts, $name); $this->assertEquals('generate_text', $data->actionname); $this->assertEquals($course2context->id, $data->contextid); $this->assertEquals('This is a test prompt 2', $data->prompt); $this->assertEquals('This is the generated content 2', $data->generatedcontent); $this->assertEquals('10', $data->prompttokens); $this->assertEquals('15', $data->completiontoken); $this->assertEquals(get_string('yes'), $data->success); $this->assertEquals('gpt-4o', $data->model); } } } /** * Test export_user_data() for generate image. */ public function test_export_user_data_for_generate_image(): void { $user = $this->getDataGenerator()->create_user(); $course1 = $this->getDataGenerator()->create_course(); $course2 = $this->getDataGenerator()->create_course(); $course1context = \context_course::instance($course1->id); $course2context = \context_course::instance($course2->id); $clock = $this->mock_clock_with_frozen(); $timecreated1 = $clock->time(); $action = new generate_image( contextid: $course1context->id, userid: $user->id, prompttext: 'This is a test prompt 1', quality: 'hd', aspectratio: 'square', numimages: 1, style: 'vivid', ); $body = [ 'revisedprompt' => 'This is a revised prompt', 'sourceurl' => 'https://example.com/image1.png', 'model' => 'dall-e-3', ]; $actionresponse = new response_generate_image( success: true, ); $actionresponse->set_response_data($body); $method = new \ReflectionMethod($this->manager, 'store_action_result'); $method->invoke($this->manager, $this->provider, $action, $actionresponse); $clock->bump(2); $timecreated2 = $clock->time(); $action = new generate_image( contextid: $course2context->id, userid: $user->id, prompttext: 'This is a test prompt 2', quality: 'hd', aspectratio: 'portrait', numimages: 2, style: 'vivid', ); $body = [ 'revisedprompt' => 'This is a revised prompt', 'sourceurl' => 'https://example.com/image2.png', 'model' => 'dall-e-3', ]; $actionresponse = new response_generate_image( success: true, ); $actionresponse->set_response_data($body); $method->invoke($this->manager, $this->provider, $action, $actionresponse); // Retrieve the user's context ids. $contextlist = provider::get_contexts_for_userid($user->id); $approvedcontextlist = new approved_contextlist($user, 'core_ai', $contextlist->get_contextids()); provider::export_user_data($approvedcontextlist); foreach ($contextlist as $context) { /** @var content_writer $writer */ $writer = writer::with_context($context); $this->assertTrue($writer->has_any_data()); if ($context->instanceid == $course1context->instanceid) { $subcontexts = [ get_string('ai', 'core_ai'), get_string('action_generate_image', 'core_ai'), date('c', $timecreated1), ]; $name = 'action_generate_image'; $data = $writer->get_related_data($subcontexts, $name); $this->assertEquals('generate_image', $data->actionname); $this->assertEquals($course1context->id, $data->contextid); $this->assertEquals('This is a test prompt 1', $data->prompt); $this->assertEquals('1', $data->numberimages); $this->assertEquals('hd', $data->quality); $this->assertEquals('square', $data->aspectratio); $this->assertEquals('vivid', $data->style); $this->assertEquals('https://example.com/image1.png', $data->sourceurl); $this->assertEquals(get_string('yes'), $data->success); $this->assertEquals('dall-e-3', $data->model); } if ($context->instanceid == $course2context->instanceid) { $subcontexts = [ get_string('ai', 'core_ai'), get_string('action_generate_image', 'core_ai'), date('c', $timecreated2), ]; $name = 'action_generate_image'; $data = $writer->get_related_data($subcontexts, $name); $this->assertEquals('generate_image', $data->actionname); $this->assertEquals($course2context->id, $data->contextid); $this->assertEquals('This is a test prompt 2', $data->prompt); $this->assertEquals('2', $data->numberimages); $this->assertEquals('hd', $data->quality); $this->assertEquals('portrait', $data->aspectratio); $this->assertEquals('vivid', $data->style); $this->assertEquals('https://example.com/image2.png', $data->sourceurl); $this->assertEquals(get_string('yes'), $data->success); $this->assertEquals('dall-e-3', $data->model); } } } /** * Test export_user_data() for summarise text. */ public function test_export_user_data_for_summarise_text(): void { $user = $this->getDataGenerator()->create_user(); $course1 = $this->getDataGenerator()->create_course(); $course2 = $this->getDataGenerator()->create_course(); $course1context = \context_course::instance($course1->id); $course2context = \context_course::instance($course2->id); $clock = $this->mock_clock_with_frozen(); $timecreated1 = $clock->time(); $action = new summarise_text( contextid: $course1context->id, userid: $user->id, prompttext: 'This is a test prompt 1', ); $body = [ 'id' => 'chatcmpl-123', 'fingerprint' => 'fp_44709d6fcb', 'generatedcontent' => 'This is the generated content 1', 'finishreason' => 'stop', 'prompttokens' => 9, 'completiontokens' => 12, 'model' => 'gpt-4o', ]; $actionresponse = new response_summarise_text( success: true, ); $actionresponse->set_response_data($body); $method = new \ReflectionMethod($this->manager, 'store_action_result'); $method->invoke($this->manager, $this->provider, $action, $actionresponse); $clock->bump(2); $timecreated2 = $clock->time(); $action = new summarise_text( contextid: $course2context->id, userid: $user->id, prompttext: 'This is a test prompt 2', ); $body = [ 'id' => 'chatcmpl-123', 'fingerprint' => 'fp_44709d6fcb', 'generatedcontent' => 'This is the generated content 2', 'finishreason' => 'stop', 'prompttokens' => 10, 'completiontokens' => 15, 'model' => 'gpt-4o', ]; $actionresponse = new response_summarise_text( success: true, ); $actionresponse->set_response_data($body); $method->invoke($this->manager, $this->provider, $action, $actionresponse); // Retrieve the user's context ids. $contextlist = provider::get_contexts_for_userid($user->id); $approvedcontextlist = new approved_contextlist($user, 'core_ai', $contextlist->get_contextids()); provider::export_user_data($approvedcontextlist); foreach ($contextlist as $context) { /** @var content_writer $writer */ $writer = writer::with_context($context); $this->assertTrue($writer->has_any_data()); if ($context->instanceid == $course1context->instanceid) { $subcontexts = [ get_string('ai', 'core_ai'), get_string('action_summarise_text', 'core_ai'), date('c', $timecreated1), ]; $name = 'action_summarise_text'; $data = $writer->get_related_data($subcontexts, $name); $this->assertEquals('summarise_text', $data->actionname); $this->assertEquals($course1context->id, $data->contextid); $this->assertEquals('This is a test prompt 1', $data->prompt); $this->assertEquals('This is the generated content 1', $data->generatedcontent); $this->assertEquals('9', $data->prompttokens); $this->assertEquals('12', $data->completiontoken); $this->assertEquals(get_string('yes'), $data->success); $this->assertEquals('gpt-4o', $data->model); } if ($context->instanceid == $course2context->instanceid) { $subcontexts = [ get_string('ai', 'core_ai'), get_string('action_summarise_text', 'core_ai'), date('c', $timecreated2), ]; $name = 'action_summarise_text'; $data = $writer->get_related_data($subcontexts, $name); $this->assertEquals('summarise_text', $data->actionname); $this->assertEquals($course2context->id, $data->contextid); $this->assertEquals('This is a test prompt 2', $data->prompt); $this->assertEquals('This is the generated content 2', $data->generatedcontent); $this->assertEquals('10', $data->prompttokens); $this->assertEquals('15', $data->completiontoken); $this->assertEquals(get_string('yes'), $data->success); $this->assertEquals('gpt-4o', $data->model); } } } /** * Test delete_data_for_all_users_in_context() for AI policy. */ public function test_delete_data_for_all_users_in_context_for_policy(): void { global $DB; $user1 = $this->getDataGenerator()->create_user(); $user2 = $this->getDataGenerator()->create_user(); $user3 = $this->getDataGenerator()->create_user(); $course1 = $this->getDataGenerator()->create_course(); $course2 = $this->getDataGenerator()->create_course(); $course1context = \context_course::instance($course1->id); $course2context = \context_course::instance($course2->id); // Set the user policy. manager::user_policy_accepted($user1->id, $course1context->id); manager::user_policy_accepted($user2->id, $course1context->id); manager::user_policy_accepted($user3->id, $course2context->id); provider::delete_data_for_all_users_in_context($course1context); // Verify all policy data for Course 1 has been deleted. $datas = $DB->get_records('ai_policy_register', ['contextid' => $course1context->id]); $this->assertCount(0, $datas); // Verify policy data for Course 2 are still present. $datas = $DB->get_records('ai_policy_register', ['contextid' => $course2context->id]); $this->assertCount(1, $datas); } /** * Test delete_data_for_all_users_in_context() for generate text. */ public function test_delete_data_for_all_users_in_context_for_generate_text(): void { global $DB; $user = $this->getDataGenerator()->create_user(); $course1 = $this->getDataGenerator()->create_course(); $course2 = $this->getDataGenerator()->create_course(); $course1context = \context_course::instance($course1->id); $course2context = \context_course::instance($course2->id); $clock = $this->mock_clock_with_frozen(); $action = new generate_text( contextid: $course1context->id, userid: $user->id, prompttext: 'This is a test prompt 1', ); $body = [ 'id' => 'chatcmpl-123', 'fingerprint' => 'fp_44709d6fcb', 'generatedcontent' => 'This is the generated content 1', 'finishreason' => 'stop', 'prompttokens' => 9, 'completiontokens' => 12, 'model' => 'gpt-4o', ]; $actionresponse = new response_generate_text( success: true, ); $actionresponse->set_response_data($body); $method = new \ReflectionMethod($this->manager, 'store_action_result'); $storeresult1 = $method->invoke($this->manager, $this->provider, $action, $actionresponse); $clock->bump(2); $action = new generate_text( contextid: $course2context->id, userid: $user->id, prompttext: 'This is a test prompt 2', ); $body = [ 'id' => 'chatcmpl-123', 'fingerprint' => 'fp_44709d6fcb', 'generatedcontent' => 'This is the generated content 2', 'finishreason' => 'stop', 'prompttokens' => 10, 'completiontokens' => 15, 'model' => 'gpt-4o', ]; $actionresponse = new response_generate_text( success: true, ); $actionresponse->set_response_data($body); $method = new \ReflectionMethod($this->manager, 'store_action_result'); $storeresult2 = $method->invoke($this->manager, $this->provider, $action, $actionresponse); provider::delete_data_for_all_users_in_context($course1context); $actionid1 = $DB->get_field('ai_action_register', 'actionid', ['id' => $storeresult1]); $actionid2 = $DB->get_field('ai_action_register', 'actionid', ['id' => $storeresult2]); $record = $DB->get_record('ai_action_generate_text', ['id' => $actionid1]); $this->assertEquals('', $record->prompt); $this->assertEquals('', $record->responseid); $this->assertEquals('', $record->fingerprint); $this->assertEquals('', $record->generatedcontent); $record = $DB->get_record('ai_action_generate_text', ['id' => $actionid2]); $this->assertNotEquals('', $record->prompt); $this->assertNotEquals('', $record->responseid); $this->assertNotEquals('', $record->fingerprint); $this->assertNotEquals('', $record->generatedcontent); } /** * Test delete_data_for_all_users_in_context() for generate image. */ public function test_delete_data_for_all_users_in_context_for_generate_image(): void { global $DB; $user = $this->getDataGenerator()->create_user(); $course1 = $this->getDataGenerator()->create_course(); $course2 = $this->getDataGenerator()->create_course(); $course1context = \context_course::instance($course1->id); $course2context = \context_course::instance($course2->id); $clock = $this->mock_clock_with_frozen(); $action = new generate_image( contextid: $course1context->id, userid: $user->id, prompttext: 'This is a test prompt 1', quality: 'hd', aspectratio: 'square', numimages: 1, style: 'vivid', ); $body = [ 'revisedprompt' => 'This is a revised prompt', 'sourceurl' => 'https://example.com/image1.png', 'model' => 'dall-e-3', ]; $actionresponse = new response_generate_image( success: true, ); $actionresponse->set_response_data($body); $method = new \ReflectionMethod($this->manager, 'store_action_result'); $storeresult1 = $method->invoke($this->manager, $this->provider, $action, $actionresponse); $clock->bump(2); $action = new generate_image( contextid: $course2context->id, userid: $user->id, prompttext: 'This is a test prompt 2', quality: 'hd', aspectratio: 'square', numimages: 1, style: 'vivid', ); $body = [ 'revisedprompt' => 'This is a revised prompt', 'sourceurl' => 'https://example.com/image2.png', 'model' => 'dall-e-3', ]; $actionresponse = new response_generate_image( success: true, ); $actionresponse->set_response_data($body); $method = new \ReflectionMethod($this->manager, 'store_action_result'); $storeresult2 = $method->invoke($this->manager, $this->provider, $action, $actionresponse); provider::delete_data_for_all_users_in_context($course1context); $actionid1 = $DB->get_field('ai_action_register', 'actionid', ['id' => $storeresult1]); $actionid2 = $DB->get_field('ai_action_register', 'actionid', ['id' => $storeresult2]); $record = $DB->get_record('ai_action_generate_image', ['id' => $actionid1]); $this->assertEquals('', $record->prompt); $this->assertEquals('', $record->sourceurl); $this->assertEquals('', $record->revisedprompt); $record = $DB->get_record('ai_action_generate_image', ['id' => $actionid2]); $this->assertNotEquals('', $record->prompt); $this->assertNotEquals('', $record->sourceurl); $this->assertNotEquals('', $record->revisedprompt); } /** * Test delete_data_for_all_users_in_context() for summarise text. */ public function test_delete_data_for_all_users_in_context_for_summarise_text(): void { global $DB; $user = $this->getDataGenerator()->create_user(); $course1 = $this->getDataGenerator()->create_course(); $course2 = $this->getDataGenerator()->create_course(); $course1context = \context_course::instance($course1->id); $course2context = \context_course::instance($course2->id); $clock = $this->mock_clock_with_frozen(); $action = new summarise_text( contextid: $course1context->id, userid: $user->id, prompttext: 'This is a test prompt 1', ); $body = [ 'id' => 'chatcmpl-123', 'fingerprint' => 'fp_44709d6fcb', 'generatedcontent' => 'This is the generated content 1', 'finishreason' => 'stop', 'prompttokens' => 9, 'completiontokens' => 12, 'model' => 'gpt-4o', ]; $actionresponse = new response_summarise_text( success: true, ); $actionresponse->set_response_data($body); $method = new \ReflectionMethod($this->manager, 'store_action_result'); $storeresult1 = $method->invoke($this->manager, $this->provider, $action, $actionresponse); $clock->bump(2); $action = new summarise_text( contextid: $course2context->id, userid: $user->id, prompttext: 'This is a test prompt 2', ); $body = [ 'id' => 'chatcmpl-123', 'fingerprint' => 'fp_44709d6fcb', 'generatedcontent' => 'This is the generated content 2', 'finishreason' => 'stop', 'prompttokens' => 10, 'completiontokens' => 15, 'model' => 'gpt-4o', ]; $actionresponse = new response_summarise_text( success: true, ); $actionresponse->set_response_data($body); $method = new \ReflectionMethod($this->manager, 'store_action_result'); $storeresult2 = $method->invoke($this->manager, $this->provider, $action, $actionresponse); provider::delete_data_for_all_users_in_context($course1context); $actionid1 = $DB->get_field('ai_action_register', 'actionid', ['id' => $storeresult1]); $actionid2 = $DB->get_field('ai_action_register', 'actionid', ['id' => $storeresult2]); $record = $DB->get_record('ai_action_summarise_text', ['id' => $actionid1]); $this->assertEquals('', $record->prompt); $this->assertEquals('', $record->responseid); $this->assertEquals('', $record->fingerprint); $this->assertEquals('', $record->generatedcontent); $record = $DB->get_record('ai_action_summarise_text', ['id' => $actionid2]); $this->assertNotEquals('', $record->prompt); $this->assertNotEquals('', $record->responseid); $this->assertNotEquals('', $record->fingerprint); $this->assertNotEquals('', $record->generatedcontent); } /** * Test delete_data_for_all_users_in_context() for generate text. */ public function test_delete_data_for_user_for_generate_text(): void { global $DB; $user1 = $this->getDataGenerator()->create_user(); $user2 = $this->getDataGenerator()->create_user(); $course = $this->getDataGenerator()->create_course(); $coursecontext = \context_course::instance($course->id); $clock = $this->mock_clock_with_frozen(); $action = new generate_text( contextid: $coursecontext->id, userid: $user1->id, prompttext: 'This is a test prompt 1', ); $body = [ 'id' => 'chatcmpl-123', 'fingerprint' => 'fp_44709d6fcb', 'generatedcontent' => 'This is the generated content 1', 'finishreason' => 'stop', 'prompttokens' => 9, 'completiontokens' => 12, 'model' => 'gpt-4o', ]; $actionresponse = new response_generate_text( success: true, ); $actionresponse->set_response_data($body); $method = new \ReflectionMethod($this->manager, 'store_action_result'); $storeresult1 = $method->invoke($this->manager, $this->provider, $action, $actionresponse); $clock->bump(2); $action = new generate_text( contextid: $coursecontext->id, userid: $user2->id, prompttext: 'This is a test prompt 2', ); $body = [ 'id' => 'chatcmpl-123', 'fingerprint' => 'fp_44709d6fcb', 'generatedcontent' => 'This is the generated content 2', 'finishreason' => 'stop', 'prompttokens' => 10, 'completiontokens' => 15, 'model' => 'gpt-4o', ]; $actionresponse = new response_generate_text( success: true, ); $actionresponse->set_response_data($body); $method = new \ReflectionMethod($this->manager, 'store_action_result'); $storeresult2 = $method->invoke($this->manager, $this->provider, $action, $actionresponse); $contextlist = provider::get_contexts_for_userid($user1->id); $approvedcontextlist = new approved_contextlist($user1, 'core_ai', $contextlist->get_contextids()); provider::delete_data_for_user($approvedcontextlist); $actionid1 = $DB->get_field('ai_action_register', 'actionid', ['id' => $storeresult1]); $actionid2 = $DB->get_field('ai_action_register', 'actionid', ['id' => $storeresult2]); $record = $DB->get_record('ai_action_generate_text', ['id' => $actionid1]); $this->assertEquals('', $record->prompt); $this->assertEquals('', $record->responseid); $this->assertEquals('', $record->fingerprint); $this->assertEquals('', $record->generatedcontent); $record = $DB->get_record('ai_action_generate_text', ['id' => $actionid2]); $this->assertNotEquals('', $record->prompt); $this->assertNotEquals('', $record->responseid); $this->assertNotEquals('', $record->fingerprint); $this->assertNotEquals('', $record->generatedcontent); } /** * Test delete_data_for_all_users_in_context() for generate image. */ public function test_delete_data_for_user_for_generate_image(): void { global $DB; $user1 = $this->getDataGenerator()->create_user(); $user2 = $this->getDataGenerator()->create_user(); $course = $this->getDataGenerator()->create_course(); $coursecontext = \context_course::instance($course->id); $clock = $this->mock_clock_with_frozen(); $action = new generate_image( contextid: $coursecontext->id, userid: $user1->id, prompttext: 'This is a test prompt 1', quality: 'hd', aspectratio: 'square', numimages: 1, style: 'vivid', ); $body = [ 'revisedprompt' => 'This is a revised prompt', 'sourceurl' => 'https://example.com/image1.png', 'model' => 'dall-e-3', ]; $actionresponse = new response_generate_image( success: true, ); $actionresponse->set_response_data($body); $method = new \ReflectionMethod($this->manager, 'store_action_result'); $storeresult1 = $method->invoke($this->manager, $this->provider, $action, $actionresponse); $clock->bump(2); $action = new generate_image( contextid: $coursecontext->id, userid: $user2->id, prompttext: 'This is a test prompt 2', quality: 'hd', aspectratio: 'square', numimages: 1, style: 'vivid', ); $body = [ 'revisedprompt' => 'This is a revised prompt', 'sourceurl' => 'https://example.com/image2.png', 'model' => 'dall-e-3', ]; $actionresponse = new response_generate_image( success: true, ); $actionresponse->set_response_data($body); $method = new \ReflectionMethod($this->manager, 'store_action_result'); $storeresult2 = $method->invoke($this->manager, $this->provider, $action, $actionresponse); $contextlist = provider::get_contexts_for_userid($user1->id); $approvedcontextlist = new approved_contextlist($user1, 'core_ai', $contextlist->get_contextids()); provider::delete_data_for_user($approvedcontextlist); $actionid1 = $DB->get_field('ai_action_register', 'actionid', ['id' => $storeresult1]); $actionid2 = $DB->get_field('ai_action_register', 'actionid', ['id' => $storeresult2]); $record = $DB->get_record('ai_action_generate_image', ['id' => $actionid1]); $this->assertEquals('', $record->prompt); $this->assertEquals('', $record->sourceurl); $this->assertEquals('', $record->revisedprompt); $record = $DB->get_record('ai_action_generate_image', ['id' => $actionid2]); $this->assertNotEquals('', $record->prompt); $this->assertNotEquals('', $record->sourceurl); $this->assertNotEquals('', $record->revisedprompt); } /** * Test delete_data_for_all_users_in_context() for summarise text. */ public function test_delete_data_for_user_for_summarise_text(): void { global $DB; $user1 = $this->getDataGenerator()->create_user(); $user2 = $this->getDataGenerator()->create_user(); $course = $this->getDataGenerator()->create_course(); $coursecontext = \context_course::instance($course->id); $clock = $this->mock_clock_with_frozen(); $action = new summarise_text( contextid: $coursecontext->id, userid: $user1->id, prompttext: 'This is a test prompt 1', ); $body = [ 'id' => 'chatcmpl-123', 'fingerprint' => 'fp_44709d6fcb', 'generatedcontent' => 'This is the generated content 1', 'finishreason' => 'stop', 'prompttokens' => 9, 'completiontokens' => 12, 'model' => 'gpt-4o', ]; $actionresponse = new response_summarise_text( success: true, ); $actionresponse->set_response_data($body); $method = new \ReflectionMethod($this->manager, 'store_action_result'); $storeresult1 = $method->invoke($this->manager, $this->provider, $action, $actionresponse); $clock->bump(2); $action = new summarise_text( contextid: $coursecontext->id, userid: $user2->id, prompttext: 'This is a test prompt 2', ); $body = [ 'id' => 'chatcmpl-123', 'fingerprint' => 'fp_44709d6fcb', 'generatedcontent' => 'This is the generated content 2', 'finishreason' => 'stop', 'prompttokens' => 10, 'completiontokens' => 15, 'model' => 'gpt-4o', ]; $actionresponse = new response_summarise_text( success: true, ); $actionresponse->set_response_data($body); $method = new \ReflectionMethod($this->manager, 'store_action_result'); $storeresult2 = $method->invoke($this->manager, $this->provider, $action, $actionresponse); $contextlist = provider::get_contexts_for_userid($user1->id); $approvedcontextlist = new approved_contextlist($user1, 'core_ai', $contextlist->get_contextids()); provider::delete_data_for_user($approvedcontextlist); $actionid1 = $DB->get_field('ai_action_register', 'actionid', ['id' => $storeresult1]); $actionid2 = $DB->get_field('ai_action_register', 'actionid', ['id' => $storeresult2]); $record = $DB->get_record('ai_action_summarise_text', ['id' => $actionid1]); $this->assertEquals('', $record->prompt); $this->assertEquals('', $record->responseid); $this->assertEquals('', $record->fingerprint); $this->assertEquals('', $record->generatedcontent); $record = $DB->get_record('ai_action_summarise_text', ['id' => $actionid2]); $this->assertNotEquals('', $record->prompt); $this->assertNotEquals('', $record->responseid); $this->assertNotEquals('', $record->fingerprint); $this->assertNotEquals('', $record->generatedcontent); } /** * Test get_users_in_context() for policy. */ public function test_get_users_in_context_for_policy(): void { $user1 = $this->getDataGenerator()->create_user(); $user2 = $this->getDataGenerator()->create_user(); $course1 = $this->getDataGenerator()->create_course(); $course2 = $this->getDataGenerator()->create_course(); $course1context = \context_course::instance($course1->id); $course2context = \context_course::instance($course2->id); manager::user_policy_accepted($user1->id, $course1context->id); manager::user_policy_accepted($user2->id, $course2context->id); // The user list for course1context should return user1. $userlist = new \core_privacy\local\request\userlist($course1context, 'core_ai'); provider::get_users_in_context($userlist); $this->assertCount(1, $userlist); $this->assertTrue(in_array($user1->id, $userlist->get_userids())); $this->assertFalse(in_array($user2->id, $userlist->get_userids())); // The user list for course2context should return user2. $userlist = new \core_privacy\local\request\userlist($course2context, 'core_ai'); provider::get_users_in_context($userlist); $this->assertCount(1, $userlist); $this->assertTrue(in_array($user2->id, $userlist->get_userids())); $this->assertFalse(in_array($user1->id, $userlist->get_userids())); } /** * Test get_users_in_context() for generate text. */ public function test_get_users_in_context_for_generate_text(): void { global $DB; $user1 = $this->getDataGenerator()->create_user(); $user2 = $this->getDataGenerator()->create_user(); $course1 = $this->getDataGenerator()->create_course(); $course2 = $this->getDataGenerator()->create_course(); $course1context = \context_course::instance($course1->id); $course2context = \context_course::instance($course2->id); $clock = $this->mock_clock_with_frozen(); $action = new generate_text( contextid: $course1context->id, userid: $user1->id, prompttext: 'This is a test prompt 1', ); $body = [ 'id' => 'chatcmpl-123', 'fingerprint' => 'fp_44709d6fcb', 'generatedcontent' => 'This is the generated content 1', 'finishreason' => 'stop', 'prompttokens' => 9, 'completiontokens' => 12, 'model' => 'gpt-4o', ]; $actionresponse = new response_generate_text( success: true, ); $actionresponse->set_response_data($body); $method = new \ReflectionMethod($this->manager, 'store_action_result'); $method->invoke($this->manager, $this->provider, $action, $actionresponse); $clock->bump(2); $action = new generate_text( contextid: $course2context->id, userid: $user2->id, prompttext: 'This is a test prompt 2', ); $body = [ 'id' => 'chatcmpl-123', 'fingerprint' => 'fp_44709d6fcb', 'generatedcontent' => 'This is the generated content 2', 'finishreason' => 'stop', 'prompttokens' => 10, 'completiontokens' => 15, 'model' => 'gpt-4o', ]; $actionresponse = new response_generate_text( success: true, ); $actionresponse->set_response_data($body); $method = new \ReflectionMethod($this->manager, 'store_action_result'); $method->invoke($this->manager, $this->provider, $action, $actionresponse); // The user list for course1context should return user1. $userlist = new \core_privacy\local\request\userlist($course1context, 'core_ai'); provider::get_users_in_context($userlist); $this->assertCount(1, $userlist); $this->assertTrue(in_array($user1->id, $userlist->get_userids())); $this->assertFalse(in_array($user2->id, $userlist->get_userids())); // The user list for course2context should return user2. $userlist = new \core_privacy\local\request\userlist($course2context, 'core_ai'); provider::get_users_in_context($userlist); $this->assertCount(1, $userlist); $this->assertTrue(in_array($user2->id, $userlist->get_userids())); $this->assertFalse(in_array($user1->id, $userlist->get_userids())); } /** * Test get_users_in_context() for generate image. */ public function test_get_users_in_context_for_generate_image(): void { $user1 = $this->getDataGenerator()->create_user(); $user2 = $this->getDataGenerator()->create_user(); $course1 = $this->getDataGenerator()->create_course(); $course2 = $this->getDataGenerator()->create_course(); $course1context = \context_course::instance($course1->id); $course2context = \context_course::instance($course2->id); $clock = $this->mock_clock_with_frozen(); $action = new generate_image( contextid: $course1context->id, userid: $user1->id, prompttext: 'This is a test prompt 1', quality: 'hd', aspectratio: 'square', numimages: 1, style: 'vivid', ); $body = [ 'revisedprompt' => 'This is a revised prompt', 'sourceurl' => 'https://example.com/image1.png', 'model' => 'dall-e-3', ]; $actionresponse = new response_generate_image( success: true, ); $actionresponse->set_response_data($body); $method = new \ReflectionMethod($this->manager, 'store_action_result'); $method->invoke($this->manager, $this->provider, $action, $actionresponse); $clock->bump(2); $action = new generate_image( contextid: $course2context->id, userid: $user2->id, prompttext: 'This is a test prompt 2', quality: 'hd', aspectratio: 'square', numimages: 1, style: 'vivid', ); $body = [ 'revisedprompt' => 'This is a revised prompt', 'sourceurl' => 'https://example.com/image2.png', 'model' => 'dall-e-3', ]; $actionresponse = new response_generate_image( success: true, ); $actionresponse->set_response_data($body); $method = new \ReflectionMethod($this->manager, 'store_action_result'); $method->invoke($this->manager, $this->provider, $action, $actionresponse); // The user list for course1context should return user1. $userlist = new \core_privacy\local\request\userlist($course1context, 'core_ai'); provider::get_users_in_context($userlist); $this->assertCount(1, $userlist); $this->assertTrue(in_array($user1->id, $userlist->get_userids())); $this->assertFalse(in_array($user2->id, $userlist->get_userids())); // The user list for course2context should return user2. $userlist = new \core_privacy\local\request\userlist($course2context, 'core_ai'); provider::get_users_in_context($userlist); $this->assertCount(1, $userlist); $this->assertTrue(in_array($user2->id, $userlist->get_userids())); $this->assertFalse(in_array($user1->id, $userlist->get_userids())); } /** * Test get_users_in_context() for summarise text. */ public function test_get_users_in_context_for_summarise_text(): void { $user1 = $this->getDataGenerator()->create_user(); $user2 = $this->getDataGenerator()->create_user(); $course1 = $this->getDataGenerator()->create_course(); $course2 = $this->getDataGenerator()->create_course(); $course1context = \context_course::instance($course1->id); $course2context = \context_course::instance($course2->id); $clock = $this->mock_clock_with_frozen(); $action = new summarise_text( contextid: $course1context->id, userid: $user1->id, prompttext: 'This is a test prompt 1', ); $body = [ 'id' => 'chatcmpl-123', 'fingerprint' => 'fp_44709d6fcb', 'generatedcontent' => 'This is the generated content 1', 'finishreason' => 'stop', 'prompttokens' => 9, 'completiontokens' => 12, 'model' => 'gpt-4o', ]; $actionresponse = new response_summarise_text( success: true, ); $actionresponse->set_response_data($body); $method = new \ReflectionMethod($this->manager, 'store_action_result'); $method->invoke($this->manager, $this->provider, $action, $actionresponse); $clock->bump(2); $action = new summarise_text( contextid: $course2context->id, userid: $user2->id, prompttext: 'This is a test prompt 2', ); $body = [ 'id' => 'chatcmpl-123', 'fingerprint' => 'fp_44709d6fcb', 'generatedcontent' => 'This is the generated content 2', 'finishreason' => 'stop', 'prompttokens' => 10, 'completiontokens' => 15, 'model' => 'gpt-4o', ]; $actionresponse = new response_summarise_text( success: true, ); $actionresponse->set_response_data($body); $method = new \ReflectionMethod($this->manager, 'store_action_result'); $method->invoke($this->manager, $this->provider, $action, $actionresponse); // The user list for course1context should return user1. $userlist = new \core_privacy\local\request\userlist($course1context, 'core_ai'); provider::get_users_in_context($userlist); $this->assertCount(1, $userlist); $this->assertTrue(in_array($user1->id, $userlist->get_userids())); $this->assertFalse(in_array($user2->id, $userlist->get_userids())); // The user list for course2context should return user2. $userlist = new \core_privacy\local\request\userlist($course2context, 'core_ai'); provider::get_users_in_context($userlist); $this->assertCount(1, $userlist); $this->assertTrue(in_array($user2->id, $userlist->get_userids())); $this->assertFalse(in_array($user1->id, $userlist->get_userids())); } /** * Test delete_data_for_users() for policy. */ public function test_delete_data_for_users_for_policy(): void { global $DB; $user1 = $this->getDataGenerator()->create_user(); $user2 = $this->getDataGenerator()->create_user(); $user3 = $this->getDataGenerator()->create_user(); $course1 = $this->getDataGenerator()->create_course(); $course2 = $this->getDataGenerator()->create_course(); $course1context = \context_course::instance($course1->id); $course2context = \context_course::instance($course2->id); // Set the user policy. manager::user_policy_accepted($user1->id, $course1context->id); manager::user_policy_accepted($user2->id, $course1context->id); manager::user_policy_accepted($user3->id, $course2context->id); $userlist = new \core_privacy\local\request\userlist($course1context, 'core_ai'); provider::get_users_in_context($userlist); $approvedlist = new approved_userlist($course1context, 'core_ai', $userlist->get_userids()); provider::delete_data_for_users($approvedlist); // Verify all policy data for user1 and user2 have been deleted. $this->assertFalse($DB->record_exists('ai_policy_register', ['userid' => $user1->id])); $this->assertFalse($DB->record_exists('ai_policy_register', ['userid' => $user2->id])); // Verify policy data for user3 is still present. $this->assertTrue($DB->record_exists('ai_policy_register', ['userid' => $user3->id])); } /** * Test delete_data_for_users() for generate text. */ public function test_delete_data_for_users_for_generate_text(): void { global $DB; $user1 = $this->getDataGenerator()->create_user(); $user2 = $this->getDataGenerator()->create_user(); $course1 = $this->getDataGenerator()->create_course(); $course2 = $this->getDataGenerator()->create_course(); $course1context = \context_course::instance($course1->id); $course2context = \context_course::instance($course2->id); $clock = $this->mock_clock_with_frozen(); $action = new generate_text( contextid: $course1context->id, userid: $user1->id, prompttext: 'This is a test prompt 1', ); $body = [ 'id' => 'chatcmpl-123', 'fingerprint' => 'fp_44709d6fcb', 'generatedcontent' => 'This is the generated content 1', 'finishreason' => 'stop', 'prompttokens' => 9, 'completiontokens' => 12, 'model' => 'gpt-4o', ]; $actionresponse = new response_generate_text( success: true, ); $actionresponse->set_response_data($body); $method = new \ReflectionMethod($this->manager, 'store_action_result'); $storeresult1 = $method->invoke($this->manager, $this->provider, $action, $actionresponse); $clock->bump(2); $action = new generate_text( contextid: $course2context->id, userid: $user2->id, prompttext: 'This is a test prompt 2', ); $body = [ 'id' => 'chatcmpl-123', 'fingerprint' => 'fp_44709d6fcb', 'generatedcontent' => 'This is the generated content 2', 'finishreason' => 'stop', 'prompttokens' => 10, 'completiontokens' => 15, 'model' => 'gpt-4o', ]; $actionresponse = new response_generate_text( success: true, ); $actionresponse->set_response_data($body); $method = new \ReflectionMethod($this->manager, 'store_action_result'); $storeresult2 = $method->invoke($this->manager, $this->provider, $action, $actionresponse); $userlist = new \core_privacy\local\request\userlist($course1context, 'core_ai'); provider::get_users_in_context($userlist); $approvedlist = new approved_userlist($course1context, 'core_ai', $userlist->get_userids()); provider::delete_data_for_users($approvedlist); $actionid1 = $DB->get_field('ai_action_register', 'actionid', ['id' => $storeresult1]); $actionid2 = $DB->get_field('ai_action_register', 'actionid', ['id' => $storeresult2]); $record = $DB->get_record('ai_action_generate_text', ['id' => $actionid1]); $this->assertEquals('', $record->prompt); $this->assertEquals('', $record->responseid); $this->assertEquals('', $record->fingerprint); $this->assertEquals('', $record->generatedcontent); $record = $DB->get_record('ai_action_generate_text', ['id' => $actionid2]); $this->assertNotEquals('', $record->prompt); $this->assertNotEquals('', $record->responseid); $this->assertNotEquals('', $record->fingerprint); $this->assertNotEquals('', $record->generatedcontent); } /** * Test delete_data_for_users() for generate image. */ public function test_delete_data_for_users_for_generate_image(): void { global $DB; $user1 = $this->getDataGenerator()->create_user(); $user2 = $this->getDataGenerator()->create_user(); $course1 = $this->getDataGenerator()->create_course(); $course2 = $this->getDataGenerator()->create_course(); $course1context = \context_course::instance($course1->id); $course2context = \context_course::instance($course2->id); $clock = $this->mock_clock_with_frozen(); $action = new generate_image( contextid: $course1context->id, userid: $user1->id, prompttext: 'This is a test prompt 1', quality: 'hd', aspectratio: 'square', numimages: 1, style: 'vivid', ); $body = [ 'revisedprompt' => 'This is a revised prompt', 'sourceurl' => 'https://example.com/image1.png', 'model' => 'dall-e-3', ]; $actionresponse = new response_generate_image( success: true, ); $actionresponse->set_response_data($body); $method = new \ReflectionMethod($this->manager, 'store_action_result'); $storeresult1 = $method->invoke($this->manager, $this->provider, $action, $actionresponse); $clock->bump(2); $action = new generate_image( contextid: $course2context->id, userid: $user2->id, prompttext: 'This is a test prompt 2', quality: 'hd', aspectratio: 'square', numimages: 1, style: 'vivid', ); $body = [ 'revisedprompt' => 'This is a revised prompt', 'sourceurl' => 'https://example.com/image2.png', 'model' => 'dall-e-3', ]; $actionresponse = new response_generate_image( success: true, ); $actionresponse->set_response_data($body); $method = new \ReflectionMethod($this->manager, 'store_action_result'); $storeresult2 = $method->invoke($this->manager, $this->provider, $action, $actionresponse); $userlist = new \core_privacy\local\request\userlist($course1context, 'core_ai'); provider::get_users_in_context($userlist); $approvedlist = new approved_userlist($course1context, 'core_ai', $userlist->get_userids()); provider::delete_data_for_users($approvedlist); $actionid1 = $DB->get_field('ai_action_register', 'actionid', ['id' => $storeresult1]); $actionid2 = $DB->get_field('ai_action_register', 'actionid', ['id' => $storeresult2]); $record = $DB->get_record('ai_action_generate_image', ['id' => $actionid1]); $this->assertEquals('', $record->prompt); $this->assertEquals('', $record->sourceurl); $this->assertEquals('', $record->revisedprompt); $record = $DB->get_record('ai_action_generate_image', ['id' => $actionid2]); $this->assertNotEquals('', $record->prompt); $this->assertNotEquals('', $record->sourceurl); $this->assertNotEquals('', $record->revisedprompt); } /** * Test delete_data_for_users() for generate image. */ public function test_delete_data_for_users_for_summarise_text(): void { global $DB; $user = $this->getDataGenerator()->create_user(); $course1 = $this->getDataGenerator()->create_course(); $course2 = $this->getDataGenerator()->create_course(); $course1context = \context_course::instance($course1->id); $course2context = \context_course::instance($course2->id); $clock = $this->mock_clock_with_frozen(); $action = new summarise_text( contextid: $course1context->id, userid: $user->id, prompttext: 'This is a test prompt 1', ); $body = [ 'id' => 'chatcmpl-123', 'fingerprint' => 'fp_44709d6fcb', 'generatedcontent' => 'This is the generated content 1', 'finishreason' => 'stop', 'prompttokens' => 9, 'completiontokens' => 12, 'model' => 'gpt-4o', ]; $actionresponse = new response_summarise_text( success: true, ); $actionresponse->set_response_data($body); $method = new \ReflectionMethod($this->manager, 'store_action_result'); $storeresult1 = $method->invoke($this->manager, $this->provider, $action, $actionresponse); $clock->bump(2); $action = new summarise_text( contextid: $course2context->id, userid: $user->id, prompttext: 'This is a test prompt 2', ); $body = [ 'id' => 'chatcmpl-123', 'fingerprint' => 'fp_44709d6fcb', 'generatedcontent' => 'This is the generated content 2', 'finishreason' => 'stop', 'prompttokens' => 10, 'completiontokens' => 15, 'model' => 'gpt-4o', ]; $actionresponse = new response_summarise_text( success: true, ); $actionresponse->set_response_data($body); $method = new \ReflectionMethod($this->manager, 'store_action_result'); $storeresult2 = $method->invoke($this->manager, $this->provider, $action, $actionresponse); $userlist = new \core_privacy\local\request\userlist($course1context, 'core_ai'); provider::get_users_in_context($userlist); $approvedlist = new approved_userlist($course1context, 'core_ai', $userlist->get_userids()); provider::delete_data_for_users($approvedlist); $actionid1 = $DB->get_field('ai_action_register', 'actionid', ['id' => $storeresult1]); $actionid2 = $DB->get_field('ai_action_register', 'actionid', ['id' => $storeresult2]); $record = $DB->get_record('ai_action_summarise_text', ['id' => $actionid1]); $this->assertEquals('', $record->prompt); $this->assertEquals('', $record->responseid); $this->assertEquals('', $record->fingerprint); $this->assertEquals('', $record->generatedcontent); $record = $DB->get_record('ai_action_summarise_text', ['id' => $actionid2]); $this->assertNotEquals('', $record->prompt); $this->assertNotEquals('', $record->responseid); $this->assertNotEquals('', $record->fingerprint); $this->assertNotEquals('', $record->generatedcontent); } /** * Test get_name. */ public function test_get_name(): void { $this->assertEquals('aiprovider_openai', $this->provider->get_name()); } /** * Test the is_request_allowed method of the provider abstract class. */ public function test_is_request_allowed(): void { // Create action. $action1 = new summarise_text( contextid: 1, userid: 1, prompttext: 'This is a test prompt 1', ); $action2 = new summarise_text( contextid: 1, userid: 2, prompttext: 'This is a test prompt 1', ); // Create the provider instance. $config = [ 'enableuserratelimit' => true, 'userratelimit' => 3, 'enableglobalratelimit' => true, 'globalratelimit' => 5, ]; $provider = $this->manager->create_provider_instance( classname: '\aiprovider_openai\provider', name: 'dummy', config: $config, ); // Make 3 requests for the first user, all should be allowed. for ($i = 0; $i < 3; $i++) { $this->assertTrue($provider->is_request_allowed($action1)); } // The 4th request should be denied. $this->assertFalse($provider->is_request_allowed($action1)['success']); // Make 2 requests for the second user, all should be allowed. for ($i = 0; $i < 2; $i++) { $this->assertTrue($provider->is_request_allowed($action2)); } // THe final request should be denied. $this->assertFalse($provider->is_request_allowed($action2)['success']); } } PK*\]qqtests/ai_image_test.phpnu[. namespace core_ai; /** * Test ai image class methods. * * @package core_ai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \core_ai\ai_image */ final class ai_image_test extends \advanced_testcase { /** * Test get_predominant_color. */ public function test_get_predominant_color(): void { $x = 0; $y = 180; $width = 20; $height = 20; // Test for black image. $imagepath = self::get_fixture_path(__NAMESPACE__, 'black.png'); // Get the test image from the fixtures file. $image = new ai_image($imagepath); // We're working with a private method here, so we need to use reflection. $method = new \ReflectionMethod($image, 'get_predominant_color'); $color = $method->invoke($image, $x, $y, $width, $height); $this->assertEquals(0, $color['red']); $this->assertEquals(0, $color['green']); $this->assertEquals(0, $color['blue']); // Test for white image. $imagepath = self::get_fixture_path(__NAMESPACE__, 'white.png'); $image = new ai_image($imagepath); // We're working with a private method here, so we need to use reflection. $method = new \ReflectionMethod($image, 'get_predominant_color'); $color = $method->invoke($image, $x, $y, $width, $height); $this->assertEquals(255, $color['red']); $this->assertEquals(255, $color['green']); $this->assertEquals(255, $color['blue']); // Test for grey image. $imagepath = self::get_fixture_path(__NAMESPACE__, 'grey.png'); $image = new ai_image($imagepath); // We're working with a private method here, so we need to use reflection. $method = new \ReflectionMethod($image, 'get_predominant_color'); $color = $method->invoke($image, $x, $y, $width, $height); $this->assertEquals(128, $color['red']); $this->assertEquals(128, $color['green']); $this->assertEquals(128, $color['blue']); } /** * Test is_color_dark. */ public function test_is_color_dark(): void { // Load an image as it is required for class instantiation. // The color of the image is not important for this test. $imagepath = self::get_fixture_path(__NAMESPACE__, 'black.png'); $image = new ai_image($imagepath); // We're working with a private method here, so we need to use reflection. $method = new \ReflectionMethod($image, 'is_color_dark'); $color = ['red' => 0, 'green' => 0, 'blue' => 0]; $result = $method->invoke($image, $color); $this->assertTrue($result); $image = new ai_image($imagepath); // We're working with a private method here, so we need to use reflection. $method = new \ReflectionMethod($image, 'is_color_dark'); $color = ['red' => 255, 'green' => 255, 'blue' => 255]; $result = $method->invoke($image, $color); $this->assertFalse($result); $imagepath = self::get_fixture_path(__NAMESPACE__, 'grey.png'); $image = new ai_image($imagepath); // We're working with a private method here, so we need to use reflection. $method = new \ReflectionMethod($image, 'is_color_dark'); $color = ['red' => 128, 'green' => 128, 'blue' => 128]; $result = $method->invoke($image, $color); $this->assertTrue($result); } } PK*\BB#tests/behat/ai_usage_report.featurenu[@report @core_ai Feature: AI usage report displays recorded AI data In order to view AI usage data As a manager or admin I can access the AI usage report Background: Given the following "users" exist: | username | firstname | lastname | email | | manager1 | Manager | One | manager1@example.com | | student1 | Student | One | student1@example.com | | student2 | Student | Two | student2@example.com | And the following "role assigns" exist: | user | role | contextlevel | reference | | manager1 | manager | System | | And the following "core_ai > ai providers" exist: | provider | name | enabled | apikey | orgid | | aiprovider_openai | OpenAI API test | 1 | 123 | abc | And the following "core_ai > ai actions" exist: | actionname | user | success | provider | contextid | prompttokens | completiontokens | | generate_text | student1 | 1 | aiprovider_openai | 1 | 22 | 33 | | summarise_text | student1 | 0 | aiprovider_openai | 1 | | | | explain_text | student1 | 1 | aiprovider_openai | 1 | 44 | 55 | | generate_image | student2 | 1 | aiprovider_azureai | 1 | | | Scenario: Managers can view the AI usage report Given I am logged in as "manager1" When I navigate to "Reports > AI reports > AI usage" in site administration Then the following should exist in the "AI usage" table: | Action | First name | Provider | Success | Prompt tokens | Completion tokens | | Generate text | Student One | OpenAI API provider | Yes | 22 | 33 | | Summarise text | Student One | OpenAI API provider | No | | | | Explain text | Student One | OpenAI API provider | Yes | 44 | 55 | | Generate image | Student Two | Azure AI API provider | Yes | | | @javascript Scenario: Managers can filter the AI usage report Given I am logged in as "manager1" When I navigate to "Reports > AI reports > AI usage" in site administration And I click on "Filters" "button" And I set the field "Provider value" in the "Provider" "core_reportbuilder > Filter" to "OpenAI API provider" And I click on "Apply" "button" in the "[data-region='report-filters']" "css_element" Then the following should exist in the "AI usage" table: | Action | First name | Provider | Success | Prompt tokens | Completion tokens | | Generate text | Student One | OpenAI API provider | Yes | 22 | 33 | | Summarise text | Student One | OpenAI API provider | No | | | | Explain text | Student One | OpenAI API provider | Yes | 44 | 55 | And I should not see "Azure AI API provider" in the "AI usage" "table" And I set the following fields in the "Action" "core_reportbuilder > Filter" to these values: | Action operator | Is equal to | | Action value | Generate text | And I click on "Apply" "button" in the "[data-region='report-filters']" "css_element" And the following should exist in the "AI usage" table: | Action | First name | Provider | Success | Prompt tokens | Completion tokens | | Generate text | Student One | OpenAI API provider | Yes | 22 | 33 | And I should not see "Summarise text" in the "AI usage" "table" And I should not see "Azure AI API Provider" in the "AI usage" "table" PK*\. /** * General use steps definitions. * * @package core_ai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ // NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php. require_once(__DIR__ . '/../../../lib/behat/behat_base.php'); use Behat\Gherkin\Node\TableNode; /** * Steps definitions specific to the AI Subsystem. * * @package core_ai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class behat_core_ai extends behat_base { /** * Change the enabled state of an AI provider plugin. * * @Then /^I "(?P(?:[^"]|\\")*)" the ai provider with name "(?P(?:[^"]|\\")*)"$/ * * @param string $state The state to set the plugin to. * @param string $providername The name of the AI provider plugin. */ #[\core\attribute\example('Given I "disable" the ai provider with name "OpenAI API test"')] public function i_change_the_ai_provider_state_with_name(string $state, string $providername): void { $manager = \core\di::get(\core_ai\manager::class); $providers = $manager->get_provider_instances(['name' => $providername]); $provider = reset($providers); if ($state == 'disable') { $manager->disable_provider_instance($provider); } else { $manager->enable_provider_instance($provider); } } /** * Set action configuration for AI provider instances. * * @Given /^I set the following action configuration for ai provider with name "(?P(?:[^"]|\\")*)":$/ * * @param string $providername The name of the ai provider to configure actions for. * @param TableNode $data */ #[\core\attribute\example('I set the following action configuration for ai provider with name "OpenAI API test": | action | enabled | model | endpoint | | generate_text | 1 | gpt-3 | https://api.openai.com/v1/engines/gpt-3/completions | | summarise_text | 0 | gpt-4 | |')] public function configure_provider_action(string $providername, TableNode $data) { $manager = \core\di::get(\core_ai\manager::class); $providers = $manager->get_provider_instances(['name' => $providername]); $provider = reset($providers); $rows = $data->getHash(); $actiondata = []; foreach ($rows as $row) { $action = 'core_ai\\aiactions\\' . $row['action']; $actiondata[$action]['enabled'] = $row['enabled']; unset ($row['action'], $row['enabled']); $actiondata[$action]['settings'] = $row; } $manager->update_provider_instance( provider: $provider, actionconfig: $actiondata ); } } PK*\@ ں tests/behat/reports.featurenu[@report @core_ai @core_ai_reports Feature: AI reports In order to view an AI report As an admin or system role manager I need to populate relevant data and navigate to the report page Background: Given the following "users" exist: | username | firstname | lastname | email | | manager1 | Manager | One | manager1@example.com | And the following "role assigns" exist: | user | role | contextlevel | reference | | manager1 | manager | System | | And the following config values are set as admin: | enabled | 1 | aiplacement_editor | | generate_text | 1 | aiplacement_editor | | generate_image | 0 | aiplacement_editor | And the following "core_ai > ai providers" exist: |provider | name | enabled | apikey | orgid | |aiprovider_openai | OpenAI API test | 1 | 123 | abc | @javascript @editor_tiny Scenario: Mangers with a system role can see who has accepted the AI policy Given I am logged in as "admin" # Accept the AI policy as admin. And I open my profile in edit mode And I select the "p" element in position "0" of the "Description" TinyMCE editor And I expand all toolbars for the "Description" TinyMCE editor And I click on the "AI generate text" button for the "Description" TinyMCE editor And I click on "Accept and continue" "button" in the "AI usage policy" "dialogue" And I press the escape key # Accept the AI policy as manager1. And I am logged in as "manager1" And I open my profile in edit mode And I select the "p" element in position "0" of the "Description" TinyMCE editor And I expand all toolbars for the "Description" TinyMCE editor And I click on the "AI generate text" button for the "Description" TinyMCE editor And I click on "Accept and continue" "button" in the "AI usage policy" "dialogue" And I press the escape key # View the report. When I navigate to "Reports > AI reports > AI policy acceptance" in site administration Then I should see "Admin User" in the "reportbuilder-table" "table" And I should see "Manager One" in the "reportbuilder-table" "table" # Test date filter (check last 1 day). And I click on "Filters" "button" And I set the following fields in the "Date accepted" "core_reportbuilder > Filter" to these values: | Date accepted operator | Last | And I click on "Apply" "button" in the "[data-region='report-filters']" "css_element" And I click on "Filters" "button" And I should see "Admin User" in the "reportbuilder-table" "table" And I should see "Manager One" in the "reportbuilder-table" "table" # Test name filter. And I click on "Filters" "button" And I set the following fields in the "Full name" "core_reportbuilder > Filter" to these values: | Full name operator | Is equal to | | Full name value | Admin User | And I click on "Apply" "button" in the "[data-region='report-filters']" "css_element" And I click on "Filters" "button" And I should see "Admin User" in the "reportbuilder-table" "table" And I should not see "Manager One" in the "reportbuilder-table" "table" PK*\6N##tests/behat/admin.featurenu[@core @core_admin @core_ai @core_ai_admin Feature: An administrator can manage AI subsystem settings In order to alter the user experience As an admin I can manage AI subsystem settings @javascript Scenario: An administrator can create AI provider plugin instances using JavaScript Given I am logged in as "admin" And I navigate to "AI > AI providers" in site administration And I should see "Nothing to display" When I click on "Create a new provider instance" "link" And I select "OpenAI API provider" from the "Choose AI provider plugin" singleselect And I set the following fields to these values: | Name for instance | OpenAI API provider test| | OpenAI API key | 123 | | OpenAI organization ID| abc | And I click on "Create instance" "button" And I should see "OpenAI API provider test AI provider instance created" And I should see "OpenAI API provider test" And I click on "Create a new provider instance" "link" And I select "Azure AI API provider" from the "Choose AI provider plugin" singleselect And I set the following fields to these values: | Name for instance | Azure AI provider test | | Azure AI API key | 123 | | Azure AI API endpoint| https://api.cognitive.microsofttranslator.com/ | And I click on "Create instance" "button" And I should see "Azure AI provider test AI provider instance created" And I should see "Azure AI provider test" @javascript Scenario: An administrator can enable AI provider plugin instances using JavaScript Given the following "core_ai > ai providers" exist: |provider | name | enabled | apikey | orgid | |aiprovider_openai| OpenAI API test | 0 | 123 | abc | And the following "core_ai > ai providers" exist: |provider | name | enabled | apikey | endpoint | |aiprovider_azureai | Azure AI API test| 0 | 123 | https://api.cognitive.microsofttranslator.com/ | And I am logged in as "admin" And I navigate to "AI > AI providers" in site administration And I should see "OpenAI API test" And I should see "Azure AI API test" And I hover "Enable OpenAI API test" "field" And "Enable OpenAI API test" "text" should be visible And I toggle the "Enable OpenAI API test" admin switch "on" And I should see "OpenAI API test enabled." And I hover "Enable Azure AI API test" "field" And "Enable Azure AI API test" "text" should be visible And I toggle the "Enable Azure AI API test" admin switch "on" And I should see "Azure AI API test enabled." And I reload the page And I should see "Disable OpenAI API test" And I should see "Disable Azure AI API test" And I hover "Disable OpenAI API test" "field" And "Disable OpenAI API test" "text" should be visible And I toggle the "Disable OpenAI API test" admin switch "off" And I should see "OpenAI API test disabled." And I hover "Disable Azure AI API test" "field" And "Disable Azure AI API test" "text" should be visible And I toggle the "Disable Azure AI API test" admin switch "off" Then I should see "Azure AI API test disabled." @javascript Scenario: An administrator can configure AI provider plugin instance action settings using JavaScript Given the following "core_ai > ai providers" exist: |provider | name | enabled | apikey | orgid | |aiprovider_openai| OpenAI API test | 0 | 123 | abc | And I am logged in as "admin" And I navigate to "AI > AI providers" in site administration And I click on the "Settings" link in the table row containing "OpenAI API test" And I should see "Configure provider instance" And I click on the "Settings" link in the table row containing "Generate text" And I should see "Generate text action settings" And I set the field "AI model" to "Custom" And I set the following fields to these values: | Custom model name | gpt-3 | | API endpoint | https://api.openai.com/v1/engines/gpt-3/completions | And I click on "Save changes" "button" Then I should see "Generate text action settings updated" @javascript Scenario: An administrator can delete AI provider plugin instances using JavaScript Given the following "core_ai > ai providers" exist: |provider | name | enabled | apikey | orgid | |aiprovider_openai| OpenAI API test | 0 | 123 | abc | And I am logged in as "admin" And I navigate to "AI > AI providers" in site administration And I click on the "Delete" link in the table row containing "OpenAI API test" And "Delete AI provider instance" "dialogue" should be visible And I click on "Delete" "button" in the "Delete AI provider instance" "dialogue" Then I should see "OpenAI API test AI provider instance deleted" @javascript Scenario: An administrator can control the enabled state of AI placement plugins using JavaScript Given I am logged in as "admin" And I navigate to "AI > AI placements" in site administration When I toggle the "Enable Text editor placement" admin switch "on" And I should see "Text editor placement enabled." And I reload the page And I should see "Disable Text editor placement" And I toggle the "Disable Text editor placement" admin switch "off" Then I should see "Text editor placement disabled." @javascript Scenario: Placement actions should be available when an Administrator enables AI providers using JavaScript Given the following "core_ai > ai providers" exist: |provider | name | enabled | apikey | orgid | |aiprovider_openai| OpenAI API test | 1 | 123 | abc | And I am logged in as "admin" And I navigate to "AI > AI placements" in site administration And I click on the "Settings" link in the table row containing "Text editor placement" Then I should not see "This action is unavailable." @javascript Scenario: Placement actions should not be available when an Administrator disables AI providers using JavaScript Given the following "core_ai > ai providers" exist: |provider | name | enabled | apikey | orgid | |aiprovider_openai| OpenAI API test | 1 | 123 | abc | And I am logged in as "admin" And I navigate to "AI > AI providers" in site administration And I toggle the "Disable OpenAI API test" admin switch "off" And I navigate to "AI > AI placements" in site administration And I click on the "Settings" link in the table row containing "Text editor placement" And I should see "This action is unavailable." in the table row containing "Generate text" Then I should see "This action is unavailable." in the table row containing "Generate image" @javascript Scenario: Placement actions should not be available for enabled Providers when an Administrator disables an Action using JavaScript Given the following "core_ai > ai providers" exist: |provider | name | enabled | apikey | orgid | |aiprovider_openai| OpenAI API test | 1 | 123 | abc | And I am logged in as "admin" And I navigate to "AI > AI providers" in site administration And I click on the "Settings" link in the table row containing "OpenAI API test" And I toggle the "Generate text" admin switch "off" And I navigate to "AI > AI placements" in site administration And I click on the "Settings" link in the table row containing "Text editor placement" And I should see "This action is unavailable." in the table row containing "Generate text" Then I should not see "This action is unavailable." in the table row containing "Generate image" @javascript Scenario: An administrator can control the enabled state of AI placement actions using JavaScript Given the following "core_ai > ai providers" exist: | provider | name | enabled | endpoint | | aiprovider_ollama | Ollama API test | 1 | http://localhost:11434 | And I am logged in as "admin" And I navigate to "AI > AI placements" in site administration When I click on the "Settings" link in the table row containing "Text editor placement" Then I should see "Generate text" in the "flexible" "table" And I should see "Generate image" in the "flexible" "table" And I should not see "This action is unavailable. No AI providers are configured for this action." in the "Generate text" "table_row" And I should see "This action is unavailable. No AI providers are configured for this action." in the "Generate image" "table_row" And I toggle the "Generate text" admin switch "off" And I should see "Generate text disabled." PK*\*,( tests/aiactions/base_test.phpnu[. namespace core_ai\aiactions; use core_ai\aiactions\responses\response_base; use core_ai\aiactions; use ReflectionClass; /** * Test response_base action methods. * * @package core_ai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \core_ai\aiactions\base */ final class base_test extends \advanced_testcase { /** * Test get_basename. */ public function test_get_basename(): void { $basename = aiactions\generate_text::get_basename(); $this->assertEquals('generate_text', $basename); } /** * Test get_name. */ public function test_get_name(): void { $this->assertEquals( get_string('action_generate_text', 'core_ai'), aiactions\generate_text::get_name() ); } /** * Test get_description. */ public function test_get_description(): void { $this->assertEquals( get_string('action_generate_text_desc', 'core_ai'), aiactions\generate_text::get_description() ); } /** * Test that every action class implements a constructor. */ public function test_constructor(): void { $classes = []; $contextid = 1; // Create an anonymous class that extends the base class. $base = new class($contextid) extends \core_ai\aiactions\base { /** * Store the response. * @param response_base $response * @return int */ public function store(response_base $response): int { return 0; } }; $reflection = new ReflectionClass($base); // Create a ReflectionClass for the anonymous class. // Use the location of the base class to get the AI action classes. $filepath = $reflection->getParentClass()->getFileName(); $directory = dirname($filepath); $files = scandir($directory); foreach ($files as $file) { // Match files that are PHP files and not specifically just 'base.*.php'. if (str_ends_with($file, '.php')) { $classname = str_replace('.php', '', $file); $classes[] = 'core_ai\\aiactions\\' . $classname; } } // Ensure that some classes were found. $this->assertNotEmpty($classes, 'No classes were found for testing.'); // For each action class, check that they have a constructor. foreach ($classes as $class) { $reflection = new ReflectionClass($class); $constructor = $reflection->getConstructor(); $this->assertNotNull($constructor, 'Class ' . $class . ' does not have a constructor.'); } } } PK*\<'tests/aiactions/generate_image_test.phpnu[. namespace core_ai\aiactions; use core_ai\aiactions\responses\response_generate_image; use core_ai\aiactions\generate_image; /** * Test generate_image action methods. * * @package core_ai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \core_ai\aiactions\base */ final class generate_image_test extends \advanced_testcase { /** * Test constructor method. */ public function test_constructor(): void { $contextid = 1; $userid = 1; $prompttext = 'This is a test prompt'; $aspectratio = 'square'; $quality = 'hd'; $numimages = 1; $style = 'vivid'; $action = new generate_image( contextid: $contextid, userid: $userid, prompttext: $prompttext, quality: $quality, aspectratio: $aspectratio, numimages: $numimages, style: $style ); $this->assertEquals($userid, $action->get_configuration('userid')); $this->assertEquals($prompttext, $action->get_configuration('prompttext')); $this->assertEquals($aspectratio, $action->get_configuration('aspectratio')); $this->assertEquals($quality, $action->get_configuration('quality')); $this->assertEquals($numimages, $action->get_configuration('numimages')); $this->assertEquals($style, $action->get_configuration('style')); } /** * Test store method. */ public function test_store(): void { $this->resetAfterTest(); global $DB; $contextid = 1; $userid = 1; $prompttext = 'This is a test prompt'; $aspectratio = 'square'; $quality = 'hd'; $numimages = 1; $style = 'vivid'; $action = new generate_image( contextid: $contextid, userid: $userid, prompttext: $prompttext, quality: $quality, aspectratio: $aspectratio, numimages: $numimages, style: $style ); $body = [ 'revisedprompt' => 'This is a revised prompt', 'sourceurl' => 'https://example.com/image.png', 'model' => 'dall-e-3', ]; $actionresponse = new response_generate_image( success: true, ); $actionresponse->set_response_data($body); $storeid = $action->store($actionresponse); // Check the stored record. $record = $DB->get_record('ai_action_generate_image', ['id' => $storeid]); $this->assertEquals($prompttext, $record->prompt); $this->assertEquals($numimages, $record->numberimages); $this->assertEquals($quality, $record->quality); $this->assertEquals($aspectratio, $record->aspectratio); $this->assertEquals($style, $record->style); $this->assertEquals($body['sourceurl'], $record->sourceurl); $this->assertEquals($body['revisedprompt'], $record->revisedprompt); } } PK*\آU :tests/aiactions/responses/response_generate_image_test.phpnu[. namespace core_ai\aiactions\responses; use core\exception\coding_exception; use core_ai\aiactions\responses\response_generate_image; /** * Test response_generate_image_test action methods. * * @package core_ai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \core_ai\aiactions\responses\response_generate_image */ final class response_generate_image_test extends \advanced_testcase { /** * Test get_basename. */ public function test_get_success(): void { $actionresponse = new response_generate_image( success: true, ); $this->assertTrue($actionresponse->get_success()); $this->assertEquals('generate_image', $actionresponse->get_actionname()); } /** * Test constructor with error. */ public function test_construct_error(): void { $this->expectException(coding_exception::class); $this->expectExceptionMessage('Error code and message must exist in an error response.'); new response_generate_image( success: false, ); } /** * Test set_response_data. */ public function test_set_response_data(): void { $this->resetAfterTest(); // Create a file to store. $fs = get_file_storage(); $filerecord = new \stdClass(); $filerecord->contextid = 1; $filerecord->component = 'core_ai'; $filerecord->filearea = 'draft'; $filerecord->itemid = 0; $filerecord->filepath = '/'; $filerecord->filename = 'test.txt'; $file = $fs->create_file_from_string($filerecord, 'This is a test file'); $body = [ 'draftfile' => $file, 'revisedprompt' => 'This is a revised prompt', 'sourceurl' => 'https://example.com/image.png', ]; $actionresponse = new response_generate_image( success: true, ); $actionresponse->set_response_data($body); $this->assertEquals($file, $actionresponse->get_response_data()['draftfile']); $this->assertEquals('This is a revised prompt', $actionresponse->get_response_data()['revisedprompt']); $this->assertEquals('https://example.com/image.png', $actionresponse->get_response_data()['sourceurl']); } } PK*\W 8tests/aiactions/responses/response_explain_text_test.phpnu[. namespace core_ai\aiactions\responses; use core_ai\aiactions\responses\response_explain_text; /** * Test response_generate_image_test action methods. * * @package core_ai * @copyright 2024 David Woloszyn * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \core_ai\aiactions\responses\response_explain_text */ final class response_explain_text_test extends \advanced_testcase { /** * Test get_basename. */ public function test_get_success(): void { $actionresponse = new response_explain_text( success: true, ); $this->assertTrue($actionresponse->get_success()); $this->assertEquals('explain_text', $actionresponse->get_actionname()); } /** * Test constructor with error. */ public function test_construct_error(): void { $this->expectException(\coding_exception::class); $this->expectExceptionMessage('Error code and message must exist in an error response.'); new response_explain_text( success: false, ); } /** * Test set_response_data. */ public function test_set_response_data(): void { $this->resetAfterTest(); $body = [ 'id' => 'chatcmpl-123', 'fingerprint' => 'fp_44709d6fcb', 'generatedcontent' => 'This is the generated content', 'finishreason' => 'stop', 'prompttokens' => 9, 'completiontokens' => 12, ]; $actionresponse = new response_explain_text( success: true, ); $actionresponse->set_response_data($body); $this->assertEquals($body['id'], $actionresponse->get_response_data()['id']); $this->assertEquals($body['fingerprint'], $actionresponse->get_response_data()['fingerprint']); $this->assertEquals($body['generatedcontent'], $actionresponse->get_response_data()['generatedcontent']); $this->assertEquals($body['finishreason'], $actionresponse->get_response_data()['finishreason']); $this->assertEquals($body['prompttokens'], $actionresponse->get_response_data()['prompttokens']); $this->assertEquals($body['completiontokens'], $actionresponse->get_response_data()['completiontokens']); } } PK*\9 :tests/aiactions/responses/response_summarise_text_test.phpnu[. namespace core_ai\aiactions\responses; use core_ai\aiactions\responses\response_summarise_text; /** * Test response_generate_image_test action methods. * * @package core_ai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \core_ai\aiactions\responses\response_summarise_text */ final class response_summarise_text_test extends \advanced_testcase { /** * Test get_basename. */ public function test_get_success(): void { $actionresponse = new response_summarise_text( success: true, ); $this->assertTrue($actionresponse->get_success()); $this->assertEquals('summarise_text', $actionresponse->get_actionname()); } /** * Test constructor with error. */ public function test_construct_error(): void { $this->expectException(\coding_exception::class); $this->expectExceptionMessage('Error code and message must exist in an error response.'); new response_summarise_text( success: false, ); } /** * Test set_response_data. */ public function test_set_response_data(): void { $this->resetAfterTest(); $body = [ 'id' => 'chatcmpl-123', 'fingerprint' => 'fp_44709d6fcb', 'generatedcontent' => 'This is the generated content', 'finishreason' => 'stop', 'prompttokens' => 9, 'completiontokens' => 12, ]; $actionresponse = new response_summarise_text( success: true, ); $actionresponse->set_response_data($body); $this->assertEquals($body['id'], $actionresponse->get_response_data()['id']); $this->assertEquals($body['fingerprint'], $actionresponse->get_response_data()['fingerprint']); $this->assertEquals($body['generatedcontent'], $actionresponse->get_response_data()['generatedcontent']); $this->assertEquals($body['finishreason'], $actionresponse->get_response_data()['finishreason']); $this->assertEquals($body['prompttokens'], $actionresponse->get_response_data()['prompttokens']); $this->assertEquals($body['completiontokens'], $actionresponse->get_response_data()['completiontokens']); } } PK*\0H 9tests/aiactions/responses/response_generate_text_test.phpnu[. namespace core_ai\aiactions\responses; use core_ai\aiactions\responses\response_generate_text; /** * Test response_generate_image_test action methods. * * @package core_ai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \core_ai\aiactions\responses\response_generate_text */ final class response_generate_text_test extends \advanced_testcase { /** * Test get_basename. */ public function test_get_success(): void { $actionresponse = new response_generate_text( success: true, ); $this->assertTrue($actionresponse->get_success()); $this->assertEquals('generate_text', $actionresponse->get_actionname()); } /** * Test constructor with error. */ public function test_construct_error(): void { $this->expectException(\coding_exception::class); $this->expectExceptionMessage('Error code and message must exist in an error response.'); new response_generate_text( success: false, ); } /** * Test set_response_data. */ public function test_set_response_data(): void { $this->resetAfterTest(); $body = [ 'id' => 'chatcmpl-123', 'fingerprint' => 'fp_44709d6fcb', 'generatedcontent' => 'This is the generated content', 'finishreason' => 'stop', 'prompttokens' => 9, 'completiontokens' => 12, ]; $actionresponse = new response_generate_text( success: true, ); $actionresponse->set_response_data($body); $this->assertEquals($body['id'], $actionresponse->get_response_data()['id']); $this->assertEquals($body['fingerprint'], $actionresponse->get_response_data()['fingerprint']); $this->assertEquals($body['generatedcontent'], $actionresponse->get_response_data()['generatedcontent']); $this->assertEquals($body['finishreason'], $actionresponse->get_response_data()['finishreason']); $this->assertEquals($body['prompttokens'], $actionresponse->get_response_data()['prompttokens']); $this->assertEquals($body['completiontokens'], $actionresponse->get_response_data()['completiontokens']); } } PK*\/T T 'tests/aiactions/summarise_text_test.phpnu[. namespace core_ai\aiactions; use core_ai\aiactions\responses\response_summarise_text; use core_ai\aiactions\summarise_text; /** * Test summarise_text action methods. * * @package core_ai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \core_ai\aiactions\base */ final class summarise_text_test extends \advanced_testcase { /** * Test configure method. */ public function test_configure(): void { $contextid = 1; $userid = 1; $prompttext = 'This is a test prompt'; $action = new summarise_text( contextid: $contextid, userid: $userid, prompttext: $prompttext ); $this->assertEquals($userid, $action->get_configuration('userid')); $this->assertEquals($prompttext, $action->get_configuration('prompttext')); } /** * Test store method. */ public function test_store(): void { $this->resetAfterTest(); global $DB; $contextid = 1; $userid = 1; $prompttext = 'This is a test prompt'; $action = new summarise_text( contextid: $contextid, userid: $userid, prompttext: $prompttext ); $body = [ 'id' => 'chatcmpl-123', 'fingerprint' => 'fp_44709d6fcb', 'generatedcontent' => 'This is the generated content', 'finishreason' => 'stop', 'prompttokens' => 9, 'completiontokens' => 12, 'model' => 'gpt-4o', ]; $actionresponse = new response_summarise_text( success: true, ); $actionresponse->set_response_data($body); $storeid = $action->store($actionresponse); // Check the stored record. $record = $DB->get_record('ai_action_summarise_text', ['id' => $storeid]); $this->assertEquals($prompttext, $record->prompt); $this->assertEquals($body['id'], $record->responseid); $this->assertEquals($body['fingerprint'], $record->fingerprint); $this->assertEquals($body['generatedcontent'], $record->generatedcontent); $this->assertEquals($body['finishreason'], $record->finishreason); $this->assertEquals($body['prompttokens'], $record->prompttokens); $this->assertEquals($body['completiontokens'], $record->completiontoken); } } PK*\De L L &tests/aiactions/generate_text_test.phpnu[. namespace core_ai\aiactions; use core_ai\aiactions\responses\response_generate_text; use core_ai\aiactions\generate_text; /** * Test generate_text action methods. * * @package core_ai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \core_ai\aiactions\base */ final class generate_text_test extends \advanced_testcase { /** * Test configure method. */ public function test_configure(): void { $contextid = 1; $userid = 1; $prompttext = 'This is a test prompt'; $action = new generate_text( contextid: $contextid, userid: $userid, prompttext: $prompttext ); $this->assertEquals($userid, $action->get_configuration('userid')); $this->assertEquals($prompttext, $action->get_configuration('prompttext')); } /** * Test store method. */ public function test_store(): void { $this->resetAfterTest(); global $DB; $contextid = 1; $userid = 1; $prompttext = 'This is a test prompt'; $action = new generate_text( contextid: $contextid, userid: $userid, prompttext: $prompttext ); $body = [ 'id' => 'chatcmpl-123', 'fingerprint' => 'fp_44709d6fcb', 'generatedcontent' => 'This is the generated content', 'finishreason' => 'stop', 'prompttokens' => 9, 'completiontokens' => 12, 'model' => 'gpt-4o', ]; $actionresponse = new response_generate_text( success: true, ); $actionresponse->set_response_data($body); $storeid = $action->store($actionresponse); // Check the stored record. $record = $DB->get_record('ai_action_generate_text', ['id' => $storeid]); $this->assertEquals($prompttext, $record->prompt); $this->assertEquals($body['id'], $record->responseid); $this->assertEquals($body['fingerprint'], $record->fingerprint); $this->assertEquals($body['generatedcontent'], $record->generatedcontent); $this->assertEquals($body['finishreason'], $record->finishreason); $this->assertEquals($body['prompttokens'], $record->prompttokens); $this->assertEquals($body['completiontokens'], $record->completiontoken); } } PK*\beetests/manager_test.phpnu[. namespace core_ai; use core_ai\aiactions\generate_image; use core_ai\aiactions\generate_text; use core_ai\aiactions\summarise_text; use core_ai\aiactions\explain_text; use core_ai\aiactions\responses\response_generate_image; /** * Test ai subsystem manager methods. * * @package core_ai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \core_ai\manager */ final class manager_test extends \advanced_testcase { /** * Test get_ai_plugin_classname. */ public function test_get_ai_plugin_classname(): void { $manager = \core\di::get(manager::class); // We're working with a private method here, so we need to use reflection. $method = new \ReflectionMethod($manager, 'get_ai_plugin_classname'); // Test a provider plugin. $classname = $method->invoke($manager, 'aiprovider_fooai'); $this->assertEquals('aiprovider_fooai\\provider', $classname); // Test a placement plugin. $classname = $method->invoke($manager, 'aiplacement_fooplacement'); $this->assertEquals('aiplacement_fooplacement\\placement', $classname); // Test an invalid plugin. $this->expectException(\coding_exception::class); $this->expectExceptionMessage('Plugin name does not start with \'aiprovider_\' or \'aiplacement_\': bar'); $method->invoke($manager, 'bar'); } /** * Test get_supported_actions. */ public function test_get_supported_actions(): void { $manager = \core\di::get(manager::class); $actions = $manager->get_supported_actions('aiprovider_openai'); // Assert array keys match the expected actions. $this->assertEquals([ generate_text::class, generate_image::class, summarise_text::class, explain_text::class, ], $actions); } /** * Test create_provider_instance method. */ public function test_create_provider_instance(): void { $this->resetAfterTest(); global $DB; // Create the provider instance. $manager = \core\di::get(\core_ai\manager::class); $config = ['data' => 'goeshere']; $provider = $manager->create_provider_instance( classname: '\aiprovider_openai\provider', name: 'dummy', config: $config, ); $this->assertIsInt($provider->id); $this->assertFalse($provider->enabled); $this->assertEquals('goeshere', $provider->config['data']); // Check the record was written to the DB. $record = $DB->get_record('ai_providers', ['id' => $provider->id], '*', MUST_EXIST); $this->assertEquals($provider->id, $record->id); } /** * Test create_provider_instance non provider class. */ public function test_create_provider_instance_non_provider_class(): void { $this->resetAfterTest(); // Should throw an exception as the class is not a provider. $this->expectException(\coding_exception::class); $this->expectExceptionMessage(' Provider class not valid: ' . $this::class); // Create the provider instance. $manager = \core\di::get(\core_ai\manager::class); $manager->create_provider_instance( classname: $this::class, name: 'dummy', ); } /** * Test get_provider_record method */ public function test_get_provider_record(): void { global $DB; $this->resetAfterTest(); // Create a dummy provider record directly in the database. $config = ['data' => 'goeshere']; $record = new \stdClass(); $record->name = 'dummy1'; $record->provider = 'dummy'; $record->enabled = 1; $record->config = json_encode($config); $record->actionconfig = json_encode(['generate_text' => 1]); $record->id = $DB->insert_record('ai_providers', $record); $manager = \core\di::get(\core_ai\manager::class); $provider = $manager->get_provider_record(['provider' => 'dummy']); $this->assertEquals($record->id, $provider->id); } /** * Test get_provider_records method. */ public function test_get_provider_records(): void { $this->resetAfterTest(); global $DB; // Create some dummy provider records directly in the database. $config = ['data' => 'goeshere']; $record1 = new \stdClass(); $record1->name = 'dummy1'; $record1->provider = 'dummy'; $record1->enabled = 1; $record1->config = json_encode($config); $record1->actionconfig = json_encode(['generate_text' => 1]); $record2 = new \stdClass(); $record2->name = 'dummy2'; $record2->provider = 'dummy'; $record2->enabled = 1; $record2->config = json_encode($config); $record2->actionconfig = json_encode(['generate_text' => 1]); $DB->insert_records('ai_providers', [ $record1, $record2, ]); // Get the provider records. $manager = \core\di::get(\core_ai\manager::class); $providers = $manager->get_provider_records(['provider' => 'dummy']); // Assert that the records were returned. $this->assertCount(2, $providers); } /** * Test get_provider_instances method. */ public function test_get_provider_instances(): void { $this->resetAfterTest(); global $DB; $manager = \core\di::get(\core_ai\manager::class); $config = ['data' => 'goeshere']; $provider = $manager->create_provider_instance( classname: '\aiprovider_openai\provider', name: 'dummy', config: $config, ); // Create an instance record for a non provider class. $record = new \stdClass(); $record->name = 'dummy'; $record->provider = 'dummy'; $record->enabled = 1; $record->config = json_encode($config); $DB->insert_record('ai_providers', $record); // Get the provider instances. $instances = $manager->get_provider_instances(); $this->assertDebuggingCalled('Unable to find a provider class for dummy'); $this->assertCount(1, $instances); $this->assertEquals($provider->id, $instances[$provider->id]->id); } /** * Test update_provider_instance method. */ public function test_update_provider_instance(): void { $this->resetAfterTest(); global $DB; // Create the provider instance. $manager = \core\di::get(\core_ai\manager::class); $config = ['data' => 'goeshere']; $provider = $manager->create_provider_instance( classname: '\aiprovider_openai\provider', name: 'dummy', config: $config, ); // Update the provider instance. $config['data'] = 'updateddata'; $manager->update_provider_instance($provider, $config); // Check the record was updated in the DB. $record = $DB->get_record('ai_providers', ['id' => $provider->id], '*', MUST_EXIST); $this->assertEquals($provider->id, $record->id); $this->assertEquals('updateddata', json_decode($record->config)->data); } /** * Test delete_provider_instance method. */ public function test_delete_provider_instance(): void { $this->resetAfterTest(); global $DB; // Create the provider instance. $manager = \core\di::get(\core_ai\manager::class); $config = ['data' => 'goeshere']; $provider = $manager->create_provider_instance( classname: '\aiprovider_openai\provider', name: 'dummy', config: $config, ); // Delete the provider instance. $manager->delete_provider_instance($provider); // Check the record was deleted from the DB. $record = $DB->record_exists('ai_providers', ['id' => $provider->id]); $this->assertFalse($record); } /** * Test get_providers_for_actions. */ public function test_get_providers_for_actions(): void { $this->resetAfterTest(); $manager = \core\di::get(manager::class); $actions = [ generate_text::class, summarise_text::class, explain_text::class, ]; // Create two provider instances. $config = [ 'apikey' => 'goeshere', 'endpoint' => 'https://example.com', ]; $provider1 = $manager->create_provider_instance( classname: '\aiprovider_openai\provider', name: 'dummy1', enabled: true, config: $config, ); $config['apiendpoint'] = 'https://example.com'; $provider2 = $manager->create_provider_instance( classname: '\aiprovider_azureai\provider', name: 'dummy2', enabled: true, config: $config, ); // Get the providers for the actions. $providers = $manager->get_providers_for_actions($actions); // Assert that the providers array is indexed by action name. $this->assertEquals($actions, array_keys($providers)); // Assert that there is only one provider for each action. $this->assertCount(2, $providers[generate_text::class]); $this->assertCount(2, $providers[summarise_text::class]); $this->assertCount(2, $providers[explain_text::class]); // Disable the generate text action for the Open AI provider. $setresult = $manager->set_action_state( plugin: $provider1->provider, actionbasename: generate_text::class::get_basename(), enabled: 0, instanceid: $provider1->id); // Assert that the action was disabled. $this->assertFalse($setresult); $providers = $manager->get_providers_for_actions($actions, true); // 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); } /** * Test process_action fail. */ public function test_process_action_fail(): void { $this->resetAfterTest(); global $DB; $managermock = $this->getMockBuilder(manager::class) ->setConstructorArgs([$DB]) ->onlyMethods(['call_action_provider']) ->getMock(); $expectedresult = new aiactions\responses\response_generate_image( success: true, ); // Set up the expectation for call_action_provider to return the defined result. $managermock->expects($this->any()) ->method('call_action_provider') ->willReturn($expectedresult); $action = new generate_image( contextid: 1, userid: 1, prompttext: 'This is a test prompt', quality: 'hd', aspectratio: 'square', numimages: 1, style: 'vivid', ); // Success should be false as there are no enabled providers. $result = $managermock->process_action($action); $this->assertFalse($result->get_success()); } /** * Test process_action. */ public function test_process_action(): void { $this->resetAfterTest(); global $DB; // Create two provider instances. $manager = \core\di::get(manager::class); $config = ['apikey' => 'goeshere']; $provider1 = $manager->create_provider_instance( classname: '\aiprovider_openai\provider', name: 'dummy1', enabled: true, config: $config, ); $config['apiendpoint'] = 'https://example.com'; $provider2 = $manager->create_provider_instance( classname: '\aiprovider_azureai\provider', name: 'dummy2', enabled: true, config: $config, ); $managermock = $this->getMockBuilder(manager::class) ->setConstructorArgs([$DB]) ->onlyMethods(['call_action_provider']) ->getMock(); $expectedresult = new aiactions\responses\response_generate_image( success: true, ); // Set up the expectation for call_action_provider to return the defined result. $managermock->expects($this->any()) ->method('call_action_provider') ->willReturn($expectedresult); $action = new generate_image( contextid: 1, userid: 1, prompttext: 'This is a test prompt', quality: 'hd', aspectratio: 'square', numimages: 1, style: 'vivid', ); // Should now return the expected result. $result = $managermock->process_action($action); $this->assertEquals($expectedresult, $result); } /** * Test set_user_policy. */ public function test_set_user_policy(): void { $this->resetAfterTest(); global $DB; $result = manager::user_policy_accepted(1, 1); $this->assertTrue($result); // Check record exists. $record = $DB->record_exists('ai_policy_register', ['userid' => 1, 'contextid' => 1]); $this->assertTrue($record); } /** * Test get_user_policy. */ public function test_get_user_policy(): void { $this->resetAfterTest(); global $DB; // Should be false for user initially. $result = manager::get_user_policy_status(1); $this->assertFalse($result); // Manually add record to the database. $record = new \stdClass(); $record->userid = 1; $record->contextid = 1; $record->timeaccepted = time(); $DB->insert_record('ai_policy_register', $record); // Should be true for user now. $result = manager::get_user_policy_status(1); $this->assertTrue($result); } /** * Test policy cache data source. */ public function test_user_policy_caching(): void { global $DB; $this->resetAfterTest(); $user1 = $this->getDataGenerator()->create_user(); $user2 = $this->getDataGenerator()->create_user(); $user3 = $this->getDataGenerator()->create_user(); // Manually add records to the database. $record1 = new \stdClass(); $record1->userid = $user1->id; $record1->contextid = 1; $record1->timeaccepted = time(); $record2 = new \stdClass(); $record2->userid = $user2->id; $record2->contextid = 1; $record2->timeaccepted = time(); $DB->insert_records('ai_policy_register', [ $record1, $record2, ]); $policycache = \cache::make('core', 'ai_policy'); // Test single user. $this->assertFalse($policycache->has($user1->id)); $this->assertFalse($policycache->has($user2->id)); $this->assertFalse($policycache->has($user3->id)); $result = $policycache->get($user1->id); $this->assertTrue($result); $result = $policycache->get($user2->id); $this->assertTrue($result); $result = $policycache->get($user3->id); $this->assertFalse($result); $this->assertTrue($policycache->has($user1->id)); $this->assertTrue($policycache->has($user2->id)); // Purge the cache. $policycache->purge(); // Test multiple users. $this->assertFalse($policycache->has($user1->id)); $this->assertFalse($policycache->has($user2->id)); $this->assertFalse($policycache->has($user3->id)); $result = $policycache->get_many([$user1->id, $user2->id, $user3->id]); $this->assertNotEmpty($policycache->get_many($result)); $this->assertTrue($result[$user1->id]); $this->assertTrue($result[$user2->id]); $this->assertFalse($result[$user3->id]); $this->assertTrue($policycache->has($user1->id)); $this->assertTrue($policycache->has($user2->id)); } /** * Test store_action_result. */ public function test_store_action_result(): void { $this->resetAfterTest(); global $DB; $contextid = 1; $userid = 1; $prompttext = 'This is a test prompt'; $aspectratio = 'square'; $quality = 'hd'; $numimages = 1; $style = 'vivid'; $action = new generate_image( contextid: 1, userid: $userid, prompttext: $prompttext, quality: $quality, aspectratio: $aspectratio, numimages: $numimages, style: $style ); $body = [ 'revisedprompt' => 'This is a revised prompt', 'imageurl' => 'https://example.com/image.png', 'model' => 'dall-e-3', ]; $actionresponse = new response_generate_image( success: true, ); $actionresponse->set_response_data($body); $manager = \core\di::get(manager::class); $config = ['data' => 'goeshere']; $provider = $manager->create_provider_instance( classname: '\aiprovider_openai\provider', name: 'dummy', config: $config, ); // We're working with a private method here, so we need to use reflection. $method = new \ReflectionMethod($manager, 'store_action_result'); $storeresult = $method->invoke($manager, $provider, $action, $actionresponse); // Check the record was written to the DB with expected values. $record = $DB->get_record('ai_action_register', ['id' => $storeresult], '*', MUST_EXIST); $this->assertEquals($action->get_basename(), $record->actionname); $this->assertEquals($userid, $record->userid); $this->assertEquals($contextid, $record->contextid); $this->assertEquals($provider->get_name(), $record->provider); $this->assertEquals($actionresponse->get_errorcode(), $record->errorcode); $this->assertEquals($actionresponse->get_errormessage(), $record->errormessage); $this->assertEquals($action->get_configuration('timecreated'), $record->timecreated); $this->assertEquals($actionresponse->get_timecreated(), $record->timecompleted); $this->assertEquals($actionresponse->get_model_used(), $record->model); } /** * Test call_action_provider. */ public function test_call_action_provider(): void { $this->resetAfterTest(); $contextid = 1; $userid = 1; $prompttext = 'This is a test prompt'; $aspectratio = 'square'; $quality = 'hd'; $numimages = 1; $style = 'vivid'; $action = new generate_image( contextid: $contextid, userid: $userid, prompttext: $prompttext, quality: $quality, aspectratio: $aspectratio, numimages: $numimages, style: $style ); $manager = \core\di::get(manager::class); $config = ['apikey' => 'goeshere']; $provider = $manager->create_provider_instance( classname: '\aiprovider_openai\provider', name: 'dummy', config: $config, ); // We're working with a private method here, so we need to use reflection. $method = new \ReflectionMethod($manager, 'call_action_provider'); $actionresult = $method->invoke($manager, $provider, $action); // Assert the result was of the correct type. $this->assertInstanceOf(response_generate_image::class, $actionresult); } /** * Test is_action enabled. */ public function test_is_action_enabled(): void { $this->resetAfterTest(); $action = generate_image::class; $manager = \core\di::get(manager::class); $config = ['apikey' => 'goeshere']; $provider = $manager->create_provider_instance( classname: '\aiprovider_openai\provider', name: 'dummy', config: $config, ); // Should be enabled by default. $result = $manager->is_action_enabled( plugin: $provider->provider, actionclass: $action, instanceid: $provider->id ); $this->assertTrue($result); // Disable the action. $manager->set_action_state( plugin: $provider->provider, actionbasename: $action::get_basename(), enabled: 0, instanceid: $provider->id ); // Should now be disabled. $result = $manager->is_action_enabled( plugin: $provider->provider, actionclass: $action, instanceid: $provider->id ); $this->assertFalse($result); } /** * Test enable_action. */ public function test_enable_action(): void { $this->resetAfterTest(); $action = generate_image::class; $manager = \core\di::get(manager::class); $config = ['apikey' => 'goeshere']; $provider = $manager->create_provider_instance( classname: '\aiprovider_openai\provider', name: 'dummy', config: $config, ); // Disable the action. $setresult = $manager->set_action_state( plugin: $provider->provider, actionbasename: $action::get_basename(), enabled: 0, instanceid: $provider->id); // Should now be disabled. $this->assertFalse($setresult); $result = $manager->is_action_enabled( plugin: $provider->provider, actionclass: $action, instanceid: $provider->id ); $this->assertFalse($result); // Enable the action. $manager = \core\di::get(manager::class); $result = $manager->set_action_state( plugin: $provider->provider, actionbasename: generate_text::class::get_basename(), enabled: 1, instanceid: $provider->id); $this->assertTrue($result); } /** * Test is_action_available method. */ public function test_is_action_available(): void { $this->resetAfterTest(); $action = generate_image::class; // Plugin is disabled by default, action state should not matter. Everything should be false. $manager = \core\di::get(manager::class); $result = $manager->is_action_available($action); $this->assertFalse($result); // Create the provider instance, actions will be enabled by default when the plugin is enabled. $config = ['apikey' => 'goeshere']; $provider = $manager->create_provider_instance( classname: '\aiprovider_openai\provider', name: 'dummy', enabled: true, config: $config, ); // Should now be available. $result = $manager->is_action_available($action); $this->assertTrue($result); // Disable the action. $manager->set_action_state( plugin: $provider->provider, actionbasename: $action::get_basename(), enabled: 0, instanceid: $provider->id); // Should now be unavailable. $result = $manager->is_action_available($action); $this->assertFalse($result); } } PK*\vv configure.phpnu[. /** * Configure provider instances. * * @package core_ai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ require_once('../config.php'); require_login(); $context = context_system::instance(); require_capability('moodle/site:config', $context); $id = optional_param('id', 0, PARAM_INT); // If we have an id we have existing settings. $provider = optional_param('aiprovider', null, PARAM_PLUGIN); $returnurl = optional_param('returnurl', null, PARAM_LOCALURL); $title = get_string('createnewprovider', 'core_ai'); $data = []; // Handle return URL. if (empty($returnurl)) { $returnurl = new moodle_url( url: '/admin/settings.php', params: ['section' => 'aiprovider'] ); } else { $returnurl = new moodle_url($returnurl); } $data['returnurl'] = $returnurl; if ($provider) { $configs = ['aiprovider' => $provider]; $data['providerconfigs'] = $configs; } if ($id !== 0) { // If we have an id we are updating an existing provider instance. $manager = \core\di::get(\core_ai\manager::class); $providerrecord = $manager->get_provider_record(['id' => $id], MUST_EXIST); $plugin = explode('\\', $providerrecord->provider); $plugin = $plugin[0]; $configs = json_decode($providerrecord->config, true, 512, JSON_THROW_ON_ERROR); $configs['aiprovider'] = $plugin; $configs['id'] = $providerrecord->id; $configs['name'] = $providerrecord->name; $data['providerconfigs'] = $configs; $title = get_string('configureprovider', 'core_ai'); } // Initial Page setup. $PAGE->set_context($context); $PAGE->set_url('/ai/configure.php', ['id' => $id]); $PAGE->set_pagelayout('admin'); $PAGE->set_title($title); $PAGE->set_heading($title); // Explode if there are no provider plugins installed. $plugins = core_plugin_manager::instance()->get_plugins_of_type('aiprovider'); if (empty($plugins)) { throw new moodle_exception('noproviderplugins', 'core_ai'); } // Provider instance form processing. $mform = new \core_ai\form\ai_provider_form(customdata: $data); if ($mform->is_cancelled()) { $data = $mform->get_data(); if (isset($data->returnurl)) { redirect($data->returnurl); } else { redirect($returnurl); } } if ($data = $mform->get_data()) { $data = (array)$data; $manager = \core\di::get(\core_ai\manager::class); $aiprovider = $data['aiprovider']; $providername = $data['name']; unset($data->aiprovider, $data->name, $data->id, $data->returnurl, $data->updateandreturn); if ($id !== 0) { $providerinstance = $manager->get_provider_instances(['id' => $id]); $providerinstance = reset($providerinstance); $providerinstance->name = $providername; $manager->update_provider_instance( provider:$providerinstance, config: $data ); \core\notification::add( get_string('providerinstanceupdated', 'core_ai', $providername), \core\notification::SUCCESS ); } else { $classname = $aiprovider . '\\' . 'provider'; $manager->create_provider_instance( classname: $classname, name: $providername, config: $data, ); \core\notification::add( get_string('providerinstancecreated', 'core_ai', $providername), \core\notification::SUCCESS ); } redirect($returnurl); } // Page output. echo $OUTPUT->header(); // Add the provider instance form. $mform->display(); // Add the per provider action settings only if we have an existing provider. if ($id !== 0) { echo $OUTPUT->render_from_template('core_ai/admin_action_settings', []); $provider = $mform->get_customdata()['aiprovider']; $tableid = $provider . '-' . $id; // This is the table id for the action settings table. $actiontable = new \core_ai\table\aiprovider_action_management_table($tableid); $actiontable->out(); } echo $OUTPUT->footer(); PK*\3 configure_actions.phpnu[. /** * Configure provider instance action settings. * * @package core_ai * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ require_once('../config.php'); require_login(); $context = context_system::instance(); require_capability('moodle/site:config', $context); $provider = required_param('provider', PARAM_PLUGIN); $action = required_param('action', PARAM_TEXT); $id = required_param('providerid', PARAM_INT); $returnurl = optional_param('returnurl', null, PARAM_LOCALURL); $customdata = ['providerid' => $id]; // Handle return URL. if (empty($returnurl)) { $returnurl = new moodle_url( url: '/ai/configure.php', params: ['id' => $id] ); } else { $returnurl = new moodle_url($returnurl); } $customdata['returnurl'] = $returnurl; $manager = \core\di::get(\core_ai\manager::class); $providerrecord = $manager->get_provider_record(['id' => $id], MUST_EXIST); $actionconfig = json_decode($providerrecord->actionconfig, true, 512, JSON_THROW_ON_ERROR); $actionconfig = $actionconfig[$action]; $customdata['actionconfig'] = $actionconfig; $customdata['providername'] = $provider; $urlparams = [ 'provider' => $provider, 'action' => $action, 'id' => $id, ]; // Page setup. $title = get_string('actionsettingprovider', 'core_ai', $action::get_name()); $PAGE->set_context($context); $PAGE->set_url('/ai/configure.php_actions', $urlparams); $PAGE->set_pagelayout('admin'); $PAGE->set_title($title); $PAGE->set_heading($title); $providerclass = "\\$provider\\provider"; $mform = $providerclass::get_action_settings($action, $customdata); if ($mform->is_cancelled()) { $data = $mform->get_data(); if (isset($data->returnurl)) { redirect($data->returnurl); } else { redirect($returnurl); } } if ($data = $mform->get_data()) { $manager = \core\di::get(\core_ai\manager::class); $aiprovider = $data->provider; unset($data->provider, $data->id, $data->action, $data->returnurl, $data->submitbutton); $providerinstance = $manager->get_provider_instances(['id' => $id]); $providerinstance = reset($providerinstance); $actionconfig = $providerinstance->actionconfig; $actionconfig[$action]['settings'] = (array)$data; $actionconfig[$action]['enabled'] = $providerinstance->actionconfig[$action]['enabled']; $manager->update_provider_instance( provider: $providerinstance, actionconfig: $actionconfig, ); \core\notification::add( get_string('providerinstanceactionupdated', 'core_ai', $action::get_name()), \core\notification::SUCCESS ); redirect($returnurl); } echo $OUTPUT->header(); $mform->display(); echo $OUTPUT->footer(); PK*\-'!placement/courseassist/styles.cssnu[.ai-drawer { position: fixed; top: 60px; bottom: 0; right: calc(-315px + -10px); width: 315px; background-color: #f8f9fa; z-index: 1016; transition: right 0.2s ease, top 0.2s ease, bottom 0.2s ease, visibility 0.2s ease, transform 0.5s ease; visibility: hidden; } .ai-drawer.show { right: 0; visibility: visible; } .ai-drawer-header { padding: 0; height: 60px; display: flex; align-items: center; } .ai-drawer-header .ai-drawer-button { margin-left: auto; margin-right: 5px; } .ai-drawer-body { position: relative; height: calc(100vh - 120px); display: flex; flex-direction: column; flex-wrap: nowrap; padding: 0.4rem; overflow-y: auto; scrollbar-width: thin; scrollbar-color: #6a737b #f8f9fa; } .course-assist-response-content { font-size: 0.875em; } #ai-features .icon { min-width: 16px; } .course-assist-response-header .icon { width: 30px; height: 30px; } .course-assist-controls .icon, .ai-course-assist-sparkles-icon .icon { margin-right: 0; } .course-assist-controls .ai-action { color: unset; } /* Toggle visibility of sparkles icon on hover. */ .course-assist-controls .ai-action .ai-course-assist-sparkles-icon, .course-assist-controls .ai-action:not([disabled]):hover .ai-course-assist-sparkles-icon.white, .course-assist-controls.show .ai-action:not([disabled]) .ai-course-assist-sparkles-icon.white { display: inline-block; } .course-assist-controls .ai-action .ai-course-assist-sparkles-icon.white, .course-assist-controls .ai-action:not([disabled]):hover .ai-course-assist-sparkles-icon, .course-assist-controls.show .ai-action:not([disabled]) .ai-course-assist-sparkles-icon { display: none; } PK*\ДLL&placement/courseassist/db/services.phpnu[. /** * Course Assistance Placement webservice definitions. * * @package aiplacement_courseassist * @copyright 2024 Huong Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $functions = [ 'aiplacement_courseassist_summarise_text' => [ 'classname' => 'aiplacement_courseassist\external\summarise_text', 'description' => 'Summarise text for the Course Assistance Placement', 'type' => 'write', 'ajax' => true, 'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE], ], 'aiplacement_courseassist_explain_text' => [ 'classname' => 'aiplacement_courseassist\external\explain_text', 'description' => 'Explain text for the Course Assistance Placement', 'type' => 'write', 'ajax' => true, 'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE], ], ]; PK*\gss#placement/courseassist/db/hooks.phpnu[. /** * Hook callbacks for the course assist placement * * @package aiplacement_courseassist * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $callbacks = [ [ 'hook' => \core\hook\output\before_footer_html_generation::class, 'callback' => \aiplacement_courseassist\hook_callbacks::class . '::before_footer_html_generation', 'priority' => 0, ], [ 'hook' => \core\hook\output\after_http_headers::class, 'callback' => \aiplacement_courseassist\hook_callbacks::class . '::after_http_headers', 'priority' => 0, ], ]; PK*\pfRR$placement/courseassist/db/access.phpnu[. /** * Capabilities for the aiplacement_courseassist plugin. * * @package aiplacement_courseassist * @copyright 2024 Huong Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $capabilities = [ 'aiplacement/courseassist:summarise_text' => [ 'captype' => 'write', 'contextlevel' => CONTEXT_COURSE, 'archetypes' => [ 'manager' => CAP_ALLOW, 'editingteacher' => CAP_ALLOW, 'teacher' => CAP_ALLOW, 'student' => CAP_ALLOW, ], ], 'aiplacement/courseassist:explain_text' => [ 'captype' => 'write', 'contextlevel' => CONTEXT_COURSE, 'archetypes' => [ 'manager' => CAP_ALLOW, 'editingteacher' => CAP_ALLOW, 'teacher' => CAP_ALLOW, 'student' => CAP_ALLOW, ], ], ]; PK*\諃;placement/courseassist/lang/en/aiplacement_courseassist.phpnu[. /** * Strings for component aiplacement_courseassist, language 'en'. * * @package aiplacement_courseassist * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['aidrawerlabel'] = 'AI-generated content drawer'; $string['aiexplain'] = 'AI explain'; $string['aifeatures'] = 'AI features'; $string['aisummary'] = 'AI summary'; $string['courseassist:explain_text'] = 'Explain text'; $string['courseassist:summarise_text'] = 'Summarise text'; $string['copy'] = 'Copy'; $string['explain'] = 'Explain'; $string['explain_tooltips'] = 'Create an AI-generated explanation of the page content'; $string['generatefailtitle'] = 'Something went wrong'; $string['generating'] = 'Generating your response'; $string['nocourseassist'] = 'An AI-generated summary is not available for this context.'; $string['pluginname'] = 'Course assistance placement'; $string['privacy:metadata'] = 'The Course assistance placement plugin does not store any personal data.'; $string['regenerate'] = 'Regenerate'; $string['summarise'] = 'Summarise'; $string['summarise_tooltips'] = 'Create an AI-generated summary of the page content'; $string['tryagain'] = 'Try again'; PK*\d-placement/courseassist/pix/sparkles-white.svgnu[ PK*\w@|22'placement/courseassist/pix/sparkles.svgnu[ PK*\ܵYY"placement/courseassist/version.phpnu[. /** * Version information for aiplacement_courseassist. * * @package aiplacement_courseassist * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'aiplacement_courseassist'; $plugin->version = 2025041400; $plugin->requires = 2025040800; $plugin->maturity = MATURITY_STABLE; PK*\:placement/courseassist/classes/external/summarise_text.phpnu[. namespace aiplacement_courseassist\external; use aiplacement_courseassist\utils; use core_external\external_api; use core_external\external_function_parameters; use core_external\external_value; /** * External API to call summarise text action for this placement. * * @package aiplacement_courseassist * @copyright 2024 Huong Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class summarise_text extends external_api { /** * Summarise text parameters. * * @return external_function_parameters * @since Moodle 4.5 */ public static function execute_parameters(): external_function_parameters { return new external_function_parameters([ 'contextid' => new external_value( PARAM_INT, 'The context ID', VALUE_REQUIRED, ), 'prompttext' => new external_value( PARAM_RAW, 'The prompt text for the AI service', VALUE_REQUIRED, ), ]); } /** * Summarise text from the AI placement. * * @param int $contextid The context ID. * @param string $prompttext The data encoded as a json array. * @return array The generated content. * @since Moodle 4.5 */ public static function execute( int $contextid, string $prompttext ): array { global $USER; // Parameter validation. [ 'contextid' => $contextid, 'prompttext' => $prompttext, ] = self::validate_parameters(self::execute_parameters(), [ 'contextid' => $contextid, 'prompttext' => $prompttext, ]); // Context validation and permission check. // Get the context from the passed in ID. $context = \context::instance_by_id($contextid); // Check the user has permission to use the AI service. self::validate_context($context); if (!utils::is_course_assist_available($context)) { throw new \moodle_exception('nocourseassist', 'aiplacement_courseassist'); } // Prepare the action. $action = new \core_ai\aiactions\summarise_text( contextid: $contextid, userid: $USER->id, prompttext: $prompttext, ); // Send the action to the AI manager. $manager = \core\di::get(\core_ai\manager::class); $response = $manager->process_action($action); $generatedcontent = $response->get_response_data()['generatedcontent'] ?? ''; // Return the response. return [ 'success' => $response->get_success(), 'generatedcontent' => \core_external\util::format_text($generatedcontent, FORMAT_PLAIN, $contextid)[0], 'finishreason' => $response->get_response_data()['finishreason'] ?? '', 'errorcode' => $response->get_errorcode(), 'error' => $response->get_errormessage(), 'timecreated' => $response->get_timecreated(), 'prompttext' => $prompttext, ]; } /** * Generate content return value. * * @return external_function_parameters * @since Moodle 4.5 */ public static function execute_returns(): external_function_parameters { return new external_function_parameters([ 'success' => new external_value( PARAM_BOOL, 'Was the request successful', VALUE_REQUIRED ), 'timecreated' => new external_value( PARAM_INT, 'The time the request was created', VALUE_REQUIRED, ), 'prompttext' => new external_value( PARAM_RAW, 'The prompt text for the AI service', VALUE_REQUIRED, ), 'generatedcontent' => new external_value( PARAM_RAW, 'The text generated by AI.', VALUE_DEFAULT, ), 'finishreason' => new external_value( PARAM_ALPHAEXT, 'The reason generation was stopped', VALUE_DEFAULT, 'stop', ), 'errorcode' => new external_value( PARAM_INT, 'Error code if any', VALUE_DEFAULT, 0, ), 'error' => new external_value( PARAM_TEXT, 'Error message if any', VALUE_DEFAULT, '', ), ]); } } PK*\+8placement/courseassist/classes/external/explain_text.phpnu[. namespace aiplacement_courseassist\external; use aiplacement_courseassist\utils; use core_external\external_api; use core_external\external_function_parameters; use core_external\external_value; /** * External API to call explain text action for this placement. * * @package aiplacement_courseassist * @copyright 2024 David Woloszyn * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class explain_text extends external_api { /** * Explain text parameters. * * @return external_function_parameters * @since Moodle 5.0 */ public static function execute_parameters(): external_function_parameters { return new external_function_parameters([ 'contextid' => new external_value( PARAM_INT, 'The context ID', VALUE_REQUIRED, ), 'prompttext' => new external_value( PARAM_RAW, 'The prompt text for the AI service', VALUE_REQUIRED, ), ]); } /** * Explain text from the AI placement. * * @param int $contextid The context ID. * @param string $prompttext The data encoded as a json array. * @return array The generated content. * @since Moodle 5.0 */ public static function execute( int $contextid, string $prompttext, ): array { global $USER; // Parameter validation. [ 'contextid' => $contextid, 'prompttext' => $prompttext, ] = self::validate_parameters(self::execute_parameters(), [ 'contextid' => $contextid, 'prompttext' => $prompttext, ]); // Context validation and permission check. // Get the context from the passed in ID. $context = \context::instance_by_id($contextid); // Check the user has permission to use the AI service. self::validate_context($context); if (!utils::is_course_assist_available($context)) { throw new \moodle_exception('nocourseassist', 'aiplacement_courseassist'); } // Prepare the action. $action = new \core_ai\aiactions\explain_text( contextid: $contextid, userid: $USER->id, prompttext: $prompttext, ); // Send the action to the AI manager. $manager = \core\di::get(\core_ai\manager::class); $response = $manager->process_action($action); $generatedcontent = $response->get_response_data()['generatedcontent'] ?? ''; // Return the response. return [ 'success' => $response->get_success(), 'generatedcontent' => \core_external\util::format_text($generatedcontent, FORMAT_PLAIN, $contextid)[0], 'finishreason' => $response->get_response_data()['finishreason'] ?? '', 'errorcode' => $response->get_errorcode(), 'error' => $response->get_errormessage(), 'timecreated' => $response->get_timecreated(), 'prompttext' => $prompttext, ]; } /** * Generate content return value. * * @return external_function_parameters * @since Moodle 5.0 */ public static function execute_returns(): external_function_parameters { return new external_function_parameters([ 'success' => new external_value( PARAM_BOOL, 'Was the request successful', VALUE_REQUIRED ), 'timecreated' => new external_value( PARAM_INT, 'The time the request was created', VALUE_REQUIRED, ), 'prompttext' => new external_value( PARAM_RAW, 'The prompt text for the AI service', VALUE_REQUIRED, ), 'generatedcontent' => new external_value( PARAM_RAW, 'The text generated by AI.', VALUE_DEFAULT, ), 'finishreason' => new external_value( PARAM_ALPHAEXT, 'The reason generation was stopped', VALUE_DEFAULT, 'stop', ), 'errorcode' => new external_value( PARAM_INT, 'Error code if any', VALUE_DEFAULT, 0, ), 'error' => new external_value( PARAM_TEXT, 'Error message if any', VALUE_DEFAULT, '', ), ]); } } PK*\ 3placement/courseassist/classes/privacy/provider.phpnu[. namespace aiplacement_courseassist\privacy; use core_privacy\local\metadata\null_provider; /** * Privacy Subsystem for course assistance placement implementing null_provider. * * @package aiplacement_courseassist * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @codeCoverageIgnore */ class provider implements null_provider { #[\Override] public static function get_reason(): string { return 'privacy:metadata'; } } PK*\h=nn1placement/courseassist/classes/hook_callbacks.phpnu[. namespace aiplacement_courseassist; use core\hook\output\after_http_headers; use core\hook\output\before_footer_html_generation; /** * Hook callbacks for the course assist AI Placement. * * @package aiplacement_courseassist * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class hook_callbacks { /** * Bootstrap the course assist UI. * * @param before_footer_html_generation $hook */ public static function before_footer_html_generation(before_footer_html_generation $hook): void { \aiplacement_courseassist\output\assist_ui::load_assist_ui($hook); } /** * Bootstrap the action buttons. * * @param after_http_headers $hook */ public static function after_http_headers(after_http_headers $hook): void { \aiplacement_courseassist\output\assist_ui::action_buttons_handler($hook); } } PK*\G,placement/courseassist/classes/placement.phpnu[. namespace aiplacement_courseassist; /** * Class placement. * * @package aiplacement_courseassist * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class placement extends \core_ai\placement { #[\Override] public static function get_action_list(): array { return [ \core_ai\aiactions\summarise_text::class, \core_ai\aiactions\explain_text::class, ]; } } PK*\ 3placement/courseassist/classes/output/assist_ui.phpnu[. namespace aiplacement_courseassist\output; use aiplacement_courseassist\utils; use core\hook\output\after_http_headers; use core\hook\output\before_footer_html_generation; /** * Output handler for the course assist AI Placement. * * @package aiplacement_courseassist * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class assist_ui { /** * Bootstrap the course assist UI. * * @param before_footer_html_generation $hook */ public static function load_assist_ui(before_footer_html_generation $hook): void { global $PAGE, $OUTPUT, $USER; // Preflight checks. if (!self::preflight_checks()) { return; } // Load the markup for the assist interface. $params = [ 'userid' => $USER->id, 'contextid' => $PAGE->context->id, ]; $html = $OUTPUT->render_from_template('aiplacement_courseassist/drawer', $params); $hook->add_html($html); } /** * Determine if we should be loading a single button or a dropdown. * * @param after_http_headers $hook */ public static function action_buttons_handler(after_http_headers $hook): void { global $PAGE, $OUTPUT; // Preflight checks. if (!self::preflight_checks()) { return; } $actions['actions'] = utils::get_actions_available($PAGE->context); // No actions available. if (empty($actions['actions'])) { return; } if (count($actions['actions']) > 1) { $actions['isdropdown'] = true; } $html = $OUTPUT->render_from_template('aiplacement_courseassist/actions', $actions); $hook->add_html($html); } /** * Preflight checks to determine if the assist UI should be loaded. * * @return bool */ private static function preflight_checks(): bool { global $PAGE; if (during_initial_install()) { return false; } if (!get_config('aiplacement_courseassist', 'version')) { return false; } if (in_array($PAGE->pagelayout, ['maintenance', 'print', 'redirect', 'embedded'])) { // Do not try to show assist UI inside iframe, in maintenance mode, // when printing, or during redirects. return false; } // Check we are in the right context, exit if not activity. if ($PAGE->context->contextlevel != CONTEXT_MODULE) { return false; } // Check if the user has permission to use the AI service. return utils::is_course_assist_available($PAGE->context); } } PK*\Vh (placement/courseassist/classes/utils.phpnu[. namespace aiplacement_courseassist; use core_ai\aiactions\explain_text; use core_ai\aiactions\summarise_text; use core_ai\manager; /** * AI Placement course assist utils. * * @package aiplacement_courseassist * @copyright 2024 Huong Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class utils { /** * Check if AI Placement course assist is available for the context. * * @param \context $context The context. * @return bool True if AI Placement course assist is available, false otherwise. */ public static function is_course_assist_available(\context $context): bool { [$plugintype, $pluginname] = explode('_', \core_component::normalize_componentname('aiplacement_courseassist'), 2); $pluginmanager = \core_plugin_manager::resolve_plugininfo_class($plugintype); if (!$pluginmanager::is_plugin_enabled($pluginname)) { return false; } if (empty(self::get_actions_available($context))) { return false; } return true; } /** * Get all the actions available and return action data for template. * * @param \context $context The context. * @return array Return the actions available with data. */ public static function get_actions_available(\context $context): array { $actions = []; $actionclasses = [ summarise_text::class, explain_text::class, ]; $manager = \core\di::get(manager::class); $providers = $manager->get_providers_for_actions($actionclasses, true); // Summarise text. if (has_capability('aiplacement/courseassist:summarise_text', $context) && $manager->is_action_available(summarise_text::class) && $manager->is_action_enabled('aiplacement_courseassist', summarise_text::class) && !empty($providers[summarise_text::class]) ) { $actions[] = [ 'action' => 'summarise', 'buttontext' => get_string('summarise', 'aiplacement_courseassist'), 'title' => get_string('summarise_tooltips', 'aiplacement_courseassist'), ]; } // Explain text. if (has_capability('aiplacement/courseassist:explain_text', $context) && $manager->is_action_available(explain_text::class) && $manager->is_action_enabled('aiplacement_courseassist', explain_text::class) && !empty($providers[explain_text::class]) ) { $actions[] = [ 'action' => 'explain', 'buttontext' => get_string('explain', 'aiplacement_courseassist'), 'title' => get_string('explain_tooltips', 'aiplacement_courseassist'), ]; } return $actions; } } PK*\a1placement/courseassist/templates/loading.mustachenu[{{! 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 . }} {{! @template aiplacement_courseassist/loading Template to display loading message when AI placement is in progress. Context variables required for this template: * none Example context (json): { } }}

{{#pix}} i/loading, core {{/pix}} {{#str}} generating, aiplacement_courseassist {{/str}}

PK*\T 2placement/courseassist/templates/response.mustachenu[{{! 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 . }} {{! @template aiplacement_courseassist/response Template to display reponse content for AI placement. Context variables required for this template: * content - Content to display * heading - The heading to display * action - The action being performed Example context (json): { "content": "

Content to display

", "heading": "AI Explain", "action": "explain" } }}

{{#pix}} sparkles, aiplacement_courseassist {{/pix}} {{heading}}

{{{content}}}
{{#pix}} sparkles, aiplacement_courseassist {{/pix}} {{#str}} contentwatermark, core_ai {{/str}}
PK*\yjBii:placement/courseassist/templates/actions_dropdown.mustachenu[{{! 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 . }} {{! @template aiplacement_courseassist/actions_dropdown Template to display AI action in a dropdown for the course assist. Context variables required for this template: * actions - List of actions to generate buttons for Example context (json): { "actions": [ { "action": "summarise", "buttontext": "Summarise", "title": "Create an AI-generated summary of the page content" }, { "action": "explain", "buttontext": "Explain", "title": "Create an AI-generated explanation of the page content" } ] } }} PK*\ހ,--7placement/courseassist/templates/sparkles_icon.mustachenu[{{! 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 . }} {{! @template aiplacement_courseassist/sparkles_icon Template to display sparkles icon. Example context (json): { } }} {{#pix}} sparkles, aiplacement_courseassist {{/pix}} {{#pix}} sparkles-white, aiplacement_courseassist {{/pix}} PK*\.`0placement/courseassist/templates/drawer.mustachenu[{{! 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 . }} {{! @template aiplacement_courseassist/drawer Template to display the AI drawer for the course assist. Context variables required for this template: * userid - User ID * contextid - Context ID Example context (json): { "userid": "1", "contextid": "1" } }}
{{#js}} require(['aiplacement_courseassist/placement'], function(AICourseAssist) { const AI = new AICourseAssist({{userid}}, {{contextid}}); }); {{/js}} PK*\SS1placement/courseassist/templates/actions.mustachenu[{{! 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 . }} {{! @template aiplacement_courseassist/actions Template to display AI action in a dropdown or single action button for the course assist. Context variables required for this template: * isdropdown - If this is a dropdown, load the appropriate layout * actions - List of actions to generate buttons for Example context (json): { "isdropdown": true, "actions": [ { "action": "summarise", "buttontext": "Summarise", "title": "Create an AI-generated summary of the page content" }, { "action": "explain", "buttontext": "Explain", "title": "Create an AI-generated explanation of the page content" } ] } }}
{{#isdropdown}} {{> aiplacement_courseassist/actions_dropdown }} {{/isdropdown}} {{^isdropdown}} {{#actions}} {{> aiplacement_courseassist/action_button }} {{/actions}} {{/isdropdown}} {{> core_message/message_jumpto }}
PK*\^QG/placement/courseassist/templates/error.mustachenu[{{! 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 . }} {{! @template aiplacement_courseassist/error Template to display error message when AI placement fails. Context variables required for this template: * none Example context (json): { } }}

{{#pix}} req, core {{/pix}} {{#str}} generatefailtitle, aiplacement_courseassist {{/str}}

PK*\kpp7placement/courseassist/templates/action_button.mustachenu[{{! 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 . }} {{! @template aiplacement_courseassist/action_button * isdropdown - Is this a dropdown item * actions - The action being performed * buttontext - The text to display for the button * title - The tooltip/title text to use Template to display sparkles icon. Example context (json): { "isdropdown": true, "action": "explain", "buttontext": "Explain", "title": "Create an AI-generated explanation of the page content" } }} PK*\,=GG+placement/courseassist/amd/src/placement.jsnu[// 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 . /** * Module to load and render the tools for the AI assist plugin. * * @module aiplacement_courseassist/placement * @copyright 2024 Huong Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ import Templates from 'core/templates'; import Ajax from 'core/ajax'; import 'core/copy_to_clipboard'; import Notification from 'core/notification'; import Selectors from 'aiplacement_courseassist/selectors'; import Policy from 'core_ai/policy'; import AIHelper from 'core_ai/helper'; import DrawerEvents from 'core/drawer_events'; import {subscribe} from 'core/pubsub'; import * as MessageDrawerHelper from 'core_message/message_drawer_helper'; import {getString} from 'core/str'; import * as FocusLock from 'core/local/aria/focuslock'; import {isSmall} from "core/pagehelpers"; const AICourseAssist = class { /** * The user ID. * @type {Integer} */ userId; /** * The context ID. * @type {Integer} */ contextId; /** * Constructor. * @param {Integer} userId The user ID. * @param {Integer} contextId The context ID. */ constructor(userId, contextId) { this.userId = userId; this.contextId = contextId; this.aiDrawerElement = document.querySelector(Selectors.ELEMENTS.AIDRAWER); this.aiDrawerBodyElement = document.querySelector(Selectors.ELEMENTS.AIDRAWER_BODY); this.pageElement = document.querySelector(Selectors.ELEMENTS.PAGE); this.jumpToElement = document.querySelector(Selectors.ELEMENTS.JUMPTO); this.actionElement = document.querySelector(Selectors.ELEMENTS.ACTION); this.aiDrawerCloseElement = this.aiDrawerElement.querySelector(Selectors.ELEMENTS.AIDRAWER_CLOSE); this.lastAction = ''; this.responses = new Map(); this.isDrawerFocusLocked = false; this.registerEventListeners(); } /** * Register event listeners. */ registerEventListeners() { document.addEventListener('click', async(e) => { // Display summarise. const summariseAction = e.target.closest(Selectors.ACTIONS.SUMMARY); if (summariseAction) { e.preventDefault(); this.openAIDrawer(); this.lastAction = 'summarise'; this.actionElement.focus(); const isPolicyAccepted = await this.isPolicyAccepted(); if (!isPolicyAccepted) { // Display policy. this.displayPolicy(); return; } this.displayAction(this.lastAction); } // Display explain. const explainAction = e.target.closest(Selectors.ACTIONS.EXPLAIN); if (explainAction) { e.preventDefault(); this.openAIDrawer(); this.lastAction = 'explain'; this.actionElement.focus(); const isPolicyAccepted = await this.isPolicyAccepted(); if (!isPolicyAccepted) { // Display policy. this.displayPolicy(); return; } this.displayAction(this.lastAction); } // Close AI drawer. const closeAiDrawer = e.target.closest(Selectors.ELEMENTS.AIDRAWER_CLOSE); if (closeAiDrawer) { e.preventDefault(); this.closeAIDrawer(); } }); document.addEventListener('keydown', e => { if (this.isAIDrawerOpen() && e.key === 'Escape') { this.closeAIDrawer(); } }); // Close AI drawer if message drawer is shown. subscribe(DrawerEvents.DRAWER_SHOWN, () => { if (this.isAIDrawerOpen()) { this.closeAIDrawer(); } }); // Focus on the AI drawer's close button when the jump-to element is focused. this.jumpToElement.addEventListener('focus', () => { this.aiDrawerCloseElement.focus(); }); // Focus on the action element when the AI drawer container receives focus. this.aiDrawerElement.addEventListener('focus', () => { this.actionElement.focus(); }); // Remove active from the action element when it loses focus. this.actionElement.addEventListener('blur', () => { this.actionElement.classList.remove('active'); }); } /** * Register event listeners for the policy. */ registerPolicyEventListeners() { const acceptAction = document.querySelector(Selectors.ACTIONS.ACCEPT); const declineAction = document.querySelector(Selectors.ACTIONS.DECLINE); if (acceptAction && this.lastAction.length) { acceptAction.addEventListener('click', (e) => { e.preventDefault(); this.acceptPolicy().then(() => { return this.displayAction(this.lastAction); }).catch(Notification.exception); }); } if (declineAction) { declineAction.addEventListener('click', (e) => { e.preventDefault(); this.closeAIDrawer(); }); } } /** * Register event listeners for the error. */ registerErrorEventListeners() { const retryAction = document.querySelector(Selectors.ACTIONS.RETRY); if (retryAction && this.lastAction.length) { retryAction.addEventListener('click', (e) => { e.preventDefault(); this.displayAction(this.lastAction); }); } } /** * Register event listeners for the responses. */ registerResponseEventListeners() { // Get all regenerate action buttons (one per response in the AI drawer). const regenerateActions = document.querySelectorAll(Selectors.ACTIONS.REGENERATE); // Add event listeners for each regenerate action. regenerateActions.forEach(regenerateAction => { const responseElement = regenerateAction.closest(Selectors.ELEMENTS.RESPONSE); if (regenerateAction && responseElement) { // Get the action that this response is associated with. const actionPerformed = responseElement.getAttribute('data-action-performed'); regenerateAction.addEventListener('click', (e) => { e.preventDefault(); // Remove the old response before displaying the new one. this.removeResponseFromStack(actionPerformed); this.displayAction(actionPerformed); }); } }); } registerLoadingEventListeners() { const cancelAction = document.querySelector(Selectors.ACTIONS.CANCEL); if (cancelAction) { cancelAction.addEventListener('click', (e) => { e.preventDefault(); this.setRequestCancelled(); this.toggleAIDrawer(); this.removeResponseFromStack('loading'); // Refresh the response stack to avoid false indication of loading. const responses = this.getResponseStack(); this.aiDrawerBodyElement.innerHTML = responses; }); } } /** * Check if the AI drawer is open. * @return {boolean} True if the AI drawer is open, false otherwise. */ isAIDrawerOpen() { return this.aiDrawerElement.classList.contains('show'); } /** * Check if the request is cancelled. * @return {boolean} True if the request is cancelled, false otherwise. */ isRequestCancelled() { return this.aiDrawerBodyElement.dataset.cancelled === '1'; } setRequestCancelled() { this.aiDrawerBodyElement.dataset.cancelled = '1'; } /** * Open the AI drawer. */ openAIDrawer() { // Close message drawer if it is shown. MessageDrawerHelper.hide(); this.aiDrawerElement.classList.add('show'); this.aiDrawerElement.setAttribute('tabindex', 0); this.aiDrawerBodyElement.setAttribute('aria-live', 'polite'); if (!this.pageElement.classList.contains('show-drawer-right')) { this.addPadding(); } this.jumpToElement.setAttribute('tabindex', 0); this.jumpToElement.focus(); // If the AI drawer is opened on a small screen, we need to trap the focus tab within the AI drawer. if (isSmall()) { FocusLock.trapFocus(this.aiDrawerElement); this.aiDrawerElement.setAttribute('aria-modal', 'true'); this.aiDrawerElement.setAttribute('role', 'dialog'); this.isDrawerFocusLocked = true; } } /** * Close the AI drawer. */ closeAIDrawer() { // Untrap focus if it was locked. if (this.isDrawerFocusLocked) { FocusLock.untrapFocus(); this.aiDrawerElement.removeAttribute('aria-modal'); this.aiDrawerElement.setAttribute('role', 'region'); } this.aiDrawerElement.classList.remove('show'); this.aiDrawerElement.setAttribute('tabindex', -1); this.aiDrawerBodyElement.removeAttribute('aria-live'); if (this.pageElement.classList.contains('show-drawer-right') && this.aiDrawerBodyElement.dataset.removepadding === '1') { this.removePadding(); } this.jumpToElement.setAttribute('tabindex', -1); // We can enforce a focus-visible state on the focus element using element.focus({focusVisible: true}). // Unfortunately, this feature isn't supported in all browsers, only Firefox provides support for it. // Therefore, we will apply the active class to the action element and set focus on it. // This action will make the action element appear focused. // When the action element loses focus, // we will remove the active class at {@see registerEventListeners()} this.actionElement.classList.add('active'); this.actionElement.focus(); } /** * Toggle the AI drawer. */ toggleAIDrawer() { if (this.isAIDrawerOpen()) { this.closeAIDrawer(); } else { this.openAIDrawer(); } } /** * Add padding to the page to make space for the AI drawer. */ addPadding() { this.pageElement.classList.add('show-drawer-right'); this.aiDrawerBodyElement.dataset.removepadding = '1'; } /** * Remove padding from the page. */ removePadding() { this.pageElement.classList.remove('show-drawer-right'); this.aiDrawerBodyElement.dataset.removepadding = '0'; } /** * Get important params related to the action. * @param {string} action The action to use. * @returns {object} The params to use for the action. */ async getParamsForAction(action) { let params = {}; switch (action) { case 'summarise': params.method = 'aiplacement_courseassist_summarise_text'; params.heading = await getString('aisummary', 'aiplacement_courseassist'); break; case 'explain': params.method = 'aiplacement_courseassist_explain_text'; params.heading = await getString('aiexplain', 'aiplacement_courseassist'); break; } return params; } /** * Check if the policy is accepted. * @return {bool} True if the policy is accepted, false otherwise. */ async isPolicyAccepted() { return await Policy.getPolicyStatus(this.userId); } /** * Accept the policy. * @return {Promise} */ acceptPolicy() { return Policy.acceptPolicy(); } /** * Check if the AI drawer has already generated content for a particular action. * @param {string} action The action to check. * @return {boolean} True if the AI drawer has generated content, false otherwise. */ hasGeneratedContent(action) { return this.responses.has(action); } /** * Display the policy. */ displayPolicy() { Templates.render('core_ai/policyblock', {}).then((html) => { this.aiDrawerBodyElement.innerHTML = html; this.registerPolicyEventListeners(); return; }).catch(Notification.exception); } /** * Display the loading spinner. */ displayLoading() { Templates.render('aiplacement_courseassist/loading', {}).then((html) => { this.addResponseToStack('loading', html); const responses = this.getResponseStack(); this.aiDrawerBodyElement.innerHTML = responses; this.registerLoadingEventListeners(); return; }).then(() => { this.removeResponseFromStack('loading'); return; }).catch(Notification.exception); } /** * Display the action result in the AI drawer. * @param {string} action The action to display. */ async displayAction(action) { if (this.hasGeneratedContent(action)) { // Scroll to generated content. const existingReponse = document.querySelector('[data-action-performed="' + action + '"]'); if (existingReponse) { this.aiDrawerBodyElement.scrollTop = existingReponse.offsetTop; } } else { // Display loading spinner. this.displayLoading(); // Clear the drawer to prevent including the previously generated response in the new response prompt. this.aiDrawerBodyElement.innerHTML = ''; const params = await this.getParamsForAction(action); const request = { methodname: params.method, args: { contextid: this.contextId, prompttext: this.getTextContent(), } }; try { const responseObj = await Ajax.call([request])[0]; if (responseObj.error) { this.displayError(); return; } else { if (!this.isRequestCancelled()) { // Perform replacements on the generated context to ensure it is formatted correctly. const generatedContent = AIHelper.formatResponse(responseObj.generatedcontent); this.displayResponse(generatedContent, action); return; } else { this.aiDrawerBodyElement.dataset.cancelled = '0'; } } } catch (error) { window.console.log(error); this.displayError(); } } } /** * Add the HTML response to the response stack. * The stack will be used to display all responses in the AI drawer. * @param {String} action The action key. * @param {String} html The HTML to store. */ addResponseToStack(action, html) { this.responses.set(action, html); } /** * Remove a stored response, allowing for a regenerated one. * @param {String} action The action key. */ removeResponseFromStack(action) { if (this.responses.has(action)) { this.responses.delete(action); } } /** * Return a stack of HTML responses. * @return {String} HTML responses. */ getResponseStack() { let stack = ''; // Reverse to get newest first. const responses = [...this.responses.values()].reverse(); for (const response of responses) { stack += response; } return stack; } /** * Display the responses. * @param {String} content The content to display. * @param {String} action The action used. */ async displayResponse(content, action) { const params = await this.getParamsForAction(action); const args = { content: content, heading: params.heading, action: action, }; Templates.render('aiplacement_courseassist/response', args).then((html) => { this.addResponseToStack(action, html); const responses = this.getResponseStack(); this.aiDrawerBodyElement.innerHTML = responses; this.registerResponseEventListeners(); return; }).catch(Notification.exception); } /** * Display the error. */ displayError() { Templates.render('aiplacement_courseassist/error', {}).then((html) => { this.addResponseToStack('error', html); const responses = this.getResponseStack(); this.aiDrawerBodyElement.innerHTML = responses; this.registerErrorEventListeners(); return; }).then(() => { this.removeResponseFromStack('error'); return; }).catch(Notification.exception); } /** * Get the text content of the main region. * @return {String} The text content. */ getTextContent() { const mainRegion = document.querySelector(Selectors.ELEMENTS.MAIN_REGION); return mainRegion.innerText || mainRegion.textContent; } }; export default AICourseAssist; PK*\mc55+placement/courseassist/amd/src/selectors.jsnu[// 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 . /** * Define all of the selectors we will be using on the AI Course assistant. * * @module aiplacement_courseassist/selectors * @copyright 2024 Huong Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ export default { ELEMENTS: { AIDRAWER: '#ai-drawer', AIDRAWER_BODY: '#ai-drawer .ai-drawer-body', PAGE: '#page', MAIN_REGION: '[role="main"]', AIDRAWER_CLOSE: '#ai-drawer-close', RESPONSE: '.course-assist-response', JUMPTO: '.course-assist-controls [data-region="jumpto"]', ACTION: '.course-assist-controls [data-input-type="action"]', }, ACTIONS: { SUMMARY: '.course-assist-controls [data-action="summarise"]', EXPLAIN: '.course-assist-controls [data-action="explain"]', RETRY: '.course-assist-controls [data-action="retry"]', DECLINE: '.ai-policy-block [data-action="decline"]', ACCEPT: '.ai-policy-block [data-action="accept"]', REGENERATE: '.course-assist-controls [data-action="regenerate"]', CANCEL: '.course-assist-controls [data-action="cancel"]', } }; PK*\o.))1placement/courseassist/amd/build/placement.min.jsnu[define("aiplacement_courseassist/placement",["exports","core/templates","core/ajax","core/copy_to_clipboard","core/notification","aiplacement_courseassist/selectors","core_ai/policy","core_ai/helper","core/drawer_events","core/pubsub","core_message/message_drawer_helper","core/str","core/local/aria/focuslock","core/pagehelpers"],(function(_exports,_templates,_ajax,_copy_to_clipboard,_notification,_selectors,_policy,_helper,_drawer_events,_pubsub,MessageDrawerHelper,_str,FocusLock,_pagehelpers){function _getRequireWildcardCache(nodeInterop){if("function"!=typeof WeakMap)return null;var cacheBabelInterop=new WeakMap,cacheNodeInterop=new WeakMap;return(_getRequireWildcardCache=function(nodeInterop){return nodeInterop?cacheNodeInterop:cacheBabelInterop})(nodeInterop)}function _interopRequireWildcard(obj,nodeInterop){if(!nodeInterop&&obj&&obj.__esModule)return obj;if(null===obj||"object"!=typeof obj&&"function"!=typeof obj)return{default:obj};var cache=_getRequireWildcardCache(nodeInterop);if(cache&&cache.has(obj))return cache.get(obj);var newObj={},hasPropertyDescriptor=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var key in obj)if("default"!==key&&Object.prototype.hasOwnProperty.call(obj,key)){var desc=hasPropertyDescriptor?Object.getOwnPropertyDescriptor(obj,key):null;desc&&(desc.get||desc.set)?Object.defineProperty(newObj,key,desc):newObj[key]=obj[key]}return newObj.default=obj,cache&&cache.set(obj,newObj),newObj}function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}function _defineProperty(obj,key,value){return key in obj?Object.defineProperty(obj,key,{value:value,enumerable:!0,configurable:!0,writable:!0}):obj[key]=value,obj}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_templates=_interopRequireDefault(_templates),_ajax=_interopRequireDefault(_ajax),_notification=_interopRequireDefault(_notification),_selectors=_interopRequireDefault(_selectors),_policy=_interopRequireDefault(_policy),_helper=_interopRequireDefault(_helper),_drawer_events=_interopRequireDefault(_drawer_events),MessageDrawerHelper=_interopRequireWildcard(MessageDrawerHelper),FocusLock=_interopRequireWildcard(FocusLock);var _default=class{constructor(userId,contextId){_defineProperty(this,"userId",void 0),_defineProperty(this,"contextId",void 0),this.userId=userId,this.contextId=contextId,this.aiDrawerElement=document.querySelector(_selectors.default.ELEMENTS.AIDRAWER),this.aiDrawerBodyElement=document.querySelector(_selectors.default.ELEMENTS.AIDRAWER_BODY),this.pageElement=document.querySelector(_selectors.default.ELEMENTS.PAGE),this.jumpToElement=document.querySelector(_selectors.default.ELEMENTS.JUMPTO),this.actionElement=document.querySelector(_selectors.default.ELEMENTS.ACTION),this.aiDrawerCloseElement=this.aiDrawerElement.querySelector(_selectors.default.ELEMENTS.AIDRAWER_CLOSE),this.lastAction="",this.responses=new Map,this.isDrawerFocusLocked=!1,this.registerEventListeners()}registerEventListeners(){document.addEventListener("click",(async e=>{if(e.target.closest(_selectors.default.ACTIONS.SUMMARY)){e.preventDefault(),this.openAIDrawer(),this.lastAction="summarise",this.actionElement.focus();if(!await this.isPolicyAccepted())return void this.displayPolicy();this.displayAction(this.lastAction)}if(e.target.closest(_selectors.default.ACTIONS.EXPLAIN)){e.preventDefault(),this.openAIDrawer(),this.lastAction="explain",this.actionElement.focus();if(!await this.isPolicyAccepted())return void this.displayPolicy();this.displayAction(this.lastAction)}e.target.closest(_selectors.default.ELEMENTS.AIDRAWER_CLOSE)&&(e.preventDefault(),this.closeAIDrawer())})),document.addEventListener("keydown",(e=>{this.isAIDrawerOpen()&&"Escape"===e.key&&this.closeAIDrawer()})),(0,_pubsub.subscribe)(_drawer_events.default.DRAWER_SHOWN,(()=>{this.isAIDrawerOpen()&&this.closeAIDrawer()})),this.jumpToElement.addEventListener("focus",(()=>{this.aiDrawerCloseElement.focus()})),this.aiDrawerElement.addEventListener("focus",(()=>{this.actionElement.focus()})),this.actionElement.addEventListener("blur",(()=>{this.actionElement.classList.remove("active")}))}registerPolicyEventListeners(){const acceptAction=document.querySelector(_selectors.default.ACTIONS.ACCEPT),declineAction=document.querySelector(_selectors.default.ACTIONS.DECLINE);acceptAction&&this.lastAction.length&&acceptAction.addEventListener("click",(e=>{e.preventDefault(),this.acceptPolicy().then((()=>this.displayAction(this.lastAction))).catch(_notification.default.exception)})),declineAction&&declineAction.addEventListener("click",(e=>{e.preventDefault(),this.closeAIDrawer()}))}registerErrorEventListeners(){const retryAction=document.querySelector(_selectors.default.ACTIONS.RETRY);retryAction&&this.lastAction.length&&retryAction.addEventListener("click",(e=>{e.preventDefault(),this.displayAction(this.lastAction)}))}registerResponseEventListeners(){document.querySelectorAll(_selectors.default.ACTIONS.REGENERATE).forEach((regenerateAction=>{const responseElement=regenerateAction.closest(_selectors.default.ELEMENTS.RESPONSE);if(regenerateAction&&responseElement){const actionPerformed=responseElement.getAttribute("data-action-performed");regenerateAction.addEventListener("click",(e=>{e.preventDefault(),this.removeResponseFromStack(actionPerformed),this.displayAction(actionPerformed)}))}}))}registerLoadingEventListeners(){const cancelAction=document.querySelector(_selectors.default.ACTIONS.CANCEL);cancelAction&&cancelAction.addEventListener("click",(e=>{e.preventDefault(),this.setRequestCancelled(),this.toggleAIDrawer(),this.removeResponseFromStack("loading");const responses=this.getResponseStack();this.aiDrawerBodyElement.innerHTML=responses}))}isAIDrawerOpen(){return this.aiDrawerElement.classList.contains("show")}isRequestCancelled(){return"1"===this.aiDrawerBodyElement.dataset.cancelled}setRequestCancelled(){this.aiDrawerBodyElement.dataset.cancelled="1"}openAIDrawer(){MessageDrawerHelper.hide(),this.aiDrawerElement.classList.add("show"),this.aiDrawerElement.setAttribute("tabindex",0),this.aiDrawerBodyElement.setAttribute("aria-live","polite"),this.pageElement.classList.contains("show-drawer-right")||this.addPadding(),this.jumpToElement.setAttribute("tabindex",0),this.jumpToElement.focus(),(0,_pagehelpers.isSmall)()&&(FocusLock.trapFocus(this.aiDrawerElement),this.aiDrawerElement.setAttribute("aria-modal","true"),this.aiDrawerElement.setAttribute("role","dialog"),this.isDrawerFocusLocked=!0)}closeAIDrawer(){this.isDrawerFocusLocked&&(FocusLock.untrapFocus(),this.aiDrawerElement.removeAttribute("aria-modal"),this.aiDrawerElement.setAttribute("role","region")),this.aiDrawerElement.classList.remove("show"),this.aiDrawerElement.setAttribute("tabindex",-1),this.aiDrawerBodyElement.removeAttribute("aria-live"),this.pageElement.classList.contains("show-drawer-right")&&"1"===this.aiDrawerBodyElement.dataset.removepadding&&this.removePadding(),this.jumpToElement.setAttribute("tabindex",-1),this.actionElement.classList.add("active"),this.actionElement.focus()}toggleAIDrawer(){this.isAIDrawerOpen()?this.closeAIDrawer():this.openAIDrawer()}addPadding(){this.pageElement.classList.add("show-drawer-right"),this.aiDrawerBodyElement.dataset.removepadding="1"}removePadding(){this.pageElement.classList.remove("show-drawer-right"),this.aiDrawerBodyElement.dataset.removepadding="0"}async getParamsForAction(action){let params={};switch(action){case"summarise":params.method="aiplacement_courseassist_summarise_text",params.heading=await(0,_str.getString)("aisummary","aiplacement_courseassist");break;case"explain":params.method="aiplacement_courseassist_explain_text",params.heading=await(0,_str.getString)("aiexplain","aiplacement_courseassist")}return params}async isPolicyAccepted(){return await _policy.default.getPolicyStatus(this.userId)}acceptPolicy(){return _policy.default.acceptPolicy()}hasGeneratedContent(action){return this.responses.has(action)}displayPolicy(){_templates.default.render("core_ai/policyblock",{}).then((html=>{this.aiDrawerBodyElement.innerHTML=html,this.registerPolicyEventListeners()})).catch(_notification.default.exception)}displayLoading(){_templates.default.render("aiplacement_courseassist/loading",{}).then((html=>{this.addResponseToStack("loading",html);const responses=this.getResponseStack();this.aiDrawerBodyElement.innerHTML=responses,this.registerLoadingEventListeners()})).then((()=>{this.removeResponseFromStack("loading")})).catch(_notification.default.exception)}async displayAction(action){if(this.hasGeneratedContent(action)){const existingReponse=document.querySelector('[data-action-performed="'+action+'"]');existingReponse&&(this.aiDrawerBodyElement.scrollTop=existingReponse.offsetTop)}else{this.displayLoading(),this.aiDrawerBodyElement.innerHTML="";const request={methodname:(await this.getParamsForAction(action)).method,args:{contextid:this.contextId,prompttext:this.getTextContent()}};try{const responseObj=await _ajax.default.call([request])[0];if(responseObj.error)return void this.displayError();if(!this.isRequestCancelled()){const generatedContent=_helper.default.formatResponse(responseObj.generatedcontent);return void this.displayResponse(generatedContent,action)}this.aiDrawerBodyElement.dataset.cancelled="0"}catch(error){window.console.log(error),this.displayError()}}}addResponseToStack(action,html){this.responses.set(action,html)}removeResponseFromStack(action){this.responses.has(action)&&this.responses.delete(action)}getResponseStack(){let stack="";const responses=[...this.responses.values()].reverse();for(const response of responses)stack+=response;return stack}async displayResponse(content,action){const args={content:content,heading:(await this.getParamsForAction(action)).heading,action:action};_templates.default.render("aiplacement_courseassist/response",args).then((html=>{this.addResponseToStack(action,html);const responses=this.getResponseStack();this.aiDrawerBodyElement.innerHTML=responses,this.registerResponseEventListeners()})).catch(_notification.default.exception)}displayError(){_templates.default.render("aiplacement_courseassist/error",{}).then((html=>{this.addResponseToStack("error",html);const responses=this.getResponseStack();this.aiDrawerBodyElement.innerHTML=responses,this.registerErrorEventListeners()})).then((()=>{this.removeResponseFromStack("error")})).catch(_notification.default.exception)}getTextContent(){const mainRegion=document.querySelector(_selectors.default.ELEMENTS.MAIN_REGION);return mainRegion.innerText||mainRegion.textContent}};return _exports.default=_default,_exports.default})); //# sourceMappingURL=placement.min.js.mapPK*\Drr r 5placement/courseassist/amd/build/selectors.min.js.mapnu[{"version":3,"file":"selectors.min.js","sources":["../src/selectors.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Define all of the selectors we will be using on the AI Course assistant.\n *\n * @module aiplacement_courseassist/selectors\n * @copyright 2024 Huong Nguyen \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nexport default {\n ELEMENTS: {\n AIDRAWER: '#ai-drawer',\n AIDRAWER_BODY: '#ai-drawer .ai-drawer-body',\n PAGE: '#page',\n MAIN_REGION: '[role=\"main\"]',\n AIDRAWER_CLOSE: '#ai-drawer-close',\n RESPONSE: '.course-assist-response',\n JUMPTO: '.course-assist-controls [data-region=\"jumpto\"]',\n ACTION: '.course-assist-controls [data-input-type=\"action\"]',\n },\n ACTIONS: {\n SUMMARY: '.course-assist-controls [data-action=\"summarise\"]',\n EXPLAIN: '.course-assist-controls [data-action=\"explain\"]',\n RETRY: '.course-assist-controls [data-action=\"retry\"]',\n DECLINE: '.ai-policy-block [data-action=\"decline\"]',\n ACCEPT: '.ai-policy-block [data-action=\"accept\"]',\n REGENERATE: '.course-assist-controls [data-action=\"regenerate\"]',\n CANCEL: '.course-assist-controls [data-action=\"cancel\"]',\n }\n};\n"],"names":["ELEMENTS","AIDRAWER","AIDRAWER_BODY","PAGE","MAIN_REGION","AIDRAWER_CLOSE","RESPONSE","JUMPTO","ACTION","ACTIONS","SUMMARY","EXPLAIN","RETRY","DECLINE","ACCEPT","REGENERATE","CANCEL"],"mappings":"oLAsBe,CACXA,SAAU,CACNC,SAAU,aACVC,cAAe,6BACfC,KAAM,QACNC,YAAa,gBACbC,eAAgB,mBAChBC,SAAU,0BACVC,OAAQ,iDACRC,OAAQ,sDAEZC,QAAS,CACLC,QAAS,oDACTC,QAAS,kDACTC,MAAO,gDACPC,QAAS,2CACTC,OAAQ,0CACRC,WAAY,qDACZC,OAAQ"}PK*\$U9b9b5placement/courseassist/amd/build/placement.min.js.mapnu[{"version":3,"file":"placement.min.js","sources":["../src/placement.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Module to load and render the tools for the AI assist plugin.\n *\n * @module aiplacement_courseassist/placement\n * @copyright 2024 Huong Nguyen \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport Templates from 'core/templates';\nimport Ajax from 'core/ajax';\nimport 'core/copy_to_clipboard';\nimport Notification from 'core/notification';\nimport Selectors from 'aiplacement_courseassist/selectors';\nimport Policy from 'core_ai/policy';\nimport AIHelper from 'core_ai/helper';\nimport DrawerEvents from 'core/drawer_events';\nimport {subscribe} from 'core/pubsub';\nimport * as MessageDrawerHelper from 'core_message/message_drawer_helper';\nimport {getString} from 'core/str';\nimport * as FocusLock from 'core/local/aria/focuslock';\nimport {isSmall} from \"core/pagehelpers\";\n\nconst AICourseAssist = class {\n\n /**\n * The user ID.\n * @type {Integer}\n */\n userId;\n /**\n * The context ID.\n * @type {Integer}\n */\n contextId;\n\n /**\n * Constructor.\n * @param {Integer} userId The user ID.\n * @param {Integer} contextId The context ID.\n */\n constructor(userId, contextId) {\n this.userId = userId;\n this.contextId = contextId;\n\n this.aiDrawerElement = document.querySelector(Selectors.ELEMENTS.AIDRAWER);\n this.aiDrawerBodyElement = document.querySelector(Selectors.ELEMENTS.AIDRAWER_BODY);\n this.pageElement = document.querySelector(Selectors.ELEMENTS.PAGE);\n this.jumpToElement = document.querySelector(Selectors.ELEMENTS.JUMPTO);\n this.actionElement = document.querySelector(Selectors.ELEMENTS.ACTION);\n this.aiDrawerCloseElement = this.aiDrawerElement.querySelector(Selectors.ELEMENTS.AIDRAWER_CLOSE);\n this.lastAction = '';\n this.responses = new Map();\n this.isDrawerFocusLocked = false;\n\n this.registerEventListeners();\n }\n\n /**\n * Register event listeners.\n */\n registerEventListeners() {\n document.addEventListener('click', async(e) => {\n // Display summarise.\n const summariseAction = e.target.closest(Selectors.ACTIONS.SUMMARY);\n if (summariseAction) {\n e.preventDefault();\n this.openAIDrawer();\n this.lastAction = 'summarise';\n this.actionElement.focus();\n const isPolicyAccepted = await this.isPolicyAccepted();\n if (!isPolicyAccepted) {\n // Display policy.\n this.displayPolicy();\n return;\n }\n this.displayAction(this.lastAction);\n }\n // Display explain.\n const explainAction = e.target.closest(Selectors.ACTIONS.EXPLAIN);\n if (explainAction) {\n e.preventDefault();\n this.openAIDrawer();\n this.lastAction = 'explain';\n this.actionElement.focus();\n const isPolicyAccepted = await this.isPolicyAccepted();\n if (!isPolicyAccepted) {\n // Display policy.\n this.displayPolicy();\n return;\n }\n this.displayAction(this.lastAction);\n }\n // Close AI drawer.\n const closeAiDrawer = e.target.closest(Selectors.ELEMENTS.AIDRAWER_CLOSE);\n if (closeAiDrawer) {\n e.preventDefault();\n this.closeAIDrawer();\n }\n });\n\n document.addEventListener('keydown', e => {\n if (this.isAIDrawerOpen() && e.key === 'Escape') {\n this.closeAIDrawer();\n }\n });\n\n // Close AI drawer if message drawer is shown.\n subscribe(DrawerEvents.DRAWER_SHOWN, () => {\n if (this.isAIDrawerOpen()) {\n this.closeAIDrawer();\n }\n });\n\n // Focus on the AI drawer's close button when the jump-to element is focused.\n this.jumpToElement.addEventListener('focus', () => {\n this.aiDrawerCloseElement.focus();\n });\n\n // Focus on the action element when the AI drawer container receives focus.\n this.aiDrawerElement.addEventListener('focus', () => {\n this.actionElement.focus();\n });\n\n // Remove active from the action element when it loses focus.\n this.actionElement.addEventListener('blur', () => {\n this.actionElement.classList.remove('active');\n });\n }\n\n /**\n * Register event listeners for the policy.\n */\n registerPolicyEventListeners() {\n const acceptAction = document.querySelector(Selectors.ACTIONS.ACCEPT);\n const declineAction = document.querySelector(Selectors.ACTIONS.DECLINE);\n if (acceptAction && this.lastAction.length) {\n acceptAction.addEventListener('click', (e) => {\n e.preventDefault();\n this.acceptPolicy().then(() => {\n return this.displayAction(this.lastAction);\n }).catch(Notification.exception);\n });\n }\n if (declineAction) {\n declineAction.addEventListener('click', (e) => {\n e.preventDefault();\n this.closeAIDrawer();\n });\n }\n }\n\n /**\n * Register event listeners for the error.\n */\n registerErrorEventListeners() {\n const retryAction = document.querySelector(Selectors.ACTIONS.RETRY);\n if (retryAction && this.lastAction.length) {\n retryAction.addEventListener('click', (e) => {\n e.preventDefault();\n this.displayAction(this.lastAction);\n });\n }\n }\n\n /**\n * Register event listeners for the responses.\n */\n registerResponseEventListeners() {\n // Get all regenerate action buttons (one per response in the AI drawer).\n const regenerateActions = document.querySelectorAll(Selectors.ACTIONS.REGENERATE);\n // Add event listeners for each regenerate action.\n regenerateActions.forEach(regenerateAction => {\n const responseElement = regenerateAction.closest(Selectors.ELEMENTS.RESPONSE);\n if (regenerateAction && responseElement) {\n // Get the action that this response is associated with.\n const actionPerformed = responseElement.getAttribute('data-action-performed');\n regenerateAction.addEventListener('click', (e) => {\n e.preventDefault();\n // Remove the old response before displaying the new one.\n this.removeResponseFromStack(actionPerformed);\n this.displayAction(actionPerformed);\n });\n }\n });\n }\n\n registerLoadingEventListeners() {\n const cancelAction = document.querySelector(Selectors.ACTIONS.CANCEL);\n if (cancelAction) {\n cancelAction.addEventListener('click', (e) => {\n e.preventDefault();\n this.setRequestCancelled();\n this.toggleAIDrawer();\n this.removeResponseFromStack('loading');\n // Refresh the response stack to avoid false indication of loading.\n const responses = this.getResponseStack();\n this.aiDrawerBodyElement.innerHTML = responses;\n });\n }\n }\n\n /**\n * Check if the AI drawer is open.\n * @return {boolean} True if the AI drawer is open, false otherwise.\n */\n isAIDrawerOpen() {\n return this.aiDrawerElement.classList.contains('show');\n }\n\n /**\n * Check if the request is cancelled.\n * @return {boolean} True if the request is cancelled, false otherwise.\n */\n isRequestCancelled() {\n return this.aiDrawerBodyElement.dataset.cancelled === '1';\n }\n\n setRequestCancelled() {\n this.aiDrawerBodyElement.dataset.cancelled = '1';\n }\n\n /**\n * Open the AI drawer.\n */\n openAIDrawer() {\n // Close message drawer if it is shown.\n MessageDrawerHelper.hide();\n this.aiDrawerElement.classList.add('show');\n this.aiDrawerElement.setAttribute('tabindex', 0);\n this.aiDrawerBodyElement.setAttribute('aria-live', 'polite');\n if (!this.pageElement.classList.contains('show-drawer-right')) {\n this.addPadding();\n }\n this.jumpToElement.setAttribute('tabindex', 0);\n this.jumpToElement.focus();\n\n // If the AI drawer is opened on a small screen, we need to trap the focus tab within the AI drawer.\n if (isSmall()) {\n FocusLock.trapFocus(this.aiDrawerElement);\n this.aiDrawerElement.setAttribute('aria-modal', 'true');\n this.aiDrawerElement.setAttribute('role', 'dialog');\n this.isDrawerFocusLocked = true;\n }\n }\n\n /**\n * Close the AI drawer.\n */\n closeAIDrawer() {\n // Untrap focus if it was locked.\n if (this.isDrawerFocusLocked) {\n FocusLock.untrapFocus();\n this.aiDrawerElement.removeAttribute('aria-modal');\n this.aiDrawerElement.setAttribute('role', 'region');\n }\n\n this.aiDrawerElement.classList.remove('show');\n this.aiDrawerElement.setAttribute('tabindex', -1);\n this.aiDrawerBodyElement.removeAttribute('aria-live');\n if (this.pageElement.classList.contains('show-drawer-right') && this.aiDrawerBodyElement.dataset.removepadding === '1') {\n this.removePadding();\n }\n this.jumpToElement.setAttribute('tabindex', -1);\n\n // We can enforce a focus-visible state on the focus element using element.focus({focusVisible: true}).\n // Unfortunately, this feature isn't supported in all browsers, only Firefox provides support for it.\n // Therefore, we will apply the active class to the action element and set focus on it.\n // This action will make the action element appear focused.\n // When the action element loses focus,\n // we will remove the active class at {@see registerEventListeners()}\n this.actionElement.classList.add('active');\n this.actionElement.focus();\n }\n\n /**\n * Toggle the AI drawer.\n */\n toggleAIDrawer() {\n if (this.isAIDrawerOpen()) {\n this.closeAIDrawer();\n } else {\n this.openAIDrawer();\n }\n }\n\n /**\n * Add padding to the page to make space for the AI drawer.\n */\n addPadding() {\n this.pageElement.classList.add('show-drawer-right');\n this.aiDrawerBodyElement.dataset.removepadding = '1';\n }\n\n /**\n * Remove padding from the page.\n */\n removePadding() {\n this.pageElement.classList.remove('show-drawer-right');\n this.aiDrawerBodyElement.dataset.removepadding = '0';\n }\n\n /**\n * Get important params related to the action.\n * @param {string} action The action to use.\n * @returns {object} The params to use for the action.\n */\n async getParamsForAction(action) {\n let params = {};\n\n switch (action) {\n case 'summarise':\n params.method = 'aiplacement_courseassist_summarise_text';\n params.heading = await getString('aisummary', 'aiplacement_courseassist');\n break;\n\n case 'explain':\n params.method = 'aiplacement_courseassist_explain_text';\n params.heading = await getString('aiexplain', 'aiplacement_courseassist');\n break;\n }\n\n return params;\n }\n\n /**\n * Check if the policy is accepted.\n * @return {bool} True if the policy is accepted, false otherwise.\n */\n async isPolicyAccepted() {\n return await Policy.getPolicyStatus(this.userId);\n }\n\n /**\n * Accept the policy.\n * @return {Promise}\n */\n acceptPolicy() {\n return Policy.acceptPolicy();\n }\n\n /**\n * Check if the AI drawer has already generated content for a particular action.\n * @param {string} action The action to check.\n * @return {boolean} True if the AI drawer has generated content, false otherwise.\n */\n hasGeneratedContent(action) {\n return this.responses.has(action);\n }\n\n /**\n * Display the policy.\n */\n displayPolicy() {\n Templates.render('core_ai/policyblock', {}).then((html) => {\n this.aiDrawerBodyElement.innerHTML = html;\n this.registerPolicyEventListeners();\n return;\n }).catch(Notification.exception);\n }\n\n /**\n * Display the loading spinner.\n */\n displayLoading() {\n Templates.render('aiplacement_courseassist/loading', {}).then((html) => {\n this.addResponseToStack('loading', html);\n const responses = this.getResponseStack();\n this.aiDrawerBodyElement.innerHTML = responses;\n this.registerLoadingEventListeners();\n return;\n }).then(() => {\n this.removeResponseFromStack('loading');\n return;\n }).catch(Notification.exception);\n }\n\n /**\n * Display the action result in the AI drawer.\n * @param {string} action The action to display.\n */\n async displayAction(action) {\n if (this.hasGeneratedContent(action)) {\n // Scroll to generated content.\n const existingReponse = document.querySelector('[data-action-performed=\"' + action + '\"]');\n if (existingReponse) {\n this.aiDrawerBodyElement.scrollTop = existingReponse.offsetTop;\n }\n } else {\n // Display loading spinner.\n this.displayLoading();\n // Clear the drawer to prevent including the previously generated response in the new response prompt.\n this.aiDrawerBodyElement.innerHTML = '';\n const params = await this.getParamsForAction(action);\n const request = {\n methodname: params.method,\n args: {\n contextid: this.contextId,\n prompttext: this.getTextContent(),\n }\n };\n try {\n const responseObj = await Ajax.call([request])[0];\n if (responseObj.error) {\n this.displayError();\n return;\n } else {\n if (!this.isRequestCancelled()) {\n // Perform replacements on the generated context to ensure it is formatted correctly.\n const generatedContent = AIHelper.formatResponse(responseObj.generatedcontent);\n this.displayResponse(generatedContent, action);\n return;\n } else {\n this.aiDrawerBodyElement.dataset.cancelled = '0';\n }\n }\n } catch (error) {\n window.console.log(error);\n this.displayError();\n }\n }\n }\n\n /**\n * Add the HTML response to the response stack.\n * The stack will be used to display all responses in the AI drawer.\n * @param {String} action The action key.\n * @param {String} html The HTML to store.\n */\n addResponseToStack(action, html) {\n this.responses.set(action, html);\n }\n\n /**\n * Remove a stored response, allowing for a regenerated one.\n * @param {String} action The action key.\n */\n removeResponseFromStack(action) {\n if (this.responses.has(action)) {\n this.responses.delete(action);\n }\n }\n\n /**\n * Return a stack of HTML responses.\n * @return {String} HTML responses.\n */\n getResponseStack() {\n let stack = '';\n // Reverse to get newest first.\n const responses = [...this.responses.values()].reverse();\n for (const response of responses) {\n stack += response;\n }\n return stack;\n }\n\n /**\n * Display the responses.\n * @param {String} content The content to display.\n * @param {String} action The action used.\n */\n async displayResponse(content, action) {\n const params = await this.getParamsForAction(action);\n const args = {\n content: content,\n heading: params.heading,\n action: action,\n };\n Templates.render('aiplacement_courseassist/response', args).then((html) => {\n this.addResponseToStack(action, html);\n const responses = this.getResponseStack();\n this.aiDrawerBodyElement.innerHTML = responses;\n this.registerResponseEventListeners();\n return;\n }).catch(Notification.exception);\n }\n\n /**\n * Display the error.\n */\n displayError() {\n Templates.render('aiplacement_courseassist/error', {}).then((html) => {\n this.addResponseToStack('error', html);\n const responses = this.getResponseStack();\n this.aiDrawerBodyElement.innerHTML = responses;\n this.registerErrorEventListeners();\n return;\n }).then(() => {\n this.removeResponseFromStack('error');\n return;\n }).catch(Notification.exception);\n }\n\n /**\n * Get the text content of the main region.\n * @return {String} The text content.\n */\n getTextContent() {\n const mainRegion = document.querySelector(Selectors.ELEMENTS.MAIN_REGION);\n return mainRegion.innerText || mainRegion.textContent;\n }\n};\n\nexport default AICourseAssist;\n"],"names":["constructor","userId","contextId","aiDrawerElement","document","querySelector","Selectors","ELEMENTS","AIDRAWER","aiDrawerBodyElement","AIDRAWER_BODY","pageElement","PAGE","jumpToElement","JUMPTO","actionElement","ACTION","aiDrawerCloseElement","this","AIDRAWER_CLOSE","lastAction","responses","Map","isDrawerFocusLocked","registerEventListeners","addEventListener","async","e","target","closest","ACTIONS","SUMMARY","preventDefault","openAIDrawer","focus","isPolicyAccepted","displayPolicy","displayAction","EXPLAIN","closeAIDrawer","isAIDrawerOpen","key","DrawerEvents","DRAWER_SHOWN","classList","remove","registerPolicyEventListeners","acceptAction","ACCEPT","declineAction","DECLINE","length","acceptPolicy","then","catch","Notification","exception","registerErrorEventListeners","retryAction","RETRY","registerResponseEventListeners","querySelectorAll","REGENERATE","forEach","regenerateAction","responseElement","RESPONSE","actionPerformed","getAttribute","removeResponseFromStack","registerLoadingEventListeners","cancelAction","CANCEL","setRequestCancelled","toggleAIDrawer","getResponseStack","innerHTML","contains","isRequestCancelled","dataset","cancelled","MessageDrawerHelper","hide","add","setAttribute","addPadding","FocusLock","trapFocus","untrapFocus","removeAttribute","removepadding","removePadding","action","params","method","heading","Policy","getPolicyStatus","hasGeneratedContent","has","render","html","displayLoading","addResponseToStack","existingReponse","scrollTop","offsetTop","request","methodname","getParamsForAction","args","contextid","prompttext","getTextContent","responseObj","Ajax","call","error","displayError","generatedContent","AIHelper","formatResponse","generatedcontent","displayResponse","window","console","log","set","delete","stack","values","reverse","response","content","mainRegion","MAIN_REGION","innerText","textContent"],"mappings":"qqEAqCuB,MAkBnBA,YAAYC,OAAQC,+FACXD,OAASA,YACTC,UAAYA,eAEZC,gBAAkBC,SAASC,cAAcC,mBAAUC,SAASC,eAC5DC,oBAAsBL,SAASC,cAAcC,mBAAUC,SAASG,oBAChEC,YAAcP,SAASC,cAAcC,mBAAUC,SAASK,WACxDC,cAAgBT,SAASC,cAAcC,mBAAUC,SAASO,aAC1DC,cAAgBX,SAASC,cAAcC,mBAAUC,SAASS,aAC1DC,qBAAuBC,KAAKf,gBAAgBE,cAAcC,mBAAUC,SAASY,qBAC7EC,WAAa,QACbC,UAAY,IAAIC,SAChBC,qBAAsB,OAEtBC,yBAMTA,yBACIpB,SAASqB,iBAAiB,SAASC,MAAAA,OAEPC,EAAEC,OAAOC,QAAQvB,mBAAUwB,QAAQC,SACtC,CACjBJ,EAAEK,sBACGC,oBACAb,WAAa,iBACbL,cAAcmB,kBACYhB,KAAKiB,oCAG3BC,qBAGJC,cAAcnB,KAAKE,eAGNO,EAAEC,OAAOC,QAAQvB,mBAAUwB,QAAQQ,SACtC,CACfX,EAAEK,sBACGC,oBACAb,WAAa,eACbL,cAAcmB,kBACYhB,KAAKiB,oCAG3BC,qBAGJC,cAAcnB,KAAKE,YAGNO,EAAEC,OAAOC,QAAQvB,mBAAUC,SAASY,kBAEtDQ,EAAEK,sBACGO,oBAIbnC,SAASqB,iBAAiB,WAAWE,IAC7BT,KAAKsB,kBAA8B,WAAVb,EAAEc,UACtBF,yCAKHG,uBAAaC,cAAc,KAC7BzB,KAAKsB,uBACAD,wBAKR1B,cAAcY,iBAAiB,SAAS,UACpCR,qBAAqBiB,gBAIzB/B,gBAAgBsB,iBAAiB,SAAS,UACtCV,cAAcmB,gBAIlBnB,cAAcU,iBAAiB,QAAQ,UACnCV,cAAc6B,UAAUC,OAAO,aAO5CC,qCACUC,aAAe3C,SAASC,cAAcC,mBAAUwB,QAAQkB,QACxDC,cAAgB7C,SAASC,cAAcC,mBAAUwB,QAAQoB,SAC3DH,cAAgB7B,KAAKE,WAAW+B,QAChCJ,aAAatB,iBAAiB,SAAUE,IACpCA,EAAEK,sBACGoB,eAAeC,MAAK,IACdnC,KAAKmB,cAAcnB,KAAKE,cAChCkC,MAAMC,sBAAaC,cAG1BP,eACAA,cAAcxB,iBAAiB,SAAUE,IACrCA,EAAEK,sBACGO,mBAQjBkB,oCACUC,YAActD,SAASC,cAAcC,mBAAUwB,QAAQ6B,OACzDD,aAAexC,KAAKE,WAAW+B,QAC/BO,YAAYjC,iBAAiB,SAAUE,IACnCA,EAAEK,sBACGK,cAAcnB,KAAKE,eAQpCwC,iCAE8BxD,SAASyD,iBAAiBvD,mBAAUwB,QAAQgC,YAEpDC,SAAQC,yBAChBC,gBAAkBD,iBAAiBnC,QAAQvB,mBAAUC,SAAS2D,aAChEF,kBAAoBC,gBAAiB,OAE/BE,gBAAkBF,gBAAgBG,aAAa,yBACrDJ,iBAAiBvC,iBAAiB,SAAUE,IACxCA,EAAEK,sBAEGqC,wBAAwBF,sBACxB9B,cAAc8B,wBAMnCG,sCACUC,aAAenE,SAASC,cAAcC,mBAAUwB,QAAQ0C,QAC1DD,cACAA,aAAa9C,iBAAiB,SAAUE,IACpCA,EAAEK,sBACGyC,2BACAC,sBACAL,wBAAwB,iBAEvBhD,UAAYH,KAAKyD,wBAClBlE,oBAAoBmE,UAAYvD,aASjDmB,wBACWtB,KAAKf,gBAAgByC,UAAUiC,SAAS,QAOnDC,2BAC0D,MAA/C5D,KAAKT,oBAAoBsE,QAAQC,UAG5CP,2BACShE,oBAAoBsE,QAAQC,UAAY,IAMjD/C,eAEIgD,oBAAoBC,YACf/E,gBAAgByC,UAAUuC,IAAI,aAC9BhF,gBAAgBiF,aAAa,WAAY,QACzC3E,oBAAoB2E,aAAa,YAAa,UAC9ClE,KAAKP,YAAYiC,UAAUiC,SAAS,2BAChCQ,kBAEJxE,cAAcuE,aAAa,WAAY,QACvCvE,cAAcqB,SAGf,4BACAoD,UAAUC,UAAUrE,KAAKf,sBACpBA,gBAAgBiF,aAAa,aAAc,aAC3CjF,gBAAgBiF,aAAa,OAAQ,eACrC7D,qBAAsB,GAOnCgB,gBAEQrB,KAAKK,sBACL+D,UAAUE,mBACLrF,gBAAgBsF,gBAAgB,mBAChCtF,gBAAgBiF,aAAa,OAAQ,gBAGzCjF,gBAAgByC,UAAUC,OAAO,aACjC1C,gBAAgBiF,aAAa,YAAa,QAC1C3E,oBAAoBgF,gBAAgB,aACrCvE,KAAKP,YAAYiC,UAAUiC,SAAS,sBAA2E,MAAnD3D,KAAKT,oBAAoBsE,QAAQW,oBACxFC,qBAEJ9E,cAAcuE,aAAa,YAAa,QAQxCrE,cAAc6B,UAAUuC,IAAI,eAC5BpE,cAAcmB,QAMvBwC,iBACQxD,KAAKsB,sBACAD,qBAEAN,eAOboD,kBACS1E,YAAYiC,UAAUuC,IAAI,0BAC1B1E,oBAAoBsE,QAAQW,cAAgB,IAMrDC,qBACShF,YAAYiC,UAAUC,OAAO,0BAC7BpC,oBAAoBsE,QAAQW,cAAgB,6BAQ5BE,YACjBC,OAAS,UAELD,YACC,YACDC,OAAOC,OAAS,0CAChBD,OAAOE,cAAgB,kBAAU,YAAa,sCAG7C,UACDF,OAAOC,OAAS,wCAChBD,OAAOE,cAAgB,kBAAU,YAAa,mCAI/CF,6CAQMG,gBAAOC,gBAAgB/E,KAAKjB,QAO7CmD,sBACW4C,gBAAO5C,eAQlB8C,oBAAoBN,eACT1E,KAAKG,UAAU8E,IAAIP,QAM9BxD,mCACcgE,OAAO,sBAAuB,IAAI/C,MAAMgD,YACzC5F,oBAAoBmE,UAAYyB,UAChCvD,kCAENQ,MAAMC,sBAAaC,WAM1B8C,oCACcF,OAAO,mCAAoC,IAAI/C,MAAMgD,YACtDE,mBAAmB,UAAWF,YAC7BhF,UAAYH,KAAKyD,wBAClBlE,oBAAoBmE,UAAYvD,eAChCiD,mCAENjB,MAAK,UACCgB,wBAAwB,cAE9Bf,MAAMC,sBAAaC,+BAONoC,WACZ1E,KAAKgF,oBAAoBN,QAAS,OAE5BY,gBAAkBpG,SAASC,cAAc,2BAA6BuF,OAAS,MACjFY,uBACK/F,oBAAoBgG,UAAYD,gBAAgBE,eAEtD,MAEEJ,sBAEA7F,oBAAoBmE,UAAY,SAE/B+B,QAAU,CACZC,kBAFiB1F,KAAK2F,mBAAmBjB,SAEtBE,OACnBgB,KAAM,CACFC,UAAW7F,KAAKhB,UAChB8G,WAAY9F,KAAK+F,6BAIfC,kBAAoBC,cAAKC,KAAK,CAACT,UAAU,MAC3CO,YAAYG,uBACPC,mBAGApG,KAAK4D,qBAAsB,OAEtByC,iBAAmBC,gBAASC,eAAeP,YAAYQ,mCACxDC,gBAAgBJ,iBAAkB3B,aAGlCnF,oBAAoBsE,QAAQC,UAAY,IAGvD,MAAOqC,OACLO,OAAOC,QAAQC,IAAIT,YACdC,iBAWjBf,mBAAmBX,OAAQS,WAClBhF,UAAU0G,IAAInC,OAAQS,MAO/BhC,wBAAwBuB,QAChB1E,KAAKG,UAAU8E,IAAIP,cACdvE,UAAU2G,OAAOpC,QAQ9BjB,uBACQsD,MAAQ,SAEN5G,UAAY,IAAIH,KAAKG,UAAU6G,UAAUC,cAC1C,MAAMC,YAAY/G,UACnB4G,OAASG,gBAENH,4BAQWI,QAASzC,cAErBkB,KAAO,CACTuB,QAASA,QACTtC,eAHiB7E,KAAK2F,mBAAmBjB,SAGzBG,QAChBH,OAAQA,2BAEFQ,OAAO,oCAAqCU,MAAMzD,MAAMgD,YACzDE,mBAAmBX,OAAQS,YAC1BhF,UAAYH,KAAKyD,wBAClBlE,oBAAoBmE,UAAYvD,eAChCuC,oCAENN,MAAMC,sBAAaC,WAM1B8D,kCACclB,OAAO,iCAAkC,IAAI/C,MAAMgD,YACpDE,mBAAmB,QAASF,YAC3BhF,UAAYH,KAAKyD,wBAClBlE,oBAAoBmE,UAAYvD,eAChCoC,iCAENJ,MAAK,UACCgB,wBAAwB,YAE9Bf,MAAMC,sBAAaC,WAO1ByD,uBACUqB,WAAalI,SAASC,cAAcC,mBAAUC,SAASgI,oBACtDD,WAAWE,WAAaF,WAAWG"}PK*\ֵ1placement/courseassist/amd/build/selectors.min.jsnu[define("aiplacement_courseassist/selectors",["exports"],(function(_exports){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0;return _exports.default={ELEMENTS:{AIDRAWER:"#ai-drawer",AIDRAWER_BODY:"#ai-drawer .ai-drawer-body",PAGE:"#page",MAIN_REGION:'[role="main"]',AIDRAWER_CLOSE:"#ai-drawer-close",RESPONSE:".course-assist-response",JUMPTO:'.course-assist-controls [data-region="jumpto"]',ACTION:'.course-assist-controls [data-input-type="action"]'},ACTIONS:{SUMMARY:'.course-assist-controls [data-action="summarise"]',EXPLAIN:'.course-assist-controls [data-action="explain"]',RETRY:'.course-assist-controls [data-action="retry"]',DECLINE:'.ai-policy-block [data-action="decline"]',ACCEPT:'.ai-policy-block [data-action="accept"]',REGENERATE:'.course-assist-controls [data-action="regenerate"]',CANCEL:'.course-assist-controls [data-action="cancel"]'}},_exports.default})); //# sourceMappingURL=selectors.min.js.mapPK*\^u###placement/courseassist/UPGRADING.mdnu[# aiplacement_courseassist Upgrade notes ## 5.0 ### Added - The `aiplacement_courseassist` templates and CSS have been modified. These changes allow for multiple actions to be nested in a dropdown menu. For more information see [MDL-82942](https://tracker.moodle.org/browse/MDL-82942) PK*\m ) =placement/courseassist/tests/external/summarise_text_test.phpnu[. namespace aiplacement_courseassist\external; use core_ai\aiactions\summarise_text; /** * Test summarise text external webservice calls. * * @package aiplacement_courseassist * @copyright 2025 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \aiplacement_courseassist\external\summarise_text */ final class summarise_text_test extends \advanced_testcase { /** * Test summarise_text webservice. */ public function test_execute(): void { $this->resetAfterTest(); set_config('enabled', 1, 'aiplacement_courseassist'); $this->setAdminUser(); // Get course context. $course = $this->getDataGenerator()->create_course(); $context = \context_course::instance($course->id); // Mock the manager class call. $response = new \core_ai\aiactions\responses\response_summarise_text(success: true); $response->set_response_data( [ 'generatedcontent' => 'This was a test', 'finishreason' => 'max_-tokens', // Alphanumext. ] ); $mockmanager = $this->createMock(\core_ai\manager::class); $mockmanager->method('process_action')->willReturn($response); $mockmanager->method('is_action_available')->willReturn(true); $mockmanager->method('is_action_enabled')->willReturn(true); $mockmanager->method('get_providers_for_actions')->willReturn([ summarise_text::class => ['aiprovider_openai'], ]); \core\di::set(\core_ai\manager::class, function() use ($mockmanager) { return $mockmanager; }); $_POST['sesskey'] = sesskey(); $params = [ 'contextid' => $context->id, 'prompttext' => 'This is a test', ]; $result = \core_external\external_api::call_external_function( 'aiplacement_courseassist_summarise_text', $params, ); $this->assertFalse($result['error']); $this->assertEquals('max_-tokens', $result['data']['finishreason']); $this->assertEquals('This was a test', $result['data']['generatedcontent']); $this->assertTrue($result['data']['success']); } } PK*\Xsp ;placement/courseassist/tests/external/explain_text_test.phpnu[. namespace aiplacement_courseassist\external; use core_ai\aiactions\explain_text; /** * Test explain text external webservice calls. * * @package aiplacement_courseassist * @copyright 2025 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \aiplacement_courseassist\external\explain_text */ final class explain_text_test extends \advanced_testcase { /** * Test explain_text webservice. */ public function test_execute(): void { $this->resetAfterTest(); set_config('enabled', 1, 'aiplacement_courseassist'); $this->setAdminUser(); // Get course context. $course = $this->getDataGenerator()->create_course(); $context = \context_course::instance($course->id); // Mock the manager class call. $response = new \core_ai\aiactions\responses\response_explain_text(success: true); $response->set_response_data( [ 'generatedcontent' => 'This was a test', 'finishreason' => 'max_-tokens', // Alphanumext. ] ); $mockmanager = $this->createMock(\core_ai\manager::class); $mockmanager->method('process_action')->willReturn($response); $mockmanager->method('is_action_available')->willReturn(true); $mockmanager->method('is_action_enabled')->willReturn(true); $mockmanager->method('get_providers_for_actions')->willReturn([ explain_text::class => ['aiprovider_openai'], ]); \core\di::set(\core_ai\manager::class, function() use ($mockmanager) { return $mockmanager; }); $_POST['sesskey'] = sesskey(); $params = [ 'contextid' => $context->id, 'prompttext' => 'This is a test', ]; $result = \core_external\external_api::call_external_function( 'aiplacement_courseassist_explain_text', $params, ); $this->assertFalse($result['error']); $this->assertEquals('max_-tokens', $result['data']['finishreason']); $this->assertEquals('This was a test', $result['data']['generatedcontent']); $this->assertTrue($result['data']['success']); } } PK*\+placement/courseassist/tests/utils_test.phpnu[. namespace aiplacement_courseassist; use core_ai\aiactions\generate_text; use core_ai\aiactions\summarise_text; use core_ai\manager; /** * AI Placement course assist utils test. * * @package aiplacement_courseassist * @copyright 2024 Huong Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \aiplacement_courseassist\utils */ final class utils_test extends \advanced_testcase { /** * Test is_course_assist_available method. */ public function test_is_course_assist_available(): void { global $DB; $this->resetAfterTest(); $user1 = $this->getDataGenerator()->create_user(); $user2 = $this->getDataGenerator()->create_user(); $course = $this->getDataGenerator()->create_course(); $context = \context_course::instance($course->id); $teacherrole = $DB->get_record('role', ['shortname' => 'editingteacher']); $this->getDataGenerator()->enrol_user($user1->id, $course->id, 'manager'); $this->getDataGenerator()->enrol_user($user2->id, $course->id, 'editingteacher'); // Provider is not enabled. $this->setUser($user1); $this->assertFalse(utils::is_course_assist_available($context)); // Provider is enabled, but plugin is not enabled. set_config('enabled', 1, 'aiprovider_openai'); set_config('apikey', '123', 'aiprovider_openai'); set_config('enabled', 0, 'aiplacement_courseassist'); $this->assertFalse(utils::is_course_assist_available($context)); // Plugin is enabled but user does not have capability. assign_capability('aiplacement/courseassist:summarise_text', CAP_PROHIBIT, $teacherrole->id, $context); assign_capability('aiplacement/courseassist:explain_text', CAP_PROHIBIT, $teacherrole->id, $context); $this->setUser($user2); set_config('enabled', 1, 'aiplacement_courseassist'); $this->assertFalse(utils::is_course_assist_available($context)); // Plugin is enabled, user has capability and placement action is not available. $this->setUser($user1); set_config('summarise_text', 0, 'aiplacement_courseassist'); set_config('explain_text', 0, 'aiplacement_courseassist'); $this->assertFalse(utils::is_course_assist_available($context)); // Plugin is enabled, user has capability and provider action is not available. $this->setUser($user1); set_config('summarise_text', 0, 'aiprovider_openai'); set_config('summarise_text', 1, 'aiplacement_courseassist'); set_config('explain_text', 0, 'aiprovider_openai'); set_config('explain_text', 1, 'aiplacement_courseassist'); $this->assertFalse(utils::is_course_assist_available($context)); // Plugin is enabled, user has capability, placement action is available and provider action is available. $mockmanager = $this->createMock(manager::class); $mockmanager->method('is_action_available')->willReturn(true); $mockmanager->method('is_action_enabled')->willReturn(true); $mockmanager->method('get_providers_for_actions')->willReturn([ summarise_text::class => ['aiprovider_openai'], ]); \core\di::set(manager::class, function() use ($mockmanager) { return $mockmanager; }); $this->setUser($user1); set_config('summarise_text', 1, 'aiplacement_courseassist'); set_config('explain_text', 1, 'aiprovider_openai'); set_config('explain_text', 1, 'aiplacement_courseassist'); $this->assertTrue(utils::is_course_assist_available($context)); } /** * Test get_actions_available method. */ public function test_get_actions_available(): void { global $DB; $this->resetAfterTest(); $user1 = $this->getDataGenerator()->create_user(); $course = $this->getDataGenerator()->create_course(); $context = \context_course::instance($course->id); $this->getDataGenerator()->enrol_user($user1->id, $course->id, 'manager'); $this->setUser($user1); // Two actions enabled. set_config('enabled', 1, 'aiprovider_openai'); set_config('apikey', '123', 'aiprovider_openai'); set_config('explain_text', 1, 'aiplacement_courseassist'); set_config('summarise_text', 1, 'aiplacement_courseassist'); $manager = \core\di::get(manager::class); $manager->create_provider_instance( classname: '\aiprovider_openai\provider', name: 'dummy', enabled: true, config: ['apikey' => '123'], ); $this->assertCount(2, utils::get_actions_available($context)); // One action enabled. set_config('summarise_text', 0, 'aiplacement_courseassist'); $this->assertCount(1, utils::get_actions_available($context)); // No actions enabled. set_config('explain_text', 0, 'aiplacement_courseassist'); $this->assertCount(0, utils::get_actions_available($context)); } } PK*\/KAplacement/courseassist/tests/behat/course_assist_features.featurenu[@core_ai @aiplacement_courseassist Feature: AI course assist features In order to use AI course assist features As a user I need to access a course and choose the desired AI feature Background: Given the following "users" exist: | username | firstname | lastname | email | | teacher1 | Teacher | 1 | t1@example.com | | teacher2 | Teacher | 2 | t2@example.com | And the following "courses" exist: | fullname | shortname | format | | Course 1 | C1 | topics | And the following "roles" exist: | name | shortname | description | archetype | | Custom editing teacher | custom1 | My custom role 1 | editingteacher | | Custom teacher | custom2 | My custom role 2 | editingteacher | And the following "course enrolments" exist: | user | course | role | | teacher1 | C1 | custom1 | | teacher2 | C1 | custom2 | And the following "activities" exist: | activity | name | intro | introformat | course | content | contentformat | idnumber | | page | PageName1 | PageDesc1 | 1 | C1 | PageContent | 1 | 1 | And the following "permission overrides" exist: | capability | permission | role | contextlevel | reference | | aiplacement/courseassist:summarise_text | Prohibit | custom2 | Course | C1 | And the following "core_ai > ai providers" exist: | provider | name | enabled | apikey | orgid | | aiprovider_openai | OpenAI API test | 1 | 123 | abc | And the following config values are set as admin: | enabled | 1 | aiplacement_courseassist | Scenario: AI features dropdown is visible when more than one feature is enabled When I am on the "PageName1" "page activity" page logged in as teacher1 Then "AI features" "button" should exist # Check nested buttons exist too. And "Summarise" "button" should exist And "Explain" "button" should exist Scenario: AI features dropdown is not visible when only one feature is enabled Given I set the following action configuration for ai provider with name "OpenAI API test": | action | enabled | | explain_text | 0 | | summarise_text | 1 | When I am on the "PageName1" "page activity" page logged in as teacher1 Then "AI features" "button" should not exist And "Explain" "button" should not exist # Only the summarise button should exist. And "Summarise" "button" should exist Scenario: AI features are not available if placement is not enabled Given the following config values are set as admin: | enabled | | aiplacement_courseassist | When I am on the "PageName1" "page activity" page logged in as teacher1 Then "AI features" "button" should not exist Scenario: AI features are not available if provider action is not enabled Given I set the following action configuration for ai provider with name "OpenAI API test": | action | enabled | | explain_text | 0 | | summarise_text | 0 | When I am on the "PageName1" "page activity" page logged in as teacher1 Then "AI features" "button" should not exist Scenario: AI features are not available if placement action is not enabled Given I set the following action configuration for ai provider with name "OpenAI API test": | action | enabled | | explain_text | 0 | | summarise_text | 0 | When I am on the "PageName1" "page activity" page logged in as teacher1 Then "AI features" "button" should not exist Scenario: AI features are not available if the user does not have permission When I am on the "PageName1" "page activity" page logged in as teacher2 Then "AI features" "button" should not exist @javascript Scenario: I can view the AI drawer contents using the AI features dropdown Given I am on the "PageName1" "page activity" page logged in as teacher1 When I click on "AI features" "button" And I click on "Summarise" "button" Then I should see "Welcome to the new AI feature!" in the ".ai-drawer" "css_element" PK*\5GG placement/editor/db/services.phpnu[. /** * HTML Text editor AI Placement webservice definitions. * * @package aiplacement_editor * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $functions = [ 'aiplacement_editor_generate_image' => [ 'classname' => \aiplacement_editor\external\generate_image::class, 'description' => 'Generate image for the HTML Text editor AI Placement', 'type' => 'write', 'ajax' => true, 'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE], ], 'aiplacement_editor_generate_text' => [ 'classname' => \aiplacement_editor\external\generate_text::class, 'description' => 'Generate text for the HTML Text editor AI Placement', 'type' => 'write', 'ajax' => true, 'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE], ], ]; PK*\Č@@placement/editor/db/access.phpnu[. /** * Capabilities for the aiplacement_editor plugin. * * @package aiplacement_editor * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $capabilities = [ 'aiplacement/editor:generate_image' => [ 'captype' => 'write', 'contextlevel' => CONTEXT_COURSE, 'archetypes' => [ 'manager' => CAP_ALLOW, 'editingteacher' => CAP_ALLOW, 'teacher' => CAP_ALLOW, 'student' => CAP_ALLOW, ], ], 'aiplacement/editor:generate_text' => [ 'captype' => 'write', 'contextlevel' => CONTEXT_COURSE, 'archetypes' => [ 'manager' => CAP_ALLOW, 'editingteacher' => CAP_ALLOW, 'teacher' => CAP_ALLOW, 'student' => CAP_ALLOW, ], ], ]; PK*\R/placement/editor/lang/en/aiplacement_editor.phpnu[. /** * Strings for component aiplacement_editor, language 'en'. * * @package aiplacement_editor * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['generatecontent'] = 'Generate AI content'; $string['generateimage'] = 'AI generate image'; $string['generateimagesetting'] = 'Enable generate image'; $string['generateimagesetting_desc'] = 'Enable or disable the generation of images from a text prompt.'; $string['generatetext'] = 'AI generate text'; $string['generatetextsetting'] = 'Enable generate text'; $string['generatetextsetting_desc'] = 'Enable or disable the generation of text from a text prompt.'; $string['noeditor'] = 'Text editor placement is not available for this context.'; $string['pluginname'] = 'Text editor placement'; $string['privacy:metadata'] = 'The Text editor placement plugin does not store any personal data.'; $string['editor:generate_image'] = 'Generate AI images in text editor'; $string['editor:generate_text'] = 'Generate AI text in text editor'; PK*\z7GGplacement/editor/version.phpnu[. /** * Version information for aiplacement_editor. * * @package aiplacement_editor * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'aiplacement_editor'; $plugin->version = 2025041400; $plugin->requires = 2025040800; $plugin->maturity = MATURITY_STABLE; PK*\j3placement/editor/classes/external/generate_text.phpnu[. namespace aiplacement_editor\external; use aiplacement_editor\utils; use core_external\external_api; use core_external\external_function_parameters; use core_external\external_value; /** * External API to call an action for this placement. * * @package aiplacement_editor * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class generate_text extends external_api { /** * Generate image parameters. * * @return external_function_parameters * @since Moodle 4.5 */ public static function execute_parameters(): external_function_parameters { return new external_function_parameters([ 'contextid' => new external_value( PARAM_INT, 'The context ID', VALUE_REQUIRED, ), 'prompttext' => new external_value( PARAM_RAW, 'The prompt text for the AI service', VALUE_REQUIRED, ), ]); } /** * Generate text from the AI placement. * * @param int $contextid The context ID. * @param string $prompttext The data encoded as a json array. * @return array The generated content. * @since Moodle 4.5 */ public static function execute( int $contextid, string $prompttext ): array { global $USER; // Parameter validation. [ 'contextid' => $contextid, 'prompttext' => $prompttext, ] = self::validate_parameters(self::execute_parameters(), [ 'contextid' => $contextid, 'prompttext' => $prompttext, ]); // Context validation and permission check. // Get the context from the passed in ID. $context = \core\context::instance_by_id($contextid); // Check the user has permission to use the AI service. self::validate_context($context); if (!utils::is_html_editor_placement_action_available($context, 'generate_text', \core_ai\aiactions\generate_text::class)) { throw new \moodle_exception('noeditor', 'aiplacement_editor'); } // Prepare the action. $action = new \core_ai\aiactions\generate_text( contextid: $contextid, userid: $USER->id, prompttext: $prompttext, ); // Send the action to the AI manager. $manager = \core\di::get(\core_ai\manager::class); $response = $manager->process_action($action); // Return the response. return [ 'success' => $response->get_success(), 'generatedcontent' => $response->get_response_data()['generatedcontent'] ?? '', 'finishreason' => $response->get_response_data()['finishreason'] ?? '', 'errorcode' => $response->get_errorcode(), 'error' => $response->get_errormessage(), 'timecreated' => $response->get_timecreated(), 'prompttext' => $prompttext, ]; } /** * Generate content return value. * * @return external_function_parameters * @since Moodle 4.5 */ public static function execute_returns(): external_function_parameters { return new external_function_parameters([ 'success' => new external_value( PARAM_BOOL, 'Was the request successful', VALUE_REQUIRED, ), 'timecreated' => new external_value( PARAM_INT, 'The time the request was created', VALUE_REQUIRED, ), 'prompttext' => new external_value( PARAM_RAW, 'The prompt text for the AI service', VALUE_REQUIRED, ), 'generatedcontent' => new external_value( PARAM_TEXT, 'The text generated by AI.', VALUE_DEFAULT, ), 'finishreason' => new external_value( PARAM_ALPHAEXT, 'The reason generation was stopped', VALUE_DEFAULT, 'stop', ), 'errorcode' => new external_value( PARAM_INT, 'Error code if any', VALUE_DEFAULT, 0, ), 'error' => new external_value( PARAM_TEXT, 'Error message if any', VALUE_DEFAULT, '', ), ]); } } PK*\I~S4placement/editor/classes/external/generate_image.phpnu[. namespace aiplacement_editor\external; use aiplacement_editor\utils; use core_external\external_api; use core_external\external_function_parameters; use core_external\external_value; /** * External API to call an action for this placement. * * @package aiplacement_editor * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class generate_image extends external_api { /** * Generate image parameters. * * @return external_function_parameters * @since Moodle 4.5 */ public static function execute_parameters(): external_function_parameters { return new external_function_parameters([ 'contextid' => new external_value( PARAM_INT, 'The context ID', VALUE_REQUIRED, ), 'prompttext' => new external_value( PARAM_RAW, 'The prompt text for the AI service', VALUE_REQUIRED, ), 'aspectratio' => new external_value( PARAM_ALPHA, 'The aspect ratio of the image', VALUE_REQUIRED, ), 'quality' => new external_value( PARAM_ALPHA, 'The quality of the image', VALUE_REQUIRED, ), 'numimages' => new external_value( PARAM_INT, 'The number of images to generate', VALUE_DEFAULT, 1, ), 'style' => new external_value( PARAM_ALPHA, 'The style of the image', VALUE_DEFAULT, 'natural', ), ]); } /** * Generate image from the AI placement. * * @param int $contextid The context ID. * @param string $prompttext The data encoded as a json array. * @param string $aspectratio The aspect ratio of the image. * @param string $quality The quality of the image. * @param string $numimages The number of images to generate. * @param string $style The style of the image. * @return array The generated content. * @since Moodle 4.5 */ public static function execute( int $contextid, string $prompttext, string $aspectratio, string $quality, string $numimages, string $style = '', ): array { global $USER; // Parameter validation. [ 'contextid' => $contextid, 'prompttext' => $prompttext, 'aspectratio' => $aspectratio, 'quality' => $quality, 'numimages' => $numimages, 'style' => $style, ] = self::validate_parameters(self::execute_parameters(), [ 'contextid' => $contextid, 'prompttext' => $prompttext, 'aspectratio' => $aspectratio, 'quality' => $quality, 'numimages' => $numimages, 'style' => $style, ]); // Context validation and permission check. // Get the context from the passed in ID. $context = \context::instance_by_id($contextid); // Check the user has permission to use the AI service. self::validate_context($context); if (!utils::is_html_editor_placement_action_available($context, 'generate_text', \core_ai\aiactions\generate_image::class)) { throw new \moodle_exception('noeditor', 'aiplacement_editor'); } // Prepare the action. $action = new \core_ai\aiactions\generate_image( contextid: $contextid, userid: $USER->id, prompttext: $prompttext, quality: $quality, aspectratio: $aspectratio, numimages: $numimages, style: $style, ); // Send the action to the AI manager. $manager = \core\di::get(\core_ai\manager::class); $response = $manager->process_action($action); // If we have a successful response, generate the URL for the draft file. if ($response->get_success()) { $draftfile = $response->get_response_data()['draftfile']; $drafturl = \moodle_url::make_draftfile_url( $draftfile->get_itemid(), $draftfile->get_filepath(), $draftfile->get_filename(), false, )->out(false); } else { $drafturl = ''; } // Return the response. return [ 'success' => $response->get_success(), 'revisedprompt' => $response->get_response_data()['revisedprompt'] ?? '', 'drafturl' => $drafturl, 'errorcode' => $response->get_errorcode(), 'error' => $response->get_errormessage(), ]; } /** * Generate content return value. * * @return external_function_parameters * @since Moodle 4.5 */ public static function execute_returns(): external_function_parameters { return new external_function_parameters([ 'success' => new external_value( PARAM_BOOL, 'Was the request successful', VALUE_REQUIRED, ), 'revisedprompt' => new external_value( PARAM_TEXT, 'Revised prompt generated by the AI', VALUE_DEFAULT, '', ), 'drafturl' => new external_value( PARAM_URL, 'Draft file URL for the image', VALUE_DEFAULT, '', ), 'errorcode' => new external_value( PARAM_INT, 'Error code if any', VALUE_DEFAULT, 0, ), 'error' => new external_value( PARAM_TEXT, 'Error message if any', VALUE_DEFAULT, '', ), ]); } } PK*\g@ TT-placement/editor/classes/privacy/provider.phpnu[. namespace aiplacement_editor\privacy; use core_privacy\local\metadata\null_provider; /** * Privacy Subsystem for HTML Text editor placement implementing null_provider. * * @package aiplacement_editor * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @codeCoverageIgnore */ class provider implements 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'; } } PK*\1  &placement/editor/classes/placement.phpnu[. namespace aiplacement_editor; /** * Class placement. * * @package aiplacement_editor * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class placement extends \core_ai\placement { /** * Get the list of actions that this placement uses. * * @return array An array of action class names. */ public static function get_action_list(): array { return [ \core_ai\aiactions\generate_text::class, \core_ai\aiactions\generate_image::class, ]; } } PK*\U5q]RR"placement/editor/classes/utils.phpnu[. namespace aiplacement_editor; use core_ai\manager; /** * AI Placement HTML editor utils. * * @package aiplacement_editor * @copyright 2024 Huong Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class utils { /** * Check if AI Placement HTML editor action is available for the context. * * @param \context $context The context. * @param string $actionname The name of the action. * @param string $actionclass The class name of the action. * @return bool True if the action is available, false otherwise. */ public static function is_html_editor_placement_action_available( \context $context, string $actionname, string $actionclass ): bool { [$plugintype, $pluginname] = explode('_', \core_component::normalize_componentname('aiplacement_editor'), 2); $pluginmanager = \core_plugin_manager::resolve_plugininfo_class($plugintype); if (!$pluginmanager::is_plugin_enabled($pluginname)) { return false; } $aimanager = \core\di::get(manager::class); if ( has_capability("aiplacement/editor:{$actionname}", $context) && $aimanager->is_action_available($actionclass) && $aimanager->is_action_enabled('aiplacement_editor', $actionclass) ) { return true; } return false; } } PK*\B 6placement/editor/tests/external/generate_text_test.phpnu[. namespace aiplacement_editor\external; /** * Test generate text external webservice calls. * * @package aiplacement_editor * @copyright 2025 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \aiplacement_editor\external\generate_text */ final class generate_text_test extends \advanced_testcase { /** * Test generate_text webservice. */ public function test_execute(): void { $this->resetAfterTest(); set_config('enabled', 1, 'aiplacement_editor'); $this->setAdminUser(); // Get system context. $context = \core\context\system::instance(); // Mock the manager class call. $response = new \core_ai\aiactions\responses\response_generate_text(success: true); $response->set_response_data( [ 'generatedcontent' => 'This was a test', 'finishreason' => 'max_-tokens', // Alphanumext. ] ); $mockmanager = $this->createMock(\core_ai\manager::class); $mockmanager->method('process_action')->willReturn($response); $mockmanager->method('is_action_available')->willReturn(true); $mockmanager->method('is_action_enabled')->willReturn(true); \core\di::set(\core_ai\manager::class, function() use ($mockmanager) { return $mockmanager; }); $_POST['sesskey'] = sesskey(); $params = [ 'contextid' => $context->id, 'prompttext' => 'This is a test', ]; $result = \core_external\external_api::call_external_function( 'aiplacement_editor_generate_text', $params, ); $this->assertFalse($result['error']); $this->assertEquals('max_-tokens', $result['data']['finishreason']); $this->assertEquals('This was a test', $result['data']['generatedcontent']); $this->assertTrue($result['data']['success']); } } PK*\6%placement/editor/tests/utils_test.phpnu[. namespace aiplacement_editor; use core_ai\aiactions\generate_image; use core_ai\aiactions\generate_text; /** * Text editor placement utils test. * * @package aiplacement_editor * @copyright 2024 Huong Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \aiplacement_courseassist\utils */ final class utils_test extends \advanced_testcase { /** @var array List of users. */ private array $users; /** @var \stdClass Course object. */ private \stdClass $course; /** @var \context_course Course context. */ private \context_course $context; /** @var \stdClass Teacher role. */ private \stdClass $teacherrole; public function setUp(): void { global $DB; parent::setUp(); $this->resetAfterTest(); $this->users[1] = $this->getDataGenerator()->create_user(); $this->users[2] = $this->getDataGenerator()->create_user(); $this->course = $this->getDataGenerator()->create_course(); $this->context = \context_course::instance($this->course->id); $this->teacherrole = $DB->get_record('role', ['shortname' => 'editingteacher']); $this->getDataGenerator()->enrol_user($this->users[1]->id, $this->course->id, 'manager'); $this->getDataGenerator()->enrol_user($this->users[2]->id, $this->course->id, 'editingteacher'); } /** * Test is_html_editor_placement_action_available method. * * @param string $actionname Action name. * @param string $actionclass Action class. * @dataProvider html_editor_placement_action_available_provider */ public function test_is_html_editor_placement_action_available( string $actionname, string $actionclass, ): void { // Provider is not enabled. $this->setUser($this->users[1]); $this->assertFalse(utils::is_html_editor_placement_action_available( context: $this->context, actionname: $actionname, actionclass: $actionclass )); // Plugin is not enabled. $this->setUser($this->users[1]); set_config('enabled', 0, 'aiplacement_editor'); $this->assertFalse(utils::is_html_editor_placement_action_available( context: $this->context, actionname: $actionname, actionclass: $actionclass )); // Plugin is enabled but user does not have capability. assign_capability("aiplacement/editor:{$actionname}", CAP_PROHIBIT, $this->teacherrole->id, $this->context); $this->setUser($this->users[2]); set_config('enabled', 1, 'aiplacement_editor'); $this->assertFalse(utils::is_html_editor_placement_action_available( context: $this->context, actionname: $actionname, actionclass: $actionclass )); // Plugin is enabled, user has capability and placement action is not available. $this->setUser($this->users[1]); set_config($actionname, 0, 'aiplacement_editor'); $this->assertFalse(utils::is_html_editor_placement_action_available( context: $this->context, actionname: $actionname, actionclass: $actionclass )); // Plugin is enabled, user has capability and provider action is not available. $this->setUser($this->users[1]); set_config($actionname, 1, 'aiplacement_editor'); $this->assertFalse(utils::is_html_editor_placement_action_available( context: $this->context, actionname: $actionname, actionclass: $actionclass )); // Plugin is enabled, user has capability, placement action is available and provider action is available. $mockmanager = $this->createMock(\core_ai\manager::class); $mockmanager->method('is_action_available')->willReturn(true); $mockmanager->method('is_action_enabled')->willReturn(true); \core\di::set(\core_ai\manager::class, function() use ($mockmanager) { return $mockmanager; }); $this->setUser($this->users[1]); $this->assertTrue(utils::is_html_editor_placement_action_available( context: $this->context, actionname: $actionname, actionclass: $actionclass )); } /** * Data provider for {@see test_is_html_editor_placement_action_available} * * @return array */ public static function html_editor_placement_action_available_provider(): array { return [ 'Text generation' => [ 'generate_text', generate_text::class, ], 'Image generation' => [ 'generate_image', generate_image::class, ], ]; } } PK*\++usage_report.phpnu[. /** * Display AI usage report. * * @package core_ai * @copyright 2024 David Woloszyn * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ use core_reportbuilder\system_report_factory; use core_ai\reportbuilder\local\systemreports\usage; require(__DIR__ . '/../config.php'); require_once($CFG->libdir . '/adminlib.php'); admin_externalpage_setup('aiusagereport'); // Set up the page. $systemcontext = context_system::instance(); $pageurl = new moodle_url($CFG->wwwroot . '/ai/usage_report.php'); $PAGE->set_url($pageurl); $PAGE->set_context($systemcontext); $PAGE->set_pagelayout('report'); $PAGE->set_primary_active_tab('siteadminnode'); echo $OUTPUT->header(); $report = system_report_factory::create(usage::class, $systemcontext); echo $OUTPUT->heading($report::get_name()); echo $report->output(); echo $OUTPUT->footer(); PK*\5 |configure_providers.phpnu[PK*\A77Vclasses/ai_image.phpnu[PK*\A,,;@classes/reportbuilder/local/entities/ai_action_register.phpnu[PK*\ ;mclasses/reportbuilder/local/entities/ai_policy_register.phpnu[PK*\ljj3{classes/reportbuilder/local/systemreports/usage.phpnu[PK*\c?classes/reportbuilder/local/systemreports/policy_acceptance.phpnu[PK*\?~ ~  classes/process_base.phpnu[PK*\n44!Ҩclasses/form/ai_provider_form.phpnu[PK*\poJ J %Wclasses/form/action_settings_form.phpnu[PK*\4 &classes/external/set_policy_status.phpnu[PK*\8classes/external/set_action.phpnu[PK*\> 'classes/external/set_provider_order.phpnu[PK*\5R &]classes/external/get_policy_status.phpnu[PK*\X Ƕ(Yclasses/external/set_provider_status.phpnu[PK*\0l-gclasses/external/delete_provider_instance.phpnu[PK*\6^ !classes/privacy/provider.phpnu[PK*\u϶classes/rate_limiter.phpnu[PK*\\WWAclasses/aimodel/base.phpnu[PK*\Uccclasses/manager.phpnu[PK*\1ג2-.classes/admin/admin_settingspage_provider.phpnu[PK*\ .V4classes/admin/admin_setting_action_manager.phpnu[PK*\ҪQe e 0?classes/admin/admin_setting_provider_manager.phpnu[PK*\%_fJclasses/placement.phpnu[PK*\cQ-qq3Pclasses/hook/after_ai_action_settings_form_hook.phpnu[PK*\g/"")_Wclasses/hook/before_provider_disabled.phpnu[PK*\/2(]classes/hook/before_provider_deleted.phpnu[PK*\(IAjj,Gdclasses/hook/after_ai_provider_form_hook.phpnu[PK*\q/e kclasses/provider.phpnu[PK*\V7oclasses/table/aiprovider_management_table_filterset.phpnu[PK*\u5΍classes/table/aiplacement_action_management_table.phpnu[PK*\n??.classes/table/aiplacement_management_table.phpnu[PK*\*?classes/table/aiplacement_action_management_table_filterset.phpnu[PK*\M22-classes/table/aiprovider_management_table.phpnu[PK*\qNm#//4classes/table/aiprovider_action_management_table.phpnu[PK*\ZL>%classes/table/aiprovider_action_management_table_filterset.phpnu[PK*\jax classes/cache/policy.phpnu[PK*\c6 $classes/aiactions/summarise_text.phpnu[PK*\K #classes/aiactions/generate_text.phpnu[PK*\)poU "(classes/aiactions/explain_text.phpnu[PK*\Y $2classes/aiactions/generate_image.phpnu[PK*\?o. . 7>classes/aiactions/responses/response_generate_image.phpnu[PK*\I3  7Iclasses/aiactions/responses/response_summarise_text.phpnu[PK*\- Wclasses/aiactions/responses/response_base.phpnu[PK*\///  5fclasses/aiactions/responses/response_explain_text.phpnu[PK*\X{  6sclasses/aiactions/responses/response_generate_text.phpnu[PK*\vWlclasses/aiactions/base.phpnu[PK*\TV8(5templates/admin_action_settings.mustachenu[PK*\6A{LLtemplates/policymodal.mustachenu[PK*\Z@@?templates/policyblock.mustachenu[PK*\A_"__$ͣtemplates/admin_noproviders.mustachenu[PK*\,,%templates/admin_add_provider.mustachenu[PK*\7ą22(̭templates/admin_delete_provider.mustachenu[PK*\]]Vprovider/openai/db/hooks.phpnu[PK*\]GDD-provider/openai/lang/en/aiprovider_openai.phpnu[PK*\:&provider/openai/lang/en/deprecated.txtnu[PK*\sA/DDprovider/openai/version.phpnu[PK*\( ;Zprovider/openai/classes/form/action_generate_image_form.phpnu[PK*\=)k,Uprovider/openai/classes/form/action_form.phpnu[PK*\lX :provider/openai/classes/form/action_generate_text_form.phpnu[PK*\ ,provider/openai/classes/privacy/provider.phpnu[PK*\Π2$provider/openai/classes/process_generate_image.phpnu[PK*\[*5provider/openai/classes/aimodel/dalle3.phpnu[PK*\ /d;provider/openai/classes/aimodel/openai_base.phpnu[PK*\c&@provider/openai/classes/aimodel/o1.phpnu[PK*\Zxt t )Hprovider/openai/classes/aimodel/gpt4o.phpnu[PK*\ l2Sprovider/openai/classes/process_summarise_text.phpnu[PK*\2n)/Xprovider/openai/classes/hook_listener.phpnu[PK*\9[C" iprovider/openai/classes/helper.phpnu[PK*\n .(qprovider/openai/classes/abstract_processor.phpnu[PK*\r0__$)provider/openai/classes/provider.phpnu[PK*\cˑ0ܔprovider/openai/classes/process_explain_text.phpnu[PK*\ 6 provider/openai/classes/test/testcase_helper_trait.phpnu[PK*\$L; 1provider/openai/classes/process_generate_text.phpnu[PK*\ۻ{(('%provider/openai/amd/src/modelchooser.jsnu[PK*\_iKK1provider/openai/amd/build/modelchooser.min.js.mapnu[PK*\PHUU-Pprovider/openai/amd/build/modelchooser.min.jsnu[PK*\u 8provider/openai/tests/fixtures/text_request_success.jsonnu[PK*\]>69provider/openai/tests/fixtures/image_request_success.jsonnu[PK*\:Dr44'$provider/openai/tests/fixtures/test.jpgnu[PK*\A5Q5Q4nprovider/openai/tests/process_generate_text_test.phpnu[PK*\ll5Hprovider/openai/tests/process_generate_image_test.phpnu[PK*\C_PP5,provider/openai/tests/process_summarise_text_test.phpnu[PK*\'}provider/openai/tests/provider_test.phpnu[PK*\cWWprovider/ollama/db/hooks.phpnu[PK*\\-^provider/ollama/lang/en/aiprovider_ollama.phpnu[PK*\|xDDprovider/ollama/version.phpnu[PK*\Gx  ,provider/ollama/classes/form/action_form.phpnu[PK*\L% :provider/ollama/classes/form/action_generate_text_form.phpnu[PK*\@[F F ,provider/ollama/classes/privacy/provider.phpnu[PK*\SYY Y +provider/ollama/classes/aimodel/llama33.phpnu[PK*\Zo/6provider/ollama/classes/aimodel/ollama_base.phpnu[PK*\2provider/ollama/classes/process_summarise_text.phpnu[PK*\Nf##)provider/ollama/classes/hook_listener.phpnu[PK*\." provider/ollama/classes/helper.phpnu[PK*\hUU.provider/ollama/classes/abstract_processor.phpnu[PK*\iH/5 5 $'provider/ollama/classes/provider.phpnu[PK*\e0U4provider/ollama/classes/process_explain_text.phpnu[PK*\Ҙ< < 18provider/ollama/classes/process_generate_text.phpnu[PK*\=(('.Eprovider/ollama/amd/src/modelchooser.jsnu[PK*\0F2@KK1Kprovider/ollama/amd/build/modelchooser.min.js.mapnu[PK*\&bzUU-YTprovider/ollama/amd/build/modelchooser.min.jsnu[PK*\nu  8 Xprovider/ollama/tests/fixtures/text_request_success.jsonnu[PK*\]+OO4cprovider/ollama/tests/process_generate_text_test.phpnu[PK*\[[/provider/ollama/tests/testcase_helper_trait.phpnu[PK*\lvNvN5provider/ollama/tests/process_summarise_text_test.phpnu[PK*\ds$' provider/ollama/tests/provider_test.phpnu[PK*\A+N+N3provider/ollama/tests/process_explain_text_test.phpnu[PK*\Vdiiprovider/azureai/db/hooks.phpnu[PK*\6͛_'Inprovider/azureai/lang/en/deprecated.txtnu[PK*\M l/.qprovider/azureai/lang/en/aiprovider_azureai.phpnu[PK*\!cNGG_provider/azureai/version.phpnu[PK*\{Z Z <provider/azureai/classes/form/action_generate_image_form.phpnu[PK*\.t6;provider/azureai/classes/form/action_generate_text_form.phpnu[PK*\ūA  -provider/azureai/classes/privacy/provider.phpnu[PK*\hІ3provider/azureai/classes/process_generate_image.phpnu[PK*\3provider/azureai/classes/process_summarise_text.phpnu[PK*\in*Aprovider/azureai/classes/hook_listener.phpnu[PK*\7/[provider/azureai/classes/abstract_processor.phpnu[PK*\nF F %provider/azureai/classes/provider.phpnu[PK*\mR*1Pprovider/azureai/classes/process_explain_text.phpnu[PK*\)2provider/azureai/classes/process_generate_text.phpnu[PK*\(9 provider/azureai/tests/fixtures/text_request_success.jsonnu[PK*\]>6: provider/azureai/tests/fixtures/image_request_success.jsonnu[PK*\:Dr44( provider/azureai/tests/fixtures/test.jpgnu[PK*\DwJwJ5 provider/azureai/tests/process_generate_text_test.phpnu[PK*\Vpdd6f provider/azureai/tests/process_generate_image_test.phpnu[PK*\F JJ6w_ provider/azureai/tests/process_summarise_text_test.phpnu[PK*\2ê( provider/azureai/tests/provider_test.phpnu[PK*\ffu policy_acceptance_report.phpnu[PK*\'' amd/src/providerchooser.jsnu[PK*\o`E amd/src/policymodal.jsnu[PK*\X*B  -z amd/src/aiprovider_action_management_table.jsnu[PK*\F""/ amd/src/aiprovider_instance_management_table.jsnu[PK*\sv^ amd/src/repository.jsnu[PK*\Qk k  amd/src/helper.jsnu[PK*\j##= amd/src/policy.jsnu[PK*\󮀹 amd/build/repository.min.jsnu[PK*\Fee3 amd/build/aiprovider_action_management_table.min.jsnu[PK*\SFd d 5 amd/build/aiprovider_instance_management_table.min.jsnu[PK*\>pp amd/build/repository.min.js.mapnu[PK*\Ec M amd/build/policymodal.min.js.mapnu[PK*\S6_F F 7m+ amd/build/aiprovider_action_management_table.min.js.mapnu[PK*\c:667 amd/build/policymodal.min.jsnu[PK*\< = amd/build/providerchooser.min.jsnu[PK*\>Ƃ2 2 A amd/build/helper.min.js.mapnu[PK*\7]IIM amd/build/helper.min.jsnu[PK*\T9P amd/build/aiprovider_instance_management_table.min.js.mapnu[PK*\7  e amd/build/policy.min.jsnu[PK*\o \$ m amd/build/providerchooser.min.js.mapnu[PK*\`vk  bu amd/build/policy.min.js.mapnu[PK*\# ~ UPGRADING.mdnu[PK*\SI_ tests/fixtures/black.pngnu[PK*\Pbb tests/fixtures/grey.pngnu[PK*\!Gbbɇ tests/fixtures/white.pngnu[PK*\Ys tests/generator/lib.phpnu[PK*\)mHH+J tests/generator/behat_core_ai_generator.phpnu[PK*\H P P & tests/external/provider_order_test.phpnu[PK*\bߝ tests/external/policy_test.phpnu[PK*\tOB~ tests/rate_limiter_test.phpnu[PK*\&&  tests/provider/provider_test.phpnu[PK*\]qqG tests/ai_image_test.phpnu[PK*\BB# tests/behat/ai_usage_report.featurenu[PK*\