Commit 8ce53ed8 by Felix Sun

Got rid of answer signatures - these don't work with approximate answers at all.…

Got rid of answer signatures - these don't work with approximate answers at all.  Instead, implemented comparison-based hint matching.

Tests are broken, hint manager is probably broken.
parent 4ee8111c
...@@ -917,6 +917,27 @@ class NumericalResponse(LoncapaResponse): ...@@ -917,6 +917,27 @@ class NumericalResponse(LoncapaResponse):
# TODO: add check_hint_condition(self, hxml_set, student_answers) # TODO: add check_hint_condition(self, hxml_set, student_answers)
def answer_compare(self, a, b):
"""
Outside-facing function that lets us compare two numerical answers,
with this problem's tolerance.
"""
return compare_with_tolerance(
evaluator(dict(), dict(), a),
evaluator(dict(), dict(), b),
self.tolerance
)
def validate_answer(self, answer):
"""
Returns whether this answer is in a valid form.
"""
try:
evaluator(dict(), dict(), answer)
return True
except StudentInputError:
return False
def get_answers(self): def get_answers(self):
return {self.answer_id: self.correct_answer} return {self.answer_id: self.correct_answer}
...@@ -1858,6 +1879,24 @@ class FormulaResponse(LoncapaResponse): ...@@ -1858,6 +1879,24 @@ class FormulaResponse(LoncapaResponse):
return "incorrect" return "incorrect"
return "correct" return "correct"
def answer_compare(self, a, b):
"""
An external interface for comparing whether a and b are equal.
"""
internal_result = self.check_formula(a, b, self.samples)
return internal_result == "correct"
def validate_answer(self, answer):
"""
Returns whether this answer is in a valid form.
"""
var_dict_list = self.randomize_variables(self.samples)
try:
self.hash_answers(answer, var_dict_list)
return True
except StudentInputError:
return False
def strip_dict(self, d): def strip_dict(self, d):
''' Takes a dict. Returns an identical dict, with all non-word ''' Takes a dict. Returns an identical dict, with all non-word
keys and all non-numeric values stripped out. All values also keys and all non-numeric values stripped out. All values also
......
...@@ -42,18 +42,11 @@ class CrowdsourceHinterFields(object): ...@@ -42,18 +42,11 @@ class CrowdsourceHinterFields(object):
mod_queue = Dict(help='A dictionary containing hints still awaiting approval', scope=Scope.content, mod_queue = Dict(help='A dictionary containing hints still awaiting approval', scope=Scope.content,
default={}) default={})
hint_pk = Integer(help='Used to index hints.', scope=Scope.content, default=0) hint_pk = Integer(help='Used to index hints.', scope=Scope.content, default=0)
# signature_to_ans maps an answer signature to an answer string that shows that answer in a
# human-readable form.
signature_to_ans = Dict(help='Maps a signature to a representative formula.', scope=Scope.content,
default={})
# A list of dictionaries, each of which represents an n-dimenstional point that we plug into
# formulas. Each dictionary maps variables to values, eg {'x': 5.1}.
formula_test_values = List(help='The values that we plug into formula responses', scope=Scope.content,
default=[])
# A list of previous answers this student made to this problem. # A list of previous answers this student made to this problem.
# Of the form [answer, [hint_pk_1, hint_pk_2, hint_pk_3]] for each problem. hint_pk's are # Of the form [answer, [hint_pk_1, ...]] for each problem.
# None if the hint was not given. previous_answers = List(help='A list of hints viewed.', scope=Scope.user_state, default=[])
previous_answers = List(help='A list of previous submissions.', scope=Scope.user_state, default=[]) user_submissions = List(help='A list of previous submissions', scope=Scope.user_state, default=[])
user_voted = Boolean(help='Specifies if the user has voted on this problem or not.', user_voted = Boolean(help='Specifies if the user has voted on this problem or not.',
scope=Scope.user_state, default=False) scope=Scope.user_state, default=False)
...@@ -82,13 +75,20 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): ...@@ -82,13 +75,20 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
XModule.__init__(self, *args, **kwargs) XModule.__init__(self, *args, **kwargs)
# We need to know whether we are working with a FormulaResponse problem. # We need to know whether we are working with a FormulaResponse problem.
self.is_formula = (type(self.get_display_items()[0].lcp.responders.values()[0]) == FormulaResponse) responder = self.get_display_items()[0].lcp.responders.values()[0]
self.is_formula = (type(responder) == FormulaResponse)
if self.is_formula: if self.is_formula:
self.answer_to_str = self.formula_answer_to_str self.answer_to_str = self.formula_answer_to_str
self.answer_signature = self.formula_answer_signature
else: else:
self.answer_to_str = self.numerical_answer_to_str self.answer_to_str = self.numerical_answer_to_str
self.answer_signature = self.numerical_answer_signature # answer_compare is expected to return whether its two inputs are close enough
# to be equal, or raise a StudentInputError if one of the inputs is malformatted.
try:
self.answer_compare = responder.answer_compare
self.validate_answer = responder.validate_answer
except AttributeError:
# This response type is not supported!
log.exception('Response type not supported for hinting: ' + str(responder))
def get_html(self): def get_html(self):
""" """
...@@ -136,38 +136,12 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): ...@@ -136,38 +136,12 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule):
""" """
return str(answer.values()[0]) return str(answer.values()[0])
def numerical_answer_signature(self, answer): def get_matching_answers(self, answer):
""" """
Runs the answer string through the evaluator. (This is because Look in self.hints, and find all answer keys that are "equal with tolerance"
symbolic expressions like sin(pi/12)*3 are allowed.) to the input answer.
""" """
try: return [key for key in self.hints if self.answer_compare(key, answer)]
out = str(evaluator(dict(), dict(), answer))
except (UndefinedVariable, ParseException):
out = None
return out
def formula_answer_signature(self, answer):
"""
Converts a capa answer string (output of formula_answer_to_str)
to a string unique to each formula equality class.
So, x^2 and x*x would have the same signature, which would differ
from the signature of 2*x^2.
"""
responder = self.get_display_items()[0].lcp.responders.values()[0]
if self.formula_test_values == []:
# Make a set of test values, and save them.
self.formula_test_values = responder.randomize_variables(responder.samples)
try:
# TODO, maybe: add some rounding to signature generation, so that floating point
# errors don't make a difference.
out = str(responder.hash_answers(answer, self.formula_test_values))
except StudentInputError:
# I'm not sure what's the best thing to do here. I'm returning
# None, for now, so that the calling function has a chance to catch
# the error without having to import StudentInputError.
return None
return out
def handle_ajax(self, dispatch, data): def handle_ajax(self, dispatch, data):
""" """
...@@ -211,47 +185,67 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): ...@@ -211,47 +185,67 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule):
# Sometimes, we get an answer that's just not parsable. Do nothing. # Sometimes, we get an answer that's just not parsable. Do nothing.
log.exception('Answer not parsable: ' + str(data)) log.exception('Answer not parsable: ' + str(data))
return return
# Make a signature of the answer, for formula responses. if not self.validate_answer(answer):
signature = self.answer_signature(answer) # Answer is not in the right form.
if signature is None: log.exception('Answer not valid: ' + str(answer))
# Sometimes, signature conversion may fail. if answer not in self.user_submissions:
log.exception('Signature conversion failed: ' + str(answer)) self.user_submissions += [answer]
return
# Look for a hint to give. # Look for a hint to give.
# Make a local copy of self.hints - this means we only need to do one json unpacking. # Make a local copy of self.hints - this means we only need to do one json unpacking.
# (This is because xblocks storage makes the following command a deep copy.) # (This is because xblocks storage makes the following command a deep copy.)
local_hints = self.hints local_hints = self.hints
if (signature not in local_hints) or (len(local_hints[signature]) == 0): # For all answers similar enough to our own, accumulate all hints together.
# Also track the original answer of each hint.
matching_answers = self.get_matching_answers(answer)
matching_hints = {}
for matching_answer in matching_answers:
temp_dict = local_hints[matching_answer]
for key, value in temp_dict.items():
# Each value now has hint, votes, matching_answer.
temp_dict[key] = value + [matching_answer]
matching_hints.update(local_hints[matching_answer])
# matching_hints now maps pk's to lists of [hint, votes, matching_answer]
if len(matching_hints) == 0:
# No hints to give. Return. # No hints to give. Return.
self.previous_answers += [[answer, [None, None, None]]]
return return
# Get the top hint, plus two random hints. # Get the top hint, plus two random hints.
n_hints = len(local_hints[signature]) n_hints = len(matching_hints)
best_hint_index = max(local_hints[signature], key=lambda key: local_hints[signature][key][1]) hints = []
best_hint = local_hints[signature][best_hint_index][0] best_hint_index = max(matching_hints, key=lambda key: matching_hints[key][1])
if len(local_hints[signature]) == 1: hints.append(matching_hints[best_hint_index][0])
rand_hint_1 = '' best_hint_answer = matching_hints[best_hint_index][2]
rand_hint_2 = '' # The brackets surrounding the index are for backwards compatability purposes.
self.previous_answers += [[answer, [best_hint_index, None, None]]] # (It used to be that each answer was paired with multiple hints in a list.)
elif n_hints == 2: self.previous_answers += [[best_hint_answer, [best_hint_index]]]
best_hint = local_hints[signature].values()[0][0] for i in xrange(min(2, n_hints-1)):
best_hint_index = local_hints[signature].keys()[0] # Keep making random hints until we hit a target, or run out.
rand_hint_1 = local_hints[signature].values()[1][0] (hint_index, (rand_hint, votes, hint_answer)) =\
hint_index_1 = local_hints[signature].keys()[1] random.choice(matching_hints.items())
rand_hint_2 = '' if rand_hint in hints:
self.previous_answers += [[answer, [best_hint_index, hint_index_1, None]]] # Don't show the same hint twice. Try again.
else: i -= 1
(hint_index_1, rand_hint_1), (hint_index_2, rand_hint_2) =\ continue
random.sample(local_hints[signature].items(), 2) hints.append(rand_hint)
rand_hint_1 = rand_hint_1[0] self.previous_answers += [[hint_index, [hint_answer]]]
rand_hint_2 = rand_hint_2[0] return {'hints': hints,
self.previous_answers += [[answer, [best_hint_index, hint_index_1, hint_index_2]]]
return {'best_hint': best_hint,
'rand_hint_1': rand_hint_1,
'rand_hint_2': rand_hint_2,
'answer': answer} 'answer': answer}
# rand_hint_1 = ''
# rand_hint_2 = ''
# if n_hints == 2:
# best_hint = matching_hints.values()[0][0]
# best_hint_index = matching_hints.keys()[0]
# rand_hint_1 = matching_hints.values()[1][0]
# hint_index_1 = matching_hints.keys()[1]
# rand_hint_2 = ''
# self.previous_answers += [[answer, [best_hint_index, hint_index_1]]]
# else:
# (hint_index_1, rand_hint_1), (hint_index_2, rand_hint_2) =\
# random.sample(matching_hints.items(), 2)
# rand_hint_1 = rand_hint_1[0]
# rand_hint_2 = rand_hint_2[0]
# self.previous_answers += [[answer, [best_hint_index, hint_index_1, hint_index_2]]]
def get_feedback(self, data): def get_feedback(self, data):
""" """
The student got it correct. Ask him to vote on hints, or submit a hint. The student got it correct. Ask him to vote on hints, or submit a hint.
...@@ -271,32 +265,24 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): ...@@ -271,32 +265,24 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule):
# be allowed to make one vote / submission, but he can choose which wrong answer # be allowed to make one vote / submission, but he can choose which wrong answer
# he wants to look at. # he wants to look at.
answer_to_hints = {} # answer_to_hints[answer text][hint pk] -> hint text answer_to_hints = {} # answer_to_hints[answer text][hint pk] -> hint text
signature_to_ans = {} # Lets us combine equivalent answers
# Same mapping as the field, but local.
# Go through each previous answer, and populate index_to_hints and index_to_answer. # Go through each previous answer, and populate index_to_hints and index_to_answer.
for i in xrange(len(self.previous_answers)): for i in xrange(len(self.previous_answers)):
answer, hints_offered = self.previous_answers[i] answer, hints_offered = self.previous_answers[i]
# Does this answer equal one of the ones offered already?
signature = self.answer_signature(answer)
if signature in signature_to_ans:
# Re-assign this answer text to the one we've seen already.
answer = signature_to_ans[signature]
else:
signature_to_ans[signature] = answer
if answer not in answer_to_hints: if answer not in answer_to_hints:
answer_to_hints[answer] = {} answer_to_hints[answer] = {}
if signature in self.hints: if answer in self.hints:
# Go through each hint, and add to index_to_hints # Go through each hint, and add to index_to_hints
for hint_id in hints_offered: for hint_id in hints_offered:
if (hint_id is not None) and (hint_id not in answer_to_hints[answer]): if (hint_id is not None) and (hint_id not in answer_to_hints[answer]):
try: try:
answer_to_hints[answer][hint_id] = self.hints[signature][str(hint_id)][0] answer_to_hints[answer][hint_id] = self.hints[answer][str(hint_id)][0]
except KeyError: except KeyError:
# Sometimes, the hint that a user saw will have been deleted by the instructor. # Sometimes, the hint that a user saw will have been deleted by the instructor.
continue continue
return {'answer_to_hints': answer_to_hints} return {'answer_to_hints': answer_to_hints,
'user_submissions': self.user_submissions}
def tally_vote(self, data): def tally_vote(self, data):
""" """
...@@ -315,8 +301,7 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): ...@@ -315,8 +301,7 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule):
if self.user_voted: if self.user_voted:
return {'error': 'Sorry, but you have already voted!'} return {'error': 'Sorry, but you have already voted!'}
ans = data['answer'] ans = data['answer']
signature = self.answer_signature(ans) if not self.validate_answer(ans):
if signature is None:
# Uh oh. Invalid answer. # Uh oh. Invalid answer.
log.exception('Failure in hinter tally_vote: Unable to parse answer: ' + ans) log.exception('Failure in hinter tally_vote: Unable to parse answer: ' + ans)
return {'error': 'Failure in voting!'} return {'error': 'Failure in voting!'}
...@@ -324,7 +309,7 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): ...@@ -324,7 +309,7 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule):
# We use temp_dict because we need to do a direct write for the database to update. # We use temp_dict because we need to do a direct write for the database to update.
temp_dict = self.hints temp_dict = self.hints
try: try:
temp_dict[signature][hint_pk][1] += 1 temp_dict[ans][hint_pk][1] += 1
except KeyError: except KeyError:
log.exception('Failure in hinter tally_vote: User voted for non-existant hint: Answer=' + log.exception('Failure in hinter tally_vote: User voted for non-existant hint: Answer=' +
ans + ' pk=' + hint_pk) ans + ' pk=' + hint_pk)
...@@ -337,19 +322,19 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): ...@@ -337,19 +322,19 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule):
pk_list = json.loads(data['pk_list']) pk_list = json.loads(data['pk_list'])
hint_and_votes = [] hint_and_votes = []
for answer, vote_pk in pk_list: for answer, vote_pk in pk_list:
signature = self.answer_signature(answer) if not self.validate_answer(answer):
if signature is None:
log.exception('In hinter tally_vote, couldn\'t parse ' + answer) log.exception('In hinter tally_vote, couldn\'t parse ' + answer)
continue continue
try: try:
hint_and_votes.append(temp_dict[signature][str(vote_pk)]) hint_and_votes.append(temp_dict[answer][str(vote_pk)])
except KeyError: except KeyError:
log.exception('In hinter tally_vote, couldn\'t find: ' log.exception('In hinter tally_vote, couldn\'t find: '
+ answer + ', ' + str(vote_pk)) + answer + ', ' + str(vote_pk))
hint_and_votes.sort(key=lambda pair: pair[1], reverse=True) hint_and_votes.sort(key=lambda pair: pair[1], reverse=True)
# Reset self.previous_answers. # Reset self.previous_answers and user_submissions.
self.previous_answers = [] self.previous_answers = []
self.user_submissions = []
return {'hint_and_votes': hint_and_votes} return {'hint_and_votes': hint_and_votes}
def submit_hint(self, data): def submit_hint(self, data):
...@@ -365,8 +350,7 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): ...@@ -365,8 +350,7 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule):
# Do html escaping. Perhaps in the future do profanity filtering, etc. as well. # Do html escaping. Perhaps in the future do profanity filtering, etc. as well.
hint = escape(data['hint']) hint = escape(data['hint'])
answer = data['answer'] answer = data['answer']
signature = self.answer_signature(answer) if not self.validate_answer(answer):
if signature is None:
log.exception('Failure in hinter submit_hint: Unable to parse answer: ' + answer) log.exception('Failure in hinter submit_hint: Unable to parse answer: ' + answer)
return {'error': 'Could not submit answer'} return {'error': 'Could not submit answer'}
# Only allow a student to vote or submit a hint once. # Only allow a student to vote or submit a hint once.
...@@ -379,12 +363,9 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): ...@@ -379,12 +363,9 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule):
else: else:
temp_dict = self.hints temp_dict = self.hints
if answer in temp_dict: if answer in temp_dict:
temp_dict[signature][str(self.hint_pk)] = [hint, 1] # With one vote (the user himself). temp_dict[answer][str(self.hint_pk)] = [hint, 1] # With one vote (the user himself).
else: else:
temp_dict[signature] = {str(self.hint_pk): [hint, 1]} temp_dict[answer] = {str(self.hint_pk): [hint, 1]}
# Add the signature to signature_to_ans, if it's not there yet.
# This allows instructors to see a human-readable answer that corresponds to each signature.
self.add_signature(signature, answer)
self.hint_pk += 1 self.hint_pk += 1
if self.moderate == 'True': if self.moderate == 'True':
self.mod_queue = temp_dict self.mod_queue = temp_dict
...@@ -393,18 +374,9 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): ...@@ -393,18 +374,9 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule):
# Mark the user has having voted; reset previous_answers # Mark the user has having voted; reset previous_answers
self.user_voted = True self.user_voted = True
self.previous_answers = [] self.previous_answers = []
self.user_submissions = []
return {'message': 'Thank you for your hint!'} return {'message': 'Thank you for your hint!'}
def add_signature(self, signature, answer):
"""
Add a signature to self.signature_to_ans. If the signature already
exists, do nothing.
"""
if signature not in self.signature_to_ans:
local_sta = self.signature_to_ans
local_sta[signature] = answer
self.signature_to_ans = local_sta
class CrowdsourceHinterDescriptor(CrowdsourceHinterFields, RawDescriptor): class CrowdsourceHinterDescriptor(CrowdsourceHinterFields, RawDescriptor):
module_class = CrowdsourceHinterModule module_class = CrowdsourceHinterModule
......
...@@ -3,18 +3,14 @@ ...@@ -3,18 +3,14 @@
<%def name="get_hint()"> <%def name="get_hint()">
% if best_hint != '': % if len(hints) > 0:
<h4> Hints from students who made similar mistakes: </h4> <h4> Hints from students who made similar mistakes: </h4>
<ul> <ul>
<li> ${best_hint} </li> % for hint in hints:
% endif <li> ${hint} </li>
% if rand_hint_1 != '': % endfor
<li> ${rand_hint_1} </li> </ul>
% endif
% if rand_hint_2 != '':
<li> ${rand_hint_2} </li>
% endif % endif
</ul>
</%def> </%def>
<%def name="get_feedback()"> <%def name="get_feedback()">
...@@ -66,17 +62,13 @@ ...@@ -66,17 +62,13 @@
<div id="answer-tabs"> <div id="answer-tabs">
<ul> <ul>
% for answer in answer_to_hints: % for answer in user_submissions:
<li><a href="#previous-answer-${unspace(answer)}"> ${answer} </a></li> <li><a href="#previous-answer-${unspace(answer)}"> ${answer} </a></li>
% endfor % endfor
</ul> </ul>
% for answer, pk_dict in answer_to_hints.items(): % for answer in user_submissions:
<% <div class = "previous-answer" id="previous-answer-${unspace(answer)}" data-answer="${answer}">
import json
all_pks = json.dumps(pk_dict.keys())
%>
<div class = "previous-answer" id="previous-answer-${unspace(answer)}" data-answer="${answer}" data-all-pks='${all_pks}'>
<div class = "hint-inner-container"> <div class = "hint-inner-container">
<p> <p>
What hint would you give a student who made the same mistake you did? Please don't give away the answer. What hint would you give a student who made the same mistake you did? Please don't give away the answer.
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment