Files
edx-platform/common/lib/capa/capa_problem.py
ichuang 46b45969d0 first pass in capa cleanup:
- responsetype used to be instantiated multiple times(!) in capa_problem
     now it is instantiated once, and stored in self.responders
   - responsetypes.GenericResponse restructured; each superclass
     show now provide setup_response (and not __init__), and may
     provide get_max_score(); general __init__ provided to
     clean up superclasses.
2012-06-09 18:36:27 -04:00

400 lines
17 KiB
Python

#
# 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
import logging
import math
import numpy
import os
import random
import re
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
import eia
log = logging.getLogger(__name__)
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
# How to convert from original XML to HTML
# We should do this with xlst later
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'},
}
global_context = {'random': random,
'numpy': numpy,
'math': math,
'scipy': scipy,
'calc': calc,
'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']
# 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,
# }
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.student_answers = dict()
self.correct_map = dict()
self.done = False
self.problem_id = id
self.system = system
self.seed = seed
if state:
if 'seed' in state:
self.seed = state['seed']
if 'student_answers' in state:
self.student_answers = state['student_answers']
if 'correct_map' in state:
self.correct_map = state['correct_map']
if 'done' in state:
self.done = state['done']
# TODO: Does this deplete the Linux entropy pool? Is this fast enough?
if not self.seed:
self.seed = struct.unpack('i', os.urandom(4))[0]
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()
file_text = re.sub("startouttext\s*/", "text", file_text) # Convert startouttext and endouttext to proper <text></text>
file_text = re.sub("endouttext\s*/", "/text", file_text)
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 list (self.responders) of Response instances for each question in the problem
self.preprocess_problem(self.tree, correct_map=self.correct_map, answer_map=self.student_answers)
def __unicode__(self):
return u"LoncapaProblem ({0})".format(self.fileobject)
def get_state(self):
''' 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,
'done': self.done}
def get_max_score(self):
'''
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.
'''
maxscore = 0
for responder in self.responders:
if hasattr(responder,'get_max_score'):
try:
maxscore += responder.get_max_score()
except Exception, err:
log.error('responder %s failed to properly return from get_max_score()' % responder)
raise
else:
try:
maxscore += len(responder.get_answers())
except:
log.error('responder %s failed to properly return get_answers()' % responder)
raise
return maxscore
def get_score(self):
correct = 0
for key in self.correct_map:
if self.correct_map[key] == u'correct':
correct += 1
if (not self.student_answers) or len(self.student_answers) == 0:
return {'score': 0,
'total': self.get_max_score()}
else:
return {'score': correct,
'total': self.get_max_score()}
def grade_answers(self, answers):
'''
Grade student responses. Called by capa_module.check_problem.
answers is a dict of all the entries from request.POST, but with the first part
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()
log.info('%s: in grade_answers, answers=%s' % (self,answers))
for responder in self.responders:
results = responder.get_score(answers) # call the responsetype instance to do the actual grading
self.correct_map.update(results)
return self.correct_map
def get_question_answers(self):
"""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()
for responder in self.responders:
results = responder.get_answers()
answer_map.update(results) # dict of (id,correct_answer)
# This should be handled in each responsetype, not here.
# example for the following: <textline size="5" correct_answer="saturated" />
for responder in self.responders:
for entry in responder.inputfields:
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 <solution>...</solution> 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
return answer_map
def get_answer_ids(self):
"""Return the IDs of all the responses -- these are the keys used for
the dicts returned by grade_answers and get_question_answers. (Though
get_question_answers may only return a subset of these."""
answer_ids = []
for responder in self.responders:
answer_ids.append(responder.get_answers().keys())
return answer_ids
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)[0]), self.context)
# ======= Private ========
def extract_context(self, tree, seed=struct.unpack('i', os.urandom(4))[0]): # private
'''
Extract content of <script>...</script> from the problem.xml file, and exec it in the
context of this problem. Provides ability to randomize problems, and also set
variables for problem answer checking.
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
#for script in tree.xpath('/problem/script'):
for script in tree.findall('.//script'):
stype = script.get('type')
if stype:
if 'javascript' in stype:
continue # skip javascript
if 'perl' in stype:
continue # skip perl
# TODO: evaluate only python
code = script.text
XMLESC = {"&apos;": "'", "&quot;": '"'}
code = unescape(code, XMLESC)
try:
exec code in context, context # use "context" for global context; thus defs in code are global within code
except Exception:
log.exception("Error while execing code: " + code)
return context
def extract_html(self, problemtree): # private
''' Helper function for get_html. Recursively converts XML tree to 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"
if problemid in self.correct_map:
status = self.correct_map[problemtree.get('id')]
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}
},
use='capa_input')
return render_object.get_html() # function(problemtree, value, status, msg) # render the special response (textline, schematic,...)
tree = 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)
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, "")
# 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
'''
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
self.responders = []
for response in tree.xpath('//' + "|//".join(response_types)):
response_id_str = self.problem_id + "_" + str(response_id)
response.attrib['id'] = response_id_str # create and save ID for this response
# if response_id not in correct_map: correct = 'unsubmitted' # unused - to be removed
# response.attrib['state'] = correct
response_id += response_id
answer_id = 1
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_types[response.tag](response, inputfields, self.context, self.system) # instantiate capa Response
self.responders.append(responder) # save in list in self
# <solution>...</solution> 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