- 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.
400 lines
17 KiB
Python
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 = {"'": "'", """: '"'}
|
|
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
|