Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 57 additions & 2 deletions question.php
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,9 @@ class qtype_aitext_question extends question_graded_automatically_with_countback
/** @var array */
public $sampleanswers;

/** @var int|null Cached context id of the current attempt usage. */
protected $attemptcontextid = null;

/**
* Required by the interface question_automatically_gradable_with_countback.
*
Expand All @@ -153,6 +156,7 @@ public function compute_final_grade($responses, $totaltries) {
*/
public function apply_attempt_state(question_attempt_step $step) {
$this->step = $step;
$this->attemptcontextid = null;
}
/**
* Call the llm using either the 4.5 core api or the backend provided by
Expand All @@ -165,10 +169,11 @@ public function perform_request(string $prompt, string $purpose = 'feedback'): s
if (defined('BEHAT_SITE_RUNNING') || (defined('PHPUNIT_TEST') && PHPUNIT_TEST)) {
return "AI Feedback";
}
$contextid = $this->get_contextid_for_ai_request();
$backend = get_config('qtype_aitext', 'backend');
if ($backend == 'local_ai_manager') {
$manager = new local_ai_manager\manager($purpose);
$llmresponse = (object) $manager->perform_request($prompt, 'qtype_aitext', $this->contextid);
$llmresponse = (object) $manager->perform_request($prompt, 'qtype_aitext', $contextid);
if ($llmresponse->get_code() !== 200) {
throw new moodle_exception(
'err_retrievingfeedback',
Expand All @@ -182,7 +187,7 @@ public function perform_request(string $prompt, string $purpose = 'feedback'): s
} else if ($backend == 'core_ai_subsystem') {
global $USER;
$action = new \core_ai\aiactions\generate_text(
contextid: $this->contextid,
contextid: $contextid,
userid: $USER->id,
prompttext: $prompt
);
Expand Down Expand Up @@ -830,4 +835,54 @@ public function get_word_count_message_for_review(array $response): string {
return get_string('wordcount', 'qtype_aitext', $count);
}
}

/**
* Resolve the context id which should be used for AI requests.
*
* This context is important because it will be used to perform permission checks. So we need the context in which
* the user *actually* requests AI functionalities.
*
* Prefers the current question usage context (attempt/quiz context). If not available or the determined one is a user
* context, the question bank context of the question will be used.
*
* @return int the id of the context to use for AI requests
*/
public function get_contextid_for_ai_request(): int {
global $DB;

if (!is_null($this->attemptcontextid)) {
return $this->attemptcontextid;
}

$stepid = 0;
if (!empty($this->step) && method_exists($this->step, 'get_id')) {
$stepid = (int) $this->step->get_id();
}

if ($stepid > 0) {
// We are ignoring user contexts here, because the permissions for AI requests cannot be evaluated for user contexts.
// User contexts typically mean that a question preview is being done. In this case we use the question's context, so
// basically the context of the question bank it belongs to.
$sql = "SELECT qu.contextid
FROM {question_attempt_steps} qas
JOIN {question_attempts} qa ON qa.id = qas.questionattemptid
JOIN {question_usages} qu ON qu.id = qa.questionusageid
JOIN {context} c ON c.id = qu.contextid
WHERE qas.id = :stepid AND c.contextlevel <> :contextlevel";
$attemptcontextid = $DB->get_field_sql($sql, ['stepid' => $stepid, 'contextlevel' => CONTEXT_USER]);
if (!empty($attemptcontextid)) {
$this->attemptcontextid = (int) $attemptcontextid;
return $this->attemptcontextid;
}
}

if (!empty($this->contextid)) {
$this->attemptcontextid = $this->contextid;
return $this->attemptcontextid;
}

// We usually should not get here, but if we do, we are falling back to the system context.
$this->attemptcontextid = context_system::instance()->id;
return $this->attemptcontextid;
}
}
148 changes: 148 additions & 0 deletions tests/question_test.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
require_once($CFG->dirroot . '/question/engine/tests/helpers.php');
require_once($CFG->dirroot . '/question/type/aitext/tests/helper.php');
require_once($CFG->dirroot . '/question/type/aitext/questiontype.php');
require_once($CFG->dirroot . '/mod/quiz/locallib.php');

use qtype_aitext_test_helper;

Expand Down Expand Up @@ -603,4 +604,151 @@ public function test_is_same_response_with_template(): void {
['answer' => '0']
));
}

/**
* Verify that a real quiz attempt resolves the request context to the quiz module context.
*
* @covers ::get_contextid_for_ai_request
*/
public function test_get_contextid_for_ai_request_uses_quiz_attempt_context(): void {
$this->resetAfterTest();

// To verify that we really return the current quiz activity's context we avoid creating the question in the same context of
// course, but use a qbank in a different course.
$qbankcourse = $this->getDataGenerator()->create_course();
$qbank = $this->getDataGenerator()->create_module('qbank', ['course' => $qbankcourse->id]);
$cm = get_coursemodule_from_instance('qbank', $qbank->id, $qbankcourse->id, false, MUST_EXIST);
$qbankcontext = \context_module::instance($cm->id);

// Create question in the qbank context.
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
$questioncategory = $questiongenerator->create_question_category([
'contextid' => $qbankcontext->id,
]);
$questionrecord = $questiongenerator->create_question('aitext', 'editor', [
'category' => $questioncategory->id,
]);

// Create quiz in different course.
$quizcourse = $this->getDataGenerator()->create_course();
$student = $this->getDataGenerator()->create_user();
$quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz');
$quiz = $quizgenerator->create_instance([
'course' => $quizcourse->id,
'questionsperpage' => 0,
'grade' => 1.0,
'sumgrades' => 1.0,
]);

quiz_add_quiz_question($questionrecord->id, $quiz);

$quizobj = \mod_quiz\quiz_settings::create($quiz->id, $student->id);
$quba = \question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context());
$quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour);

$timenow = time();
$attempt = quiz_create_attempt($quizobj, 1, false, $timenow, false, $student->id);
quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timenow);
quiz_attempt_save_started($quizobj, $quba, $attempt);

$loadedquba = \question_engine::load_questions_usage_by_activity($attempt->uniqueid);
$firststep = $loadedquba->get_question_attempt(1)->get_step(0);
$this->assertNotNull($firststep->get_id());

$aitext = \question_bank::load_question($questionrecord->id);
$this->assertEquals($qbankcontext->id, (int) $aitext->contextid);
$this->assertNotEquals($quizobj->get_context()->id, (int) $aitext->contextid);

$aitext->apply_attempt_state($firststep);
$resolvedcontextid = $aitext->get_contextid_for_ai_request();

$this->assertEquals($quizobj->get_context()->id, $resolvedcontextid);
}

/**
* Verify that preview usage resolves to the qbank context where preview is executed.
*
* @covers ::get_contextid_for_ai_request
*/
public function test_get_contextid_for_ai_request_uses_qbank_context_in_preview(): void {
$this->resetAfterTest();

$course = $this->getDataGenerator()->create_course();
$qbank = $this->getDataGenerator()->create_module('qbank', ['course' => $course->id]);
$cm = get_coursemodule_from_instance('qbank', $qbank->id, $course->id, false, MUST_EXIST);
$qbankcontext = \context_module::instance($cm->id);

$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
$questioncategory = $questiongenerator->create_question_category([
'contextid' => \context_course::instance($course->id)->id,
]);
$questionrecord = $questiongenerator->create_question('aitext', 'editor', [
'category' => $questioncategory->id,
]);
$aitext = \question_bank::load_question($questionrecord->id);

$previewquba = \question_engine::make_questions_usage_by_activity('core_question_preview', $qbankcontext);
$previewquba->set_preferred_behaviour('deferredfeedback');
$slot = $previewquba->add_question($aitext, 1);
$previewquba->start_all_questions();
\question_engine::save_questions_usage_by_activity($previewquba);

$previewquba = \question_engine::load_questions_usage_by_activity($previewquba->get_id());
$firststep = $previewquba->get_question_attempt($slot)->get_step(0);
$this->assertNotNull($firststep->get_id());

$this->assertNotEquals($qbankcontext->id, $aitext->contextid);

$aitext->apply_attempt_state($firststep);
$resolvedcontextid = $aitext->get_contextid_for_ai_request();

$this->assertEquals($qbankcontext->id, $resolvedcontextid);
}

/**
* Verify that question preview with user context falls back to question bank context.
*
* @covers ::get_contextid_for_ai_request
*/
public function test_get_contextid_for_ai_request_ignores_user_context_in_preview(): void {
global $USER;
$this->resetAfterTest();
$this->setAdminUser();

$course = $this->getDataGenerator()->create_course();
$qbank = $this->getDataGenerator()->create_module('qbank', ['course' => $course->id]);
$cm = get_coursemodule_from_instance('qbank', $qbank->id, $course->id, false, MUST_EXIST);
$qbankcontext = \context_module::instance($cm->id);

$usercontext = \context_user::instance($USER->id);

$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
$questioncategory = $questiongenerator->create_question_category([
'contextid' => $qbankcontext->id,
]);
$questionrecord = $questiongenerator->create_question('aitext', 'editor', [
'category' => $questioncategory->id,
]);
$aitext = \question_bank::load_question($questionrecord->id);

$previewquba = \question_engine::make_questions_usage_by_activity('core_question_preview', $usercontext);
$previewquba->set_preferred_behaviour('deferredfeedback');
$slot = $previewquba->add_question($aitext, 1);
$previewquba->start_all_questions();
\question_engine::save_questions_usage_by_activity($previewquba);

$previewquba = \question_engine::load_questions_usage_by_activity($previewquba->get_id());
$firststep = $previewquba->get_question_attempt($slot)->get_step(0);
$this->assertNotNull($firststep->get_id());

// Verify that the preview quba is actually using the user context.
$this->assertEquals($usercontext->id, $previewquba->get_owning_context()->id);

$aitext->apply_attempt_state($firststep);
$resolvedcontextid = $aitext->get_contextid_for_ai_request();

// Assert that the resolved context is not returning the user context, but the qbank context instead.
$this->assertNotEquals($usercontext->id, $resolvedcontextid);
$this->assertEquals($qbankcontext->id, $resolvedcontextid);
}
}
Loading