Skip to content

Commit 20bcfc0

Browse files
committed
MBS-10581: Build new prompt template system
1 parent b1e24ac commit 20bcfc0

16 files changed

+823
-280
lines changed

amd/build/expertmode.min.js

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

amd/build/expertmode.min.js.map

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

amd/src/expertmode.js

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// This file is part of Moodle - http://moodle.org/
2+
//
3+
// Moodle is free software: you can redistribute it and/or modify
4+
// it under the terms of the GNU General Public License as published by
5+
// the Free Software Foundation, either version 3 of the License, or
6+
// (at your option) any later version.
7+
//
8+
// Moodle is distributed in the hope that it will be useful,
9+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
10+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11+
// GNU General Public License for more details.
12+
//
13+
// You should have received a copy of the GNU General Public License
14+
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
15+
16+
/**
17+
* Expert mode template insertion for AI Text question type.
18+
*
19+
* @module qtype_aitext/expertmode
20+
* @copyright 2026 ISB Bayern
21+
* @author Dr. Peter Mayer
22+
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23+
*/
24+
25+
import {get_string} from 'core/str';
26+
import Notification from 'core/notification';
27+
28+
/**
29+
* Initialize the expert mode template button.
30+
*
31+
* @param {string} template The expert mode template to insert.
32+
*/
33+
export const init = (template) => {
34+
const button = document.getElementById('id_expertmodetemplatebtn');
35+
const aipromptTextarea = document.getElementById('id_aiprompt');
36+
37+
if (!button || !aipromptTextarea) {
38+
return;
39+
}
40+
41+
button.addEventListener('click', async(e) => {
42+
e.preventDefault();
43+
44+
// Check if the textarea already has content.
45+
const currentValue = aipromptTextarea.value.trim();
46+
47+
if (currentValue) {
48+
// Ask for confirmation before replacing existing content.
49+
const confirmMessage = await get_string('expertmodeconfirm', 'qtype_aitext');
50+
51+
Notification.confirm(
52+
await get_string('useexpertmodetemplate', 'qtype_aitext'),
53+
confirmMessage,
54+
await get_string('yes', 'core'),
55+
await get_string('no', 'core'),
56+
() => {
57+
insertTemplate(aipromptTextarea, template);
58+
}
59+
);
60+
} else {
61+
insertTemplate(aipromptTextarea, template);
62+
}
63+
});
64+
};
65+
66+
/**
67+
* Insert the template into the textarea.
68+
*
69+
* @param {HTMLTextAreaElement} textarea The textarea element.
70+
* @param {string} template The template to insert.
71+
*/
72+
const insertTemplate = (textarea, template) => {
73+
textarea.value = template;
74+
// Trigger input event so any listeners are notified.
75+
textarea.dispatchEvent(new Event('input', {bubbles: true}));
76+
textarea.dispatchEvent(new Event('change', {bubbles: true}));
77+
// Focus the textarea.
78+
textarea.focus();
79+
};

db/upgrade.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ function xmldb_qtype_aitext_upgrade($oldversion) {
3232

3333
$dbman = $DB->get_manager();
3434

35+
// Include upgrade helper functions for data migrations.
36+
require_once(__DIR__ . '/upgradelib.php');
37+
3538
if ($oldversion < 2024050300) {
3639
$table = new xmldb_table('qtype_aitext');
3740
// Used for prompt testing in the edit form.
@@ -100,5 +103,17 @@ function xmldb_qtype_aitext_upgrade($oldversion) {
100103
upgrade_plugin_savepoint(true, 2025072200, 'qtype', 'aitext');
101104
}
102105

106+
if ($oldversion < 2026020601) {
107+
// Migrate legacy expert mode prompts from [[placeholder]] to {{placeholder}} syntax.
108+
$migratedcount = qtype_aitext_upgrade_migrate_legacy_prompts();
109+
110+
if ($migratedcount > 0) {
111+
mtrace("Migrated {$migratedcount} legacy expert mode prompts to new {{placeholder}} syntax.");
112+
}
113+
114+
// Aitext savepoint reached.
115+
upgrade_plugin_savepoint(true, 2026020601, 'qtype', 'aitext');
116+
}
117+
103118
return true;
104119
}

db/upgradelib.php

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
<?php
2+
// This file is part of Moodle - http://moodle.org/
3+
//
4+
// Moodle is free software: you can redistribute it and/or modify
5+
// it under the terms of the GNU General Public License as published by
6+
// the Free Software Foundation, either version 3 of the License, or
7+
// (at your option) any later version.
8+
//
9+
// Moodle is distributed in the hope that it will be useful,
10+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
// GNU General Public License for more details.
13+
//
14+
// You should have received a copy of the GNU General Public License
15+
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
16+
17+
/**
18+
* Upgrade helper functions for qtype_aitext.
19+
*
20+
* @package qtype_aitext
21+
* @copyright 2026 ISB Bayern
22+
* @author Dr. Peter Mayer
23+
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
24+
*/
25+
26+
/**
27+
* Execute the full database migration for legacy expert mode prompts.
28+
*
29+
* This function finds all qtype_aitext records that contain the legacy
30+
* [[expert]] or [[response]] syntax and migrates them to the new
31+
* {{placeholder}} syntax.
32+
*
33+
* Conversions performed:
34+
* - [[expert]] is removed (was only a mode indicator)
35+
* - [[response]] becomes {{response}}
36+
* - [[questiontext]] becomes {{questiontext}}
37+
* - [[userlang]] becomes {{language}}
38+
* - Multiple whitespace is collapsed to single space
39+
*
40+
* @return int The number of prompts that were migrated.
41+
*/
42+
function qtype_aitext_upgrade_migrate_legacy_prompts(): int {
43+
global $DB;
44+
45+
$legacyprompts = $DB->get_records_select(
46+
'qtype_aitext',
47+
"aiprompt LIKE '%[[expert]]%' OR aiprompt LIKE '%[[response]]%'",
48+
null,
49+
'',
50+
'id, aiprompt'
51+
);
52+
53+
$migratedcount = 0;
54+
foreach ($legacyprompts as $record) {
55+
$newprompt = qtype_aitext_migrate_legacy_expert_prompt($record->aiprompt);
56+
57+
if ($newprompt !== $record->aiprompt) {
58+
$DB->update_record('qtype_aitext', (object)[
59+
'id' => $record->id,
60+
'aiprompt' => $newprompt,
61+
]);
62+
$migratedcount++;
63+
}
64+
}
65+
66+
return $migratedcount;
67+
}
68+
69+
/**
70+
* Migrate a legacy expert mode prompt to the new {{placeholder}} syntax.
71+
*
72+
* @param string $aiprompt The original AI prompt with legacy placeholders.
73+
* @return string The migrated prompt with new placeholder syntax.
74+
*/
75+
function qtype_aitext_migrate_legacy_expert_prompt(string $aiprompt): string {
76+
$newprompt = str_replace(
77+
['[[expert]]', '[[response]]', '[[questiontext]]', '[[userlang]]'],
78+
['', '{{response}}', '{{questiontext}}', '{{language}}'],
79+
$aiprompt
80+
);
81+
82+
return preg_replace('/\s+/', ' ', trim($newprompt));
83+
}

edit_aitext_form.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,13 @@ protected function definition_inner($mform) {
6767
$mform->addHelpButton('aiprompt', 'aiprompt', 'qtype_aitext');
6868
$mform->addRule('aiprompt', get_string('aipromptmissing', 'qtype_aitext'), 'required');
6969

70+
// Expert mode template button.
71+
$mform->addElement(
72+
'button',
73+
'expertmodetemplatebtn',
74+
get_string('useexpertmodetemplate', 'qtype_aitext')
75+
);
76+
7077
// Markscheme.
7178
$mform->addElement(
7279
'textarea',
@@ -210,6 +217,13 @@ protected function definition_inner($mform) {
210217

211218
// Load any JS that we need to make things happen, specifically the prompt tester.
212219
$PAGE->requires->js_call_amd('qtype_aitext/responserun', 'init', [$this->context->id]);
220+
221+
// Initialize expert mode template button.
222+
$experttemplate = get_config('qtype_aitext', 'prompttemplate');
223+
if (empty($experttemplate)) {
224+
$experttemplate = get_string('defaultprompttemplate', 'qtype_aitext');
225+
}
226+
$PAGE->requires->js_call_amd('qtype_aitext/expertmode', 'init', [$experttemplate]);
213227
}
214228

215229
/**

format_base_renderer.php

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
<?php
2+
// This file is part of Moodle - http://moodle.org/
3+
//
4+
// Moodle is free software: you can redistribute it and/or modify
5+
// it under the terms of the GNU General Public License as published by
6+
// the Free Software Foundation, either version 3 of the License, or
7+
// (at your option) any later version.
8+
//
9+
// Moodle is distributed in the hope that it will be useful,
10+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
// GNU General Public License for more details.
13+
//
14+
// You should have received a copy of the GNU General Public License
15+
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
16+
17+
/**
18+
* Abstract base renderer for different response formats.
19+
*
20+
* @package qtype_aitext
21+
* @subpackage aitext
22+
* @copyright 2026 ISB Bayern
23+
* @author Dr. Peter Mayer
24+
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
25+
*/
26+
27+
/**
28+
* Abstract out the differences between different type of response format.
29+
*
30+
* @copyright 2026 ISB Bayern
31+
* @author Dr. Peter Mayer
32+
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
33+
*/
34+
abstract class qtype_aitext_format_renderer_base extends plugin_renderer_base {
35+
/** @var question_display_options Question display options instance for any necessary information for rendering the question. */
36+
protected $displayoptions;
37+
38+
/**
39+
* Question number setter.
40+
*
41+
* @param question_display_options $displayoptions
42+
*/
43+
public function set_displayoptions(question_display_options $displayoptions): void {
44+
$this->displayoptions = $displayoptions;
45+
}
46+
47+
/**
48+
* Render the students response when the question is in read-only mode.
49+
*
50+
* @param string $name the variable name this input edits.
51+
* @param question_attempt $qa the question attempt being display.
52+
* @param question_attempt_step $step the current step.
53+
* @param int $lines approximate size of input box to display.
54+
* @param object $context the context teh output belongs to.
55+
* @return string html to display the response.
56+
*/
57+
public function response_area_read_only($name, $qa, $step, $lines, $context) {
58+
global $USER;
59+
60+
$question = $qa->get_question();
61+
$uniqid = uniqid();
62+
$readonlyareaid = 'aitext_readonly_area' . $uniqid;
63+
$spellcheckeditbuttonid = 'aitext_spellcheckedit' . $uniqid;
64+
65+
if ($question->spellcheck) {
66+
$this->page->requires->js_call_amd('qtype_aitext/diff');
67+
$this->page->requires->js_call_amd(
68+
'qtype_aitext/spellcheck',
69+
'init',
70+
['#' . $readonlyareaid, '#' . $spellcheckeditbuttonid]
71+
);
72+
$stepspellcheck = $qa->get_last_step_with_qt_var('-spellcheckresponse');
73+
$stepanswer = $qa->get_last_step_with_qt_var('answer');
74+
}
75+
// Lib to display the spellcheck diff.
76+
$labelbyid = $qa->get_qt_field_name($name) . '_label';
77+
$responselabel = $this->displayoptions->add_question_identifier_to_label(get_string('answertext', 'qtype_aitext'));
78+
$output = html_writer::tag('h4', $responselabel, ['id' => $labelbyid, 'class' => 'sr-only']);
79+
80+
$divoptions = [
81+
'id' => $readonlyareaid,
82+
'role' => 'textbox',
83+
'aria-readonly' => 'true',
84+
'aria-labelledby' => $labelbyid,
85+
'class' => $this->class_name() . ' qtype_aitext_response readonly',
86+
'style' => 'min-height: ' . ($lines * 1.25) . 'em;',
87+
];
88+
89+
if ($qa->get_question()->spellcheck) {
90+
$divoptions['data-spellcheck'] = $this->prepare_response('-spellcheckresponse', $qa, $stepspellcheck, $context);
91+
$divoptions['data-spellcheckattemptstepid'] = $stepspellcheck->get_id();
92+
$divoptions['data-spellcheckattemptstepanswerid'] = $stepanswer->get_id();
93+
$divoptions['data-answer'] = $this->prepare_response($name, $qa, $step, $context);
94+
}
95+
96+
$output .= html_writer::tag('div', $this->prepare_response($name, $qa, $step, $context), $divoptions);
97+
98+
if (
99+
$qa->get_question()->spellcheck &&
100+
(
101+
has_capability('mod/quiz:grade', $context) ||
102+
has_capability('mod/quiz:regrade', $context) ||
103+
($context->contextlevel === CONTEXT_USER && intval($USER->id) === intval($context->instanceid))
104+
)
105+
) {
106+
$btnoptions = ['id' => $spellcheckeditbuttonid, 'class' => 'btn btn-link'];
107+
$output .= html_writer::tag(
108+
'button',
109+
$this->output->pix_icon(
110+
'i/edit',
111+
get_string('spellcheckedit', 'qtype_aitext'),
112+
'moodle'
113+
) . " " . get_string('spellcheckedit', 'qtype_aitext'),
114+
$btnoptions
115+
);
116+
}
117+
// Height $lines * 1.25 because that is a typical line-height on web pages.
118+
// That seems to give results that look OK.
119+
120+
return $output;
121+
}
122+
123+
/**
124+
* Render the students respone when the question is in read-only mode.
125+
* @param string $name the variable name this input edits.
126+
* @param question_attempt $qa the question attempt being display.
127+
* @param question_attempt_step $step the current step.
128+
* @param int $lines approximate size of input box to display.
129+
* @param object $context the context teh output belongs to.
130+
* @return string html to display the response for editing.
131+
*/
132+
abstract public function response_area_input(
133+
$name,
134+
question_attempt $qa,
135+
question_attempt_step $step,
136+
$lines,
137+
$context
138+
);
139+
140+
/**
141+
* Specific class name to add to the input element.
142+
*
143+
* @return string
144+
*/
145+
abstract protected function class_name();
146+
}

0 commit comments

Comments
 (0)