def get_counter(i, counter_type): """Converts an integer counter to the specified CSS counter type""" if counter_type == 'lower-alpha': return pl.index2key(i - 1) elif counter_type == 'upper-alpha': return pl.index2key(i - 1).upper() elif counter_type == 'decimal': return str(i) elif counter_type == 'full-text': return '' else: raise Exception(f'Illegal counter-type in pl-matching element: "{counter_type}" should be "decimal", "lower-alpha", "upper-alpha", or "full-text".')
def test(element_html, data): element = lxml.html.fragment_fromstring(element_html) name = pl.get_string_attrib(element, 'answers-name') weight = pl.get_integer_attrib(element, 'weight', WEIGHT_DEFAULT) correct_key = data['correct_answers'].get(name, { 'key': None }).get('key', None) if correct_key is None: raise Exception('could not determine correct_key') number_answers = len(data['params'][name]) all_keys = [pl.index2key(i) for i in range(number_answers)] incorrect_keys = list(set(all_keys) - set([correct_key])) result = data['test_type'] if result == 'correct': data['raw_submitted_answers'][name] = data['correct_answers'][name][ 'key'] data['partial_scores'][name] = {'score': 1, 'weight': weight} elif result == 'incorrect': if len(incorrect_keys) > 0: data['raw_submitted_answers'][name] = random.choice(incorrect_keys) data['partial_scores'][name] = {'score': 0, 'weight': weight} else: # actually an invalid submission data['raw_submitted_answers'][name] = '0' data['format_errors'][name] = 'INVALID choice' elif result == 'invalid': data['raw_submitted_answers'][name] = '0' data['format_errors'][name] = 'INVALID choice' # FIXME: add more invalid choices else: raise Exception('invalid result: %s' % result)
def test(element_html, data): element = lxml.html.fragment_fromstring(element_html) name = pl.get_string_attrib(element, 'answers-name') weight = pl.get_integer_attrib(element, 'weight', WEIGHT_DEFAULT) partial_credit = pl.get_boolean_attrib(element, 'partial-credit', PARTIAL_CREDIT_DEFAULT) partial_credit_method = pl.get_string_attrib(element, 'partial-credit-method', PARTIAL_CREDIT_METHOD_DEFAULT) correct_answer_list = data['correct_answers'].get(name, []) correct_keys = [answer['key'] for answer in correct_answer_list] number_answers = len(data['params'][name]) all_keys = [pl.index2key(i) for i in range(number_answers)] result = data['test_type'] if result == 'correct': if len(correct_keys) == 1: data['raw_submitted_answers'][name] = correct_keys[0] elif len(correct_keys) > 1: data['raw_submitted_answers'][name] = correct_keys else: pass # no raw_submitted_answer if no correct keys data['partial_scores'][name] = {'score': 1, 'weight': weight} elif result == 'incorrect': while True: # select answer keys at random ans = [k for k in all_keys if random.choice([True, False])] # break and use this choice if it isn't correct if (len(ans) >= 1): if set(ans) != set(correct_keys): if not pl.get_boolean_attrib(element, 'detailed-help-text', DETAILED_HELP_TEXT_DEFAULT): break else: min_correct = pl.get_integer_attrib(element, 'min-correct', 1) max_correct = pl.get_integer_attrib(element, 'max-correct', len(correct_answer_list)) if len(ans) <= max_correct and len(ans) >= min_correct: break if partial_credit: if partial_credit_method == 'PC': if set(ans) == set(correct_keys): score = 1 else: n_correct_answers = len(set(correct_keys)) - len(set(correct_keys) - set(ans)) points = n_correct_answers - len(set(ans) - set(correct_keys)) score = max(0, points / len(set(correct_keys))) else: # this is the EDC method number_wrong = len(set(ans) - set(correct_keys)) + len(set(correct_keys) - set(ans)) score = 1 - 1.0 * number_wrong / number_answers else: score = 0 data['raw_submitted_answers'][name] = ans data['partial_scores'][name] = {'score': score, 'weight': weight} elif result == 'invalid': # FIXME: add more invalid examples data['raw_submitted_answers'][name] = None data['format_errors'][name] = 'You must select at least one option.' else: raise Exception('invalid result: %s' % result)
def prepare(element_html, data): element = lxml.html.fragment_fromstring(element_html) required_attribs = ['answers-name'] optional_attribs = ['weight', 'number-answers', 'min-correct', 'max-correct', 'fixed-order', 'inline', 'hide-answer-panel', 'hide-help-text', 'detailed-help-text', 'partial-credit', 'partial-credit-method', 'hide-letter-keys', 'hide-score-badge'] pl.check_attribs(element, required_attribs, optional_attribs) name = pl.get_string_attrib(element, 'answers-name') partial_credit = pl.get_boolean_attrib(element, 'partial-credit', PARTIAL_CREDIT_DEFAULT) partial_credit_method = pl.get_string_attrib(element, 'partial-credit-method', None) if not partial_credit and partial_credit_method is not None: raise Exception('Cannot specify partial-credit-method if partial-credit is not enabled') correct_answers = [] incorrect_answers = [] index = 0 for child in element: if child.tag in ['pl-answer', 'pl_answer']: pl.check_attribs(child, required_attribs=[], optional_attribs=['correct']) correct = pl.get_boolean_attrib(child, 'correct', False) child_html = pl.inner_html(child) answer_tuple = (index, correct, child_html) if correct: correct_answers.append(answer_tuple) else: incorrect_answers.append(answer_tuple) index += 1 len_correct = len(correct_answers) len_incorrect = len(incorrect_answers) len_total = len_correct + len_incorrect if len_correct == 0: raise ValueError('At least one option must be true.') number_answers = pl.get_integer_attrib(element, 'number-answers', len_total) min_correct = pl.get_integer_attrib(element, 'min-correct', 1) max_correct = pl.get_integer_attrib(element, 'max-correct', len(correct_answers)) if min_correct < 1: raise ValueError('The attribute min-correct is {:d} but must be at least 1'.format(min_correct)) # FIXME: why enforce a maximum number of options? max_answers = 26 # will not display more than 26 checkbox answers number_answers = max(0, min(len_total, min(max_answers, number_answers))) min_correct = min(len_correct, min(number_answers, max(0, max(number_answers - len_incorrect, min_correct)))) max_correct = min(len_correct, min(number_answers, max(min_correct, max_correct))) if not (0 <= min_correct <= max_correct <= len_correct): raise ValueError('INTERNAL ERROR: correct number: (%d, %d, %d, %d)' % (min_correct, max_correct, len_correct, len_incorrect)) min_incorrect = number_answers - max_correct max_incorrect = number_answers - min_correct if not (0 <= min_incorrect <= max_incorrect <= len_incorrect): raise ValueError('INTERNAL ERROR: incorrect number: (%d, %d, %d, %d)' % (min_incorrect, max_incorrect, len_incorrect, len_correct)) number_correct = random.randint(min_correct, max_correct) number_incorrect = number_answers - number_correct sampled_correct = random.sample(correct_answers, number_correct) sampled_incorrect = random.sample(incorrect_answers, number_incorrect) sampled_answers = sampled_correct + sampled_incorrect random.shuffle(sampled_answers) fixed_order = pl.get_boolean_attrib(element, 'fixed-order', FIXED_ORDER_DEFAULT) if fixed_order: # we can't simply skip the shuffle because we already broke the original # order by separating into correct/incorrect lists sampled_answers.sort(key=lambda a: a[0]) # sort by stored original index display_answers = [] correct_answer_list = [] for (i, (index, correct, html)) in enumerate(sampled_answers): keyed_answer = {'key': pl.index2key(i), 'html': html} display_answers.append(keyed_answer) if correct: correct_answer_list.append(keyed_answer) if name in data['params']: raise Exception('duplicate params variable name: %s' % name) if name in data['correct_answers']: raise Exception('duplicate correct_answers variable name: %s' % name) data['params'][name] = display_answers data['correct_answers'][name] = correct_answer_list
def prepare(element_html, data): element = lxml.html.fragment_fromstring(element_html) required_attribs = ['answers-name'] optional_attribs = ['weight', 'number-answers', 'fixed-order', 'inline', 'hide-letter-keys', 'none-of-the-above', 'none-of-the-above-feedback', 'all-of-the-above', 'all-of-the-above-feedback', 'external-json', 'external-json-correct-key', 'external-json-incorrect-key'] pl.check_attribs(element, required_attribs, optional_attribs) name = pl.get_string_attrib(element, 'answers-name') correct_answers, incorrect_answers = categorize_options(element, data) len_correct = len(correct_answers) len_incorrect = len(incorrect_answers) len_total = len_correct + len_incorrect enable_nota = pl.get_boolean_attrib(element, 'none-of-the-above', NONE_OF_THE_ABOVE_DEFAULT) enable_aota = pl.get_boolean_attrib(element, 'all-of-the-above', ALL_OF_THE_ABOVE_DEFAULT) nota_correct = False aota_correct = False if enable_nota or enable_aota: prob_space = len_correct + enable_nota + enable_aota rand_int = random.randint(1, prob_space) # Either 'None of the above' or 'All of the above' is correct # with probability 1/(number_correct + enable-nota + enable-aota). # However, if len_correct is 0, nota_correct is guaranteed to be True. # Thus, if no correct option is provided, 'None of the above' will always # be correct, and 'All of the above' always incorrect nota_correct = enable_nota and (rand_int == 1 or len_correct == 0) # 'All of the above' will always be correct when no incorrect option is # provided, while still never both True aota_correct = enable_aota and (rand_int == 2 or len_incorrect == 0) and not nota_correct if len_correct < 1 and not enable_nota: # This means the code needs to handle the special case when len_correct == 0 raise Exception('pl-multiple-choice element must have at least 1 correct answer or set none-of-the-above') if enable_aota and len_correct < 2: # To prevent confusion on the client side raise Exception('pl-multiple-choice element must have at least 2 correct answers when all-of-the-above is set') # 1. Pick the choice(s) to display number_answers = pl.get_integer_attrib(element, 'number-answers', None) # determine if user provides number-answers set_num_answers = True if number_answers is None: set_num_answers = False number_answers = len_total + enable_nota + enable_aota # figure out how many choice(s) to choose from the *provided* choices, # excluding 'none-of-the-above' and 'all-of-the-above' number_answers -= (enable_nota + enable_aota) expected_num_answers = number_answers if enable_aota: # min number if 'All of the above' is correct number_answers = min(len_correct, number_answers) # raise exception when the *provided* number-answers can't be satisfied if set_num_answers and number_answers < expected_num_answers: raise Exception(f'Not enough correct choices for all-of-the-above. Need {expected_num_answers - number_answers} more') if enable_nota: # if nota correct number_answers = min(len_incorrect, number_answers) # raise exception when the *provided* number-answers can't be satisfied if set_num_answers and number_answers < expected_num_answers: raise Exception(f'Not enough incorrect choices for none-of-the-above. Need {expected_num_answers - number_answers} more') # this is the case for # - 'All of the above' is incorrect # - 'None of the above' is incorrect # - nota and aota disabled number_answers = min(min(1, len_correct) + len_incorrect, number_answers) if aota_correct: # when 'All of the above' is correct, we choose all from correct # and none from incorrect number_correct = number_answers number_incorrect = 0 elif nota_correct: # when 'None of the above' is correct, we choose all from incorrect # and none from correct number_correct = 0 number_incorrect = number_answers else: # PROOF: by the above probability, if len_correct == 0, then nota_correct # conversely; if not nota_correct, then len_correct != 0. Since len_correct # is none negative, this means len_correct >= 1. number_correct = 1 number_incorrect = max(0, number_answers - number_correct) if not (0 <= number_incorrect <= len_incorrect): raise Exception('INTERNAL ERROR: number_incorrect: (%d, %d, %d)' % (number_incorrect, len_incorrect, number_answers)) # 2. Sample correct and incorrect choices sampled_correct = random.sample(correct_answers, number_correct) sampled_incorrect = random.sample(incorrect_answers, number_incorrect) sampled_answers = sampled_correct + sampled_incorrect random.shuffle(sampled_answers) # 3. Modify sampled choices fixed_order = pl.get_boolean_attrib(element, 'fixed-order', FIXED_ORDER_DEFAULT) if fixed_order: # we can't simply skip the shuffle because we already broke the original # order by separating into correct/incorrect lists sampled_answers.sort(key=lambda a: a[0]) # sort by stored original index inline = pl.get_boolean_attrib(element, 'inline', INLINE_DEFAULT) if enable_aota: if inline: aota_text = 'All of these' else: aota_text = 'All of the above' # Add 'All of the above' option after shuffling aota_feedback = pl.get_string_attrib(element, 'all-of-the-above-feedback', FEEDBACK_DEFAULT) sampled_answers.append((len_total, aota_correct, aota_text, aota_feedback)) if enable_nota: if inline: nota_text = 'None of these' else: nota_text = 'None of the above' # Add 'None of the above' option after shuffling nota_feedback = pl.get_string_attrib(element, 'none-of-the-above-feedback', FEEDBACK_DEFAULT) sampled_answers.append((len_total + 1, nota_correct, nota_text, nota_feedback)) # 4. Write to data # Because 'All of the above' is below all the correct choice(s) when it's # true, the variable correct_answer will save it as correct, and # overwriting previous choice(s) display_answers = [] correct_answer = None for (i, (index, correct, html, feedback)) in enumerate(sampled_answers): keyed_answer = {'key': pl.index2key(i), 'html': html, 'feedback': feedback} display_answers.append(keyed_answer) if correct: correct_answer = keyed_answer if name in data['params']: raise Exception('duplicate params variable name: %s' % name) if name in data['correct_answers']: raise Exception('duplicate correct_answers variable name: %s' % name) data['params'][name] = display_answers data['correct_answers'][name] = correct_answer
def test(element_html, data): element = lxml.html.fragment_fromstring(element_html) name = pl.get_string_attrib(element, 'answers-name') weight = pl.get_integer_attrib(element, 'weight', WEIGHT_DEFAULT) partial_credit = pl.get_boolean_attrib(element, 'partial-credit', PARTIAL_CREDIT_DEFAULT) partial_credit_method = pl.get_string_attrib(element, 'partial-credit-method', PARTIAL_CREDIT_METHOD_DEFAULT) correct_answer_list = data['correct_answers'].get(name, []) correct_keys = [answer['key'] for answer in correct_answer_list] number_answers = len(data['params'][name]) all_keys = [pl.index2key(i) for i in range(number_answers)] min_options_to_select = _get_min_options_to_select(element, MIN_SELECT_DEFAULT) max_options_to_select = _get_max_options_to_select(element, number_answers) result = data['test_type'] if result == 'correct': if len(correct_keys) == 1: data['raw_submitted_answers'][name] = correct_keys[0] elif len(correct_keys) > 1: data['raw_submitted_answers'][name] = correct_keys else: pass # no raw_submitted_answer if no correct keys feedback = {option['key']: option.get('feedback', None) for option in data['params'][name]} data['partial_scores'][name] = {'score': 1, 'weight': weight, 'feedback': feedback} elif result == 'incorrect': while True: # select answer keys at random ans = [k for k in all_keys if random.choice([True, False])] # break and use this choice if it isn't correct if set(ans) != set(correct_keys) and min_options_to_select <= len(ans) <= max_options_to_select: break if partial_credit: if partial_credit_method == 'PC': if set(ans) == set(correct_keys): score = 1 else: n_correct_answers = len(set(correct_keys)) - len(set(correct_keys) - set(ans)) points = n_correct_answers - len(set(ans) - set(correct_keys)) score = max(0, points / len(set(correct_keys))) elif partial_credit_method == 'EDC': number_wrong = len(set(ans) - set(correct_keys)) + len(set(correct_keys) - set(ans)) score = 1 - 1.0 * number_wrong / number_answers elif partial_credit_method == 'COV': n_correct_answers = len(set(correct_keys) & set(ans)) base_score = n_correct_answers / len(set(correct_keys)) guessing_factor = n_correct_answers / len(set(ans)) score = base_score * guessing_factor else: raise ValueError(f'Unknown value for partial_credit_method: {partial_credit_method}') else: score = 0 feedback = {option['key']: option.get('feedback', None) for option in data['params'][name]} data['raw_submitted_answers'][name] = ans data['partial_scores'][name] = {'score': score, 'weight': weight, 'feedback': feedback} elif result == 'invalid': # FIXME: add more invalid examples data['raw_submitted_answers'][name] = None data['format_errors'][name] = 'You must select at least one option.' else: raise Exception('invalid result: %s' % result)