From 988700a09ac3bdadad5c243f6713107f88b11f13 Mon Sep 17 00:00:00 2001 From: Jeff Ericson Date: Tue, 3 Dec 2013 13:26:29 -0800 Subject: [PATCH] Added answer-pool feature to capa_problem Updated CHANGELOG with notes on the feature. --- CHANGELOG.rst | 12 + common/lib/capa/capa/capa_problem.py | 122 +++- .../lib/capa/capa/tests/test_answer_pool.py | 553 ++++++++++++++++++ 3 files changed, 685 insertions(+), 2 deletions(-) create mode 100644 common/lib/capa/capa/tests/test_answer_pool.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 06a63723abf0..e796a9f64433 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -115,6 +115,18 @@ Studio: Change course overview page, checklists, assets, import, export, and cou management page URLs to a RESTful interface. Also removed "\listing", which duplicated "\index". +Studio: Support answer pools for multiple choice question choices, so authors can provide +multiple incorrect and correct choices for a question and have 1 correct choice and n-1 +incorrect choices randomly selected and shuffled before being presented to the student. +In XML: enables an answer pool of 4 choices: 3 +correct choices and 1 incorrect choice. To provide multiple solution expanations, wrap +all solution elements within a , and make sure to add an attribute called +"explanation-id" to both the tag and its corresponding tag, and be +sure that the value for this "explanation-id" attribute is the same for both. Note that +this feature is only supported in the advanced XML problem editor, not the regular one. +Also note that if you want your question to have a different set of answers for different +attempts, be sure in the problem settings in Studio to set "Randomization" to "Always" + LMS: Fixed accessibility bug where users could not tab through wiki (LMS-1307) Blades: When start time and end time are specified for a video, a visual range diff --git a/common/lib/capa/capa/capa_problem.py b/common/lib/capa/capa/capa_problem.py index 08a223f609b8..47f626718e4e 100644 --- a/common/lib/capa/capa/capa_problem.py +++ b/common/lib/capa/capa/capa_problem.py @@ -34,6 +34,8 @@ from pytz import UTC +from random import Random + # dict of tagname, Response Class -- this should come from auto-registering response_tag_dict = dict([(x.response_tag, x) for x in responsetypes.__all__]) @@ -381,11 +383,128 @@ def get_answer_ids(self): answer_ids.append(results.keys()) return answer_ids + def sample_from_answer_pool(self, choices, rnd, num_choices): + """ + Takes in: + 1. list of choices + 2. random number generator + 3. max number of total choices to return + + Returns a list with 2 items: + 1. the solution_id corresponding with the chosen correct answer + 2. (subset) list of choice nodes with 3 incorrect and 1 correct + """ + + correct_choices = [] + incorrect_choices = [] + subset_choices = [] + + for choice in choices: + if choice.get('correct') == 'true': + correct_choices.append(choice) + elif choice.get('correct') == 'false': + incorrect_choices.append(choice) + + # Always 1 correct and num_choices at least as large as this; if not, return list with no choices + num_correct = 1 + if len(correct_choices) < num_correct or num_choices < num_correct: + return [] + + # Ensure number of incorrect choices is no more than the number of incorrect choices to choose from + num_incorrect = num_choices - num_correct + num_incorrect = min(num_incorrect, len(incorrect_choices)) + + # Use rnd given to us to generate a random number (see details in tree_using_answer_pool method) + index = rnd.randint(0, len(correct_choices) - 1) + correct_choice = correct_choices[index] + subset_choices.append(correct_choice) + solution_id = correct_choice.get('explanation-id') + + # Add incorrect choices + to_add = num_incorrect + while to_add > 0: + index = rnd.randint(0, len(incorrect_choices) - 1) + choice = incorrect_choices[index] + subset_choices.append(choice) + incorrect_choices.remove(choice) + to_add = to_add - 1 + + # Randomize correct answer position + index = rnd.randint(0, num_incorrect) + if index != 0: + tmp = subset_choices[index] + subset_choices[index] = subset_choices[0] # where we put the correct answer + subset_choices[0] = tmp + + return [solution_id, subset_choices] + + def tree_using_answer_pool(self, tree): + """ + Allows for problem questions with a pool of answers, from which answer options shown to the student + and randomly selected so that there is always 1 correct answer and n-1 incorrect answers, + where the user specifies n as the value of the attribute "answer-pool" within + + The tag must have an attribute 'answer-pool' with integer value of n + - if so, this method will modify the tree + - if not, this method will not modify the tree + + These problems are colloquially known as "Gradiance" problems. + """ + + query = '//multiplechoiceresponse[@answer-pool]' + + # There are no questions with an answer pool + if not tree.xpath(query): + return + + # Uses self.seed -- but want to randomize every time reaches this problem, + # so problem's "randomization" should be set to "always" + rnd = Random(self.seed) + + for mult_choice_response in tree.xpath(query): + # Determine number of choices to display; if invalid number of choices, skip over + num_choices = mult_choice_response.get('answer-pool') + if not num_choices.isdigit(): + continue + num_choices = int(num_choices) + if num_choices < 1: + continue + + # Grab the first choicegroup (there should only be one within each tag) + choicegroup = mult_choice_response.xpath('./choicegroup[@type="MultipleChoice"]')[0] + choices_list = list(choicegroup.iter('choice')) + + # Remove all choices in the choices_list (we will add some back in later) + for choice in choices_list: + choicegroup.remove(choice) + + # Sample from the answer pool to get the subset choices and solution id + [solution_id, subset_choices] = self.sample_from_answer_pool(choices_list, rnd, num_choices) + + # Add back in randomly selected choices + for choice in subset_choices: + choicegroup.append(choice) + + # Filter out solutions that don't correspond to the correct answer we selected to show + # Note that this means that if the user simply provides a tag, nothing is filtered + solutionset = mult_choice_response.xpath('./following-sibling::solutionset') + if len(solutionset) != 0: + solutionset = solutionset[0] + solutions = solutionset.xpath('./solution') + for solution in solutions: + if solution.get('explanation-id') != solution_id: + solutionset.remove(solution) + + return + def get_html(self): ''' Main method called externally to get the HTML to be rendered for this capa Problem. ''' + + self.tree_using_answer_pool(self.tree) html = contextualize_text(etree.tostring(self._extract_html(self.tree)), self.context) + return html def handle_input_ajax(self, data): @@ -562,8 +681,7 @@ def _extract_html(self, problemtree): # private # other than to examine .tag to see if it's a string. :( return - if (problemtree.tag == 'script' and problemtree.get('type') - and 'javascript' in problemtree.get('type')): + if (problemtree.tag == 'script' and problemtree.get('type') and 'javascript' in problemtree.get('type')): # leave javascript intact. return deepcopy(problemtree) diff --git a/common/lib/capa/capa/tests/test_answer_pool.py b/common/lib/capa/capa/tests/test_answer_pool.py new file mode 100644 index 000000000000..6bece9382658 --- /dev/null +++ b/common/lib/capa/capa/tests/test_answer_pool.py @@ -0,0 +1,553 @@ +""" +Tests the logic of the "answer-pool" attribute for MultipleChoice questions, +i.e. those with the element +""" + +import unittest +import textwrap +from . import test_system, new_loncapa_problem + + +class CapaAnswerPoolTest(unittest.TestCase): + ''' + Testing class + ''' + + def setUp(self): + super(CapaAnswerPoolTest, self).setUp() + self.system = test_system() + + def test_answer_pool_4_choices_1_multiplechoiceresponse_seed1(self): + xml_str = textwrap.dedent(""" + + +

What is the correct answer?

+ + + wrong-1 + wrong-2 + correct-1 + wrong-3 + wrong-4 + correct-2 + + + + + +
+

Explanation

+

This is the 1st solution

+

Not much to explain here, sorry!

+
+
+ + +
+

Explanation

+

This is the 2nd solution

+
+
+
+ +
+ + """) + + problem = new_loncapa_problem(xml_str) + problem.seed = 723 + the_html = problem.get_html() + self.assertRegexpMatches(the_html, r"
.*\[.*'wrong-3'.*'wrong-1'.*'wrong-2'.*'correct-2'.*\].*
") + self.assertRegexpMatches(the_html, r"
\{.*'1_solution_2'.*\}
") + + def test_answer_pool_4_choices_1_multiplechoiceresponse_seed2(self): + xml_str = textwrap.dedent(""" + + +

What is the correct answer?

+ + + wrong-1 + wrong-2 + correct-1 + wrong-3 + wrong-4 + correct-2 + + + + + +
+

Explanation

+

This is the 1st solution

+

Not much to explain here, sorry!

+
+
+ + +
+

Explanation

+

This is the 2nd solution

+
+
+
+ +
+ + """) + + problem = new_loncapa_problem(xml_str) + problem.seed = 9 + the_html = problem.get_html() + self.assertRegexpMatches(the_html, r"
.*\[.*'correct-1'.*'wrong-2'.*'wrong-1'.*'wrong-4'.*\].*
") + self.assertRegexpMatches(the_html, r"
\{.*'1_solution_1'.*\}
") + + def test_no_answer_pool_4_choices_1_multiplechoiceresponse(self): + xml_str = textwrap.dedent(""" + + +

What is the correct answer?

+ + + wrong-1 + wrong-2 + correct-1 + wrong-3 + wrong-4 + correct-2 + + + + + +
+

Explanation

+

This is the 1st solution

+

Not much to explain here, sorry!

+
+
+ + +
+

Explanation

+

This is the 2nd solution

+
+
+
+ +
+ + """) + + problem = new_loncapa_problem(xml_str) + the_html = problem.get_html() + self.assertRegexpMatches(the_html, r"
.*\[.*'wrong-1'.*'wrong-2'.*'correct-1'.*'wrong-3'.*'wrong-4'.*'correct-2'.*\].*
") + self.assertRegexpMatches(the_html, r"
\{.*'1_solution_1'.*'1_solution_2'.*\}
") + + def test_0_answer_pool_4_choices_1_multiplechoiceresponse(self): + xml_str = textwrap.dedent(""" + + +

What is the correct answer?

+ + + wrong-1 + wrong-2 + correct-1 + wrong-3 + wrong-4 + correct-2 + + + + + +
+

Explanation

+

This is the 1st solution

+

Not much to explain here, sorry!

+
+
+ + +
+

Explanation

+

This is the 2nd solution

+
+
+
+ +
+ + """) + + problem = new_loncapa_problem(xml_str) + the_html = problem.get_html() + self.assertRegexpMatches(the_html, r"
.*\[.*'wrong-1'.*'wrong-2'.*'correct-1'.*'wrong-3'.*'wrong-4'.*'correct-2'.*\].*
") + self.assertRegexpMatches(the_html, r"
\{.*'1_solution_1'.*'1_solution_2'.*\}
") + + def test_invalid_answer_pool_4_choices_1_multiplechoiceresponse(self): + xml_str = textwrap.dedent(""" + + +

What is the correct answer?

+ + + wrong-1 + wrong-2 + correct-1 + wrong-3 + wrong-4 + correct-2 + + + + + +
+

Explanation

+

This is the 1st solution

+

Not much to explain here, sorry!

+
+
+ + +
+

Explanation

+

This is the 2nd solution

+
+
+
+ +
+ + """) + + problem = new_loncapa_problem(xml_str) + the_html = problem.get_html() + self.assertRegexpMatches(the_html, r"
.*\[.*'wrong-1'.*'wrong-2'.*'correct-1'.*'wrong-3'.*'wrong-4'.*'correct-2'.*\].*
") + self.assertRegexpMatches(the_html, r"
\{.*'1_solution_1'.*'1_solution_2'.*\}
") + + def test_answer_pool_5_choices_1_multiplechoiceresponse_seed1(self): + xml_str = textwrap.dedent(""" + + +

What is the correct answer?

+ + + wrong-1 + wrong-2 + correct-1 + wrong-3 + wrong-4 + correct-2 + + + + + +
+

Explanation

+

This is the 1st solution

+

Not much to explain here, sorry!

+
+
+ + +
+

Explanation

+

This is the 2nd solution

+
+
+
+ +
+ + """) + + problem = new_loncapa_problem(xml_str) + problem.seed = 723 + the_html = problem.get_html() + self.assertRegexpMatches(the_html, r"
.*\[.*'wrong-2'.*'wrong-1'.*'correct-2'.*'wrong-3'.*'wrong-4'.*\].*
") + self.assertRegexpMatches(the_html, r"
\{.*'1_solution_2'.*\}
") + + def test_answer_pool_2_multiplechoiceresponses_seed1(self): + xml_str = textwrap.dedent(""" + + +

What is the correct answer?

+ + + wrong-1 + wrong-2 + correct-1 + wrong-3 + wrong-4 + correct-2 + + + + + +
+

Explanation

+

This is the 1st solution

+

Not much to explain here, sorry!

+
+
+ + +
+

Explanation

+

This is the 2nd solution

+
+
+
+ +

What is the correct answer?

+ + + wrong-1 + wrong-2 + correct-1 + wrong-3 + wrong-4 + correct-2 + + + + + +
+

Explanation

+

This is the 1st solution

+

Not much to explain here, sorry!

+
+
+ + +
+

Explanation

+

This is the 2nd solution

+
+
+
+ +
+ + """) + + problem = new_loncapa_problem(xml_str) + problem.seed = 723 + the_html = problem.get_html() + + str1 = r"
.*\[.*'wrong-3'.*'wrong-1'.*'wrong-2'.*'correct-2'.*\].*
" + str2 = r"
.*\[.*'wrong-4'.*'wrong-2'.*'correct-2'.*\].*
" + str3 = r"
\{.*'1_solution_2'.*\}
" + str4 = r"
\{.*'1_solution_4'.*\}
" + + self.assertRegexpMatches(the_html, str1) + self.assertRegexpMatches(the_html, str2) + self.assertRegexpMatches(the_html, str3) + self.assertRegexpMatches(the_html, str4) + + without_new_lines = the_html.replace("\n", "") + + self.assertRegexpMatches(without_new_lines, str1 + r".*" + str2) + self.assertRegexpMatches(without_new_lines, str3 + r".*" + str4) + + def test_answer_pool_2_multiplechoiceresponses_seed2(self): + xml_str = textwrap.dedent(""" + + +

What is the correct answer?

+ + + wrong-1 + wrong-2 + correct-1 + wrong-3 + wrong-4 + correct-2 + + + + + +
+

Explanation

+

This is the 1st solution

+

Not much to explain here, sorry!

+
+
+ + +
+

Explanation

+

This is the 2nd solution

+
+
+
+ +

What is the correct answer?

+ + + wrong-1 + wrong-2 + correct-1 + wrong-3 + wrong-4 + correct-2 + + + + + +
+

Explanation

+

This is the 1st solution

+

Not much to explain here, sorry!

+
+
+ + +
+

Explanation

+

This is the 2nd solution

+
+
+
+ +
+ + """) + + problem = new_loncapa_problem(xml_str) + problem.seed = 9 + the_html = problem.get_html() + + str1 = r"
.*\[.*'wrong-1'.*'wrong-2'.*'correct-1'.*\].*
" + str2 = r"
.*\[.*'wrong-4'.*'wrong-3'.*'correct-1'.*'wrong-1'.*\].*
" + str3 = r"
\{.*'1_solution_1'.*\}
" + str4 = r"
\{.*'1_solution_3'.*\}
" + + self.assertRegexpMatches(the_html, str1) + self.assertRegexpMatches(the_html, str2) + self.assertRegexpMatches(the_html, str3) + self.assertRegexpMatches(the_html, str4) + + without_new_lines = the_html.replace("\n", "") + + self.assertRegexpMatches(without_new_lines, str1 + r".*" + str2) + self.assertRegexpMatches(without_new_lines, str3 + r".*" + str4) + + def test_answer_pool_and_no_answer_pool(self): + xml_str = textwrap.dedent(""" + + +

What is the correct answer?

+ + + wrong-1 + wrong-2 + correct-1 + wrong-3 + wrong-4 + + + + +
+

Explanation

+

This is the solution

+

Not much to explain here, sorry!

+
+
+ +

What is the correct answer?

+ + + wrong-1 + wrong-2 + correct-1 + wrong-3 + wrong-4 + correct-2 + + + + + +
+

Explanation

+

This is the 1st solution

+

Not much to explain here, sorry!

+
+
+ + +
+

Explanation

+

This is the 2nd solution

+
+
+
+ +
+ + """) + + problem = new_loncapa_problem(xml_str) + problem.seed = 723 + the_html = problem.get_html() + + str1 = r"
.*\[.*'wrong-1'.*'wrong-2'.*'correct-1'.*'wrong-3'.*'wrong-4'.*\].*
" + str2 = r"
.*\[.*'wrong-3'.*'wrong-1'.*'wrong-2'.*'correct-2'.*\].*
" + str3 = r"
\{.*'1_solution_1'.*\}
" + str4 = r"
\{.*'1_solution_3'.*\}
" + + self.assertRegexpMatches(the_html, str1) + self.assertRegexpMatches(the_html, str2) + self.assertRegexpMatches(the_html, str3) + self.assertRegexpMatches(the_html, str4) + + without_new_lines = the_html.replace("\n", "") + + self.assertRegexpMatches(without_new_lines, str1 + r".*" + str2) + self.assertRegexpMatches(without_new_lines, str3 + r".*" + str4) + + def test_answer_pool_without_solutionset(self): + xml_str = textwrap.dedent(""" + + +

What is the correct answer?

+ + + wrong-1 + wrong-2 + correct-1 + wrong-3 + wrong-4 + correct-2 + + + + +
+

Explanation

+

This is the solution

+

Not much to explain here, sorry!

+
+
+ +
+ + """) + + problem = new_loncapa_problem(xml_str) + problem.seed = 723 + the_html = problem.get_html() + + self.assertRegexpMatches(the_html, r"
.*\[.*'wrong-3'.*'wrong-1'.*'wrong-2'.*'correct-2'.*\].*
") + self.assertRegexpMatches(the_html, r"
\{.*'1_solution_1'.*\}
")