diff --git a/common/lib/xmodule/setup.py b/common/lib/xmodule/setup.py index 43d970d8981eb83fc6d9557ec0e2710ebdd1d6f8..6b106dd94db8ed499966c93f83e7f1ebaee52159 100644 --- a/common/lib/xmodule/setup.py +++ b/common/lib/xmodule/setup.py @@ -55,6 +55,7 @@ setup( "word_cloud = xmodule.word_cloud_module:WordCloudDescriptor", "hidden = xmodule.hidden_module:HiddenDescriptor", "raw = xmodule.raw_module:RawDescriptor", + "crowdsource_hinter = xmodule.crowdsource_hinter:CrowdsourceHinterDescriptor", ], 'console_scripts': [ 'xmodule_assets = xmodule.static_content:main', diff --git a/common/lib/xmodule/xmodule/crowdsource_hinter.py b/common/lib/xmodule/xmodule/crowdsource_hinter.py new file mode 100644 index 0000000000000000000000000000000000000000..1d424b7fff4198317b9fc2c2cc2e2c067a1af8f8 --- /dev/null +++ b/common/lib/xmodule/xmodule/crowdsource_hinter.py @@ -0,0 +1,261 @@ +import logging +import copy +import json +import os +import re +import string +import random + +from pkg_resources import resource_listdir, resource_string, resource_isdir + +from lxml import etree + +from xmodule.modulestore import Location +from xmodule.modulestore.exceptions import ItemNotFoundError +from xmodule.x_module import XModule +from xmodule.xml_module import XmlDescriptor +from xblock.core import XBlock, Scope, String, Integer, Float, Object, Boolean + +from django.utils.html import escape + +log = logging.getLogger(__name__) + + +class CrowdsourceHinterFields(object): + has_children = True + hints = Object(help='''A dictionary mapping answers to lists of [hint, number_of_votes] pairs. + ''', scope=Scope.content, default= { + '4': + [['This is a hint.', 5], + ['This is hint 2', 3], + ['This is hint 3', 2], + ['This is hint 4', 1]]}) + ''' + Testing data for hints: + + ''' + previous_answers = Object(help='''A list of previous answers this student made to this problem. + Of the form (answer, (hint_id_1, hint_id_2, hint_id_3)) for each problem. hint_id's are + None if the hint was not given.''', + scope=Scope.user_state, default=[]) + + user_voted = Boolean(help='Specifies if the user has voted on this problem or not.', + scope=Scope.user_state, default=False) + + +class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): + ''' An Xmodule that makes crowdsourced hints. + ''' + icon_class = 'crowdsource_hinter' + + js = {'coffee': [resource_string(__name__, 'js/src/crowdsource_hinter/display.coffee'), + ], + 'js': []} + js_module_name = "Hinter" + + + def __init__(self, system, location, descriptor, model_data): + XModule.__init__(self, system, location, descriptor, model_data) + + + def get_html(self): + ''' + Does a regular expression find and replace to change the AJAX url. + - Dependent on lon-capa problem. + ''' + # Reset the user vote, for debugging only! Remove for prod. + self.user_voted = False + for child in self.get_display_items(): + out = child.get_html() + # The event listener uses the ajax url to find the child. + child_url = child.system.ajax_url + break + # Wrap the module in a <section>. This lets us pass data attributes to the javascript. + out += '<section class="crowdsource-wrapper" data-url="' + self.system.ajax_url +\ + '" data-child-url = "' + child_url + '"> </section>' + return out + + def capa_make_answer_hashable(self, answer): + ''' + Capa answer format: dict[problem name] -> [list of answers] + Output format: ((problem name, (answers))) + ''' + out = [] + for problem, a in answer.items(): + out.append((problem, tuple(a))) + return str(tuple(sorted(out))) + + + def ans_to_text(self, answer): + ''' + Converts capa answer format to a string representation + of the answer. + -Lon-capa dependent. + ''' + return answer.values()[0][0] + + + def handle_ajax(self, dispatch, get): + ''' + This is the landing method for AJAX calls. + ''' + if dispatch == 'get_hint': + return self.get_hint(get) + if dispatch == 'get_feedback': + return self.get_feedback(get) + if dispatch == 'vote': + return self.tally_vote(get) + if dispatch == 'submit_hint': + return self.submit_hint(get) + + def get_hint(self, get): + ''' + The student got the incorrect answer found in get. Give him a hint. + ''' + print self.hints + answer = self.ans_to_text(get) + # Look for a hint to give. + if answer not in self.hints: + # No hints to give. Return. + self.previous_answers += [(answer, (None, None, None))] + return json.dumps({'contents': ' '}) + # Get the top hint, plus two random hints. + n_hints = len(self.hints[answer]) + best_hint_index = max(xrange(n_hints), key=lambda i:self.hints[answer][i][1]) + best_hint = self.hints[answer][best_hint_index][0] + if len(self.hints[answer]) == 1: + rand_hint_1 = '' + rand_hint_2 = '' + self.previous_answers += [(answer, (0, None, None))] + elif len(self.hints[answer]) == 2: + best_hint = self.hints[answer][0][0] + rand_hint_1 = self.hints[answer][1][0] + rand_hint_2 = '' + self.previous_answers += [(answer, (0, 1, None))] + else: + hint_index_1, hint_index_2 = random.sample(xrange(len(self.hints[answer])), 2) + rand_hint_1 = self.hints[answer][hint_index_1][0] + rand_hint_2 = self.hints[answer][hint_index_2][0] + self.previous_answers += [(answer, (best_hint_index, hint_index_1, hint_index_2))] + hint_text = best_hint + '<br />' + rand_hint_1 + '<br />' + rand_hint_2 + return json.dumps({'contents': hint_text}) + + def get_feedback(self, get): + ''' + The student got it correct. Ask him to vote on hints, or submit a hint. + ''' + # The student got it right. + # Did he submit at least one wrong answer? + out = ' ' + if len(self.previous_answers) == 0: + # No. Nothing to do here. + return json.dumps({'contents': out}) + # Make a hint-voting interface for each wrong answer. The student will only + # be allowed to make one vote / submission, but he can choose which wrong answer + # he wants to look at. + pretty_answers = [] + for i in xrange(len(self.previous_answers)): + answer, hints_offered = self.previous_answers[i] + pretty_answers.append(answer) + # If there are previous hints for this answer, ask the student to vote on one. + if answer in self.hints: + out += '<div class = "previous-answer" id="previous-answer-' + str(i) + \ + '" style="display:none"> Which hint was most helpful when you got the wrong answer of '\ + + answer + '?' + # Add each hint to the html string, with a vote button. + for j, hint_id in enumerate(hints_offered): + if hint_id != None: + out += '<br /><input class="vote" data-answer="'+str(i)+'" data-hintno="'+str(j)+\ + '" type="button" value="Vote"> ' + self.hints[answer][hint_id][0] + + + # Or, let the student create his own hint + out += '''<br /> If you didn\'t like any of these, plese submit your own: <br /> + <textarea cols="50" id="custom-hint-'''+str(i)+'''"> +What would you say to help someone who got this wrong answer? +(Don't give away the answer, please.) + </textarea>''' + + out += '<input class="submit-hint" data-answer="' + str(i) + '" type="button" value="submit">' + + # Close the .previous-answer div. + out += '</div>' + + # Add preamble. + out2 = '''Help us improve our hinting system by voting on the hint that was most helpful + to you. Start by picking one of your previous incorrect answers from below: <br /> + <select id="feedback-select">''' + for i, answer in enumerate(pretty_answers): + out2 += '<option value=' + str(i) + '>' + str(answer) + '</option>' + out2 += '</select><br />' + return json.dumps({'contents': out2 + out}) + + + def tally_vote(self, get): + ''' + Tally a user's vote on his favorite hint. + get: + 'answer': ans_no (index in previous_answers) + 'hint': hint_no + ''' + if self.user_voted: + return json.dumps({'contents': 'Sorry, but you have already voted!'}) + ans_no = int(get['answer']) + hint_no = int(get['hint']) + answer = self.previous_answers[ans_no][0] + temp_dict = self.hints + temp_dict[answer][hint_no][1] += 1 + # Awkward, but you need to do a direct write for the database to update. + self.hints = temp_dict + # Don't let the user vote again! + self.user_voted = True + # Reset self.previous_answers. + self.previous_answers = [] + # In the future, return a list of how many votes each hint got, maybe? + return json.dumps({'contents': 'Congrats, you\'ve voted!'}) + + + def submit_hint(self, get): + ''' + Take a hint submission and add it to the database. + get: + 'answer': answer index in previous_answers + 'hint': text of the new hint that the user is adding + ''' + # Do html escaping. Perhaps in the future do profanity filtering, etc. as well. + hint = escape(get['hint']) + answer = self.previous_answers[int(get['answer'])][0] + # Add the new hint to self.hints. (Awkward because a direct write + # is necessary.) + temp_dict = self.hints + temp_dict[answer].append([hint, 1]) # With one vote (the user himself). + self.hints = temp_dict + # Mark the user has having voted; reset previous_answers + self.user_voted = True + self.previous_answers = [] + return json.dumps({'contents': 'Thank you for your hint!'}) + + +class CrowdsourceHinterDescriptor(CrowdsourceHinterFields, XmlDescriptor): + module_class = CrowdsourceHinterModule + stores_state = True + + @classmethod + def definition_from_xml(cls, xml_object, system): + children = [] + for child in xml_object: + try: + children.append(system.process_xml(etree.tostring(child, encoding='unicode')).location.url()) + except Exception as e: + log.exception("Unable to load child when parsing CrowdsourceHinter. Continuing...") + if system.error_tracker is not None: + system.error_tracker("ERROR: " + str(e)) + continue + return {}, children + + def definition_to_xml(self, resource_fs): + xml_object = etree.Element('crowdsource_hinter') + for child in self.get_children(): + xml_object.append( + etree.fromstring(child.export_to_xml(resource_fs))) + return xml_object \ No newline at end of file diff --git a/common/lib/xmodule/xmodule/js/src/capa/display.coffee b/common/lib/xmodule/xmodule/js/src/capa/display.coffee index 1f3be9e5e9cfc649bb3de5197bd297827a3fa62d..4640f7555da2ab7efdb14c299ad7d51fe07d17bb 100644 --- a/common/lib/xmodule/xmodule/js/src/capa/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/capa/display.coffee @@ -223,6 +223,7 @@ class @Problem @el.removeClass 'showed' else @gentle_alert response.success + Logger.log 'problem_graded', [@answers, response.contents], @url reset: => Logger.log 'problem_reset', @answers diff --git a/common/lib/xmodule/xmodule/js/src/crowdsource_hinter/display.coffee b/common/lib/xmodule/xmodule/js/src/crowdsource_hinter/display.coffee new file mode 100644 index 0000000000000000000000000000000000000000..1e38ff0e828455f65cf338c625cce8d909372192 --- /dev/null +++ b/common/lib/xmodule/xmodule/js/src/crowdsource_hinter/display.coffee @@ -0,0 +1,62 @@ +class @Hinter + + constructor: (element) -> + @el = $(element).find('.crowdsource-wrapper') + @url = @el.data('url') + Logger.listen('problem_graded', @el.data('child-url'), @capture_problem) + # The line below will eventually be generated by Python. + @render() + + capture_problem: (event_type, data, element) => + # After a problem gets graded, we get the info here. + # We want to send this info to the server in another AJAX + # request. + answers = data[0] + response = data[1] + if response.search(/class="correct "/) == -1 + # Incorrect. Get hints. + $.postWithPrefix "#{@url}/get_hint", answers, (response) => + @render(response.contents) + else + # Correct. Get feedback from students. + $.postWithPrefix "#{@url}/get_feedback", answers, (response) => + @render(response.contents) + + $: (selector) -> + $(selector, @el) + + bind: => + window.update_schematics() + @$('input.vote').click @vote + @$('#feedback-select').change @feedback_ui_change + @$('input.submit-hint').click @submit_hint + + + vote: (eventObj) => + target = @$(eventObj.currentTarget) + post_json = {'answer': target.data('answer'), 'hint': target.data('hintno')} + $.postWithPrefix "#{@url}/vote", post_json, (response) => + @render(response.contents) + + submit_hint: (eventObj) => + target = @$(eventObj.currentTarget) + textarea_id = '#custom-hint-' + target.data('answer') + console.debug(textarea_id) + post_json = {'answer': target.data('answer'), 'hint': @$(textarea_id).val()} + $.postWithPrefix "#{@url}/submit_hint",post_json, (response) => + @render(response.contents) + + feedback_ui_change: => + # Make all of the previous-answer divs hidden. + @$('.previous-answer').css('display', 'none') + # But, now find the selected div, and make it visible. + selector = '#previous-answer-' + @$('#feedback-select option:selected').attr('value') + @$(selector).css('display', 'inline') + + + render: (content) -> + if content + @el.html(content) + JavascriptLoader.executeModuleScripts @el, () => + @bind() + @$('#previous-answer-0').css('display', 'inline') \ No newline at end of file diff --git a/common/static/coffee/src/logger.coffee b/common/static/coffee/src/logger.coffee index f2dfef513229af0fc0fcc97e0fc725f3920fdbca..6eaa497255dc8493f3e12aea201f79bedfc090d4 100644 --- a/common/static/coffee/src/logger.coffee +++ b/common/static/coffee/src/logger.coffee @@ -1,8 +1,11 @@ class @Logger + # events we want sent to Segment.io for tracking SEGMENT_IO_WHITELIST = ["seq_goto", "seq_next", "seq_prev", "problem_check", "problem_reset", "problem_show", "problem_save"] - @log: (event_type, data) -> + # listeners[event_type][element] -> list of callbacks + listeners = {} + @log: (event_type, data, element = null) -> # Segment.io event tracking if event_type in SEGMENT_IO_WHITELIST # to avoid changing the format of data sent to our servers, we only massage it here @@ -11,11 +14,36 @@ class @Logger else analytics.track event_type, data + # Check to see if we're listening for the event type. + if event_type of listeners + # Cool. Do the elements also match? + # null element in the listener dictionary means any element will do. + # null element in the @log call means we don't know the element name. + if null of listeners[event_type] + # Make the callbacks. + for callback in listeners[event_type][null] + callback(event_type, data, element) + else if element of listeners[event_type] + for callback in listeners[event_type][element] + callback(event_type, data, element) + + # Regardless of whether any callbacks were made, log this event. $.getWithPrefix '/event', event_type: event_type event: JSON.stringify(data) page: window.location.href + @listen: (event_type, element, callback) -> + # Add a listener. If you want any element to trigger this listener, + # do element = null + if event_type not of listeners + listeners[event_type] = {} + if element not of listeners[event_type] + listeners[event_type][element] = [callback] + else + listeners[event_type][element].push callback + + @bind: -> window.onunload = -> $.ajaxWithPrefix