diff --git a/.env b/.env index a37007467..05708724c 100755 --- a/.env +++ b/.env @@ -11,7 +11,7 @@ # Only `BOOL_` options become boolean values, and ONLY `true` evaluates to true # # Docker/Dev notes: -# docker/config/materia-docker.env.local is loaded instead of .env.local +# docker/.env.local is used instead of .env.local # GENERAL =================== @@ -93,3 +93,14 @@ LTI_KEY="materia-production-lti-key" #BOOL_LTI_USE_LAUNCH_ROLES=true #BOOL_LTI_GRACEFUL_CONFIG_FALLBACK=true #BOOL_LTI_LOG_FOR_DEBUGGING=false + +# Question Generation === + +#GENERATION_ENABLED=true +#GENERATION_ALLOW_IMAGES=false +#GENERATION_API_PROVIDER= +#GENERATION_API_ENDPOINT= +#GENERATION_API_KEY= +#GENERATION_API_VERSION= +#GENERATION_API_MODEL= +#GENERATION_LOG_STATS=true \ No newline at end of file diff --git a/.github/workflows/test_and_build.yml b/.github/workflows/test_and_build.yml index a9f3b6853..5c0847f01 100644 --- a/.github/workflows/test_and_build.yml +++ b/.github/workflows/test_and_build.yml @@ -29,6 +29,13 @@ jobs: - name: Checkout code uses: actions/checkout@v2 + - name: Create .env.local to satisfy build requirements + run: | + cd docker + if [ ! -f .env.local ]; then + touch .env.local + fi + - name: Build App Image run: | cd docker diff --git a/.gitignore b/.gitignore index 605b7ac7c..b43a8c2ea 100644 --- a/.gitignore +++ b/.gitignore @@ -85,6 +85,7 @@ public/js/materia.storage.table.js public/js/student.js public/js/vendor/* +public/openai_usage.txt # Installed Widgets public/widget diff --git a/composer.json b/composer.json index 1ad7a7777..eb2f0bc63 100644 --- a/composer.json +++ b/composer.json @@ -52,7 +52,8 @@ "eher/oauth": "1.0.7", "aws/aws-sdk-php": "^3.314", "symfony/dotenv": "^5.1", - "ucfopen/materia-theme-ucf": "2.0.4" + "ucfopen/materia-theme-ucf": "2.0.4", + "openai-php/client": "^0.8.5" }, "suggest": { "ext-memcached": "*" diff --git a/composer.lock b/composer.lock index 9f230fe17..9cf9815b4 100644 --- a/composer.lock +++ b/composer.lock @@ -1201,6 +1201,98 @@ "time": "2023-08-25T10:54:48+00:00" }, { + "name": "openai-php/client", + "version": "v0.8.5", + "source": { + "type": "git", + "url": "https://github.com/openai-php/client.git", + "reference": "0f755fafa4d3f8d5c8ed964d3166d078fac0605a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/openai-php/client/zipball/0f755fafa4d3f8d5c8ed964d3166d078fac0605a", + "reference": "0f755fafa4d3f8d5c8ed964d3166d078fac0605a", + "shasum": "" + }, + "require": { + "php": "^8.1.0", + "php-http/discovery": "^1.19.4", + "php-http/multipart-stream-builder": "^1.3.0", + "psr/http-client": "^1.0.3", + "psr/http-client-implementation": "^1.0.1", + "psr/http-factory-implementation": "*", + "psr/http-message": "^1.1.0|^2.0.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.8.1", + "guzzlehttp/psr7": "^2.6.2", + "laravel/pint": "^1.15.0", + "mockery/mockery": "^1.6.11", + "nunomaduro/collision": "^7.10.0", + "pestphp/pest": "^2.34.6", + "pestphp/pest-plugin-arch": "^2.7", + "pestphp/pest-plugin-type-coverage": "^2.8.1", + "phpstan/phpstan": "^1.10.66", + "rector/rector": "^1.0.4", + "symfony/var-dumper": "^6.4.4" + }, + "type": "library", + "autoload": { + "files": [ + "src/OpenAI.php" + ], + "psr-4": { + "OpenAI\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + }, + { + "name": "Sandro Gehri" + } + ], + "description": "OpenAI PHP is a supercharged PHP API client that allows you to interact with the Open AI API", + "keywords": [ + "GPT-3", + "api", + "client", + "codex", + "dall-e", + "language", + "natural", + "openai", + "php", + "processing", + "sdk" + ], + "support": { + "issues": "https://github.com/openai-php/client/issues", + "source": "https://github.com/openai-php/client/tree/v0.8.5" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/gehrisandro", + "type": "github" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + } + ], + "time": "2024-04-15T19:11:23+00:00" + }, + { "name": "paragonie/constant_time_encoding", "version": "v3.0.0", "source": { @@ -1403,6 +1495,141 @@ }, "time": "2024-04-24T12:06:31+00:00" }, + { + "name": "php-http/discovery", + "version": "1.19.4", + "source": { + "type": "git", + "url": "https://github.com/php-http/discovery.git", + "reference": "0700efda8d7526335132360167315fdab3aeb599" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/discovery/zipball/0700efda8d7526335132360167315fdab3aeb599", + "reference": "0700efda8d7526335132360167315fdab3aeb599", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0|^2.0", + "php": "^7.1 || ^8.0" + }, + "conflict": { + "nyholm/psr7": "<1.0", + "zendframework/zend-diactoros": "*" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "*", + "psr/http-factory-implementation": "*", + "psr/http-message-implementation": "*" + }, + "require-dev": { + "composer/composer": "^1.0.2|^2.0", + "graham-campbell/phpspec-skip-example-extension": "^5.0", + "php-http/httplug": "^1.0 || ^2.0", + "php-http/message-factory": "^1.0", + "phpspec/phpspec": "^5.1 || ^6.1 || ^7.3", + "sebastian/comparator": "^3.0.5 || ^4.0.8", + "symfony/phpunit-bridge": "^6.4.4 || ^7.0.1" + }, + "type": "composer-plugin", + "extra": { + "class": "Http\\Discovery\\Composer\\Plugin", + "plugin-optional": true + }, + "autoload": { + "psr-4": { + "Http\\Discovery\\": "src/" + }, + "exclude-from-classmap": [ + "src/Composer/Plugin.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "Finds and installs PSR-7, PSR-17, PSR-18 and HTTPlug implementations", + "homepage": "http://php-http.org", + "keywords": [ + "adapter", + "client", + "discovery", + "factory", + "http", + "message", + "psr17", + "psr7" + ], + "support": { + "issues": "https://github.com/php-http/discovery/issues", + "source": "https://github.com/php-http/discovery/tree/1.19.4" + }, + "time": "2024-03-29T13:00:05+00:00" + }, + { + "name": "php-http/multipart-stream-builder", + "version": "1.3.0", + "source": { + "type": "git", + "url": "https://github.com/php-http/multipart-stream-builder.git", + "reference": "f5938fd135d9fa442cc297dc98481805acfe2b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/multipart-stream-builder/zipball/f5938fd135d9fa442cc297dc98481805acfe2b6a", + "reference": "f5938fd135d9fa442cc297dc98481805acfe2b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "php-http/discovery": "^1.15", + "psr/http-factory-implementation": "^1.0" + }, + "require-dev": { + "nyholm/psr7": "^1.0", + "php-http/message": "^1.5", + "php-http/message-factory": "^1.0.2", + "phpunit/phpunit": "^7.5.15 || ^8.5 || ^9.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Http\\Message\\MultipartStream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + } + ], + "description": "A builder class that help you create a multipart stream", + "homepage": "http://php-http.org", + "keywords": [ + "factory", + "http", + "message", + "multipart stream", + "stream" + ], + "support": { + "issues": "https://github.com/php-http/multipart-stream-builder/issues", + "source": "https://github.com/php-http/multipart-stream-builder/tree/1.3.0" + }, + "time": "2023-04-28T14:10:22+00:00" + }, { "name": "phpseclib/phpseclib", "version": "3.0.39", diff --git a/docker/.env b/docker/.env index 408b2914b..118ee0654 100644 --- a/docker/.env +++ b/docker/.env @@ -1,4 +1,7 @@ -# Database settings +## docker/.env contains environment variables used by Materia during local development +## we do not recommend making edits directly to this file. Instead, make a .env.local in the same directory (docker/) and override the values below as desired. + +# database settings MYSQL_ROOT_PASSWORD=drRoots MYSQL_USER=materia MYSQL_PASSWORD=odin @@ -12,12 +15,33 @@ DEV_ONLY_AUTH_SIMPLEAUTH_SALT=33e0d379060e3877d634632853c10a70dff9710b751e5af00a DEV_ONLY_SECRET_CIPHER_KEY=e0beaea1704555ae3c75650703bb106fac24b8967c77a667124fbe745c3346ed # s3-specific asset storage values -ASSET_STORAGE_DRIVER=s3 # overrides default value in the base .env (which isn't loaded into dev environment) -ASSET_STORAGE_S3_CREDENTIAL_PROVIDER=env # env | imds +# overrides default value in the base .env (which isn't loaded into dev environment) +ASSET_STORAGE_DRIVER=s3 +# provider must be one of the following: env | imds +ASSET_STORAGE_S3_CREDENTIAL_PROVIDER=env ASSET_STORAGE_S3_BUCKET=fake_bucket ASSET_STORAGE_S3_ENDPOINT=http://fakes3:10001 ASSET_STORAGE_S3_KEY=KEY ASSET_STORAGE_S3_SECRET=SECRET + # set to true if using real S3 on development +# DEV_ONLY_FAKES3_DISABLED=false + +# question generation environment variables. Different variables are required depending on provider. -# DEV_ONLY_FAKES3_DISABLED=false # set to true if using real S3 on development \ No newline at end of file +# required to be true for generation to be enabled +GENERATION_ENABLED=false +# explicitly enable or disable image generation. defaults to false if not provided. +GENERATION_ALLOW_IMAGES=false +# required. provider must be one of the following: openai | azure_openai +GENERATION_API_PROVIDER=openai +# required for both +GENERATION_API_KEY= +# required for azure +GENERATION_API_ENDPOINT= +# required for azure +GENERATION_API_VERSION= +# required for openai +GENERATION_API_MODEL= + # not required. stat logging is set to debug threshold +GENERATION_LOG_STATS=true \ No newline at end of file diff --git a/docker/docker-compose.override.yml b/docker/docker-compose.override.yml index 60e46b5fc..06ac82046 100644 --- a/docker/docker-compose.override.yml +++ b/docker/docker-compose.override.yml @@ -15,14 +15,9 @@ services: - ./config/nginx/nginx-dev.conf:/etc/nginx/nginx.conf:ro app: - environment: - # values sourced from docker/env - - ASSET_STORAGE_DRIVER - - ASSET_STORAGE_S3_CREDENTIAL_PROVIDER - - ASSET_STORAGE_S3_BUCKET - - ASSET_STORAGE_S3_ENDPOINT - - ASSET_STORAGE_S3_KEY - - ASSET_STORAGE_S3_SECRET + env_file: + - .env + - .env.local volumes: - ..:/var/www/html/ - uploaded_widgets:/var/www/html/public/widget/ diff --git a/fuel/app/classes/basetest.php b/fuel/app/classes/basetest.php index 25ab46453..c46749f3b 100644 --- a/fuel/app/classes/basetest.php +++ b/fuel/app/classes/basetest.php @@ -102,6 +102,7 @@ protected function make_disposable_widget(string $name = 'TestWidget', bool $res 'is_playable' => true, 'is_editable' => true, 'in_catalog' => true, + 'is_generable' => false, 'restrict_publish' => $restrict_publish, 'api_version' => 2, ], diff --git a/fuel/app/classes/controller/qsets.php b/fuel/app/classes/controller/qsets.php index b639486e0..fa1c1d119 100644 --- a/fuel/app/classes/controller/qsets.php +++ b/fuel/app/classes/controller/qsets.php @@ -27,4 +27,25 @@ public function action_import() return Response::forge($theme->render()); } + + public function action_generate() + { + // Validate Logged in + if (\Service_User::verify_session() !== true ) throw new HttpNotFoundException; + + $theme = Theme::instance(); + $theme->set_template('layouts/react'); + $theme->get_template() + ->set('title', 'QSet Generation') + ->set('page_type', 'generate'); + + Js::push_inline('var BASE_URL = "'.Uri::base().'";'); + Js::push_inline('var WIDGET_URL = "'.Config::get('materia.urls.engines').'";'); + Js::push_inline('var STATIC_CROSSDOMAIN = "'.Config::get('materia.urls.static').'";'); + + Css::push_group(['qset_generator']); + Js::push_group(['react', 'qset_generator']); + + return Response::forge($theme->render()); + } } diff --git a/fuel/app/classes/materia/api/v1.php b/fuel/app/classes/materia/api/v1.php index f045dde1f..b250c4575 100644 --- a/fuel/app/classes/materia/api/v1.php +++ b/fuel/app/classes/materia/api/v1.php @@ -109,7 +109,7 @@ static public function widget_instance_access_perms_verify($inst_id) * @return object, contains properties indicating whether the current * user can edit the widget and a message object describing why, if not */ - + // !! this endpoint should be significantly refactored or removed in the future API overhaul !! static public function widget_instance_edit_perms_verify(string $inst_id) { @@ -841,6 +841,59 @@ static public function question_set_get($inst_id, $play_id = null, $timestamp = return $inst->qset; } + /** + * Generates a question set based on a given instance ID, widget ID, topic, and whether to include images. + * @param string $inst_id The instance ID, if there is an instance associated with this request. May be null. + * @param string $widget_id The ID of the widget engine associated with this request. Must be set. + * @param string $topic The topic for which to generate a question set + * @param bool $include_images whether or not to include images in the generated qset + * @param int $num_questions How many questions should be generated in the qset + * @param bool $build_off_existing Whether to build from an existing qset, or generate one from scratch + * @return object The generated question set + */ + static public function question_set_generate($inst_id, $widget_id, $topic, $include_images, $num_questions, $build_off_existing) + { + // short-circuit if generation is not available + if ( ! Widget_Question_Generator::is_enabled()) return Msg::failure(); + + // verify eligibility + if ( ! \Service_User::verify_session(['basic_author', 'super_user'])) return Msg::no_perm(); + + $inst = null; + + // validate instance (but only if an instance id is provided) + if (Util_Validator::is_valid_hash($inst_id)) + { + if ( ! ($inst = Widget_Instance_Manager::get($inst_id))) throw new \HttpNotFoundException; + if ( ! $inst->playable_by_current_user()) return Msg::no_login(); + } + + $widget = new Widget(); + if ( $widget->get($widget_id) == false) return Msg::invalid_input('Invalid widget type'); + if ( ! $widget->is_generable) return Msg::invalid_input('Widget engine does not support generation'); + + // clean topic of any special characters + $topic = preg_replace('/[^a-zA-Z0-9\s]/', '', $topic); + + // validate number of questions + if ($num_questions < 1) $num_questions = 8; + if ($num_questions > 32) $num_questions = 32; + + $query = Widget_Question_Generator::generate_qset($inst, $widget, $topic, $include_images, $num_questions, $build_off_existing); + if ( ! $query instanceof Msg && is_array($query)) + { + return [ + ...$query, + 'title' => $topic + ]; + } + else + { + \Log::error(print_r($query, true)); + return $query; + } + } + /** * Gets the question with the given QID or an array of questions * with the given ids (passed as an array) diff --git a/fuel/app/classes/materia/widget.php b/fuel/app/classes/materia/widget.php index 6014a53ad..482304544 100644 --- a/fuel/app/classes/materia/widget.php +++ b/fuel/app/classes/materia/widget.php @@ -20,6 +20,7 @@ class Widget public $is_scalable = 0; public $is_scorable = true; public $is_storage_enabled = false; + public $is_generable = false; public $package_hash = ''; public $meta_data = null; public $name = ''; @@ -109,6 +110,7 @@ public function get($id_or_clean_name) 'score_screen' => $w['score_screen'], 'restrict_publish' => $w['restrict_publish'], 'is_storage_enabled' => $w['is_storage_enabled'], + 'is_generable' => $w['is_generable'], 'package_hash' => $w['package_hash'], 'width' => $w['width'], 'creator_guide' => $w['creator_guide'], @@ -121,6 +123,10 @@ public function get($id_or_clean_name) { $this->creator = \Config::get('materia.urls.static').'default-creator/creator.html'; } + + if ( ! \Service_User::verify_session('basic_author')) $this->is_generable = '0'; + else $this->is_generable = $this->is_generable == '1' && Widget_Question_Generator::is_enabled() ? '1' : '0'; + return true; } @@ -143,6 +149,7 @@ private static function db_get_metadata($id) # multiple items with these keys will be placed in an array case 'features': case 'supported_data': + case 'generation_prompt': case 'playdata_exporters': if ( ! isset($meta_data[$name])) $meta_data[$name] = []; // initialize if needed $meta_data[$name][] = $value; @@ -175,6 +182,14 @@ public function get_property($prop) ->where('name', $prop) ->execute()[0]['value']; } + + if ($prop == 'is_generable') + { + if ( ! \Service_User::verify_session('basic_author')) return '0'; + elseif ( Widget_Question_Generator::is_enabled() && $val == '1') return '1'; + else return '0'; + } + return $val; } diff --git a/fuel/app/classes/materia/widget/installer.php b/fuel/app/classes/materia/widget/installer.php index 8e4f9d54d..7595c4e1c 100644 --- a/fuel/app/classes/materia/widget/installer.php +++ b/fuel/app/classes/materia/widget/installer.php @@ -547,6 +547,7 @@ public static function generate_install_params(array $manifest_data, string $pac 'is_editable' => Util_Validator::cast_to_bool_enum($manifest_data['general']['is_editable']), 'is_scorable' => Util_Validator::cast_to_bool_enum($manifest_data['score']['is_scorable']), 'in_catalog' => Util_Validator::cast_to_bool_enum($manifest_data['general']['in_catalog']), + 'is_generable' => isset($manifest_data['general']['is_generable']) ? Util_Validator::cast_to_bool_enum($manifest_data['general']['is_generable']) : '0', 'clean_name' => $clean_name, 'api_version' => (string)(int)$manifest_data['general']['api_version'], 'package_hash' => $package_hash, diff --git a/fuel/app/classes/materia/widget/question/generator.php b/fuel/app/classes/materia/widget/question/generator.php new file mode 100644 index 000000000..dfe8982f2 --- /dev/null +++ b/fuel/app/classes/materia/widget/question/generator.php @@ -0,0 +1,429 @@ +withBaseUri($endpoint) + ->withHttpHeader('api-key', $api_key) + ->withQueryParam('api-version', $api_version) + ->make(); + } + catch (\Exception $e) + { + \Log::error('GENERATION ERROR: error in initializing openAI client'); + \Log::error($e); + return null; + } + } + elseif (\Config::get('materia.ai_generation.provider') == 'openai') + { + $api_key = \Config::get('materia.ai_generation.api_key'); + + if (empty($api_key)) + { + \Log::error('OpenAI Platform question generation configs missing.'); + return null; + } + + self::$client = \OpenAI::client($api_key); + } + else + { + \Log::error('GENERATION ERROR: Question generation provider config invalid.'); + return null; + } + } + return self::$client; + } + + /** + * Quick reference method to determine whether question generation is enabled. + * + * @return bool Returns true if the widget generator is enabled, false otherwise. + */ + public static function is_enabled() + { + return ! empty(\Config::get('materia.ai_generation.enabled')); + } + + /** + * Submits a prompt to the configured question generation provider. + * + * @param string $prompt The prompt for the query. + * @return object The result of the query. + */ + public static function query($prompt, $format='json') + { + $client = static::get_client(); + if (empty($client)) return Msg::failure('Failed to initialize generation client.'); + + $params = [ + 'messages' => [ + ['role' => 'user', 'content' => $prompt] + ], + 'max_tokens' => 16000, + 'frequency_penalty' => 0, // 0 to 1 + 'presence_penalty' => 0, // 0 to 1 + 'temperature' => 1, // 0 to 1 + 'top_p' => 1, // 0 to 1 + ]; + + if ( ! empty(\Config::get('materia.ai_generation.model'))) $params['model'] = \Config::get('materia.ai_generation.model'); + if ($format == 'json') $params['response_format'] = (object) ['type' => 'json_object']; + + return $client->chat()->create($params); + } + + /** + * Generate a question set for a widget instance + * + * @param Widget_Instance $inst the instance associated with this request (if present) + * @param Widget $widget the widget engine associated with this request + * @param string $topic the topic to be used as the basis of the generated qset + * @param bool $include_images whether or not to include images in the generated qset + * @param int $num_questions the number of questions to generate within the qset + * @param bool $existing whether to build on an existing qset or generate one from scratch + * @return array returns an array with the generated qset + */ + static public function generate_qset($inst, $widget, $topic, $include_images, $num_questions, $existing) + { + if ( ! self::is_enabled()) return Msg::failure('Question generation is not enabled.'); + + // 'allow images' environment variable overrides whatever the api request sends + if ( empty(\Config::get('materia.ai_generation.allow_images'))) $include_images = false; + + $demo = Widget_Instance_Manager::get($widget->meta_data['demo']); + if ( ! $demo) return Msg::not_found(); + + if ($inst) $instance_name = $inst->name; + $widget_name = $widget->name; + $about = $widget->meta_data['about']; + $qset_version = 1; + + // grab the custom prompt from the widget engine, if it's available + $custom_engine_prompt = isset($widget->meta_data['generation_prompt']) ? $widget->meta_data['generation_prompt'][0] : null; + + // time for logging + $start_time = microtime(true); + $time_elapsed_secs = 0; + + // ********************************** + // prompt assembly + // ********************************** + + // appending new questions to an existing qset. The instance must have been previously saved. + if ($existing) + { + if ( ! $inst) return Msg::invalid_input('Requires a previously saved instance to build from.'); + $inst->get_qset($inst->id); + if ( ! $inst->qset->data) return Msg::failure('No existing question set found.'); + if ($inst->qset->version) $qset_version = $inst->qset->version; + + $qset_text = json_encode($inst->qset->data); + + // non-demo non-image prompt + $text = "{$widget->name} is a 'widget', an interactive piece of educational web content described as: '{$about}'. ". + 'Using the exact same json format of the following question set, without changing any field keys or data types and without changing any of the existing questions, '. + "generate {$num_questions} more questions and add them to the existing question set. ". + "The name of this particular instance of {$widget->name} is {$instance_name} and the new questions must be based on this topic: '{$topic}'. ". + 'Return only the JSON for the resulting question set.'; + + if ($include_images) + { + $text = $text." In every asset or assets object in each new question, add a field titled 'description' ". + "that best describes the image within the answer or question's context, unless otherwise specified later on in this prompt. ". + "Do not generate descriptions that would violate OpenAI's image generation safety system and do not use real names. IDs must be null."; + } + else + { + $text = $text.' Leave the asset field empty or otherwise equivalent to asset fields in questions with no associated asset. IDs must be null.'; + } + + if ($custom_engine_prompt && ! empty($custom_engine_prompt)) + { + $text = $text." Lastly, the following instructions apply to the {$widget->name} widget specifically, and supersede earlier instructions where applicable: {$custom_engine_prompt}"; + } + + $text = $text."\n{$qset_text}"; + } + else // creating a new qset based on the demo. Does not require a previously saved instance + { + // get the qset from the demo instance + if ( ! ($demo_inst = Widget_Instance_Manager::get($widget->meta_data['demo']))) return Msg::not_found('Could not locate demo instance for widget engine.'); + $demo_inst->get_qset($demo_inst->id); + if ( ! $demo_inst->qset) return Msg::not_found('Could not locate demo question set for widget engine.'); + if ($demo_inst->qset->version) $qset_version = $demo_inst->qset->version; + $qset_text = json_encode($demo_inst->qset->data); + + // non-image prompt + $text = "{$widget->name} is a 'widget', an interactive piece of educational web content described as: '{$about}'. ". + "The following is a 'demo' question set for the widget titled {$demo->name}. ". + 'Using the same json format as the demo question set, and without changing any field keys or data types, return only the JSON '. + "for a question set based on this topic: '{$topic}'. Ignore the topic of the demo contents entirely. ". + "Replace the relevant field values with generated values. Generate a total {$num_questions} of questions. ". + 'IDs must be NULL.'; + + // image prompt + if ($include_images) + { + $text = $text." In every asset or assets object in each new question, add a field titled 'description' ". + "that best describes the image within the answer or question's context, unless otherwise specified later on in this prompt. ". + "Do not generate descriptions that would violate OpenAI's image generation safety system and do not use real names. IDs must be null."; + } + else + { + $text = $text.' Asset fields associated with media (image, audio, or video) should be left blank. '. + "For text assets, or if the 'materiaType' of an asset is 'text', create a field titled 'value' ". + 'with the text inside the asset object.'; + } + + if ($custom_engine_prompt && ! empty($custom_engine_prompt)) + { + $text = $text." Lastly, the following instructions apply to the {$widget->name} widget specifically, and supersede earlier instructions where applicable: {$custom_engine_prompt}"; + } + + $text = $text."\n{$qset_text}"; + } + + // send the prompt to to the generative AI provider + try { + $result = self::query($text, 'json'); + + // received the qset - decode the json string from the result + $question_set = json_decode($result->choices[0]->message->content); + \Log::info('Generated question set: '.print_r(json_encode($question_set), true)); + + if (\Config::get('materia.ai_generation.log_stats')) + { + $time_elapsed_secs = microtime(true) - $start_time; + + \Log::debug(PHP_EOL + .'Widget: '.$widget_name.PHP_EOL + .'Date: '.date('Y-m-d H:i:s').PHP_EOL + .'Time to complete (in seconds): '.$time_elapsed_secs.PHP_EOL + .'Number of questions asked to generate: '.$num_questions.PHP_EOL + .'Included images: '.$include_images.PHP_EOL + .'Prompt tokens: '.$result->usage->promptTokens.PHP_EOL + .'Completion tokens: '.$result->usage->completionTokens.PHP_EOL + .'Total tokens: '.$result->usage->totalTokens.PHP_EOL); + } + + } catch (\Exception $e) { + \Log::error('Error generating question set:'.PHP_EOL + .'Widget: '.$widget_name.PHP_EOL + .'Date: '.date('Y-m-d H:i:s').PHP_EOL + .'Time to complete (in seconds): '.$time_elapsed_secs.PHP_EOL + .'Number of questions asked to generate: '.$num_questions.PHP_EOL + .'Error: '.$e->getMessage().PHP_EOL); + + return Msg::failure('Error generating question set.'); + } + + if ($include_images) $question_set = static::generate_images($question_set, $existing); + + return [ + 'qset' => $question_set, + 'version' => $qset_version + ]; + } + + + /** + * Generate images for a question set. + * + * This function generates images for a given question set and existing images. + * + * @param array $question_set The question set for which images need to be generated. + * @return void + */ + static public function generate_images($question_set) + { + // get an array of asset descriptions from the qset + $assets = static::comb_assets($question_set); + + $num_assets = count($assets); + if ($num_assets < 1) return $question_set; + + // the dall-e-2 model can generate multiple images for a single prompt, but those are variations of the same image + // in order to generate images for each individual description, calls must be made concurrently + // this is not ideal - perhaps individual image generation is tied to an api endpoint which is facilitated by the front end + foreach ($assets as $description) + { + // generate image + try { + $client = static::get_client(); + $dalle_result = $client->images()->create([ + 'model' => 'dall-e-2', + 'prompt' => $description, + 'response_format' => 'b64_json', + 'size' => '512x512' // 256x256, 512x512, 1024x1024 + ]); + + } catch (\Exception $e) { + \Log::error('Error generating images: '.$e->getMessage()); + \Log::error('Trace: '.$e->getTraceAsString()); + + return $question_set; + } + + // decode the base64 file data + $file_data = base64_decode($dalle_result->data[0]->b64_json); + + // Create a temporary file to store the binary image contents + $temp_file_path = tempnam(sys_get_temp_dir(), 'dalle_sideload_'); + file_put_contents($temp_file_path, $file_data); + + // copy asset to where files would normally be uploaded to + // this is largely mirrored from sideloading demo assets + $src_area = \File::forge(['basedir' => sys_get_temp_dir()]); // restrict copying from system tmp dir + $mock_upload_file_path = \Config::get('file.dirs.media_uploads').uniqid('sideload_'); + \File::copy($temp_file_path, $mock_upload_file_path, $src_area, 'media'); + + // process the upload and turn it into a file + $upload_info = \File::file_info($mock_upload_file_path, 'media'); + $asset = \Materia\Widget_Asset_Manager::new_asset_from_file(static::string_to_slug($description), $upload_info); + + if ( ! isset($asset->id)) + { + \Log::error('Unable to create asset'); + } + else + { + static::assign_asset($question_set, $description, $asset); + } + } + return $question_set; + } + + /** + * Combines all asset descriptions in a question set into a single array + * @param array $qset The question set array + * @return array The array of asset descriptions + */ + static public function comb_assets($qset) + { + $assets = []; + foreach ($qset as $key => $value) + { + if (is_object($value) || is_array($value)) + { + $value = (array) $value; + if ($key == 'asset' || $key == 'image' || $key == 'audio' || $key == 'video' || $key == 'options') + { + if (key_exists('description', $value) && ! empty($value['description'])) + { + $assets[] = $value['description']; + } + } + if ($key == 'assets') + { + $value = (array) $value; + foreach ($value as $asset) + { + $asset = (array) $asset; + if (key_exists('description', $asset) && ! empty($asset['description'])) + { + $assets[] = $asset['description']; + } + } + } + $assets = array_merge($assets, static::comb_assets($value)); + } + } + return $assets; + } + + /** + * Assigns a generated image asset to a qset based on the image description + * @param array $array The question set + * @param string $description the string used to describe (and generate) the image asset + * @param object $asset the asset object (of type \Materia\Widget_Asset) + * @return bool Returns true if asset was inserted into the question set + */ + static public function assign_asset(&$array, $description, $asset) + { + foreach ($array as $key => &$value) + { + if (is_object($value) || is_array($value)) + { + if ($key == 'asset' || $key == 'image' || $key == 'audio' || $key == 'video') + { + if (isset($value->description) && $value->description == $description) + { + $value->id = $asset->id; + return true; + } + else return false; + } + elseif ($key == 'assets') + { + foreach ($value as &$item) + { + if (isset($item->description) && $item->description == $description) + { + $item->id = $asset->id; + return true; + } + else return false; + } + } + else + { + $result = self::assign_asset($value, $description, $asset); + if ($result == true) return $result; + } + } + } + return false; + } + + // helper function to turn a natural language description into a url-safe and filesystem-safe slug + static public function string_to_slug($string) + { + // Convert the string to lowercase + $string = strtolower($string); + + // Remove non-alphanumeric characters (except spaces) + $string = preg_replace('/[^a-z0-9\s]/', '', $string); + + // Replace spaces with hyphens + $string = str_replace(' ', '-', $string); + + // Trim any leading or trailing hyphens + $string = trim($string, '-'); + + return $string; + } +} \ No newline at end of file diff --git a/fuel/app/config/css.php b/fuel/app/config/css.php index a00308cf2..93e077d30 100644 --- a/fuel/app/config/css.php +++ b/fuel/app/config/css.php @@ -38,6 +38,7 @@ $webpack.'css/util-question-import.css', $webpack.'css/question-importer.css', ], + 'qset_generator' => [$webpack.'css/qset-generator.css'], 'questionimport' => [$webpack.'css/question-importer.css'], 'qset_history' => [$webpack.'css/qset-history.css'], 'rollback_dialog' => [$webpack.'css/util-rollback-confirm.css'], diff --git a/fuel/app/config/js.php b/fuel/app/config/js.php index f23e1cfb8..ea8a86260 100644 --- a/fuel/app/config/js.php +++ b/fuel/app/config/js.php @@ -32,6 +32,7 @@ '500' => [$webpack.'js/500.js'], 'media' => [$webpack.'js/media.js'], 'qset_history' => [$webpack.'js/qset-history.js'], + 'qset_generator' => [$webpack.'js/qset-generator.js'], 'post_login' => [$webpack.'js/lti-post-login.js'], 'select_item' => [$webpack.'js/lti-select-item.js'], 'open_preview' => [$webpack.'js/lti-open-preview.js'], diff --git a/fuel/app/config/materia.php b/fuel/app/config/materia.php index fcb0277d9..30cce8412 100644 --- a/fuel/app/config/materia.php +++ b/fuel/app/config/materia.php @@ -122,6 +122,17 @@ ] : null ), + ], + + 'ai_generation' => [ + 'enabled' => filter_var($_ENV['GENERATION_ENABLED'] ?? false, FILTER_VALIDATE_BOOLEAN), + 'allow_images' => filter_var($_ENV['GENERATION_ALLOW_IMAGES'] ?? false, FILTER_VALIDATE_BOOLEAN), + 'provider' => $_ENV['GENERATION_API_PROVIDER'] ?? '', + 'endpoint' => $_ENV['GENERATION_API_ENDPOINT'] ?? '', + 'api_key' => $_ENV['GENERATION_API_KEY'] ?? '', + 'api_version' => $_ENV['GENERATION_API_VERSION'] ?? '', + 'model' => $_ENV['GENERATION_API_MODEL'] ?? '', + 'log_stats' => filter_var($_ENV['GENERATION_LOG_STATS'] ?? false, FILTER_VALIDATE_BOOLEAN) ] ]; diff --git a/fuel/app/migrations/056_add_is_generable_field_to_widget_table.php b/fuel/app/migrations/056_add_is_generable_field_to_widget_table.php new file mode 100644 index 000000000..555eb43ba --- /dev/null +++ b/fuel/app/migrations/056_add_is_generable_field_to_widget_table.php @@ -0,0 +1,20 @@ + ['constraint' => "'0','1'", 'type' => 'enum', 'default' => '0'], + )); + } + + public function down() + { + \DBUtil::drop_fields('widget', array( + 'is_generable', + )); + } +} diff --git a/fuel/app/tests/api/v1.php b/fuel/app/tests/api/v1.php index cdd2a709c..a9ff19737 100644 --- a/fuel/app/tests/api/v1.php +++ b/fuel/app/tests/api/v1.php @@ -1132,6 +1132,44 @@ public function test_question_set_get() } } + public function test_question_set_generate() + { + // ======= GENERATION DISABLED ======== + if ( ! \Materia\Widget_Question_Generator::is_enabled()) + { + $output = Api_V1::question_set_generate(null, 1, 'Pixar Films', false, 8, false); + $this->assert_failure_message($output); + } + else + { + // ======= AS NO ONE ======== + $output = Api_V1::question_set_generate(null, 1, 'Pixar Films', false, 8, false); + $this->assert_permission_denied_message($output); + + // ===== AS STUDENT ======= + $this->_as_student(); + $output = Api_V1::question_set_generate(null, 1, 'Pixar Films', false, 8, false); + $this->assert_permission_denied_message($output); + + // ======= AS AUTHOR ======= + // NOTE: We're not going to perform actual question generation, since that would slow tests down considerably and incur costs + // Tests several error boundaries instead + $this->_as_author(); + $output = Api_V1::question_set_generate(null, -1, 'Pixar Films', false, 8, false); + $this->assert_validation_error_message($output); + + try + { + $output = Api_V1::question_set_generate('11111', -1, 'Pixar Films', false, 8, false); + $this->fail('Expected exception HttpNotFoundException not thrown'); + } + catch (\Exception $e) + { + $this->assertInstanceOf('HttpNotFoundException', $e); + } + } + } + public function test_questions_get() { // ======= AS NO ONE ======== @@ -1611,6 +1649,18 @@ protected function assert_invalid_login_message($msg) $this->assertEquals('Invalid Login', $msg->title); } + protected function assert_not_found_message($msg) + { + $this->assertInstanceOf('\Materia\Msg', $msg); + $this->assertEquals('Not Found', $msg->title); + } + + protected function assert_failure_message($msg) + { + $this->assertInstanceOf('\Materia\Msg', $msg); + $this->assertEquals('Action Failed', $msg->title); + } + protected function assert_permission_denied_message($msg) { $this->assertInstanceOf('\Materia\Msg', $msg); diff --git a/fuel/app/tests/widget_source/test_widget/src/install.yaml b/fuel/app/tests/widget_source/test_widget/src/install.yaml index 2ea7afcbd..551d6d991 100755 --- a/fuel/app/tests/widget_source/test_widget/src/install.yaml +++ b/fuel/app/tests/widget_source/test_widget/src/install.yaml @@ -9,6 +9,7 @@ general: is_storage_enabled: No is_qset_encrypted: No is_answer_encrypted: No + is_generable: No api_version: 2 files: player: player.html diff --git a/fuel/app/tests/widgets/installer.php b/fuel/app/tests/widgets/installer.php index 972055c10..5282fc239 100644 --- a/fuel/app/tests/widgets/installer.php +++ b/fuel/app/tests/widgets/installer.php @@ -24,6 +24,7 @@ public function test_generate_install_params() 'is_playable' => '0', 'is_editable' => 'true', 'in_catalog' => 'false', + 'is_generable' => '0', 'api_version' => '2', ], 'score' => [ @@ -46,6 +47,7 @@ public function test_generate_install_params() 'is_qset_encrypted' => '0', 'is_answer_encrypted' => '1', 'is_storage_enabled' => '1', + 'is_generable' => '0', 'is_playable' => '0', 'is_editable' => '0', 'is_scorable' => '1', diff --git a/public/dist/package.json b/public/dist/package.json index 6ae44d552..8755abeeb 100644 --- a/public/dist/package.json +++ b/public/dist/package.json @@ -18,6 +18,8 @@ "css/qset-history.css", "js/question-importer.js", "css/question-importer.css", + "js/qset-generator.js", + "css/qset-generator.css", "js/guides.js", "css/guides.css", "js/scores.js", diff --git a/src/components/hooks/useQuestionGeneration.jsx b/src/components/hooks/useQuestionGeneration.jsx new file mode 100644 index 000000000..243b6d2a3 --- /dev/null +++ b/src/components/hooks/useQuestionGeneration.jsx @@ -0,0 +1,16 @@ +import { useMutation } from "react-query"; +import { apiGenerateQset } from "../../util/api"; + +export default function useQuestionGeneration() { + return useMutation( + apiGenerateQset, + { + onSuccess: (qset, variables) => { + variables.successFunc(qset) + }, + onError: (error, variables, context) => { + variables.errorFunc(error) + } + } + ) +} diff --git a/src/components/question-generator.jsx b/src/components/question-generator.jsx new file mode 100644 index 000000000..3ed1fb11f --- /dev/null +++ b/src/components/question-generator.jsx @@ -0,0 +1,149 @@ +import useQuestionGeneration from "./hooks/useQuestionGeneration" +import React, { useState, useEffect, useRef } from "react" +import './question-generator.scss' +import LoadingIcon from './loading-icon' + +const getInstId = () => { + const urlParams = new URLSearchParams(window.location.search); + const instId = urlParams.get('inst_id'); + return instId == 'undefined' ? null : instId; +} + +const getWidgetId = () => { + const urlParams = new URLSearchParams(window.location.search); + const widgetId = urlParams.get('widget_id'); + return widgetId ? widgetId : null; +} + +const QsetGenerator = () => { + const generateQuestion = useQuestionGeneration() + + const [instId, setInstId] = useState(getInstId()) + const [widgetId, setWidgetId] = useState(getWidgetId()) + const [topic, setTopic] = useState('') + const [includeImages, setIncludeImages] = useState(false) + const [numQuestions, setNumQuestions] = useState(8) + const [buildOffExisting, setBuildOffExisting] = useState(false) + + const [topicError, setTopicError] = useState('') + const [numberError, setNumberError] = useState('') + const [warning, setWarning] = useState('') + const [serverError, setServerError] = useState('') + + const loading = useRef(false) + + useEffect(() => { + if (numQuestions < 1) setNumberError('Please enter a number greater than 0') + else if (numQuestions > 16) setWarning('Note: Generating this many questions will take a while and may not work at all.') + else { + setNumberError('') + setWarning('') + } + },[numQuestions]) + + const onClickGenerate = () => { + + // validation functions required since this is an event handler + if (loading.current || ! validateNumQuestions() || ! validateTopic()) return false + + loading.current = true + + generateQuestion.mutate({ + inst_id: instId, + widget_id: widgetId, + topic: topic, + include_images: includeImages, + num_questions: numQuestions, + build_off_existing: buildOffExisting, + successFunc: (result) => { + window.parent.Materia.Creator.onQsetReselectionComplete( + JSON.stringify(result.qset), + true, // is generated + result.version, + result.title + ) + loading.current = false + }, + errorFunc: (err) => { + console.error(err) + setServerError('Error generating questions. Please try again.') + loading.current = false + } + }) + } + + const closeDialog = () => window.parent.Materia.Creator.onQsetReselectionComplete(null) + + const validateTopic = () => { + if (!topic.length) { + setTopicError('Don\'t forget to add a topic!') + return false + } else { + setTopicError('') + return true + } + } + + const validateNumQuestions = () => { + return numQuestions > 0 + } + + const onTopicChange = (e) => { + if (e.target.value.length > 0) { + setTopic(e.target.value) + setTopicError('') + } + else setTopicError('Don\'t forget to add a topic!') + } + + const onNumberChange = (e) => { + setNumQuestions(e.target.value) + } + + return ( +
+

Generate Questions

+ {loading.current &&
+ +

Generating questions. Do not close this window.

+
} +
+ Question Generation is powered by AI, so errors in the generated content can occur. After generation is complete you will be prompted to keep the content or discard it. You may need + to make edits to the generated content before saving your widget. + Note that this feature will only create text content. Image or media generation is not supported. + {serverError} +
+ {topicError} + + + The topic should be brief, concise, and describe the desired content of the widget. You may need to + experiment with specificity to achieve desired results. + +
+
+ + {numberError} + +
+ {/*
+ setIncludeImages(e.target.checked)}/> + +
*/} +
+ + setBuildOffExisting(e.target.checked)}/> + + If selected, generated content will be appended to existing content. If unselected, generated content will replace existing content. + +
+ {warning} + +
+
+ Cancel +
+
+ ) +} + +export default QsetGenerator; \ No newline at end of file diff --git a/src/components/question-generator.scss b/src/components/question-generator.scss new file mode 100644 index 000000000..cc7cde4ce --- /dev/null +++ b/src/components/question-generator.scss @@ -0,0 +1,171 @@ +@import './include.scss'; + +.generate { + border-radius: 5px; + margin: 10px; + + h1 { + background: #3690E6; + padding: 10px; + margin: 0px; + font-size: 1em; + font-weight: bold; + color: #ffffff; + } + + .loading { + display: flex; + justify-content: center; + align-items: center; + height: 100vh; + background-color: rgba(0, 0, 0, 0.5); + + backdrop-filter: blur(10px); + position: absolute; + top: 0; + left: 0; + width: 100%; + z-index: 10; + + p { + margin-bottom: 150px; + font-size: 1.1em; + color: white; + } + } + + #generate_form { + margin: 1.5em; + display: flex; + flex-direction: column; + gap: 0.35em; + + label { + font-weight: 400; + } + + input { + border: solid 1.5px #3690E6; + border-radius: 3px; + + &.invalid { + border: 1.5px solid red; + } + + &.warning { + border: 1.5px solid #d87a00; + } + } + + .description { + display: block; + padding: 1em; + background: $very-light-gray; + + font-size: 0.85em; + font-weight: 400; + + border-radius: 10px; + } + + #topic-field { + display: flex; + flex-direction: column; + gap: 0.5em; + + #topic { + padding: 0.5em; + } + } + + #num-questions-field { + position: relative; + display: flex; + flex-direction: row; + justify-content: space-between; + margin-top: 0.5em; + + input { + max-width: 100px; + } + + label { + background: #fff; + padding-right: 0.5em; + } + + &:after { + position: absolute; + top: 0.65em; + left: 0; + z-index: -1; + content: ''; + display: block; + width: calc(100% - 120px); + border-bottom: solid 1px #bbb; + } + } + + #build-off-existing-field { + position: relative; + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: space-between; + margin: 0.5em 0; + + &:after { + position: absolute; + top: 0.65em; + left: 0; + z-index: -1; + content: ''; + display: block; + width: calc(100% - 30px); + border-bottom: solid 1px #bbb; + } + + label { + background: #fff; + padding-right: 0.5em; + } + + #build-off-existing { + margin-bottom: 0.5em; + margin-right: 0.5em; + } + + .description { + flex-basis: 100%; + } + } + } + .actions { + position: fixed; + width: 80px; + left: 50%; + bottom: 0px; + margin-left: -40px; + padding-bottom: 14px; + z-index: 10; + + text-align: center; + font-size: 1.1em; + + a { + color: #000000; + } + } + + .error { + color: $color-red; + font-size: 0.8em; + font-weight: 400; + } + + .warning { + color: #d87a00; + font-size: 0.8em; + font-weight: 400; + } +} diff --git a/src/components/question-history.jsx b/src/components/question-history.jsx index 3aafddf3f..3cc8b8137 100644 --- a/src/components/question-history.jsx +++ b/src/components/question-history.jsx @@ -56,17 +56,18 @@ const QuestionHistory = () => { if (!!saves) { saves.forEach((save) => { if (id == save.id) { - return window.parent.Materia.Creator.onQsetHistorySelectionComplete( + return window.parent.Materia.Creator.onQsetReselectionComplete( JSON.stringify(save.data), + false, // is generated save.version, - save.created_at + null ) } }) } } - const closeDialog = () => window.parent.Materia.Creator.onQsetHistorySelectionComplete(null) + const closeDialog = () => window.parent.Materia.Creator.onQsetReselectionComplete(null) let savesRender = null let noSavesRender = null diff --git a/src/components/widget-creator-page.scss b/src/components/widget-creator-page.scss index 5e07ff2b5..b6c53df53 100644 --- a/src/components/widget-creator-page.scss +++ b/src/components/widget-creator-page.scss @@ -52,7 +52,7 @@ a { &:before { content: 'Editing'; // background: #b944cc; - + position: absolute; bottom: 0; left: 0; @@ -63,7 +63,7 @@ a { margin: 10px 0; // border-right: solid 1px #aaa; - + font-size: 24px; font-weight: bold; // color: #ffffff; @@ -103,7 +103,7 @@ a { } } -#qset-rollback-confirmation-bar { +.confirmation-bar { position: relative; display: flex; justify-content: space-around; @@ -132,6 +132,7 @@ a { padding: 14px 0; font-size: 0.8em; + white-space: break-spaces; span { font-weight: bold; @@ -155,6 +156,14 @@ a { background: linear-gradient(#ffffff, #ffe5cc); } } + + &#qset-generation-confirmation-bar { + background-color: #3690E6; + + button:hover { + background: linear-gradient(#ffffff, #bbdeff); + } + } } .dot { @@ -184,7 +193,7 @@ a { margin: 15px 0; background: none; - + font-size: 21px; font-weight: 700; color: #000000; @@ -221,7 +230,7 @@ a { } .edit_button { - position: relative; + position: relative; display: inline-block; margin: 10px 0 0 6px; diff --git a/src/components/widget-creator.jsx b/src/components/widget-creator.jsx index 136621370..72d7879c7 100644 --- a/src/components/widget-creator.jsx +++ b/src/components/widget-creator.jsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useRef } from 'react'; import { useQuery } from 'react-query' import LoadingIcon from './loading-icon'; -import { apiGetWidgetInstance, apiGetQuestionSet, apiCanBePublishedByCurrentUser, apiSaveWidget, apiGetWidgetLock, apiGetWidget, apiAuthorVerify} from '../util/api' +import { apiGetWidgetInstance, apiGetQuestionSet, apiCanBePublishedByCurrentUser, apiSaveWidget, apiGetWidgetLock, apiGetWidget, apiAuthorVerify, apiIsGenerable} from '../util/api' import NoPermission from './no-permission' import Alert from './alert' import { creator } from './materia-constants'; @@ -31,6 +31,7 @@ const WidgetCreator = ({instId, widgetId, minHeight='', minWidth=''}) => { creatorGuideUrl: window.location.pathname.substring(0, window.location.pathname.lastIndexOf('/')) + '/creators-guide', showActionBar: true, showRollbackConfirm: false, + showGenerationConfirm: false, saveStatus: 'idle', saveMode: null, previewUrl: null, @@ -40,6 +41,7 @@ const WidgetCreator = ({instId, widgetId, minHeight='', minWidth=''}) => { returnUrl: null, returnLocation: 'Widget Catalog', directUploadMediaFile: null, + canGenerateQset: false, isTimeoutRunning: false }) @@ -76,6 +78,7 @@ const WidgetCreator = ({instId, widgetId, minHeight='', minWidth=''}) => { onSuccess: (info) => { if (info) { setInstance({ ...instance, widget: info }) + setCreatorState({...creatorState, canGenerateQset: info.is_generable == "1"}) } }, onError: (error) => { @@ -250,7 +253,7 @@ const WidgetCreator = ({instId, widgetId, minHeight='', minWidth=''}) => { // this hook will then fire a second time when a new save postMessage is sent // the second hook will initialize an existing widget with the newly provided qset data // note: this condition will also apply when rolling back and applying the original cached qset - if (!!instIdRef.current && instance.qset && creatorState.reloadWithQset) { + if (((!!instIdRef.current && instance.qset) || instance.preSaveSpecialCondition) && creatorState.reloadWithQset) { // flip to false because creator will re-init and send start postMessage setWidgetReady(false) @@ -269,6 +272,11 @@ const WidgetCreator = ({instId, widgetId, minHeight='', minWidth=''}) => { creatorShouldInitRef.current = false + } else if (instance.preSaveSpecialCondition) { + // preSaveSpecialCondition is a flag set when generating a qset for an unsaved instance + let args = [instance.name ? instance.name : 'My Generated Widget', instance, instance.qset.data, instance.qset.version, window.BASE_URL, window.MEDIA_URL] + sendToCreator('initExistingWidget', args) + } else if (!instIdRef.current) { let args = [instance.widget, window.BASE_URL, window.MEDIA_URL] @@ -285,7 +293,9 @@ const WidgetCreator = ({instId, widgetId, minHeight='', minWidth=''}) => { creatorShouldInitRef.current = true setInstance({ ...instance, - qset: creatorState.reloadWithQset + qset: creatorState.reloadWithQset, + ...( creatorState.reloadWithQset.title && { name: creatorState.reloadWithQset.title }), // fancy syntax to only apply the name property when reloadWithQset.title is set + ...( ! instIdRef.current && { preSaveSpecialCondition: true }) // fancy syntax to ensure preSaveSpecialCondition is only applied when instIdRef.current is unavailable }) } },[creatorState.reloadWithQset]) @@ -552,10 +562,11 @@ const WidgetCreator = ({instId, widgetId, minHeight='', minWidth=''}) => { showEmbedDialog(`${window.BASE_URL}qsets/import/?inst_id=${instance.id}`, 'embed_dialog') } - // const showQsetHistoryConfirmation = () => { - // } + const showQuestionGenerator = () => { + showEmbedDialog(`${window.BASE_URL}qsets/generate/?inst_id=${instance.id}&widget_id=${widgetId}`, 'embed_dialog') + } - const qsetRollbackConfirm = (confirm) => { + const qsetConfirm = (confirm) => { // if asked to confirm rollback, we apply the cached qset to reloadWithQset // doing so will trigger the hook when reloadWithQset updates @@ -563,20 +574,24 @@ const WidgetCreator = ({instId, widgetId, minHeight='', minWidth=''}) => { // otherwise, nothing is required except to restore the action bar if (!confirm) { + // rollback to the cached qset let qsetToApply = creatorState.cachedQset setCreatorState({ ...creatorState, reloadWithQset: qsetToApply, cachedQset: null, showActionBar: true, - showRollbackConfirm: false + showRollbackConfirm: false, + showGenerationConfirm: false, }) } else { + // just remove the confirmation bar and show the action bar setCreatorState({ ...creatorState, cachedQset: null, showActionBar: true, - showRollbackConfirm: false + showRollbackConfirm: false, + showGenerationConfirm: false, }) } } @@ -640,20 +655,20 @@ const WidgetCreator = ({instId, widgetId, minHeight='', minWidth=''}) => { } }, - // When a qset is selected from the prior saves list - onQsetHistorySelectionComplete(qset, version = 1) { + // When a new qset is selected from the prior saves list or generated + onQsetReselectionComplete(qset, showGenerationConfirm = false, version = 1, title = null) { if (!qset) { setCreatorState({ ...creatorState, dialogPath: '', dialogType: 'embed_dialog', showActionBar: true, - showRollbackConfirm: false + showRollbackConfirm: false, + showGenerationConfirm: false }) } else { requestSave('history') - let parsedQsetData = JSON.parse(qset) setCreatorState({ @@ -663,11 +678,12 @@ const WidgetCreator = ({instId, widgetId, minHeight='', minWidth=''}) => { reloadWithQset: { data: parsedQsetData, version: version, - id: parsedQsetData.id + id: parsedQsetData.id, + ...(title && { title: title }), }, - // cachedQset: instance.qset, showActionBar: false, - showRollbackConfirm: true + showRollbackConfirm: showGenerationConfirm ? false : true, + showGenerationConfirm: showGenerationConfirm, }) } }, @@ -772,7 +788,8 @@ const WidgetCreator = ({instId, widgetId, minHeight='', minWidth=''}) => { ←Return to {creatorState.returnLocation} { creatorState.hasCreatorGuide ? Creator's Guide : '' } { instance.id ? Save History : '' } - Import Questions... + Import + { creatorState.canGenerateQset ? Generate : <> } { editButtonsRender }
- + + + + ) + } + + let generationConfirmBarRender = null + if (creatorState.showGenerationConfirm) { + generationConfirmBarRender = ( +
+

Previewing Generated Questions

+

Select Cancel to undo any changes made by the question generator. Select Keep to commit to using this generated version.

+ +
) } @@ -861,6 +890,7 @@ const WidgetCreator = ({instId, widgetId, minHeight='', minWidth=''}) => { { popupRender } { actionBarRender } { rollbackConfirmBarRender } + { generationConfirmBarRender }