Skip to content

Commit

Permalink
Add 'structured-randomized' option for checkbox questions
Browse files Browse the repository at this point in the history
This new option allows teachers to group the checkbox
choices into subgroups and to select a specific number of
answer choices from each subgroup.

Random selection from one large pool could include only easy
choices for some students and challenging choices for others,
hence the teacher wants to ensure that the combined random
selection includes some choices from each group.

Part of apluslms/mooc-grader#120
  • Loading branch information
ihalaij1 committed Nov 21, 2023
1 parent b50f758 commit 32af027
Show file tree
Hide file tree
Showing 2 changed files with 116 additions and 6 deletions.
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,19 @@ question options:
* `correct-count`: The number of correct answer choices (checkboxes) to randomly
select in the randomized `pick-any` question. This option is used with the
`randomized` option.
* `structured-randomized`: When this option is used, a subset of the answer choices (checkboxes)
is randomly selected for the user, based on the provided structure. The random selection
changes after the user submits, but persists when the user just reloads the web page.
The value of the option is a string expression of the format
`(pick X answer-choice-1 answer-choice-2 ...) (pick Y answer-choice-3 answer-choice-4 ...) ...`,
where `X` and `Y` are the number of choices to randomly select from their respective
groups of answer choices. Example: `(pick 1 a b) (pick 2 c d e f) (pick 1 g h)`.
The string expression may also include nested `pick` groups, e.g.,
`(pick 3 a b (pick 1 c d) e f)`. In this example, one of `c` or `d` is randomly picked first,
and then three answer choices are randomly picked from the pool of `a`, `b`, `c`, `e`, and `f`
or `a`, `b`, `d`, `e`, and `f`, depending how the choice between `c` and `d` played out.
The option `correct-count` should not be set when this option is used,
since the number of correct answer choices can be controlled using the structure.
* `preserve-questions-between-attempts`: If set, the answer choices in a `randomized`
question are preserved between submission attempts (instead of being
resampled after each attempt).
Expand Down
109 changes: 103 additions & 6 deletions directives/questionnaire.py
Original file line number Diff line number Diff line change
Expand Up @@ -429,10 +429,10 @@ def split_second_last(empty_lines):
choice_keys = []
correct_count = 0
# Travel all answer options.
for i,line in slicer(choices):
for i, line in slicer(choices):

# Split choice key off.
key,content = line[0].split(' ', 1)
key, content = line[0].split(' ', 1)
key = key.strip()
line[0] = content.strip()

Expand All @@ -451,6 +451,7 @@ def split_second_last(empty_lines):
key = key[1:]
if key.endswith('.'):
key = key[:-1]
choice_keys.append(key)

if key in choice_keys:
source, line = self.state_machine.get_source_and_line(self.lineno)
Expand Down Expand Up @@ -512,18 +513,75 @@ def split_second_last(empty_lines):
node.append(dropdown)

if 'randomized' in self.options:
if 'structured-randomized' in self.options:
source, line = self.state_machine.get_source_and_line(self.lineno)
raise SphinxError(
source + ": line " + str(line) +
"\nThe option 'randomized' can not be used together with the option 'structured-randomized'!"
)
data['randomized'] = self.options.get('randomized', 1)
if data['randomized'] > len(choices):
source, line = self.state_machine.get_source_and_line(self.lineno)
raise SphinxError(source + ": line " + str(line) +
"\nThe option 'randomized' can not be greater than the number of answer choices!")
raise SphinxError(
source + ": line " + str(line) +
"\nThe option 'randomized' can not be greater than the number of answer choices!"
)
if 'correct-count' in self.options:
data['correct_count'] = self.options.get('correct-count', 0)
if data['correct_count'] > correct_count or data['correct_count'] > data['randomized']:
source, line = self.state_machine.get_source_and_line(self.lineno)
raise SphinxError(source + ": line " + str(line) +
raise SphinxError(
source + ": line " + str(line) +
"\nThe option 'correct-count' can not be greater than "
"the number of correct choices or the value of 'randomized'!")
"the number of correct choices or the value of 'randomized'!"
)
if 'preserve-questions-between-attempts' in self.options:
data['resample_after_attempt'] = False
env.aplus_random_question_exists = True

if 'structured-randomized' in self.options:
data['structured-randomized'] = self.options.get('structured-randomized')

def check_groups_recursive(group, all_choice_keys):
pick_num = group[0]
num_choices = 0
for choice_or_subgroup in group[1]:
if isinstance(choice_or_subgroup, tuple):
num_choices += check_groups_recursive(choice_or_subgroup, all_choice_keys)
else:
all_choice_keys.append(choice_or_subgroup)
num_choices += 1
if pick_num >= num_choices:
source, line = self.state_machine.get_source_and_line(self.lineno)
raise SphinxError(
source + ": line " + str(line) +
"\nThe number of picked answer choices must be smaller than the number of choices "
"in the value for the option 'structured-randomized'!"
)
return pick_num

all_choice_keys = []
for group in data['structured-randomized']:
try:
check_groups_recursive(group, all_choice_keys)
except IndexError as e: # Should never go here
source, line = self.state_machine.get_source_and_line(self.lineno)
raise SphinxError(
source + ": line " + str(line) +
f"Something went wrong while processing the option 'structured-randomized'!"
) from e
if not all(choice in choice_keys for choice in all_choice_keys):
source, line = self.state_machine.get_source_and_line(self.lineno)
raise SphinxError(
source + ": line " + str(line) +
"\nAll the answer choices used in the value for option 'structured-randomized' must exist!"
)
if 'correct-count' in self.options:
source, line = self.state_machine.get_source_and_line(self.lineno)
raise SphinxError(
source + ": line " + str(line) +
"\nThe option 'correct-count' can not be used together with the option 'structured-randomized'!"
)
if 'preserve-questions-between-attempts' in self.options:
data['resample_after_attempt'] = False
env.aplus_random_question_exists = True
Expand Down Expand Up @@ -555,6 +613,44 @@ def input_type(self):
return 'radio'


def structured_randomized_expression(argument):
import regex # Import regex here so that courses can still build with older compile-rst versions
# Verify that the expression matches the expected format
pattern = r'(\(\s*pick\s+\d+\s+(?>(?:\w+\s*)|(?1)){2,}\)\s*)'
if not regex.match(f'^{pattern}+$', argument):
raise SphinxError(f"The option 'structured-randomized' was supplied an invalid argument '{argument}'!")

def get_group_recursive(string):
# Use regular expressions to extract the number of picks and the keys.
# Subgroups are parsed recursively.
group = []
string = string.strip()
match = regex.match(r'^(\(\s*pick\s+(\d+)\s+((?>(?:\w+\s*)|(?1)){2,})\)\s*)$', string)
if match:
pick_num = int(match.group(2))
items = regex.split(pattern, match.group(3))
for item in items:
item = item.strip()
if item.startswith('(') and item.endswith(')'):
group.append(get_group_recursive(item))
elif item: # Skip empty strings
group.extend(item.split())
return pick_num, group
else: # Should never go here
raise SphinxError(
f"Something went wrong while processing the argument '{argument}' "
"for the option 'structured-randomized'!"
)

# Use regular expressions to extract the individual parenthetical parts, i.e. groups
groups = []
groups_as_strings = regex.findall(pattern, argument)
for group_string in groups_as_strings:
groups.append(get_group_recursive(group_string))

return groups


class MultipleChoice(Choice):
''' Lists options for picking all the correct ones. '''

Expand All @@ -567,6 +663,7 @@ class MultipleChoice(Choice):
# randomized defines the number of options that are chosen randomly and
# correct-count is the number of correct options to include
'randomized': directives.positive_int,
'structured-randomized': structured_randomized_expression,
'correct-count': directives.nonnegative_int,
# Random questions may be resampled after each submission attempt or
# the questions may be preserved.
Expand Down

0 comments on commit 32af027

Please sign in to comment.