diff --git a/common/lib/capa/capa_problem.py b/common/lib/capa/capa_problem.py
index 93d5620aae..b14001ef03 100644
--- a/common/lib/capa/capa_problem.py
+++ b/common/lib/capa/capa_problem.py
@@ -12,6 +12,8 @@ Main module which shows problems (of "capa" type).
This is used by capa_module.
'''
+from __future__ import division
+
import copy
import logging
import math
@@ -32,20 +34,10 @@ import inputtypes
from util import contextualize_text
# to be replaced with auto-registering
-from responsetypes import NumericalResponse, FormulaResponse, CustomResponse, SchematicResponse, MultipleChoiceResponse, TrueFalseResponse, ExternalResponse, ImageResponse, OptionResponse, SymbolicResponse
+import responsetypes
# dict of tagname, Response Class -- this should come from auto-registering
-response_types = {'numericalresponse': NumericalResponse,
- 'formularesponse': FormulaResponse,
- 'customresponse': CustomResponse,
- 'schematicresponse': SchematicResponse,
- 'externalresponse': ExternalResponse,
- 'multiplechoiceresponse': MultipleChoiceResponse,
- 'truefalseresponse': TrueFalseResponse,
- 'imageresponse': ImageResponse,
- 'optionresponse': OptionResponse,
- 'symbolicresponse': SymbolicResponse,
- }
+response_tag_dict = dict([(x.response_tag,x) for x in responsetypes.__all__])
entry_types = ['textline', 'schematic', 'choicegroup', 'textbox', 'imageinput', 'optioninput']
solution_types = ['solution'] # extra things displayed after "show answers" is pressed
@@ -65,7 +57,7 @@ global_context = {'random': random,
'eia': eia}
# These should be removed from HTML output, including all subelements
-html_problem_semantics = ["responseparam", "answer", "script"]
+html_problem_semantics = ["responseparam", "answer", "script","hintgroup"]
#log = logging.getLogger(__name__)
log = logging.getLogger('mitx.common.lib.capa.capa_problem')
@@ -209,7 +201,7 @@ class LoncapaProblem(object):
oldcmap = self.correct_map # old CorrectMap
newcmap = CorrectMap() # start new with empty CorrectMap
for responder in self.responders.values():
- results = responder.get_score(answers,oldcmap) # call the responsetype instance to do the actual grading
+ 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))
@@ -248,7 +240,8 @@ class LoncapaProblem(object):
'''
return contextualize_text(etree.tostring(self.extract_html(self.tree)), self.context)
- # ======= Private ========
+ # ======= 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
@@ -296,15 +289,17 @@ class LoncapaProblem(object):
problemid = problemtree.get('id') # my ID
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:
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:
@@ -316,7 +311,10 @@ class LoncapaProblem(object):
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,...)
@@ -352,7 +350,7 @@ class LoncapaProblem(object):
'''
response_id = 1
self.responders = {}
- for response in tree.xpath('//' + "|//".join(response_types)):
+ for response in tree.xpath('//' + "|//".join(response_tag_dict)):
response_id_str = self.problem_id + "_" + str(response_id)
response.set('id',response_id_str) # create and save ID for this response
response_id += 1
@@ -366,7 +364,7 @@ class LoncapaProblem(object):
entry.attrib['id'] = "%s_%i_%i" % (self.problem_id, response_id, answer_id)
answer_id = answer_id + 1
- responder = response_types[response.tag](response, inputfields, self.context, self.system) # instantiate capa Response
+ 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
diff --git a/common/lib/capa/correctmap.py b/common/lib/capa/correctmap.py
index 3eac98cc3a..f694391cc6 100644
--- a/common/lib/capa/correctmap.py
+++ b/common/lib/capa/correctmap.py
@@ -5,7 +5,16 @@
class CorrectMap(object):
'''
- Stores (correctness, npoints, msg) for each answer_id.
+ 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.
'''
cmap = {}
@@ -13,11 +22,14 @@ class CorrectMap(object):
def __init__(self,*args,**kwargs):
self.set(*args,**kwargs)
- def set(self,answer_id=None,correctness=None,npoints=None,msg=''):
+ 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 }
+ 'msg': msg,
+ 'hint' : hint,
+ 'hintmode' : hintmode,
+ }
def __repr__(self):
return repr(self.cmap)
@@ -64,6 +76,20 @@ class CorrectMap(object):
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
diff --git a/common/lib/capa/inputtypes.py b/common/lib/capa/inputtypes.py
index 10fbdb7f98..1fa51f2f84 100644
--- a/common/lib/capa/inputtypes.py
+++ b/common/lib/capa/inputtypes.py
@@ -32,44 +32,57 @@ 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
- if not state:
- state = {}
+ 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.system = system
+ 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 = ''
- if 'feedback' in state and 'message' in state['feedback']:
- self.msg = state['feedback']['message']
-
+ 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']
diff --git a/common/lib/capa/responsetypes.py b/common/lib/capa/responsetypes.py
index 2de9e27893..5a5296d805 100644
--- a/common/lib/capa/responsetypes.py
+++ b/common/lib/capa/responsetypes.py
@@ -51,7 +51,7 @@ class StudentInputError(Exception):
#
# Main base class for CAPA responsetypes
-class GenericResponse(object):
+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,
@@ -60,22 +60,31 @@ class GenericResponse(object):
- 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__
- - render_html : render this Response as HTML (must return XHTML compliant string)
- - __unicode__ : unicode representation of this Response
+ - 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
+ - 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 = []
@@ -85,7 +94,7 @@ class GenericResponse(object):
Init is passed the following arguments:
- xml : ElementTree of this Response
- - inputfields : list of ElementTrees for each input entry field in 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
@@ -112,7 +121,7 @@ class GenericResponse(object):
msg += "\nSee XML source line %s" % getattr(xml,'sourceline','')
raise LoncapaProblemError(msg)
- self.answer_ids = [x.get('id') for x in self.inputfields]
+ 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
@@ -140,8 +149,85 @@ class GenericResponse(object):
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)
+ 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
@@ -358,6 +463,7 @@ def sympy_check2():
'''}]
+ response_tag = 'customresponse'
allowed_inputfields = ['textline','textbox']
def setup_response(self):
@@ -402,7 +508,7 @@ def sympy_check2():
else:
self.code = answer.text
- def get_score(self, student_answers, old_cmap):
+ def get_score(self, student_answers):
'''
student_answers is a dict with everything from request.POST, but with the first part
of each key removed (the string before the first "_").
@@ -540,6 +646,8 @@ class SymbolicResponse(CustomResponse):
'''}]
+ response_tag = 'symbolicresponse'
+
def setup_response(self):
self.xml.set('cfn','symmath_check')
code = "from symmath import *"
@@ -548,7 +656,7 @@ class SymbolicResponse(CustomResponse):
#-----------------------------------------------------------------------------
-class ExternalResponse(GenericResponse):
+class ExternalResponse(LoncapaResponse):
'''
Grade the students input using an external server.
@@ -594,6 +702,7 @@ main()
'''}]
+ response_tag = 'externalresponse'
allowed_inputfields = ['textline','textbox']
def setup_response(self):
@@ -647,7 +756,7 @@ main()
return rxml
- def get_score(self, student_answers, old_cmap):
+ def get_score(self, student_answers):
idset = sorted(self.answer_ids)
cmap = CorrectMap()
try:
@@ -707,7 +816,7 @@ main()
#-----------------------------------------------------------------------------
-class FormulaResponse(GenericResponse):
+class FormulaResponse(LoncapaResponse):
'''
Checking of symbolic math response using numerical sampling.
'''
@@ -729,6 +838,8 @@ class FormulaResponse(GenericResponse):
'''}]
+ response_tag = 'formularesponse'
+ hint_tag = 'formulahint'
allowed_inputfields = ['textline']
required_attributes = ['answer']
max_inputfields = 1
@@ -743,7 +854,7 @@ 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'
ts = xml.get('type')
if ts is None:
@@ -757,11 +868,16 @@ class FormulaResponse(GenericResponse):
else: # Default
self.case_sensitive = False
- def get_score(self, student_answers, old_cmap):
- variables=self.samples.split('@')[0].split(',')
- numsamples=int(self.samples.split('@')[1].split('#')[1])
+ def get_score(self, student_answers):
+ 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):
@@ -771,23 +887,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.debbug('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 CorrectMap(self.answer_id, "incorrect")
+ return "incorrect"
if not compare_with_tolerance(student_result, instructor_result, self.tolerance):
- return CorrectMap(self.answer_id, "incorrect")
-
- return CorrectMap(self.answer_id, "correct")
+ return "incorrect"
+ return "correct"
def strip_dict(self, d):
''' Takes a dict. Returns an identical dict, with all non-word
@@ -799,13 +918,30 @@ 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,err:
+ 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):
+class SchematicResponse(LoncapaResponse):
+ response_tag = 'schematicresponse'
allowed_inputfields = ['schematic']
def setup_response(self):
@@ -817,7 +953,7 @@ class SchematicResponse(GenericResponse):
else:
self.code = answer.text
- def get_score(self, student_answers, old_cmap):
+ def get_score(self, student_answers):
from capa_problem import global_context
submission = [json.loads(student_answers[k]) for k in sorted(self.answer_ids)]
self.context.update({'submission':submission})
@@ -831,7 +967,7 @@ class SchematicResponse(GenericResponse):
#-----------------------------------------------------------------------------
-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
@@ -847,13 +983,14 @@ class ImageResponse(GenericResponse):
'''}]
+ 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, old_cmap):
+ def get_score(self, student_answers):
correct_map = CorrectMap()
expectedset = self.get_answers()
@@ -884,3 +1021,10 @@ class ImageResponse(GenericResponse):
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 ]
+
diff --git a/common/lib/capa/util.py b/common/lib/capa/util.py
index 996f6c8dac..f1cc8f859e 100644
--- a/common/lib/capa/util.py
+++ b/common/lib/capa/util.py
@@ -7,6 +7,11 @@ from calc import evaluator, UndefinedVariable
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 = "%" in tol
if relative: