Your IP : 216.73.216.138


Current Path : /home/ereika83/public_html/wp-content/plugins/sfwd-lms/src/Core/Modules/AI/
Upload File :
Current File : /home/ereika83/public_html/wp-content/plugins/sfwd-lms/src/Core/Modules/AI/Quiz_Creation.php

<?php
/**
 * Quiz creation AI module.
 *
 * @since 4.8.0
 *
 * @package LearnDash\Core
 */

namespace LearnDash\Core\Modules\AI;

use Exception;
use InvalidArgumentException;
use LearnDash\Core\Utilities\Sanitize;
use LearnDash\Core\Modules\AI\Quiz_Creation\DTO;
use LearnDash\Core\Modules\AI\Quiz_Creation\Parser;
use LearnDash\Core\Modules\AI\Quiz_Creation\Repository;
use LearnDash\Core\Services\ChatGPT;

/**
 * Quiz creation AI class.
 *
 * @since 4.8.0
 */
class Quiz_Creation {
	/**
	 * Single choice question type key.
	 *
	 * @since 4.8.0
	 *
	 * @var string
	 */
	public static $question_type_key_single_choice = 'single';

	/**
	 * Multiple choice question type key.
	 *
	 * @since 4.8.0
	 *
	 * @var string
	 */
	public static $question_type_key_multiple_choice = 'multiple';

	/**
	 * Free question type key.
	 *
	 * @since 4.8.0
	 *
	 * @var string
	 */
	public static $question_type_key_free_choice = 'free_answer';

	/**
	 * Sort question type key.
	 *
	 * @since 4.8.0
	 *
	 * @var string
	 */
	public static $question_type_key_sorting_choice = 'sort_answer';

	/**
	 * Matrix sort question type key.
	 *
	 * @since 4.8.0
	 *
	 * @var string
	 */
	public static $question_type_key_matrix_sorting_choice = 'matrix_sort_answer';

	/**
	 * Fill in the blank question type key.
	 *
	 * @since 4.8.0
	 *
	 * @var string
	 */
	public static $question_type_key_fill_in_the_blank = 'cloze_answer';

	/**
	 * Assessment question type key.
	 *
	 * @since 4.8.0
	 *
	 * @var string
	 */
	public static $question_type_key_assessment = 'assessment_answer';

	/**
	 * Essay question type key.
	 *
	 * @since 4.8.0
	 *
	 * @var string
	 */
	public static $question_type_key_essay = 'essay';

	/**
	 * Page slug.
	 *
	 * @since 4.8.0
	 *
	 * @var string
	 */
	public static $slug = 'learndash-ai-quiz-creation';

	/**
	 * Transient key to store messages.
	 *
	 * @since 4.8.0
	 *
	 * @var string
	 */
	public static $transient_key_messages = 'learndash_ai_quiz_creation_messages';

	/**
	 * Limit number of allowed questions and answers.
	 *
	 * 30 is supposedly a safe number of items to generate by AI provider before
	 * resulting in exceeded token per request or rate limit error.
	 *
	 * @since 4.8.0
	 *
	 * @var int
	 */
	private static $questions_limit = 30;

	/**
	 * ChatGPT client.
	 *
	 * @since 4.8.0
	 *
	 * @var ChatGPT
	 */
	private $chatgpt;

	/**
	 * Quiz creation AI response parser.
	 *
	 * @since 4.8.0
	 *
	 * @var Parser
	 */
	private $parser;

	/**
	 * Quiz creation AI repository object.
	 *
	 * @since 4.8.0
	 *
	 * @var Repository
	 */
	private $repository;

	/**
	 * Quiz DTO used to pass quiz data.
	 *
	 * @since 4.8.0
	 *
	 * @var DTO\Quiz
	 */
	private $quiz;

	/**
	 * Constructor.
	 *
	 * @since 4.8.0
	 *
	 * @param ChatGPT    $chatgpt    ChatGPT client.
	 * @param Parser     $parser     Quiz creation AI response parser.
	 * @param Repository $repository Repository object.
	 */
	public function __construct( ChatGPT $chatgpt, Parser $parser, Repository $repository ) {
		$this->chatgpt    = $chatgpt;
		$this->parser     = $parser;
		$this->repository = $repository;
	}

	/**
	 * Get LearnDash question types.
	 *
	 * @since 4.8.0
	 *
	 * @return array<string, string>
	 */
	public static function get_question_types(): array {
		return [
			self::$question_type_key_single_choice         => __( 'Single choice', 'learndash' ),
			self::$question_type_key_multiple_choice       => __( 'Multiple choice', 'learndash' ),
			self::$question_type_key_free_choice           => __( 'Free choice', 'learndash' ),
			self::$question_type_key_sorting_choice        => __( 'Sorting choice', 'learndash' ),
			self::$question_type_key_matrix_sorting_choice => __( 'Matrix Sorting choice', 'learndash' ),
			self::$question_type_key_fill_in_the_blank     => __( 'Fill in the blank', 'learndash' ),
			self::$question_type_key_assessment            => __( 'Assessment', 'learndash' ),
			self::$question_type_key_essay                 => __( 'Essay / Open Answer', 'learndash' ),
		];
	}

	/**
	 * Execute ChatGPT command.
	 *
	 * @since 4.8.0
	 *
	 * @return void
	 */
	public function init(): void {
		/**
		 * General process results object.
		 *
		 * @var array<DTO\Process>
		 */
		$processes = [];

		/**
		 * Notice messages for user.
		 *
		 * @var array<array{is_success: bool, message: string}>
		 */
		$messages = [];

		try {
			$args = $this->prepare_fields();

			/**
			 * Question types.
			 *
			 * @var array<string>
			 */
			$question_types = $args['question_types'] ?? [];

			foreach ( $question_types as $question_type ) {
				$request = DTO\Request::create(
					[
						'question_type'            => $question_type,
						'total_questions_per_type' => $args['total_questions_per_type'],
						'quiz_title'               => $args['quiz_title'],
						'quiz_idea'                => $args['quiz_idea'],
					]
				);

				$this->quiz = DTO\Quiz::create(
					[
						'id'               => ! empty( $this->quiz->id )
							? $this->quiz->id
							: $args['quiz_id'],
						'title'            => $args['quiz_title'],
						'course_id'        => $args['course_id'],
						'lesson_id'        => $args['lesson_id'],
						'topic_id'         => $args['topic_id'],
						'parent_id'        => $args['parent_id'],
						'parent_post_type' => $args['parent_post_type'],
					]
				);

				$command         = $this->prepare_command( $request );
				$response        = $this->chatgpt->send_command(
					$command,
					[
						'quiz' => $this->quiz,
					]
				);
				$parsed_response = $this->parser->parse( $response, $request );
				$processes       = array_merge(
					$processes,
					$this->process( $parsed_response, $request )
				);
			}
		} catch ( Exception $e ) {
			$processes = [
				DTO\Process::create(
					[
						'is_success' => false,
						'message'    => wp_sprintf(
							'%s',
							$e->getMessage()
						),
					]
				),
			];
		}

		// Redirect after processing.

		$successes = array_filter(
			$processes,
			function( $process ) {
				return $process->is_success;
			}
		);

		if ( ! empty( $successes ) ) {
			$messages[] = [
				'is_success' => true,
				'message'    => wp_sprintf(
					// translators: 1$: quiz label, 2$: quiz title.
					__( 'The request has been processed successfully for the %1$s: %2$s.', 'learndash' ),
					learndash_get_custom_label_lower( 'quiz' ),
					'<a href="' . get_edit_post_link( $this->quiz->id ) . '">' . get_the_title( $this->quiz->id ) . '</a>'
				),
			];
		};

		/**
		 * Errors process results object.
		 *
		 * @var array<DTO\Process>
		 */
		$errors = array_filter(
			$processes,
			function( $process ) {
				return ! $process->is_success;
			}
		);

		$messages = array_merge(
			$messages,
			array_map(
				function( $error ) {
					return $error->to_array();
				},
				$errors
			)
		);

		set_transient( self::$transient_key_messages, $messages, MINUTE_IN_SECONDS );

		$redirect_url = add_query_arg(
			[
				'page'      => self::$slug,
				'processed' => true,
			],
			admin_url( 'admin.php' )
		);

		learndash_safe_redirect( $redirect_url );
	}

	/**
	 * Sanitize and prepare fields.
	 *
	 * @since 4.8.0
	 *
	 * @throws Exception                General error.
	 * @throws InvalidArgumentException Incorrect field value.
	 *
	 * @return array<string, mixed>
	 */
	protected function prepare_fields(): array {
		if (
			! current_user_can( LEARNDASH_ADMIN_CAPABILITY_CHECK )
			|| ! check_admin_referer( self::$slug )
		) {
			throw new Exception( 'Unauthorized access.' );
		}

		$args = [];

		$args['total_questions_per_type'] = absint( wp_unslash( $_POST['total_questions_per_type'] ?? 0 ) );

		$args['question_types'] = isset( $_POST['question_types'] )
			// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- The helper method sanitizes the array.
			? Sanitize::array( wp_unslash( $_POST['question_types'] ) )
			: [];

		$args['total_questions'] = count( $args['question_types'] ) * $args['total_questions_per_type'];

		if ( $args['total_questions'] > self::$questions_limit ) {
			throw new InvalidArgumentException(
				wp_sprintf(
					// translators: 1$: Maximum amount of questions, 2$: Questions label.
					__( '%1$s is a maximum number of %2$s across all question types.', 'learndash' ),
					self::$questions_limit,
					learndash_get_custom_label_lower( 'questions' )
				)
			);
		}

		/**
		 * Decode `quiz` field. We pass json string from the field to be able to contain many information for a new or an existing quiz.
		 *
		 * @var ?array{id: ?int, new: ?string, title: string}
		 */
		$quiz = isset( $_POST['quiz'] )
			? json_decode(
				sanitize_text_field( wp_unslash( $_POST['quiz'] ) ),
				true
			)
			: null;

		if ( ! $quiz ) {
			throw new InvalidArgumentException(
				wp_sprintf(
					// translators: Quiz label.
					__( '%s is required field.', 'learndash' ),
					learndash_get_custom_label( 'quiz' )
				)
			);
		}

		$args['quiz_id']    = absint( $quiz['id'] ?? 0 );
		$args['quiz_title'] = empty( $args['quiz_id'] )
			? sanitize_text_field( $quiz['title'] )
			: get_the_title( $args['quiz_id'] );

		$args['course_id'] = isset( $_POST['course_id'] ) ? absint( wp_unslash( $_POST['course_id'] ) ) : null;

		$args['lesson_id'] = isset( $_POST['lesson_id'] ) ? absint( wp_unslash( $_POST['lesson_id'] ) ) : null;

		$args['topic_id'] = isset( $_POST['topic_id'] ) ? absint( wp_unslash( $_POST['topic_id'] ) ) : null;

		if ( ! empty( $args['topic_id'] ) ) {
			$args['parent_id'] = $args['topic_id'];
		} elseif ( ! empty( $args['lesson_id'] ) ) {
			$args['parent_id'] = $args['lesson_id'];
		} elseif ( ! empty( $args['course_id'] ) ) {
			$args['parent_id'] = $args['course_id'];
		} else {
			$args['parent_id'] = null;
		}

		$parent_post_type         = get_post_type( $args['parent_id'] );
		$args['parent_post_type'] = $parent_post_type !== false ? $parent_post_type : null;

		$args['quiz_idea'] = isset( $_POST['quiz_idea'] )
			? sanitize_textarea_field( wp_unslash( $_POST['quiz_idea'] ) )
			: '';

		return $args;
	}

	/**
	 * Prepare command to be sent to AI provider.
	 *
	 * @since 4.8.0
	 *
	 * @param DTO\Request $request Request arguments.
	 *
	 * @return string
	 */
	protected function prepare_command( DTO\Request $request ): string {
		$command = '';

		// This doesn't need translation gettext function nor custom label since it'll be used as AI prompt.
		$question_label = $request->total_questions_per_type > 1
			? 'questions'
			: 'question';

		$command_label = $request->total_questions_per_type > 1
			? 'commands'
			: 'command';

		switch ( $request->question_type ) {
			case self::$question_type_key_single_choice:
				$command = "Create {$request->total_questions_per_type} single choice {$question_label} with answer options, and correct answer options for a '{$request->quiz_title}' quiz on the topic of '{$request->quiz_idea}'. Please display the results in JSON format under 'questions' key. Put each question and its answer options and correct answer options under one object. Put the {$question_label} under 'question' key, the answer options under 'answer_options' key, and correct answer options under 'correct_answers' key.";
				break;

			case self::$question_type_key_multiple_choice:
				$command = "Create {$request->total_questions_per_type} multiple choice {$question_label} with answer options, and multiple correct answer options for a '{$request->quiz_title}' quiz on the topic of '{$request->quiz_idea}'. Please display the results in JSON format under 'questions' key. Put each question and its answer options and correct answer options under one object. Put the {$question_label} under 'question' key, the answer options under 'answer_options' key, and correct answer options under 'correct_answers' key.";
				break;

			case self::$question_type_key_free_choice:
				$command = "Create {$request->total_questions_per_type} free choice {$question_label} with multiple correct answers for a '{$request->quiz_title}' quiz on the topic of '{$request->quiz_idea}'. Please display the results in JSON format under 'questions' key. Put the {$question_label} under 'question' key and correct answer options under 'correct_answers' key.";
				break;

			case self::$question_type_key_sorting_choice:
				$command = "Create {$request->total_questions_per_type} {$question_label} or {$command_label} with answer options that requires sorting the answer options for a '{$request->quiz_title}' quiz on the topic of '{$request->quiz_idea}'. Please display the results in JSON format under 'questions' key. Put each question or command and its answer options under one object. Put the {$question_label} or {$command_label} under 'question' key, the answer options under 'answer_options' key and the correct order under 'correct_answers' key.";
				break;

			case self::$question_type_key_matrix_sorting_choice:
				$command = "Create {$request->total_questions_per_type} {$question_label} that have a list of multiple criteria and its corresponding values on the topic of '{$request->quiz_idea}'.

				Please display the results in JSON format under 'questions' key. Put the {$question_label} under 'question' key, the criteria key and its single corresponding value under 'criteria' key with 'key' and 'value' sub keys.";
				break;

			case self::$question_type_key_fill_in_the_blank:
				$command = "Create {$request->total_questions_per_type} 'fill in the blank' {$question_label} and a correct answer for a '{$request->quiz_title}' quiz on the topic of '{$request->quiz_idea}'. Please display the results in JSON format with 'questions' key. Put each question and its correct answer under one object. Put the {$question_label} under 'question' key and the correct answer under 'correct_answer' key.";
				break;

			case self::$question_type_key_assessment:
				$command = "Create {$request->total_questions_per_type} {$question_label} with likert scale answer options for a '{$request->quiz_title}' quiz on the topic of '{$request->quiz_idea}'. Please display the results in JSON format under `questions` key. Put each question and its answer options under one object. Put the {$question_label} under 'question' key and the answer options under 'answer_options' key.";
				break;

			case self::$question_type_key_essay:
				$command = "Create {$request->total_questions_per_type} essay {$question_label} for a '{$request->quiz_title}' quiz on the topic of '{$request->quiz_idea}'. Please display the results in JSON format under `questions` key. Put the question under 'question' key.";
				break;
		}

		return $command;
	}

	/**
	 * Process parsed response.
	 *
	 * @since 4.8.0
	 *
	 * @throws Exception No question returned by AI.
	 *
	 * @param DTO\Parsed_Response $parsed_response Response text after being parsed.
	 * @param DTO\Request         $request         User request parameters.
	 *
	 * @return array<DTO\Process>
	 */
	protected function process( DTO\Parsed_Response $parsed_response, DTO\Request $request ): array {
		$processes = [];

		// Error handlers.

		if ( ! $parsed_response->is_success ) {
			$processes[] = DTO\Process::create(
				[
					'is_success' => false,
					'message'    => $parsed_response->message,
				]
			);

			return $processes;
		} elseif ( count( $parsed_response->questions ) < 1 ) {
			$processes[] = DTO\Process::create(
				[
					'is_success' => false,
					'message'    => wp_sprintf(
						// translators: question type.
						__( 'No questions returned for the question type: %s. Please try again with a different idea.', 'learndash' ),
						$request->question_type
					),
				]
			);

			return $processes;
		}

		// Quiz and questions creation.

		if ( empty( $this->quiz->id ) ) {
			try {
				$this->quiz->id = $this->repository->create_quiz( $this->quiz );
			} catch ( Exception $e ) {
				$processes[] = DTO\Process::create(
					[
						'is_success' => false,
						'message'    => wp_sprintf(
							// translators: 1$: quiz label, 2$: error message.
							__( 'Failed to create %1$s: %2$s', 'learndash' ),
							learndash_get_custom_label_lower( 'quiz' ),
							$e->getMessage()
						),
					]
				);
			}
		}

		foreach ( $parsed_response->questions as $question ) {
			try {
				$this->repository->create_question( $this->quiz->id, $question );
			} catch ( Exception $e ) {
				$processes[] = DTO\Process::create(
					[
						'is_success' => false,
						'message'    => wp_sprintf(
							// translators: 1$: question label, 2$: error message.
							__( 'Failed to create %1$s: %2$s', 'learndash' ),
							learndash_get_custom_label_lower( 'question' ),
							$e->getMessage()
						),
					]
				);
			}
		}

		// Success.

		$processes[] = DTO\Process::create(
			[
				'is_success' => true,
			]
		);

		return $processes;
	}
}