diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index c3d680ee20..47289aad51 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -1824,7 +1824,7 @@ class FormulaResponse(LoncapaResponse): log.debug('formularesponse: error %s in formula', err) raise StudentInputError("Invalid input: Could not parse '%s' as a formula" % cgi.escape(answer)) - return tuple(out) + return out def randomize_variables(self, samples): """ diff --git a/common/lib/xmodule/xmodule/crowdsource_hinter.py b/common/lib/xmodule/xmodule/crowdsource_hinter.py index f0f36e7ef8..6c868cb621 100644 --- a/common/lib/xmodule/xmodule/crowdsource_hinter.py +++ b/common/lib/xmodule/xmodule/crowdsource_hinter.py @@ -16,6 +16,8 @@ from xmodule.x_module import XModule from xmodule.xml_module import XmlDescriptor from xblock.core import Scope, String, Integer, Boolean, Dict, List +from capa.responsetypes import FormulaResponse, StudentInputError + from django.utils.html import escape log = logging.getLogger(__name__) @@ -37,6 +39,14 @@ class CrowdsourceHinterFields(object): mod_queue = Dict(help='A dictionary containing hints still awaiting approval', scope=Scope.content, default={}) hint_pk = Integer(help='Used to index hints.', scope=Scope.content, default=0) + # signature_to_ans maps an answer signature to an answer string that shows that answer in a + # human-readable form. + signature_to_ans = Dict(help='Maps a signature to a representative formula.', scope=Scope.content, + default={}) + # A list of dictionaries, each of which represents an n-dimenstional point that we plug into + # formulas. Each dictionary maps variables to values, eg {'x': 5.1}. + formula_test_values = List(help='The values that we plug into formula responses', scope=Scope.content, + default=[]) # A list of previous answers this student made to this problem. # Of the form [answer, [hint_pk_1, hint_pk_2, hint_pk_3]] for each problem. hint_pk's are # None if the hint was not given. @@ -68,6 +78,15 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): def __init__(self, *args, **kwargs): XModule.__init__(self, *args, **kwargs) + # We need to know whether we are working with a FormulaResponse problem. + self.is_formula = (type(self.get_display_items()[0].lcp.responders.values()[0]) == FormulaResponse) + if self.is_formula: + self.answer_to_str = self.formula_answer_to_str + self.answer_signature = self.formula_answer_signature + else: + self.answer_to_str = self.numerical_answer_to_str + # Right now, numerical problems don't need special answer signature treatment. + self.answer_signature = lambda x: x def get_html(self): """ @@ -98,15 +117,45 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): return out - def capa_answer_to_str(self, answer): + def numerical_answer_to_str(self, answer): """ - Converts capa answer format to a string representation + Converts capa numerical answer format to a string representation of the answer. -Lon-capa dependent. -Assumes that the problem only has one part. """ return str(float(answer.values()[0])) + def formula_answer_to_str(self, answer): + """ + Converts capa formula answer into a string. + -Lon-capa dependent. + -Assumes that the problem only has one part. + """ + return str(answer.values()[0]) + + def formula_answer_signature(self, answer): + """ + Converts a capa answer string (output of formula_answer_to_str) + to a string unique to each formula equality class. + So, x^2 and x*x would have the same signature, which would differ + from the signature of 2*x^2. + """ + responder = self.get_display_items()[0].lcp.responders.values()[0] + if self.formula_test_values == []: + # Make a set of test values, and save them. + self.formula_test_values = responder.randomize_variables(responder.samples) + try: + # TODO, maybe: add some rounding to signature generation, so that floating point + # errors don't make a difference. + out = str(responder.hash_answers(answer, self.formula_test_values)) + except StudentInputError: + # I'm not sure what's the best thing to do here. + # I'll return the empty string, for now. + # That way, all invalid hints are clustered together. + return '' + return out + def handle_ajax(self, dispatch, data): """ This is the landing method for AJAX calls. @@ -134,44 +183,46 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): Called by hinter javascript after a problem is graded as incorrect. Args: - `data` -- must be interpretable by capa_answer_to_str. + `data` -- must be interpretable by answer_to_str. Output keys: - 'best_hint' is the hint text with the most votes. - 'rand_hint_1' and 'rand_hint_2' are two random hints to the answer in `data`. - 'answer' is the parsed answer that was submitted. """ try: - answer = self.capa_answer_to_str(data) + answer = self.answer_to_str(data) except ValueError: # Sometimes, we get an answer that's just not parsable. Do nothing. log.exception('Answer not parsable: ' + str(data)) return + # Make a signature of the answer, for formula responses. + signature = self.answer_signature(answer) # Look for a hint to give. # Make a local copy of self.hints - this means we only need to do one json unpacking. # (This is because xblocks storage makes the following command a deep copy.) local_hints = self.hints - if (answer not in local_hints) or (len(local_hints[answer]) == 0): + if (signature not in local_hints) or (len(local_hints[signature]) == 0): # No hints to give. Return. self.previous_answers += [[answer, [None, None, None]]] return # Get the top hint, plus two random hints. - n_hints = len(local_hints[answer]) - best_hint_index = max(local_hints[answer], key=lambda key: local_hints[answer][key][1]) - best_hint = local_hints[answer][best_hint_index][0] - if len(local_hints[answer]) == 1: + n_hints = len(local_hints[signature]) + best_hint_index = max(local_hints[signature], key=lambda key: local_hints[signature][key][1]) + best_hint = local_hints[signature][best_hint_index][0] + if len(local_hints[signature]) == 1: rand_hint_1 = '' rand_hint_2 = '' self.previous_answers += [[answer, [best_hint_index, None, None]]] elif n_hints == 2: - best_hint = local_hints[answer].values()[0][0] - best_hint_index = local_hints[answer].keys()[0] - rand_hint_1 = local_hints[answer].values()[1][0] - hint_index_1 = local_hints[answer].keys()[1] + best_hint = local_hints[signature].values()[0][0] + best_hint_index = local_hints[signature].keys()[0] + rand_hint_1 = local_hints[signature].values()[1][0] + hint_index_1 = local_hints[signature].keys()[1] rand_hint_2 = '' self.previous_answers += [[answer, [best_hint_index, hint_index_1, None]]] else: (hint_index_1, rand_hint_1), (hint_index_2, rand_hint_2) =\ - random.sample(local_hints[answer].items(), 2) + random.sample(local_hints[signature].items(), 2) rand_hint_1 = rand_hint_1[0] rand_hint_2 = rand_hint_2[0] self.previous_answers += [[answer, [best_hint_index, hint_index_1, hint_index_2]]] @@ -206,12 +257,13 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): answer, hints_offered = self.previous_answers[i] if answer not in answer_to_hints: answer_to_hints[answer] = {} - if answer in self.hints: + signature = self.answer_signature(answer) + if signature in self.hints: # Go through each hint, and add to index_to_hints for hint_id in hints_offered: - if (hint_id is not None) and (hint_id not in answer_to_hints[answer]): + if (hint_id is not None) and (hint_id not in answer_to_hints[signature]): try: - answer_to_hints[answer][hint_id] = self.hints[answer][str(hint_id)][0] + answer_to_hints[answer][hint_id] = self.hints[signature][str(hint_id)][0] except KeyError: # Sometimes, the hint that a user saw will have been deleted by the instructor. continue @@ -234,11 +286,12 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): if self.user_voted: return json.dumps({'contents': 'Sorry, but you have already voted!'}) ans = data['answer'] + signature = self.answer_signature(ans) hint_pk = str(data['hint']) pk_list = json.loads(data['pk_list']) # We use temp_dict because we need to do a direct write for the database to update. temp_dict = self.hints - temp_dict[ans][hint_pk][1] += 1 + temp_dict[signature][hint_pk][1] += 1 self.hints = temp_dict # Don't let the user vote again! self.user_voted = True @@ -246,7 +299,7 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): # Return a list of how many votes each hint got. hint_and_votes = [] for vote_pk in pk_list: - hint_and_votes.append(temp_dict[ans][str(vote_pk)]) + hint_and_votes.append(temp_dict[signature][str(vote_pk)]) hint_and_votes.sort(key=lambda pair: pair[1], reverse=True) # Reset self.previous_answers. @@ -266,6 +319,7 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): # Do html escaping. Perhaps in the future do profanity filtering, etc. as well. hint = escape(data['hint']) answer = data['answer'] + signature = self.answer_signature(answer) # Only allow a student to vote or submit a hint once. if self.user_voted: return {'message': 'Sorry, but you have already voted!'} @@ -276,9 +330,15 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): else: temp_dict = self.hints if answer in temp_dict: - temp_dict[answer][str(self.hint_pk)] = [hint, 1] # With one vote (the user himself). + temp_dict[signature][str(self.hint_pk)] = [hint, 1] # With one vote (the user himself). else: - temp_dict[answer] = {str(self.hint_pk): [hint, 1]} + temp_dict[signature] = {str(self.hint_pk): [hint, 1]} + # Add the signature to signature_to_ans, if it's not there yet. + # This allows instructors to see a human-readable answer that corresponds to each signature. + if answer not in self.signature_to_ans: + local_sta = self.signature_to_ans + local_sta[signature] = answer + self.signature_to_ans = local_sta self.hint_pk += 1 if self.moderate == 'True': self.mod_queue = temp_dict