diff --git a/common/lib/capa/capa_problem.py b/common/lib/capa/capa_problem.py index b655270a9a..e70fa6ceff 100644 --- a/common/lib/capa/capa_problem.py +++ b/common/lib/capa/capa_problem.py @@ -1,13 +1,19 @@ # # File: capa/capa_problem.py # +# Nomenclature: +# +# A capa Problem is a collection of text and capa Response questions. Each Response may have one or more +# Input entry fields. The capa Problem may include a solution. +# ''' Main module which shows problems (of "capa" type). This is used by capa_module. ''' -import copy +from __future__ import division + import logging import math import numpy @@ -18,44 +24,26 @@ import scipy import struct from lxml import etree -from lxml.etree import Element from xml.sax.saxutils import unescape -from util import contextualize_text -import inputtypes - -from responsetypes import NumericalResponse, FormulaResponse, CustomResponse, SchematicResponse, MultipleChoiceResponse, TrueFalseResponse, ExternalResponse, ImageResponse, OptionResponse, SymbolicResponse - import calc +from correctmap import CorrectMap import eia +import inputtypes +from util import contextualize_text -log = logging.getLogger(__name__) +# to be replaced with auto-registering +import responsetypes + +# dict of tagname, Response Class -- this should come from auto-registering +response_tag_dict = dict([(x.response_tag,x) for x in responsetypes.__all__]) -response_types = {'numericalresponse': NumericalResponse, - 'formularesponse': FormulaResponse, - 'customresponse': CustomResponse, - 'schematicresponse': SchematicResponse, - 'externalresponse': ExternalResponse, - 'multiplechoiceresponse': MultipleChoiceResponse, - 'truefalseresponse': TrueFalseResponse, - 'imageresponse': ImageResponse, - 'optionresponse': OptionResponse, - 'symbolicresponse': SymbolicResponse, - } entry_types = ['textline', 'schematic', 'choicegroup', 'textbox', 'imageinput', 'optioninput'] -solution_types = ['solution'] # extra things displayed after "show answers" is pressed -response_properties = ["responseparam", "answer"] # these get captured as student responses +solution_types = ['solution'] # extra things displayed after "show answers" is pressed +response_properties = ["responseparam", "answer"] # these get captured as student responses -# How to convert from original XML to HTML -# We should do this with xlst later +# special problem tags which should be turned into innocuous HTML html_transforms = {'problem': {'tag': 'div'}, - "numericalresponse": {'tag': 'span'}, - "customresponse": {'tag': 'span'}, - "externalresponse": {'tag': 'span'}, - "schematicresponse": {'tag': 'span'}, - "formularesponse": {'tag': 'span'}, - "symbolicresponse": {'tag': 'span'}, - "multiplechoiceresponse": {'tag': 'span'}, "text": {'tag': 'span'}, "math": {'tag': 'span'}, } @@ -68,32 +56,38 @@ global_context = {'random': random, 'eia': eia} # These should be removed from HTML output, including all subelements -html_problem_semantics = ["responseparam", "answer", "script"] -# These should be removed from HTML output, but keeping subelements -html_skip = ["numericalresponse", "customresponse", "schematicresponse", "formularesponse", "text", "externalresponse", 'symbolicresponse'] +html_problem_semantics = ["responseparam", "answer", "script","hintgroup"] -# removed in MC -## These should be transformed -#html_special_response = {"textline":inputtypes.textline.render, -# "schematic":inputtypes.schematic.render, -# "textbox":inputtypes.textbox.render, -# "formulainput":inputtypes.jstextline.render, -# "solution":inputtypes.solution.render, -# } +log = logging.getLogger('mitx.' + __name__) +#----------------------------------------------------------------------------- +# main class for this module class LoncapaProblem(object): + ''' + Main class for capa Problems. + ''' + def __init__(self, fileobject, id, state=None, seed=None, system=None): + ''' + Initializes capa Problem. The problem itself is defined by the XML file + pointed to by fileobject. + + Arguments: + + - filesobject : an OSFS instance: see fs.osfs + - id : string used as the identifier for this problem; often a filename (no spaces) + - state : student state (represented as a dict) + - seed : random number generator seed (int) + - system : I4xSystem instance which provides OS, rendering, and user context + + ''' + ## Initialize class variables from state - self.seed = None - self.student_answers = dict() - self.correct_map = dict() - self.done = False + self.do_reset() self.problem_id = id self.system = system - - if seed is not None: - self.seed = seed + self.seed = seed if state: if 'seed' in state: @@ -101,7 +95,7 @@ class LoncapaProblem(object): if 'student_answers' in state: self.student_answers = state['student_answers'] if 'correct_map' in state: - self.correct_map = state['correct_map'] + self.correct_map.set_dict(state['correct_map']) if 'done' in state: self.done = state['done'] @@ -109,22 +103,30 @@ class LoncapaProblem(object): if not self.seed: self.seed = struct.unpack('i', os.urandom(4))[0] - ## Parse XML file - if getattr(system, 'DEBUG', False): + self.fileobject = fileobject # save problem file object, so we can use for debugging information later + if getattr(system, 'DEBUG', False): # get the problem XML string from the problem file log.info("[courseware.capa.capa_problem.lcp.init] fileobject = %s" % fileobject) file_text = fileobject.read() - self.fileobject = fileobject # save it, so we can use for debugging information later - # Convert startouttext and endouttext to proper - # TODO: Do with XML operations - file_text = re.sub("startouttext\s*/", "text", file_text) + file_text = re.sub("startouttext\s*/", "text", file_text) # Convert startouttext and endouttext to proper file_text = re.sub("endouttext\s*/", "/text", file_text) - self.tree = etree.XML(file_text) - self.preprocess_problem(self.tree, correct_map=self.correct_map, answer_map=self.student_answers) - self.context = self.extract_context(self.tree, seed=self.seed) - for response in self.tree.xpath('//' + "|//".join(response_types)): - responder = response_types[response.tag](response, self.context, self.system) - responder.preprocess_response() + self.tree = etree.XML(file_text) # parse problem XML file into an element tree + + # construct script processor context (eg for customresponse problems) + self.context = self._extract_context(self.tree, seed=self.seed) + + # pre-parse the XML tree: modifies it to add ID's and perform some in-place transformations + # this also creates the dict (self.responders) of Response instances for each question in the problem. + # the dict has keys = xml subtree of Response, values = Response instance + self._preprocess_problem(self.tree) + + def do_reset(self): + ''' + Reset internal state to unfinished, with no answers + ''' + self.student_answers = dict() + self.correct_map = CorrectMap() + self.done = False def __unicode__(self): return u"LoncapaProblem ({0})".format(self.fileobject) @@ -133,25 +135,49 @@ class LoncapaProblem(object): ''' Stored per-user session data neeeded to: 1) Recreate the problem 2) Populate any student answers. ''' + return {'seed': self.seed, 'student_answers': self.student_answers, - 'correct_map': self.correct_map, + 'correct_map': self.correct_map.get_dict(), 'done': self.done} def get_max_score(self): ''' - TODO: multiple points for programming problems. + Return maximum score for this problem. + We do this by counting the number of answers available for each question + in the problem. If the Response for a question has a get_max_score() method + then we call that and add its return value to the count. That can be + used to give complex problems (eg programming questions) multiple points. ''' - sum = 0 - for et in entry_types: - sum = sum + self.tree.xpath('count(//' + et + ')') - return int(sum) + maxscore = 0 + for responder in self.responders.values(): + if hasattr(responder,'get_max_score'): + try: + maxscore += responder.get_max_score() + except Exception: + log.debug('responder %s failed to properly return from get_max_score()' % responder) # FIXME + raise + else: + try: + maxscore += len(responder.get_answers()) + except: + log.debug('responder %s failed to properly return get_answers()' % responder) # FIXME + raise + return maxscore def get_score(self): + ''' + Compute score for this problem. The score is the number of points awarded. + Returns an integer, from 0 to get_max_score(). + ''' correct = 0 for key in self.correct_map: - if self.correct_map[key] == u'correct': - correct += 1 + try: + correct += self.correct_map.get_npoints(key) + except Exception: + log.error('key=%s, correct_map = %s' % (key,self.correct_map)) + raise + if (not self.student_answers) or len(self.student_answers) == 0: return {'score': 0, 'total': self.get_max_score()} @@ -166,42 +192,37 @@ class LoncapaProblem(object): of each key removed (the string before the first "_"). Thus, for example, input_ID123 -> ID123, and input_fromjs_ID123 -> fromjs_ID123 + + Calles the Response for each question in this problem, to do the actual grading. ''' self.student_answers = answers - self.correct_map = dict() - problems_simple = self.extract_problems(self.tree) - for response in problems_simple: - grader = response_types[response.tag](response, self.context, self.system) - results = grader.get_score(answers) # call the responsetype instance to do the actual grading - self.correct_map.update(results) - return self.correct_map + oldcmap = self.correct_map # old CorrectMap + newcmap = CorrectMap() # start new with empty CorrectMap + # log.debug('Responders: %s' % self.responders) + for responder in self.responders.values(): + results = responder.evaluate_answers(answers,oldcmap) # call the responsetype instance to do the actual grading + newcmap.update(results) + self.correct_map = newcmap + # log.debug('%s: in grade_answers, answers=%s, cmap=%s' % (self,answers,newcmap)) + return newcmap def get_question_answers(self): - """Returns a dict of answer_ids to answer values. If we can't generate + """Returns a dict of answer_ids to answer values. If we cannot generate an answer (this sometimes happens in customresponses), that answer_id is not included. Called by "show answers" button JSON request (see capa_module) """ answer_map = dict() - problems_simple = self.extract_problems(self.tree) # purified (flat) XML tree of just response queries - for response in problems_simple: - responder = response_types[response.tag](response, self.context, self.system) # instance of numericalresponse, customresponse,... + for responder in self.responders.values(): results = responder.get_answers() answer_map.update(results) # dict of (id,correct_answer) - # example for the following: - for entry in problems_simple.xpath("//" + "|//".join(response_properties + entry_types)): - answer = entry.get('correct_answer') # correct answer, when specified elsewhere, eg in a textline - if answer: - answer_map[entry.get('id')] = contextualize_text(answer, self.context) - # include solutions from ... stanzas - # Tentative merge; we should figure out how we want to handle hints and solutions for entry in self.tree.xpath("//" + "|//".join(solution_types)): answer = etree.tostring(entry) - if answer: - answer_map[entry.get('id')] = answer + if answer: answer_map[entry.get('id')] = answer + log.debug('answer_map = %s' % answer_map) return answer_map def get_answer_ids(self): @@ -209,19 +230,19 @@ class LoncapaProblem(object): the dicts returned by grade_answers and get_question_answers. (Though get_question_answers may only return a subset of these.""" answer_ids = [] - problems_simple = self.extract_problems(self.tree) - for response in problems_simple: - responder = response_types[response.tag](response, self.context) - if hasattr(responder, "answer_id"): - answer_ids.append(responder.answer_id) - # customresponse types can have multiple answer_ids - elif hasattr(responder, "answer_ids"): - answer_ids.extend(responder.answer_ids) - + for responder in self.responders.values(): + answer_ids.append(responder.get_answers().keys()) return answer_ids - # ======= Private ======== - def extract_context(self, tree, seed=struct.unpack('i', os.urandom(4))[0]): # private + def get_html(self): + ''' + Main method called externally to get the HTML to be rendered for this capa Problem. + ''' + return contextualize_text(etree.tostring(self._extract_html(self.tree)), self.context) + + # ======= Private Methods Below ======== + + def _extract_context(self, tree, seed=struct.unpack('i', os.urandom(4))[0]): # private ''' Extract content of from the problem.xml file, and exec it in the context of this problem. Provides ability to randomize problems, and also set @@ -230,12 +251,11 @@ class LoncapaProblem(object): Problem XML goes to Python execution context. Runs everything in script tags ''' random.seed(self.seed) - context = {'global_context': global_context} # save global context in here also - context.update(global_context) # initialize context to have stuff in global_context - context['__builtins__'] = globals()['__builtins__'] # put globals there also - context['the_lcp'] = self # pass instance of LoncapaProblem in + context = {'global_context': global_context} # save global context in here also + context.update(global_context) # initialize context to have stuff in global_context + context['__builtins__'] = globals()['__builtins__'] # put globals there also + context['the_lcp'] = self # pass instance of LoncapaProblem in - #for script in tree.xpath('/problem/script'): for script in tree.findall('.//script'): stype = script.get('type') if stype: @@ -253,158 +273,103 @@ class LoncapaProblem(object): log.exception("Error while execing code: " + code) return context - def get_html(self): - return contextualize_text(etree.tostring(self.extract_html(self.tree)[0]), self.context) + def _extract_html(self, problemtree): # private + ''' + Main (private) function which converts Problem XML tree to HTML. + Calls itself recursively. - def extract_html(self, problemtree): # private - ''' Helper function for get_html. Recursively converts XML tree to HTML + Returns Element tree of XHTML representation of problemtree. + Calls render_html of Response instances to render responses into XHTML. + + Used by get_html. ''' if problemtree.tag in html_problem_semantics: return problemid = problemtree.get('id') # my ID - # used to be - # if problemtree.tag in html_special_response: - if problemtree.tag in inputtypes.get_input_xml_tags(): - # status is currently the answer for the problem ID for the input element, - # but it will turn into a dict containing both the answer and any associated message - # for the problem ID for the input element. + status = "unsubmitted" + msg = '' + hint = '' + hintmode = None if problemid in self.correct_map: - status = self.correct_map[problemtree.get('id')] + pid = problemtree.get('id') + status = self.correct_map.get_correctness(pid) + msg = self.correct_map.get_msg(pid) + hint = self.correct_map.get_hint(pid) + hintmode = self.correct_map.get_hintmode(pid) value = "" if self.student_answers and problemid in self.student_answers: value = self.student_answers[problemid] - #### This code is a hack. It was merged to help bring two branches - #### in sync, but should be replaced. msg should be passed in a - #### response_type - # prepare the response message, if it exists in correct_map - if 'msg' in self.correct_map: - msg = self.correct_map['msg'] - elif ('msg_%s' % problemid) in self.correct_map: - msg = self.correct_map['msg_%s' % problemid] - else: - msg = '' - # do the rendering - # This should be broken out into a helper function - # that handles all input objects render_object = inputtypes.SimpleInput(system=self.system, xml=problemtree, state={'value': value, 'status': status, 'id': problemtree.get('id'), - 'feedback': {'message': msg} + 'feedback': {'message': msg, + 'hint' : hint, + 'hintmode' : hintmode, + } }, use='capa_input') return render_object.get_html() # function(problemtree, value, status, msg) # render the special response (textline, schematic,...) - tree = Element(problemtree.tag) + if problemtree in self.responders: # let each Response render itself + return self.responders[problemtree].render_html(self._extract_html) + + tree = etree.Element(problemtree.tag) for item in problemtree: - subitems = self.extract_html(item) - if subitems is not None: - for subitem in subitems: - tree.append(subitem) - for (key, value) in problemtree.items(): - tree.set(key, value) + item_xhtml = self._extract_html(item) # nothing special: recurse + if item_xhtml is not None: + tree.append(item_xhtml) + + if tree.tag in html_transforms: + tree.tag = html_transforms[problemtree.tag]['tag'] + else: + for (key, value) in problemtree.items(): # copy attributes over if not innocufying + tree.set(key, value) tree.text = problemtree.text tree.tail = problemtree.tail - if problemtree.tag in html_transforms: - tree.tag = html_transforms[problemtree.tag]['tag'] - # Reset attributes. Otherwise, we get metadata in HTML - # (e.g. answers) - # TODO: We should remove and not zero them. - # I'm not sure how to do that quickly with lxml - for k in tree.keys(): - tree.set(k, "") + return tree - # TODO: Fix. This loses Element().tail - #if problemtree.tag in html_skip: - # return tree - return [tree] - - def preprocess_problem(self, tree, correct_map=dict(), answer_map=dict()): # private + def _preprocess_problem(self, tree): # private ''' Assign IDs to all the responses Assign sub-IDs to all entries (textline, schematic, etc.) Annoted correctness and value In-place transformation + + Also create capa Response instances for each responsetype and save as self.responders ''' response_id = 1 - for response in tree.xpath('//' + "|//".join(response_types)): + self.responders = {} + for response in tree.xpath('//' + "|//".join(response_tag_dict)): response_id_str = self.problem_id + "_" + str(response_id) - response.attrib['id'] = response_id_str - if response_id not in correct_map: - correct = 'unsubmitted' - response.attrib['state'] = correct - response_id = response_id + 1 + response.set('id',response_id_str) # create and save ID for this response + response_id += 1 + answer_id = 1 - for entry in tree.xpath("|".join(['//' + response.tag + '[@id=$id]//' + x for x in (entry_types + solution_types)]), - id=response_id_str): - # assign one answer_id for each entry_type or solution_type + inputfields = tree.xpath("|".join(['//' + response.tag + '[@id=$id]//' + x for x in (entry_types + solution_types)]), + id=response_id_str) + for entry in inputfields: # assign one answer_id for each entry_type or solution_type entry.attrib['response_id'] = str(response_id) entry.attrib['answer_id'] = str(answer_id) entry.attrib['id'] = "%s_%i_%i" % (self.problem_id, response_id, answer_id) answer_id = answer_id + 1 + responder = response_tag_dict[response.tag](response, inputfields, self.context, self.system) # instantiate capa Response + self.responders[response] = responder # save in list in self + # ... may not be associated with any specific response; give IDs for those separately # TODO: We should make the namespaces consistent and unique (e.g. %s_problem_%i). solution_id = 1 for solution in tree.findall('.//solution'): solution.attrib['id'] = "%s_solution_%i" % (self.problem_id, solution_id) solution_id += 1 - - def extract_problems(self, problem_tree): - ''' Remove layout from the problem, and give a purified XML tree of just the problems ''' - problem_tree = copy.deepcopy(problem_tree) - tree = Element('problem') - for response in problem_tree.xpath("//" + "|//".join(response_types)): - newresponse = copy.copy(response) - for e in newresponse: - newresponse.remove(e) - # copy.copy is needed to make xpath work right. Otherwise, it starts at the root - # of the tree. We should figure out if there's some work-around - for e in copy.copy(response).xpath("//" + "|//".join(response_properties + entry_types)): - newresponse.append(e) - - tree.append(newresponse) - return tree - -if __name__ == '__main__': - problem_id = 'simpleFormula' - filename = 'simpleFormula.xml' - - problem_id = 'resistor' - filename = 'resistor.xml' - - lcp = LoncapaProblem(filename, problem_id) - - context = lcp.extract_context(lcp.tree) - problem = lcp.extract_problems(lcp.tree) - print lcp.grade_problems({'resistor_2_1': '1.0', 'resistor_3_1': '2.0'}) - #print lcp.grade_problems({'simpleFormula_2_1':'3*x^3'}) -#numericalresponse(problem, context) - -#print etree.tostring((lcp.tree)) - print '============' - print -#print etree.tostring(lcp.extract_problems(lcp.tree)) - print lcp.get_html() -#print extract_context(tree) - - - - # def handle_fr(self, element): - # problem={"answer":self.contextualize_text(answer), - # "type":"formularesponse", - # "tolerance":evaluator({},{},self.contextualize_text(tolerance)), - # "sample_range":dict(zip(variables, sranges)), - # "samples_count": numsamples, - # "id":id, - # self.questions[self.lid]=problem diff --git a/common/lib/capa/correctmap.py b/common/lib/capa/correctmap.py new file mode 100644 index 0000000000..786b2f5e2d --- /dev/null +++ b/common/lib/capa/correctmap.py @@ -0,0 +1,109 @@ +#----------------------------------------------------------------------------- +# class used to store graded responses to CAPA questions +# +# Used by responsetypes and capa_problem + +class CorrectMap(object): + ''' + Stores map between answer_id and response evaluation result for each question + in a capa problem. The response evaluation result for each answer_id includes + (correctness, npoints, msg, hint, hintmode). + + - correctness : either 'correct' or 'incorrect' + - npoints : None, or integer specifying number of points awarded for this answer_id + - msg : string (may have HTML) giving extra message response (displayed below textline or textbox) + - hint : string (may have HTML) giving optional hint (displayed below textline or textbox, above msg) + - hintmode : one of (None,'on_request','always') criteria for displaying hint + + Behaves as a dict. + ''' + def __init__(self,*args,**kwargs): + self.cmap = dict() # start with empty dict + self.items = self.cmap.items + self.keys = self.cmap.keys + self.set(*args,**kwargs) + + def __getitem__(self, *args, **kwargs): + return self.cmap.__getitem__(*args, **kwargs) + + def __iter__(self): + return self.cmap.__iter__() + + def set(self, answer_id=None, correctness=None, npoints=None, msg='', hint='', hintmode=None): + if answer_id is not None: + self.cmap[answer_id] = {'correctness': correctness, + 'npoints': npoints, + 'msg': msg, + 'hint' : hint, + 'hintmode' : hintmode, + } + + def __repr__(self): + return repr(self.cmap) + + def get_dict(self): + ''' + return dict version of self + ''' + return self.cmap + + def set_dict(self,correct_map): + ''' + set internal dict to provided correct_map dict + for graceful migration, if correct_map is a one-level dict, then convert it to the new + dict of dicts format. + ''' + if correct_map and not (type(correct_map[correct_map.keys()[0]])==dict): + self.__init__() # empty current dict + for k in correct_map: self.set(k,correct_map[k]) # create new dict entries + else: + self.cmap = correct_map + + def is_correct(self,answer_id): + if answer_id in self.cmap: return self.cmap[answer_id]['correctness'] == 'correct' + return None + + def get_npoints(self,answer_id): + if self.is_correct(answer_id): + npoints = self.cmap[answer_id].get('npoints',1) # default to 1 point if correct + return npoints or 1 + return 0 # if not correct, return 0 + + def set_property(self,answer_id,property,value): + if answer_id in self.cmap: self.cmap[answer_id][property] = value + else: self.cmap[answer_id] = {property:value} + + def get_property(self,answer_id,property,default=None): + if answer_id in self.cmap: return self.cmap[answer_id].get(property,default) + return default + + def get_correctness(self,answer_id): + return self.get_property(answer_id,'correctness') + + def get_msg(self,answer_id): + return self.get_property(answer_id,'msg','') + + def get_hint(self,answer_id): + return self.get_property(answer_id,'hint','') + + def get_hintmode(self,answer_id): + return self.get_property(answer_id,'hintmode',None) + + def set_hint_and_mode(self,answer_id,hint,hintmode): + ''' + - hint : (string) HTML text for hint + - hintmode : (string) mode for hint display ('always' or 'on_request') + ''' + self.set_property(answer_id,'hint',hint) + self.set_property(answer_id,'hintmode',hintmode) + + def update(self,other_cmap): + ''' + Update this CorrectMap with the contents of another CorrectMap + ''' + if not isinstance(other_cmap,CorrectMap): + raise Exception('CorrectMap.update called with invalid argument %s' % other_cmap) + self.cmap.update(other_cmap.get_dict()) + + + diff --git a/common/lib/capa/inputtypes.py b/common/lib/capa/inputtypes.py index 3b25be3db7..75588e8aea 100644 --- a/common/lib/capa/inputtypes.py +++ b/common/lib/capa/inputtypes.py @@ -32,17 +32,61 @@ def get_input_xml_tags(): return SimpleInput.get_xml_tags() class SimpleInput():# XModule - ''' Type for simple inputs -- plain HTML with a form element - State is a dictionary with optional keys: - * Value - * ID - * Status (answered, unanswered, unsubmitted) - * Feedback (dictionary containing keys for hints, errors, or other - feedback from previous attempt) + ''' + Type for simple inputs -- plain HTML with a form element ''' xml_tags = {} ## Maps tags to functions - + + def __init__(self, system, xml, item_id = None, track_url=None, state=None, use = 'capa_input'): + ''' + Instantiate a SimpleInput class. Arguments: + + - system : I4xSystem instance which provides OS, rendering, and user context + - xml : Element tree of this Input element + - item_id : id for this input element (assigned by capa_problem.LoncapProblem) - string + - track_url : URL used for tracking - string + - state : a dictionary with optional keys: + * Value + * ID + * Status (answered, unanswered, unsubmitted) + * Feedback (dictionary containing keys for hints, errors, or other + feedback from previous attempt) + - use : + ''' + + self.xml = xml + self.tag = xml.tag + self.system = system + if not state: state = {} + + ## ID should only come from one place. + ## If it comes from multiple, we use state first, XML second, and parameter + ## third. Since we don't make this guarantee, we can swap this around in + ## the future if there's a more logical order. + if item_id: self.id = item_id + if xml.get('id'): self.id = xml.get('id') + if 'id' in state: self.id = state['id'] + + self.value = '' + if 'value' in state: + self.value = state['value'] + + self.msg = '' + feedback = state.get('feedback') + if feedback is not None: + self.msg = feedback.get('message','') + self.hint = feedback.get('hint','') + self.hintmode = feedback.get('hintmode',None) + + # put hint above msg if to be displayed + if self.hintmode == 'always': + self.msg = self.hint + ('
' if self.msg else '') + self.msg + + self.status = 'unanswered' + if 'status' in state: + self.status = state['status'] + @classmethod def get_xml_tags(c): return c.xml_tags.keys() @@ -54,79 +98,6 @@ class SimpleInput():# XModule def get_html(self): return self.xml_tags[self.tag](self.xml, self.value, self.status, self.system.render_template, self.msg) - def __init__(self, system, xml, item_id = None, track_url=None, state=None, use = 'capa_input'): - self.xml = xml - self.tag = xml.tag - if not state: - state = {} - ## ID should only come from one place. - ## If it comes from multiple, we use state first, XML second, and parameter - ## third. Since we don't make this guarantee, we can swap this around in - ## the future if there's a more logical order. - if item_id: - self.id = item_id - if xml.get('id'): - self.id = xml.get('id') - if 'id' in state: - self.id = state['id'] - self.system = system - - self.value = '' - if 'value' in state: - self.value = state['value'] - - self.msg = '' - if 'feedback' in state and 'message' in state['feedback']: - self.msg = state['feedback']['message'] - - self.status = 'unanswered' - if 'status' in state: - self.status = state['status'] - -## TODO -# class SimpleTransform(): -# ''' Type for simple XML to HTML transforms. Examples: -# * Math tags, which go from LON-CAPA-style m-tags to MathJAX -# ''' -# xml_tags = {} ## Maps tags to functions - -# @classmethod -# def get_xml_tags(c): -# return c.xml_tags.keys() - -# @classmethod -# def get_uses(c): -# return ['capa_transform'] - -# def get_html(self): -# return self.xml_tags[self.tag](self.xml, self.value, self.status, self.msg) - -# def __init__(self, system, xml, item_id = None, track_url=None, state=None, use = 'capa_input'): -# self.xml = xml -# self.tag = xml.tag -# if not state: -# state = {} -# if item_id: -# self.id = item_id -# if xml.get('id'): -# self.id = xml.get('id') -# if 'id' in state: -# self.id = state['id'] -# self.system = system - -# self.value = '' -# if 'value' in state: -# self.value = state['value'] - -# self.msg = '' -# if 'feedback' in state and 'message' in state['feedback']: -# self.msg = state['feedback']['message'] - -# self.status = 'unanswered' -# if 'status' in state: -# self.status = state['status'] - - def register_render_function(fn, names=None, cls=SimpleInput): if names is None: SimpleInput.xml_tags[fn.__name__] = fn @@ -136,9 +107,6 @@ def register_render_function(fn, names=None, cls=SimpleInput): return fn return wrapped - - - #----------------------------------------------------------------------------- @register_render_function @@ -201,16 +169,20 @@ def choicegroup(element, value, status, render_template, msg=''): return etree.XML(html) @register_render_function -def textline(element, value, state, render_template, msg=""): +def textline(element, value, status, render_template, msg=""): ''' Simple text line input, with optional size specification. ''' if element.get('math') or element.get('dojs'): # 'dojs' flag is temporary, for backwards compatibility with 8.02x - return SimpleInput.xml_tags['textline_dynamath'](element,value,state,render_template,msg) + return SimpleInput.xml_tags['textline_dynamath'](element,value,status,render_template,msg) eid=element.get('id') + if eid is None: + msg = 'textline has no id: it probably appears outside of a known response type' + msg += "\nSee problem XML source line %s" % getattr(element,'sourceline','') + raise Exception(msg) count = int(eid.split('_')[-2])-1 # HACK size = element.get('size') - context = {'id':eid, 'value':value, 'state':state, 'count':count, 'size': size, 'msg': msg} + context = {'id':eid, 'value':value, 'state':status, 'count':count, 'size': size, 'msg': msg} html = render_template("textinput.html", context) return etree.XML(html) diff --git a/common/lib/capa/responsetypes.py b/common/lib/capa/responsetypes.py index c5683bb0bf..56dfd32343 100644 --- a/common/lib/capa/responsetypes.py +++ b/common/lib/capa/responsetypes.py @@ -21,44 +21,252 @@ import abc # specific library imports from calc import evaluator, UndefinedVariable -from util import contextualize_text +from correctmap import CorrectMap +from util import * from lxml import etree from lxml.html.soupparser import fromstring as fromstring_bs # uses Beautiful Soup!!! FIXME? -log = logging.getLogger(__name__) +log = logging.getLogger('mitx.' + __name__) -def compare_with_tolerance(v1, v2, tol): - ''' Compare v1 to v2 with maximum tolerance tol - tol is relative if it ends in %; otherwise, it is absolute +#----------------------------------------------------------------------------- +# Exceptions + +class LoncapaProblemError(Exception): ''' - relative = "%" in tol - if relative: - tolerance_rel = evaluator(dict(),dict(),tol[:-1]) * 0.01 - tolerance = tolerance_rel * max(abs(v1), abs(v2)) - else: - tolerance = evaluator(dict(),dict(),tol) - return abs(v1-v2) <= tolerance + Error in specification of a problem + ''' + pass -class GenericResponse(object): +class ResponseError(Exception): + ''' + Error for failure in processing a response + ''' + pass + +class StudentInputError(Exception): + pass + +#----------------------------------------------------------------------------- +# +# Main base class for CAPA responsetypes + +class LoncapaResponse(object): + ''' + Base class for CAPA responsetypes. Each response type (ie a capa question, + which is part of a capa problem) is represented as a subclass, + which should provide the following methods: + + - get_score : evaluate the given student answers, and return a CorrectMap + - get_answers : provide a dict of the expected answers for this problem + + Each subclass must also define the following attributes: + + - response_tag : xhtml tag identifying this response (used in auto-registering) + + In addition, these methods are optional: + + - get_max_score : if defined, this is called to obtain the maximum score possible for this question + - setup_response : find and note the answer input field IDs for the response; called by __init__ + - check_hint_condition : check to see if the student's answers satisfy a particular condition for a hint to be displayed + - render_html : render this Response as HTML (must return XHTML compliant string) + - __unicode__ : unicode representation of this Response + + Each response type may also specify the following attributes: + + - max_inputfields : (int) maximum number of answer input fields (checked in __init__ if not None) + - allowed_inputfields : list of allowed input fields (each a string) for this Response + - required_attributes : list of required attributes (each a string) on the main response XML stanza + - hint_tag : xhtml tag identifying hint associated with this response inside hintgroup + + ''' __metaclass__=abc.ABCMeta # abc = Abstract Base Class + response_tag = None + hint_tag = None + + max_inputfields = None + allowed_inputfields = [] + required_attributes = [] + + def __init__(self, xml, inputfields, context, system=None): + ''' + Init is passed the following arguments: + + - xml : ElementTree of this Response + - inputfields : ordered list of ElementTrees for each input entry field in this Response + - context : script processor context + - system : I4xSystem instance which provides OS, rendering, and user context + + ''' + self.xml = xml + self.inputfields = inputfields + self.context = context + self.system = system + + for abox in inputfields: + if abox.tag not in self.allowed_inputfields: + msg = "%s: cannot have input field %s" % (unicode(self),abox.tag) + msg += "\nSee XML source line %s" % getattr(xml,'sourceline','') + raise LoncapaProblemError(msg) + + if self.max_inputfields and len(inputfields)>self.max_inputfields: + msg = "%s: cannot have more than %s input fields" % (unicode(self),self.max_inputfields) + msg += "\nSee XML source line %s" % getattr(xml,'sourceline','') + raise LoncapaProblemError(msg) + + for prop in self.required_attributes: + if not xml.get(prop): + msg = "Error in problem specification: %s missing required attribute %s" % (unicode(self),prop) + msg += "\nSee XML source line %s" % getattr(xml,'sourceline','') + raise LoncapaProblemError(msg) + + self.answer_ids = [x.get('id') for x in self.inputfields] # ordered list of answer_id values for this response + if self.max_inputfields==1: + self.answer_id = self.answer_ids[0] # for convenience + + self.default_answer_map = {} # dict for default answer map (provided in input elements) + for entry in self.inputfields: + answer = entry.get('correct_answer') + if answer: + self.default_answer_map[entry.get('id')] = contextualize_text(answer, self.context) + + if hasattr(self,'setup_response'): + self.setup_response() + + def render_html(self,renderer): + ''' + Return XHTML Element tree representation of this Response. + + Arguments: + + - renderer : procedure which produces HTML given an ElementTree + ''' + tree = etree.Element('span') # render ourself as a + our content + for item in self.xml: + item_xhtml = renderer(item) # call provided procedure to do the rendering + if item_xhtml is not None: tree.append(item_xhtml) + tree.tail = self.xml.tail + return tree + + def evaluate_answers(self,student_answers,old_cmap): + ''' + Called by capa_problem.LoncapaProblem to evaluate student answers, and to + generate hints (if any). + + Returns the new CorrectMap, with (correctness,msg,hint,hintmode) for each answer_id. + ''' + new_cmap = self.get_score(student_answers) + self.get_hints(student_answers, new_cmap, old_cmap) + # log.debug('new_cmap = %s' % new_cmap) + return new_cmap + + def get_hints(self, student_answers, new_cmap, old_cmap): + ''' + Generate adaptive hints for this problem based on student answers, the old CorrectMap, + and the new CorrectMap produced by get_score. + + Does not return anything. + + Modifies new_cmap, by adding hints to answer_id entries as appropriate. + ''' + hintgroup = self.xml.find('hintgroup') + if hintgroup is None: return + + # hint specified by function? + hintfn = hintgroup.get('hintfn') + if hintfn: + ''' + Hint is determined by a function defined in the @@ -241,16 +501,11 @@ def sympy_check2(): '''}] - def __init__(self, xml, context, system=None): - self.xml = xml - self.system = system - ## CRITICAL TODO: Should cover all entrytypes - ## NOTE: xpath will look at root of XML tree, not just - ## what's in xml. @id=id keeps us in the right customresponse. - self.answer_ids = xml.xpath('//*[@id=$id]//textline/@id', - id=xml.get('id')) - self.answer_ids += [x.get('id') for x in xml.findall('textbox')] # also allow textbox inputs - self.context = context + response_tag = 'customresponse' + allowed_inputfields = ['textline','textbox'] + + def setup_response(self): + xml = self.xml # if has an "expect" (or "answer") attribute then save that self.expect = xml.get('expect') or xml.get('answer') @@ -271,15 +526,17 @@ def sympy_check2(): cfn = xml.get('cfn') if cfn: log.debug("cfn = %s" % cfn) - if cfn in context: - self.code = context[cfn] + if cfn in self.context: + self.code = self.context[cfn] else: - print "can't find cfn in context = ",context + msg = "%s: can't find cfn %s in context" % (unicode(self),cfn) + msg += "\nSee XML source line %s" % getattr(self.xml,'sourceline','') + raise LoncapaProblemError(msg) if not self.code: if answer is None: # raise Exception,"[courseware.capa.responsetypes.customresponse] missing code checking script! id=%s" % self.myid - print "[courseware.capa.responsetypes.customresponse] missing code checking script! id=%s" % self.myid + log.error("[courseware.capa.responsetypes.customresponse] missing code checking script! id=%s" % self.myid) self.code = '' else: answer_src = answer.get('src') @@ -294,6 +551,8 @@ def sympy_check2(): of each key removed (the string before the first "_"). ''' + log.debug('%s: student_answers=%s' % (unicode(self),student_answers)) + idset = sorted(self.answer_ids) # ordered list of answer id's try: submission = [student_answers[k] for k in idset] # ordered list of answers @@ -301,7 +560,7 @@ def sympy_check2(): msg = '[courseware.capa.responsetypes.customresponse] error getting student answer from %s' % student_answers msg += '\n idset = %s, error = %s' % (idset,err) log.error(msg) - raise Exception,msg + raise Exception(msg) # global variable in context which holds the Presentation MathML from dynamic math input dynamath = [ student_answers.get(k+'_dynamath',None) for k in idset ] # ordered list of dynamath responses @@ -364,7 +623,7 @@ def sympy_check2(): log.error("oops in customresponse (cfn) error %s" % err) # print "context = ",self.context log.error(traceback.format_exc()) - raise Exception,"oops in customresponse (cfn) error %s" % err + raise Exception("oops in customresponse (cfn) error %s" % err) log.debug("[courseware.capa.responsetypes.customresponse.get_score] ret = %s" % ret) if type(ret)==dict: correct = ['correct']*len(idset) if ret['ok'] else ['incorrect']*len(idset) @@ -386,28 +645,26 @@ def sympy_check2(): correct = ['correct']*len(idset) if ret else ['incorrect']*len(idset) # build map giving "correct"ness of the answer(s) - #correct_map = dict(zip(idset, self.context['correct'])) - correct_map = {} + correct_map = CorrectMap() for k in range(len(idset)): - correct_map[idset[k]] = correct[k] - correct_map['msg_%s' % idset[k]] = messages[k] - return correct_map + correct_map.set(idset[k], correct[k], msg=messages[k]) + return correct_map def get_answers(self): ''' Give correct answer expected for this response. - capa_problem handles correct_answers from entry objects like textline, and that - is what should be used when this response has multiple entry objects. + use default_answer_map from entry elements (eg textline), + when this response has multiple entry objects. but for simplicity, if an "expect" attribute was given by the content author - ie then return it now. + ie then that. ''' if len(self.answer_ids)>1: - return {} + return self.default_answer_map if self.expect: return {self.answer_ids[0] : self.expect} - return {} + return self.default_answer_map #----------------------------------------------------------------------------- @@ -425,16 +682,18 @@ class SymbolicResponse(CustomResponse): Your input should be typed in as a list of lists, eg [[1,2],[3,4]]. '''}] - def __init__(self, xml, context, system=None): - xml.set('cfn','symmath_check') + + response_tag = 'symbolicresponse' + + def setup_response(self): + self.xml.set('cfn','symmath_check') code = "from symmath import *" - exec code in context,context - CustomResponse.__init__(self,xml,context,system) - + exec code in self.context,self.context + CustomResponse.setup_response(self) #----------------------------------------------------------------------------- -class ExternalResponse(GenericResponse): +class ExternalResponse(LoncapaResponse): ''' Grade the students input using an external server. @@ -480,15 +739,14 @@ main() '''}] - def __init__(self, xml, context, system=None): - self.xml = xml - self.url = xml.get('url') or "http://eecs1.mit.edu:8889/pyloncapa" # FIXME - hardcoded URL - self.answer_ids = xml.xpath('//*[@id=$id]//textbox/@id|//*[@id=$id]//textline/@id', - id=xml.get('id')) - self.context = context - answer = xml.xpath('//*[@id=$id]//answer', - id=xml.get('id'))[0] + response_tag = 'externalresponse' + allowed_inputfields = ['textline','textbox'] + def setup_response(self): + xml = self.xml + self.url = xml.get('url') or "http://eecs1.mit.edu:8889/pyloncapa" # FIXME - hardcoded URL + + answer = xml.xpath('//*[@id=$id]//answer',id=xml.get('id'))[0] # FIXME - catch errors answer_src = answer.get('src') if answer_src is not None: self.code = self.system.filesystem.open('src/'+answer_src).read() @@ -519,28 +777,30 @@ main() except Exception,err: msg = 'Error %s - cannot connect to external server url=%s' % (err,self.url) log.error(msg) - raise Exception, msg + raise Exception(msg) if self.system.DEBUG: log.info('response = %s' % r.text) if (not r.text ) or (not r.text.strip()): - raise Exception,'Error: no response from external server url=%s' % self.url + raise Exception('Error: no response from external server url=%s' % self.url) try: rxml = etree.fromstring(r.text) # response is XML; prase it except Exception,err: msg = 'Error %s - cannot parse response from external server r.text=%s' % (err,r.text) log.error(msg) - raise Exception, msg + raise Exception(msg) return rxml def get_score(self, student_answers): + idset = sorted(self.answer_ids) + cmap = CorrectMap() try: - submission = [student_answers[k] for k in sorted(self.answer_ids)] + submission = [student_answers[k] for k in idset] except Exception,err: log.error('Error %s: cannot get student answer for %s; student_answers=%s' % (err,self.answer_ids,student_answers)) - raise Exception,err + raise Exception(err) self.context.update({'submission':submission}) @@ -551,25 +811,25 @@ main() except Exception, err: log.error('Error %s' % err) if self.system.DEBUG: - correct_map = dict(zip(sorted(self.answer_ids), ['incorrect'] * len(self.answer_ids) )) - correct_map['msg_%s' % self.answer_ids[0]] = '%s' % str(err).replace('<','<') - return correct_map + cmap.set_dict(dict(zip(sorted(self.answer_ids), ['incorrect'] * len(idset) ))) + cmap.set_property(self.answer_ids[0],'msg','%s' % str(err).replace('<','<')) + return cmap ad = rxml.find('awarddetail').text admap = {'EXACT_ANS':'correct', # TODO: handle other loncapa responses - 'WRONG_FORMAT': 'incorrect', + 'WRONG_FORMAT': 'incorrect', } self.context['correct'] = ['correct'] if ad in admap: self.context['correct'][0] = admap[ad] - # self.context['correct'] = ['correct','correct'] - correct_map = dict(zip(sorted(self.answer_ids), self.context['correct'])) - - # store message in correct_map - correct_map['msg_%s' % self.answer_ids[0]] = rxml.find('message').text.replace(' ',' ') + # create CorrectMap + for key in idset: + idx = idset.index(key) + msg = rxml.find('message').text.replace(' ',' ') if idx==0 else None + cmap.set(key, self.context['correct'][idx], msg=msg) - return correct_map + return cmap def get_answers(self): ''' @@ -587,15 +847,13 @@ main() if not (len(exans)==len(self.answer_ids)): log.error('Expected %d answers from external server, only got %d!' % (len(self.answer_ids),len(exans))) - raise Exception,'Short response from external server' + raise Exception('Short response from external server') return dict(zip(self.answer_ids,exans)) -class StudentInputError(Exception): - pass #----------------------------------------------------------------------------- -class FormulaResponse(GenericResponse): +class FormulaResponse(LoncapaResponse): ''' Checking of symbolic math response using numerical sampling. ''' @@ -617,8 +875,15 @@ class FormulaResponse(GenericResponse): '''}] - def __init__(self, xml, context, system=None): - self.xml = xml + response_tag = 'formularesponse' + hint_tag = 'formulahint' + allowed_inputfields = ['textline'] + required_attributes = ['answer'] + max_inputfields = 1 + + def setup_response(self): + xml = self.xml + context = self.context self.correct_answer = contextualize_text(xml.get('answer'), context) self.samples = contextualize_text(xml.get('samples'), context) try: @@ -626,16 +891,8 @@ class FormulaResponse(GenericResponse): id=xml.get('id'))[0] self.tolerance = contextualize_text(self.tolerance_xml, context) except Exception: - self.tolerance = 0 + self.tolerance = '0.00001' - try: - self.answer_id = xml.xpath('//*[@id=$id]//textline/@id', - id=xml.get('id'))[0] - except Exception: - self.answer_id = None - raise Exception, "[courseware.capa.responsetypes.FormulaResponse] Error: missing answer_id!!" - - self.context = context ts = xml.get('type') if ts is None: typeslist = [] @@ -648,12 +905,16 @@ class FormulaResponse(GenericResponse): else: # Default self.case_sensitive = False - def get_score(self, student_answers): - variables=self.samples.split('@')[0].split(',') - numsamples=int(self.samples.split('@')[1].split('#')[1]) + given = student_answers[self.answer_id] + correctness = self.check_formula(self.correct_answer, given, self.samples) + return CorrectMap(self.answer_id, correctness) + + def check_formula(self,expected, given, samples): + variables=samples.split('@')[0].split(',') + numsamples=int(samples.split('@')[1].split('#')[1]) sranges=zip(*map(lambda x:map(float, x.split(",")), - self.samples.split('@')[1].split('#')[0].split(':'))) + samples.split('@')[1].split('#')[0].split(':'))) ranges=dict(zip(variables, sranges)) for i in range(numsamples): @@ -663,23 +924,26 @@ class FormulaResponse(GenericResponse): value = random.uniform(*ranges[var]) instructor_variables[str(var)] = value student_variables[str(var)] = value - instructor_result = evaluator(instructor_variables,dict(),self.correct_answer, cs = self.case_sensitive) + #log.debug('formula: instructor_vars=%s, expected=%s' % (instructor_variables,expected)) + instructor_result = evaluator(instructor_variables,dict(),expected, cs = self.case_sensitive) try: - #print student_variables,dict(),student_answers[self.answer_id] - student_result = evaluator(student_variables,dict(), - student_answers[self.answer_id], + #log.debug('formula: student_vars=%s, given=%s' % (student_variables,given)) + student_result = evaluator(student_variables, + dict(), + given, cs = self.case_sensitive) except UndefinedVariable as uv: + log.debug('formularesponse: undefined variable in given=%s' % given) raise StudentInputError(uv.message+" not permitted in answer") - except: + except Exception, err: #traceback.print_exc() + log.debug('formularesponse: error %s in formula' % err) raise StudentInputError("Error in formula") if numpy.isnan(student_result) or numpy.isinf(student_result): - return {self.answer_id:"incorrect"} + return "incorrect" if not compare_with_tolerance(student_result, instructor_result, self.tolerance): - return {self.answer_id:"incorrect"} - - return {self.answer_id:"correct"} + return "incorrect" + return "correct" def strip_dict(self, d): ''' Takes a dict. Returns an identical dict, with all non-word @@ -691,19 +955,35 @@ class FormulaResponse(GenericResponse): isinstance(d[k], numbers.Number)]) return d + def check_hint_condition(self,hxml_set,student_answers): + given = student_answers[self.answer_id] + hints_to_show = [] + for hxml in hxml_set: + samples = hxml.get('samples') + name = hxml.get('name') + correct_answer = contextualize_text(hxml.get('answer'),self.context) + try: + correctness = self.check_formula(correct_answer, given, samples) + except Exception: + correctness = 'incorrect' + if correctness=='correct': + hints_to_show.append(name) + log.debug('hints_to_show = %s' % hints_to_show) + return hints_to_show + def get_answers(self): return {self.answer_id:self.correct_answer} #----------------------------------------------------------------------------- -class SchematicResponse(GenericResponse): - def __init__(self, xml, context, system=None): - self.xml = xml - self.answer_ids = xml.xpath('//*[@id=$id]//schematic/@id', - id=xml.get('id')) - self.context = context - answer = xml.xpath('//*[@id=$id]//answer', - id=xml.get('id'))[0] +class SchematicResponse(LoncapaResponse): + + response_tag = 'schematicresponse' + allowed_inputfields = ['schematic'] + + def setup_response(self): + xml = self.xml + answer = xml.xpath('//*[@id=$id]//answer', id=xml.get('id'))[0] answer_src = answer.get('src') if answer_src is not None: self.code = self.system.filestore.open('src/'+answer_src).read() # Untested; never used @@ -715,16 +995,17 @@ class SchematicResponse(GenericResponse): submission = [json.loads(student_answers[k]) for k in sorted(self.answer_ids)] self.context.update({'submission':submission}) exec self.code in global_context, self.context - return zip(sorted(self.answer_ids), self.context['correct']) + cmap = CorrectMap() + cmap.set_dict(dict(zip(sorted(self.answer_ids), self.context['correct']))) + return cmap def get_answers(self): - # Since this is explicitly specified in the problem, this will - # be handled by capa_problem - return {} + # use answers provided in input elements + return self.default_answer_map #----------------------------------------------------------------------------- -class ImageResponse(GenericResponse): +class ImageResponse(LoncapaResponse): """ Handle student response for image input: the input is a click on an image, which produces an [x,y] coordinate pair. The click is correct if it falls @@ -740,14 +1021,15 @@ class ImageResponse(GenericResponse): '''}] - def __init__(self, xml, context, system=None): - self.xml = xml - self.context = context - self.ielements = xml.findall('imageinput') + response_tag = 'imageresponse' + allowed_inputfields = ['imageinput'] + + def setup_response(self): + self.ielements = self.inputfields self.answer_ids = [ie.get('id') for ie in self.ielements] def get_score(self, student_answers): - correct_map = {} + correct_map = CorrectMap() expectedset = self.get_answers() for aid in self.answer_ids: # loop through IDs of fields in our stanza @@ -759,21 +1041,28 @@ class ImageResponse(GenericResponse): if not m: msg = 'Error in problem specification! cannot parse rectangle in %s' % (etree.tostring(self.ielements[aid], pretty_print=True)) - raise Exception,'[capamodule.capa.responsetypes.imageinput] '+msg + raise Exception('[capamodule.capa.responsetypes.imageinput] '+msg) (llx,lly,urx,ury) = [int(x) for x in m.groups()] # parse given answer m = re.match('\[([0-9]+),([0-9]+)]',given.strip().replace(' ','')) if not m: - raise Exception,'[capamodule.capa.responsetypes.imageinput] error grading %s (input=%s)' % (aid,given) + raise Exception('[capamodule.capa.responsetypes.imageinput] error grading %s (input=%s)' % (aid,given)) (gx,gy) = [int(x) for x in m.groups()] # answer is correct if (x,y) is within the specified rectangle if (llx <= gx <= urx) and (lly <= gy <= ury): - correct_map[aid] = 'correct' + correct_map.set(aid, 'correct') else: - correct_map[aid] = 'incorrect' + correct_map.set(aid, 'incorrect') return correct_map def get_answers(self): return dict([(ie.get('id'),ie.get('rectangle')) for ie in self.ielements]) + +#----------------------------------------------------------------------------- +# TEMPORARY: List of all response subclasses +# FIXME: To be replaced by auto-registration + +__all__ = [ NumericalResponse, FormulaResponse, CustomResponse, SchematicResponse, MultipleChoiceResponse, TrueFalseResponse, ExternalResponse, ImageResponse, OptionResponse, SymbolicResponse, StringResponse ] + diff --git a/common/lib/capa/templates/textinput_dynamath.html b/common/lib/capa/templates/textinput_dynamath.html index e8b26c5fcc..41b9c5d172 100644 --- a/common/lib/capa/templates/textinput_dynamath.html +++ b/common/lib/capa/templates/textinput_dynamath.html @@ -30,4 +30,7 @@ + % if msg: + ${msg|n} + % endif diff --git a/common/lib/capa/util.py b/common/lib/capa/util.py index d042aa21d3..58e701cbc3 100644 --- a/common/lib/capa/util.py +++ b/common/lib/capa/util.py @@ -1,3 +1,26 @@ +from calc import evaluator, UndefinedVariable + +#----------------------------------------------------------------------------- +# +# Utility functions used in CAPA responsetypes + +def compare_with_tolerance(v1, v2, tol): + ''' Compare v1 to v2 with maximum tolerance tol + tol is relative if it ends in %; otherwise, it is absolute + + - v1 : student result (number) + - v2 : instructor result (number) + - tol : tolerance (string or number) + + ''' + relative = tol.endswith('%') + if relative: + tolerance_rel = evaluator(dict(),dict(),tol[:-1]) * 0.01 + tolerance = tolerance_rel * max(abs(v1), abs(v2)) + else: + tolerance = evaluator(dict(),dict(),tol) + return abs(v1-v2) <= tolerance + def contextualize_text(text, context): # private ''' Takes a string with variables. E.g. $a+$b. Does a substitution of those variables from the context ''' diff --git a/common/lib/xmodule/capa_module.py b/common/lib/xmodule/capa_module.py index 6bd7cbebdc..439982a2c1 100644 --- a/common/lib/xmodule/capa_module.py +++ b/common/lib/xmodule/capa_module.py @@ -13,6 +13,7 @@ from lxml import etree from x_module import XModule, XModuleDescriptor from capa.capa_problem import LoncapaProblem from capa.responsetypes import StudentInputError + log = logging.getLogger("mitx.courseware") #----------------------------------------------------------------------------- @@ -365,18 +366,17 @@ class Module(XModule): self.attempts = self.attempts + 1 self.lcp.done=True - success = 'correct' - for i in correct_map: - if correct_map[i]!='correct': + success = 'correct' # success = correct if ALL questions in this problem are correct + for answer_id in correct_map: + if not correct_map.is_correct(answer_id): success = 'incorrect' - event_info['correct_map']=correct_map + event_info['correct_map']=correct_map.get_dict() # log this in the tracker event_info['success']=success - self.tracker('save_problem_check', event_info) try: - html = self.get_problem_html(encapsulate=False) + html = self.get_problem_html(encapsulate=False) # render problem into HTML except Exception,err: log.error('failed to generate html') raise Exception,err @@ -430,17 +430,10 @@ class Module(XModule): self.tracker('reset_problem_fail', event_info) return "Refresh the page and make an attempt before resetting." - self.lcp.done=False - self.lcp.answers=dict() - self.lcp.correct_map=dict() - self.lcp.student_answers = dict() - - + self.lcp.do_reset() # call method in LoncapaProblem to reset itself if self.rerandomize == "always": - self.lcp.context=dict() - self.lcp.questions=dict() # Detailed info about questions in problem instance. TODO: Should be by id and not lid. - self.lcp.seed=None - + self.lcp.seed=None # reset random number generator seed (note the self.lcp.get_state() in next line) + self.lcp=LoncapaProblem(self.filestore.open(self.filename), self.item_id, self.lcp.get_state(), system=self.system) event_info['new_state']=self.lcp.get_state() diff --git a/common/lib/xmodule/test_files/formularesponse_with_hint.xml b/common/lib/xmodule/test_files/formularesponse_with_hint.xml new file mode 100644 index 0000000000..90248dcf04 --- /dev/null +++ b/common/lib/xmodule/test_files/formularesponse_with_hint.xml @@ -0,0 +1,45 @@ + + + + +

Hints can be provided to students, based on the last response given, as well as the history of responses given. Here is an example of a hint produced by a Formula Response problem.

+ +

+What is the equation of the line which passess through ($x1,$y1) and +($x2,$y2)?

+ +

The correct answer is $answer. A common error is to invert the equation for the slope. Enter +$wrongans to see a hint.

+ +
+ + + + y = + + + + + You have inverted the slope in the question. + + + +
+ diff --git a/common/lib/xmodule/test_files/stringresponse_with_hint.xml b/common/lib/xmodule/test_files/stringresponse_with_hint.xml new file mode 100644 index 0000000000..86efdf0f18 --- /dev/null +++ b/common/lib/xmodule/test_files/stringresponse_with_hint.xml @@ -0,0 +1,25 @@ + +

Example: String Response Problem

+
+
+ + Which US state has Lansing as its capital? + + + + + + + + + The state capital of Wisconsin is Madison. + + + The state capital of Minnesota is St. Paul. + + + The state you are looking for is also known as the 'Great Lakes State' + + + +
diff --git a/common/lib/xmodule/test_files/test_files/symbolicresponse.xml b/common/lib/xmodule/test_files/symbolicresponse.xml similarity index 100% rename from common/lib/xmodule/test_files/test_files/symbolicresponse.xml rename to common/lib/xmodule/test_files/symbolicresponse.xml diff --git a/common/lib/xmodule/tests.py b/common/lib/xmodule/tests.py index 69e69aa1d9..370b3befe5 100644 --- a/common/lib/xmodule/tests.py +++ b/common/lib/xmodule/tests.py @@ -1,9 +1,9 @@ # -# unittests for courseware +# unittests for xmodule (and capa) # # Note: run this using a like like this: # -# django-admin.py test --settings=envs.test_ike --pythonpath=. courseware +# django-admin.py test --settings=lms.envs.test_ike --pythonpath=. common/lib/xmodule import unittest import os @@ -28,12 +28,13 @@ class I4xSystem(object): self.track_function = lambda x: None self.render_function = lambda x: {} # Probably incorrect self.exception404 = Exception + self.DEBUG = True def __repr__(self): return repr(self.__dict__) def __str__(self): return str(self.__dict__) -i4xs = I4xSystem +i4xs = I4xSystem() class ModelsTest(unittest.TestCase): def setUp(self): @@ -96,31 +97,31 @@ class MultiChoiceTest(unittest.TestCase): multichoice_file = os.path.dirname(__file__)+"/test_files/multichoice.xml" test_lcp = lcp.LoncapaProblem(open(multichoice_file), '1', system=i4xs) correct_answers = {'1_2_1':'choice_foil3'} - self.assertEquals(test_lcp.grade_answers(correct_answers)['1_2_1'], 'correct') + self.assertEquals(test_lcp.grade_answers(correct_answers).get_correctness('1_2_1'), 'correct') false_answers = {'1_2_1':'choice_foil2'} - self.assertEquals(test_lcp.grade_answers(false_answers)['1_2_1'], 'incorrect') + self.assertEquals(test_lcp.grade_answers(false_answers).get_correctness('1_2_1'), 'incorrect') def test_MC_bare_grades(self): multichoice_file = os.path.dirname(__file__)+"/test_files/multi_bare.xml" test_lcp = lcp.LoncapaProblem(open(multichoice_file), '1', system=i4xs) correct_answers = {'1_2_1':'choice_2'} - self.assertEquals(test_lcp.grade_answers(correct_answers)['1_2_1'], 'correct') + self.assertEquals(test_lcp.grade_answers(correct_answers).get_correctness('1_2_1'), 'correct') false_answers = {'1_2_1':'choice_1'} - self.assertEquals(test_lcp.grade_answers(false_answers)['1_2_1'], 'incorrect') + self.assertEquals(test_lcp.grade_answers(false_answers).get_correctness('1_2_1'), 'incorrect') def test_TF_grade(self): truefalse_file = os.path.dirname(__file__)+"/test_files/truefalse.xml" test_lcp = lcp.LoncapaProblem(open(truefalse_file), '1', system=i4xs) correct_answers = {'1_2_1':['choice_foil2', 'choice_foil1']} - self.assertEquals(test_lcp.grade_answers(correct_answers)['1_2_1'], 'correct') + self.assertEquals(test_lcp.grade_answers(correct_answers).get_correctness('1_2_1'), 'correct') false_answers = {'1_2_1':['choice_foil1']} - self.assertEquals(test_lcp.grade_answers(false_answers)['1_2_1'], 'incorrect') + self.assertEquals(test_lcp.grade_answers(false_answers).get_correctness('1_2_1'), 'incorrect') false_answers = {'1_2_1':['choice_foil1', 'choice_foil3']} - self.assertEquals(test_lcp.grade_answers(false_answers)['1_2_1'], 'incorrect') + self.assertEquals(test_lcp.grade_answers(false_answers).get_correctness('1_2_1'), 'incorrect') false_answers = {'1_2_1':['choice_foil3']} - self.assertEquals(test_lcp.grade_answers(false_answers)['1_2_1'], 'incorrect') + self.assertEquals(test_lcp.grade_answers(false_answers).get_correctness('1_2_1'), 'incorrect') false_answers = {'1_2_1':['choice_foil1', 'choice_foil2', 'choice_foil3']} - self.assertEquals(test_lcp.grade_answers(false_answers)['1_2_1'], 'incorrect') + self.assertEquals(test_lcp.grade_answers(false_answers).get_correctness('1_2_1'), 'incorrect') class ImageResponseTest(unittest.TestCase): def test_ir_grade(self): @@ -131,8 +132,8 @@ class ImageResponseTest(unittest.TestCase): test_answers = {'1_2_1':'[500,20]', '1_2_2':'[250,300]', } - self.assertEquals(test_lcp.grade_answers(test_answers)['1_2_1'], 'correct') - self.assertEquals(test_lcp.grade_answers(test_answers)['1_2_2'], 'incorrect') + self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_2_1'), 'correct') + self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_2_2'), 'incorrect') class SymbolicResponseTest(unittest.TestCase): def test_sr_grade(self): @@ -220,8 +221,8 @@ class SymbolicResponseTest(unittest.TestCase): ''', } - self.assertEquals(test_lcp.grade_answers(correct_answers)['1_2_1'], 'correct') - self.assertEquals(test_lcp.grade_answers(wrong_answers)['1_2_1'], 'incorrect') + self.assertEquals(test_lcp.grade_answers(correct_answers).get_correctness('1_2_1'), 'correct') + self.assertEquals(test_lcp.grade_answers(wrong_answers).get_correctness('1_2_1'), 'incorrect') class OptionResponseTest(unittest.TestCase): ''' @@ -237,8 +238,37 @@ class OptionResponseTest(unittest.TestCase): test_answers = {'1_2_1':'True', '1_2_2':'True', } - self.assertEquals(test_lcp.grade_answers(test_answers)['1_2_1'], 'correct') - self.assertEquals(test_lcp.grade_answers(test_answers)['1_2_2'], 'incorrect') + self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_2_1'), 'correct') + self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_2_2'), 'incorrect') + +class FormulaResponseWithHintTest(unittest.TestCase): + ''' + Test Formula response problem with a hint + This problem also uses calc. + ''' + def test_or_grade(self): + problem_file = os.path.dirname(__file__)+"/test_files/formularesponse_with_hint.xml" + test_lcp = lcp.LoncapaProblem(open(problem_file), '1', system=i4xs) + correct_answers = {'1_2_1':'2.5*x-5.0'} + test_answers = {'1_2_1':'0.4*x-5.0'} + self.assertEquals(test_lcp.grade_answers(correct_answers).get_correctness('1_2_1'), 'correct') + cmap = test_lcp.grade_answers(test_answers) + self.assertEquals(cmap.get_correctness('1_2_1'), 'incorrect') + self.assertTrue('You have inverted' in cmap.get_hint('1_2_1')) + +class StringResponseWithHintTest(unittest.TestCase): + ''' + Test String response problem with a hint + ''' + def test_or_grade(self): + problem_file = os.path.dirname(__file__)+"/test_files/stringresponse_with_hint.xml" + test_lcp = lcp.LoncapaProblem(open(problem_file), '1', system=i4xs) + correct_answers = {'1_2_1':'Michigan'} + test_answers = {'1_2_1':'Minnesota'} + self.assertEquals(test_lcp.grade_answers(correct_answers).get_correctness('1_2_1'), 'correct') + cmap = test_lcp.grade_answers(test_answers) + self.assertEquals(cmap.get_correctness('1_2_1'), 'incorrect') + self.assertTrue('St. Paul' in cmap.get_hint('1_2_1')) #----------------------------------------------------------------------------- # Grading tests diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index 97fd1b948c..fd419a68dc 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -169,7 +169,7 @@ def render_x_module(user, request, xml_module, module_object_preload, position=N content = instance.get_html() # special extra information about each problem, only for users who are staff - if user.is_staff: + if settings.MITX_FEATURES.get('DISPLAY_HISTOGRAMS_TO_STAFF') and user.is_staff: module_id = xml_module.get('id') histogram = grade_histogram(module_id) render_histogram = len(histogram) > 0 diff --git a/lms/envs/common.py b/lms/envs/common.py index e66e970fb0..643ee47de5 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -37,6 +37,7 @@ PERFSTATS = False MITX_FEATURES = { 'SAMPLE' : False, 'USE_DJANGO_PIPELINE' : True, + 'DISPLAY_HISTOGRAMS_TO_STAFF' : True, } # Used for A/B testing diff --git a/lms/envs/dev_ike.py b/lms/envs/dev_ike.py index ee5b6e831b..af51274433 100644 --- a/lms/envs/dev_ike.py +++ b/lms/envs/dev_ike.py @@ -27,7 +27,8 @@ DEBUG = True ENABLE_MULTICOURSE = True # set to False to disable multicourse display (see lib.util.views.mitxhome) QUICKEDIT = True -MITX_FEATURES['USE_DJANGO_PIPELINE'] = False +# MITX_FEATURES['USE_DJANGO_PIPELINE'] = False +MITX_FEATURES['DISPLAY_HISTOGRAMS_TO_STAFF'] = False COURSE_SETTINGS = {'6.002_Spring_2012': {'number' : '6.002x', 'title' : 'Circuits and Electronics', diff --git a/lms/lib/dogfood/check.py b/lms/lib/dogfood/check.py index 1a3979c238..7ec49848eb 100644 --- a/lms/lib/dogfood/check.py +++ b/lms/lib/dogfood/check.py @@ -44,9 +44,9 @@ def check_problem_code(ans,the_lcp,correct_answers,false_answers): fp = the_lcp.system.filestore.open('problems/%s.xml' % pfn) test_lcp = lcp.LoncapaProblem(fp, '1', system=the_lcp.system) - if not (test_lcp.grade_answers(correct_answers)['1_2_1']=='correct'): + if not (test_lcp.grade_answers(correct_answers).get_correctness('1_2_1')=='correct'): is_ok = False - if (test_lcp.grade_answers(false_answers)['1_2_1']=='correct'): + if (test_lcp.grade_answers(false_answers).get_correctness('1_2_1')=='correct'): is_ok = False except Exception,err: is_ok = False diff --git a/lms/static/coffee/src/modules/problem.coffee b/lms/static/coffee/src/modules/problem.coffee index c1a801d2b2..aad41f23d4 100644 --- a/lms/static/coffee/src/modules/problem.coffee +++ b/lms/static/coffee/src/modules/problem.coffee @@ -45,14 +45,14 @@ class @Problem $.each response, (key, value) => if $.isArray(value) for choice in value - @$("label[for='input_#{key}_#{choice}']").attr - correct_answer: 'true' + @$("label[for='input_#{key}_#{choice}']").attr correct_answer: 'true' else - @$("#answer_#{key}").text(value) + @$("#answer_#{key}, #solution_#{key}").html(value) + MathJax.Hub.Queue ["Typeset", MathJax.Hub] @$('.show').val 'Hide Answer' @element.addClass 'showed' else - @$('[id^=answer_]').text '' + @$('[id^=answer_], [id^=solution_]').text '' @$('[correct_answer]').attr correct_answer: null @element.removeClass 'showed' @$('.show').val 'Show Answer'