Commit f1eefc11 by Nick Parlante

Merge pull request #1499 from edx/nick/shuffle-question

Shuffle feature for multiple choice questions
parents f80d3b87 ab7d3b52
......@@ -69,6 +69,9 @@ Blades: Add view for field type Dict in Studio. BLD-658.
Blades: Refactor stub implementation of LTI Provider. BLD-601.
LMS: multiple choice features: shuffle, answer-pool, targeted-feedback,
choice name masking, submission timer
Studio: Added ability to edit course short descriptions that appear on the course catalog page.
LMS: In left accordion and progress page, due dates are now displayed in time
......
......@@ -13,7 +13,7 @@ MAXIMUM_ATTEMPTS = "Maximum Attempts"
PROBLEM_WEIGHT = "Problem Weight"
RANDOMIZATION = 'Randomization'
SHOW_ANSWER = "Show Answer"
TIMER_BETWEEN_ATTEMPTS = "Timer Between Attempts"
@step('I have created a Blank Common Problem$')
def i_created_blank_common_problem(step):
......@@ -44,6 +44,7 @@ def i_see_advanced_settings_with_values(step):
[PROBLEM_WEIGHT, "", False],
[RANDOMIZATION, "Never", False],
[SHOW_ANSWER, "Finished", False],
[TIMER_BETWEEN_ATTEMPTS, "0", False]
])
......
......@@ -178,6 +178,14 @@ class LoncapaProblem(object):
# input_id string -> InputType object
self.inputs = {}
# Run response late_transforms last (see MultipleChoiceResponse)
# Sort the responses to be in *_1 *_2 ... order.
responses = self.responders.values()
responses = sorted(responses, key=lambda resp: int(resp.id[resp.id.rindex('_') + 1:]))
for response in responses:
if hasattr(response, 'late_transforms'):
response.late_transforms(self)
self.extracted_tree = self._extract_html(self.tree)
def do_reset(self):
......@@ -419,10 +427,84 @@ class LoncapaProblem(object):
answer_ids.append(results.keys())
return answer_ids
def do_targeted_feedback(self, tree):
"""
Implements the targeted-feedback=N in-place on <multiplechoiceresponse> --
choice-level explanations shown to a student after submission.
Does nothing if there is no targeted-feedback attribute.
"""
for mult_choice_response in tree.xpath('//multiplechoiceresponse[@targeted-feedback]'):
# Note that the modifications has been done, avoiding problems if called twice.
if hasattr(self, 'has_targeted'):
continue
self.has_targeted = True # pylint: disable=W0201
show_explanation = mult_choice_response.get('targeted-feedback') == 'alwaysShowCorrectChoiceExplanation'
# Grab the first choicegroup (there should only be one within each <multiplechoiceresponse> tag)
choicegroup = mult_choice_response.xpath('./choicegroup[@type="MultipleChoice"]')[0]
choices_list = list(choicegroup.iter('choice'))
# Find the student answer key that matches our <choicegroup> id
student_answer = self.student_answers.get(choicegroup.get('id'))
expl_id_for_student_answer = None
# Keep track of the explanation-id that corresponds to the student's answer
# Also, keep track of the solution-id
solution_id = None
for choice in choices_list:
if choice.get('name') == student_answer:
expl_id_for_student_answer = choice.get('explanation-id')
if choice.get('correct') == 'true':
solution_id = choice.get('explanation-id')
# Filter out targetedfeedback that doesn't correspond to the answer the student selected
# Note: following-sibling will grab all following siblings, so we just want the first in the list
targetedfeedbackset = mult_choice_response.xpath('./following-sibling::targetedfeedbackset')
if len(targetedfeedbackset) != 0:
targetedfeedbackset = targetedfeedbackset[0]
targetedfeedbacks = targetedfeedbackset.xpath('./targetedfeedback')
for targetedfeedback in targetedfeedbacks:
# Don't show targeted feedback if the student hasn't answer the problem
# or if the target feedback doesn't match the student's (incorrect) answer
if not self.done or targetedfeedback.get('explanation-id') != expl_id_for_student_answer:
targetedfeedbackset.remove(targetedfeedback)
# Do not displace the solution under these circumstances
if not show_explanation or not self.done:
continue
# The next element should either be <solution> or <solutionset>
next_element = targetedfeedbackset.getnext()
parent_element = tree
solution_element = None
if next_element is not None and next_element.tag == 'solution':
solution_element = next_element
elif next_element is not None and next_element.tag == 'solutionset':
solutions = next_element.xpath('./solution')
for solution in solutions:
if solution.get('explanation-id') == solution_id:
parent_element = next_element
solution_element = solution
# If could not find the solution element, then skip the remaining steps below
if solution_element is None:
continue
# Change our correct-choice explanation from a "solution explanation" to within
# the set of targeted feedback, which means the explanation will render on the page
# without the student clicking "Show Answer" or seeing a checkmark next to the correct choice
parent_element.remove(solution_element)
# Add our solution instead to the targetedfeedbackset and change its tag name
solution_element.tag = 'targetedfeedback'
targetedfeedbackset.append(solution_element)
def get_html(self):
"""
Main method called externally to get the HTML to be rendered for this capa Problem.
"""
self.do_targeted_feedback(self.tree)
html = contextualize_text(etree.tostring(self._extract_html(self.tree)), self.context)
return html
......
......@@ -11,6 +11,7 @@ from .registry import TagRegistry
import logging
import re
from cgi import escape as cgi_escape
from lxml import etree
import xml.sax.saxutils as saxutils
from .registry import TagRegistry
......@@ -98,3 +99,42 @@ class SolutionRenderer(object):
return etree.XML(html)
registry.register(SolutionRenderer)
#-----------------------------------------------------------------------------
class TargetedFeedbackRenderer(object):
"""
A targeted feedback is just a <span>...</span> that is used for displaying an
extended piece of feedback to students if they incorrectly answered a question.
"""
tags = ['targetedfeedback']
def __init__(self, system, xml):
self.system = system
self.xml = xml
def get_html(self):
"""
Return the contents of this tag, rendered to html, as an etree element.
"""
html = '<section class="targeted-feedback-span"><span>{}</span></section>'.format(etree.tostring(self.xml))
try:
xhtml = etree.XML(html)
except Exception as err: # pylint: disable=broad-except
if self.system.DEBUG:
msg = """
<html>
<div class="inline-error">
<p>Error {err}</p>
<p>Failed to construct targeted feedback from <pre>{html}</pre></p>
</div>
</html>
""".format(err=cgi_escape(err), html=cgi_escape(html))
log.error(msg)
return etree.XML(msg)
else:
raise
return xhtml
registry.register(TargetedFeedbackRenderer)
......@@ -52,6 +52,6 @@ def test_capa_system():
return the_system
def new_loncapa_problem(xml, capa_system=None):
def new_loncapa_problem(xml, capa_system=None, seed=723):
"""Construct a `LoncapaProblem` suitable for unit tests."""
return LoncapaProblem(xml, id='1', seed=723, capa_system=capa_system or test_capa_system())
return LoncapaProblem(xml, id='1', seed=seed, capa_system=capa_system or test_capa_system())
......@@ -126,6 +126,23 @@ div.problem {
}
}
.targeted-feedback-span {
> span {
margin: $baseline 0;
display: block;
border: 1px solid #000;
padding: 9px 15px $baseline;
background: #fff;
position: relative;
box-shadow: inset 0 0 0 1px #eee;
border-radius: 3px;
&:empty {
display: none;
}
}
}
div {
p {
&.answer {
......@@ -628,6 +645,34 @@ div.problem {
}
}
.detailed-targeted-feedback {
> p:first-child {
color: red;
text-transform: uppercase;
font-weight: bold;
font-style: normal;
font-size: 0.9em;
}
p:last-child {
margin-bottom: 0;
}
}
.detailed-targeted-feedback-correct {
> p:first-child {
color: green;
text-transform: uppercase;
font-weight: bold;
font-style: normal;
font-size: 0.9em;
}
p:last-child {
margin-bottom: 0;
}
}
div.capa_alert {
margin-top: $baseline;
padding: 8px 12px;
......
......@@ -244,6 +244,105 @@ describe 'MarkdownEditingDescriptor', ->
</div>
</solution>
</problem>""")
it 'converts multiple choice shuffle to xml', ->
data = MarkdownEditingDescriptor.markdownToXml("""A multiple choice problem presents radio buttons for student input. Students can only select a single option presented. Multiple Choice questions have been the subject of many areas of research due to the early invention and adoption of bubble sheets.
One of the main elements that goes into a good multiple choice question is the existence of good distractors. That is, each of the alternate responses presented to the student should be the result of a plausible mistake that a student might make.
What Apple device competed with the portable CD player?
(!x@) The iPad
(@) Napster
() The iPod
( ) The vegetable peeler
( ) Android
(@) The Beatles
[Explanation]
The release of the iPod allowed consumers to carry their entire music library with them in a format that did not rely on fragile and energy-intensive spinning disks.
[Explanation]
""")
expect(data).toEqual("""<problem>
<p>A multiple choice problem presents radio buttons for student input. Students can only select a single option presented. Multiple Choice questions have been the subject of many areas of research due to the early invention and adoption of bubble sheets.</p>
<p>One of the main elements that goes into a good multiple choice question is the existence of good distractors. That is, each of the alternate responses presented to the student should be the result of a plausible mistake that a student might make.</p>
<p>What Apple device competed with the portable CD player?</p>
<multiplechoiceresponse>
<choicegroup type="MultipleChoice" shuffle="true">
<choice correct="true" fixed="true">The iPad</choice>
<choice correct="false" fixed="true">Napster</choice>
<choice correct="false">The iPod</choice>
<choice correct="false">The vegetable peeler</choice>
<choice correct="false">Android</choice>
<choice correct="false" fixed="true">The Beatles</choice>
</choicegroup>
</multiplechoiceresponse>
<solution>
<div class="detailed-solution">
<p>Explanation</p>
<p>The release of the iPod allowed consumers to carry their entire music library with them in a format that did not rely on fragile and energy-intensive spinning disks.</p>
</div>
</solution>
</problem>""")
it 'converts a series of multiplechoice to xml', ->
data = MarkdownEditingDescriptor.markdownToXml("""bleh
(!x) a
() b
() c
yatta
( ) x
( ) y
(x) z
testa
(!) i
( ) ii
(x) iii
[Explanation]
When the student is ready, the explanation appears.
[Explanation]
""")
expect(data).toEqual("""<problem>
<p>bleh</p>
<multiplechoiceresponse>
<choicegroup type="MultipleChoice" shuffle="true">
<choice correct="true">a</choice>
<choice correct="false">b</choice>
<choice correct="false">c</choice>
</choicegroup>
</multiplechoiceresponse>
<p>yatta</p>
<multiplechoiceresponse>
<choicegroup type="MultipleChoice">
<choice correct="false">x</choice>
<choice correct="false">y</choice>
<choice correct="true">z</choice>
</choicegroup>
</multiplechoiceresponse>
<p>testa</p>
<multiplechoiceresponse>
<choicegroup type="MultipleChoice" shuffle="true">
<choice correct="false">i</choice>
<choice correct="false">ii</choice>
<choice correct="true">iii</choice>
</choicegroup>
</multiplechoiceresponse>
<solution>
<div class="detailed-solution">
<p>Explanation</p>
<p>When the student is ready, the explanation appears.</p>
</div>
</solution>
</problem>""")
it 'converts OptionResponse to xml', ->
data = MarkdownEditingDescriptor.markdownToXml("""OptionResponse gives a limited set of options for students to respond with, and presents those options in a format that encourages them to search for a specific answer rather than being immediately presented with options from which to recognize the correct answer.
......
......@@ -195,25 +195,35 @@ class @MarkdownEditingDescriptor extends XModule.Descriptor
xml = xml.replace(/\n^\=\=+$/gm, '');
// group multiple choice answers
xml = xml.replace(/(^\s*\(.?\).*?$\n*)+/gm, function (match) {
var groupString = '<multiplechoiceresponse>\n',
value, correct, options;
groupString += ' <choicegroup type="MultipleChoice">\n';
options = match.split('\n');
for (i = 0; i < options.length; i += 1) {
if(options[i].length > 0) {
value = options[i].split(/^\s*\(.?\)\s*/)[1];
correct = /^\s*\(x\)/i.test(options[i]);
groupString += ' <choice correct="' + correct + '">' + value + '</choice>\n';
}
xml = xml.replace(/(^\s*\(.{0,3}\).*?$\n*)+/gm, function(match, p) {
var choices = '';
var shuffle = false;
var options = match.split('\n');
for(var i = 0; i < options.length; i++) {
if(options[i].length > 0) {
var value = options[i].split(/^\s*\(.{0,3}\)\s*/)[1];
var inparens = /^\s*\((.{0,3})\)\s*/.exec(options[i])[1];
var correct = /x/i.test(inparens);
var fixed = '';
if(/@/.test(inparens)) {
fixed = ' fixed="true"';
}
if(/!/.test(inparens)) {
shuffle = true;
}
choices += ' <choice correct="' + correct + '"' + fixed + '>' + value + '</choice>\n';
}
groupString += ' </choicegroup>\n';
groupString += '</multiplechoiceresponse>\n\n';
return groupString;
}
var result = '<multiplechoiceresponse>\n';
if(shuffle) {
result += ' <choicegroup type="MultipleChoice" shuffle="true">\n';
} else {
result += ' <choicegroup type="MultipleChoice">\n';
}
result += choices;
result += ' </choicegroup>\n';
result += '</multiplechoiceresponse>\n\n';
return result;
});
// group check answers
......
......@@ -82,6 +82,7 @@ class CapaFactory(object):
attempts=None,
problem_state=None,
correct=False,
xml=None,
**kwargs
):
"""
......@@ -102,7 +103,9 @@ class CapaFactory(object):
"""
location = Location(["i4x", "edX", "capa_test", "problem",
"SampleProblem{0}".format(cls.next_num())])
field_data = {'data': cls.sample_problem_xml}
if xml is None:
xml = cls.sample_problem_xml
field_data = {'data': xml}
field_data.update(kwargs)
descriptor = Mock(weight="1")
if problem_state is not None:
......@@ -1424,6 +1427,105 @@ class CapaModuleTest(unittest.TestCase):
module = CapaFactory.create()
self.assertEquals(module.get_problem("data"), {'html': module.get_problem_html(encapsulate=False)})
# Standard question with shuffle="true" used by a few tests
common_shuffle_xml = textwrap.dedent("""
<problem>
<multiplechoiceresponse>
<choicegroup type="MultipleChoice" shuffle="true">
<choice correct="false">Apple</choice>
<choice correct="false">Banana</choice>
<choice correct="false">Chocolate</choice>
<choice correct ="true">Donut</choice>
</choicegroup>
</multiplechoiceresponse>
</problem>
""")
def test_check_unmask(self):
"""
Check that shuffle unmasking is plumbed through: when check_problem is called,
unmasked names should appear in the track_function event_info.
"""
module = CapaFactory.create(xml=self.common_shuffle_xml)
with patch.object(module.runtime, 'track_function') as mock_track_function:
get_request_dict = {CapaFactory.input_key(): 'mask_1'} # the correct choice
module.check_problem(get_request_dict)
mock_call = mock_track_function.mock_calls[0]
event_info = mock_call[1][1]
# 'answers' key modified to use unmasked name
self.assertEqual(event_info['answers'][CapaFactory.answer_key()], 'choice_3')
# 'permutation' key added to record how problem was shown
self.assertEquals(event_info['permutation'][CapaFactory.answer_key()],
('shuffle', ['choice_3', 'choice_1', 'choice_2', 'choice_0']))
self.assertEquals(event_info['success'], 'correct')
def test_save_unmask(self):
"""On problem save, unmasked data should appear on track_function."""
module = CapaFactory.create(xml=self.common_shuffle_xml)
with patch.object(module.runtime, 'track_function') as mock_track_function:
get_request_dict = {CapaFactory.input_key(): 'mask_0'}
module.save_problem(get_request_dict)
mock_call = mock_track_function.mock_calls[0]
event_info = mock_call[1][1]
self.assertEquals(event_info['answers'][CapaFactory.answer_key()], 'choice_2')
self.assertIsNotNone(event_info['permutation'][CapaFactory.answer_key()])
def test_reset_unmask(self):
"""On problem reset, unmask names should appear track_function."""
module = CapaFactory.create(xml=self.common_shuffle_xml)
get_request_dict = {CapaFactory.input_key(): 'mask_0'}
module.check_problem(get_request_dict)
# On reset, 'old_state' should use unmasked names
with patch.object(module.runtime, 'track_function') as mock_track_function:
module.reset_problem(None)
mock_call = mock_track_function.mock_calls[0]
event_info = mock_call[1][1]
self.assertEquals(mock_call[1][0], 'reset_problem')
self.assertEquals(event_info['old_state']['student_answers'][CapaFactory.answer_key()], 'choice_2')
self.assertIsNotNone(event_info['permutation'][CapaFactory.answer_key()])
def test_rescore_unmask(self):
"""On problem rescore, unmasked names should appear on track_function."""
module = CapaFactory.create(xml=self.common_shuffle_xml)
get_request_dict = {CapaFactory.input_key(): 'mask_0'}
module.check_problem(get_request_dict)
# On rescore, state/student_answers should use unmasked names
with patch.object(module.runtime, 'track_function') as mock_track_function:
module.rescore_problem()
mock_call = mock_track_function.mock_calls[0]
event_info = mock_call[1][1]
self.assertEquals(mock_call[1][0], 'problem_rescore')
self.assertEquals(event_info['state']['student_answers'][CapaFactory.answer_key()], 'choice_2')
self.assertIsNotNone(event_info['permutation'][CapaFactory.answer_key()])
def test_check_unmask_answerpool(self):
"""Check answer-pool question track_function uses unmasked names"""
xml = textwrap.dedent("""
<problem>
<multiplechoiceresponse>
<choicegroup type="MultipleChoice" answer-pool="4">
<choice correct="false">Apple</choice>
<choice correct="false">Banana</choice>
<choice correct="false">Chocolate</choice>
<choice correct ="true">Donut</choice>
</choicegroup>
</multiplechoiceresponse>
</problem>
""")
module = CapaFactory.create(xml=xml)
with patch.object(module.runtime, 'track_function') as mock_track_function:
get_request_dict = {CapaFactory.input_key(): 'mask_0'}
module.check_problem(get_request_dict)
mock_call = mock_track_function.mock_calls[0]
event_info = mock_call[1][1]
print event_info
# 'answers' key modified to use unmasked name
self.assertEqual(event_info['answers'][CapaFactory.answer_key()], 'choice_2')
# 'permutation' key added to record how problem was shown
self.assertEquals(event_info['permutation'][CapaFactory.answer_key()],
('answerpool', ['choice_1', 'choice_3', 'choice_2', 'choice_0']))
self.assertEquals(event_info['success'], 'incorrect')
class ComplexEncoderTest(unittest.TestCase):
def test_default(self):
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment