Quiz Stats - Refactor/get rid of QuestionAnalyzer

- removed the question analyzer
  - each answer analyzer is now expected to calculate its own
    "responses" field as opposed to doing it in a generic manner
  - made the Essay analyzer generate its "responses" field
  - a self-documenting DSL for defining analyzer output metrics

Closes CNVS-13157

TEST PLAN
---- ----

It's all code movement/refactor so nothing new to test. The plan is to
verify the Essay question stats are unaffected (which is basically all
the gem supports at this point:)

  - create a quiz with an essay question
  - take the quiz by a few students
  - grade a submission
  - retrieve the stats via the API:
    GET /api/v1/courses/:course_id/quizzes/:quiz_id/statistics
    - verify the essay stats in the API are still functional, the
      "responses" field in particular

Change-Id: I42b9552c60ccb56f7c6912fed7cc1173da71852d
Reviewed-on: https://gerrit.instructure.com/35096
Reviewed-by: Jason Madsen <jmadsen@instructure.com>
QA-Review: Caleb Guanzon <cguanzon@instructure.com>
Tested-by: Jenkins <jenkins@instructure.com>
Product-Review: Ahmad Amireh <ahmad@instructure.com>
This commit is contained in:
Ahmad Amireh 2014-05-17 16:07:54 +03:00
parent dea0db86d4
commit 457e55d6a2
16 changed files with 359 additions and 254 deletions

View File

@ -19,6 +19,8 @@
require 'csv'
class Quizzes::QuizStatistics::StudentAnalysis < Quizzes::QuizStatistics::Report
CQS = CanvasQuizStatistics
include HtmlTextHelper
def readable_type
@ -357,9 +359,9 @@ class Quizzes::QuizStatistics::StudentAnalysis < Quizzes::QuizStatistics::Report
# "user_ids"=>[1,2,3],
# "multiple_responses"=>false}],
def stats_for_question(question, responses)
if [ 'essay_question' ].include?(question[:question_type].to_s)
analyzer = CanvasQuizStatistics::QuestionAnalyzer.new(question)
analysis = analyzer.run(responses)
if CQS.can_analyze?(question)
analysis = CQS.analyze(question, responses)
return question.to_hash.merge(analysis).with_indifferent_access
end

View File

@ -1,5 +1,13 @@
module CanvasQuizStatistics
require 'canvas_quiz_statistics/version'
require 'canvas_quiz_statistics/answer_analyzers'
require 'canvas_quiz_statistics/question_analyzer'
require 'canvas_quiz_statistics/analyzers'
def self.can_analyze?(question_data)
Analyzers[question_data[:question_type]] != Analyzers::Base
end
def self.analyze(question_data, responses)
analyzer = Analyzers[question_data[:question_type]].new(question_data)
analyzer.run(responses)
end
end

View File

@ -0,0 +1,46 @@
#
# Copyright (C) 2014 Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
require 'active_support/core_ext'
module CanvasQuizStatistics::Analyzers
class << self
attr_accessor :available_analyzers
# Convenient access to analyzer for a given question type, e.g:
#
# Analyzers['multiple_choice_question'].new
#
# If the question type is not supported, you will be given the Base
# analyzer which really does nothing.
def [](question_type)
self.available_analyzers ||= {}
self.available_analyzers[question_type.to_sym] || Base
end
end
class Base
def self.inherited(klass)
namespace = CanvasQuizStatistics::Analyzers
namespace.available_analyzers ||= {}
namespace.available_analyzers[klass.question_type] = klass
end
end
require 'canvas_quiz_statistics/analyzers/base'
require 'canvas_quiz_statistics/analyzers/essay'
end

View File

@ -0,0 +1,61 @@
#
# Copyright (C) 2014 Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
require 'canvas_quiz_statistics/analyzers/base/dsl'
module CanvasQuizStatistics::Analyzers
class Base
extend DSL
attr_reader :question_data
def initialize(question_data)
@question_data = question_data
@metrics = self.class.metrics[self.class.question_type]
end
def run(responses)
context = build_context(responses)
{}.tap do |stats|
@metrics.map do |metric|
params = [ responses ]
if metric[:context].any?
params += metric[:context].map { |var| context[var] }
end
stats[metric[:key]] = instance_exec(*params, &metric[:calculator])
end
end
end
def self.question_type
(self.name.demodulize.underscore + '_question').to_sym
end
private
# This is the place to prepare any shared context that's needed by the
# metric calculations. See DSL for more info on stateful metrics.
#
# @return [Hash] You must return a Hash with symbolized keys for a context.
def build_context(responses)
{}
end
end
end

View File

@ -0,0 +1,85 @@
#
# Copyright (C) 2014 Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
# A custom DSL for writing metric calculators.
#
# Here's a full example showing how to define a context-free metric calculator,
# and a stateful one that requires a shared, pre-calculated variable:
#
# module CanvasQuizStatistics::Analyzers
# class MultipleChoice < Base
# # A basic metric calculator. Your calculator block will be passed the
# # set of responses, and needs to return the value of the metric.
# #
# # The key you specify will be written to the output, e.g:
# # { "missing_answers": 1 }
# metric :missing_answers do |responses|
# responses.select { |r| r[:text].blank? }.length
# end
#
# # Let's say you need some pre-calculated variable for a bunch of metrics,
# # call it "grades", we can prepare it in the special #build_context
# # method and explicitly declare it as a dependency of each metric:
# def build_context(responses)
# ctx = {}
# ctx[:grades] = responses.map { |r| r[:grade] }
# ctx
# end
#
# # Notice how our metric definition now states that it requires the
# # "grades" context variable to run, and it receives it as a block arg:
# metric :graded_correctly => [ :grades ] do |responses, grades|
# grades.select { |grade| grade == 'correct' }.length
# end
# end
# end
module CanvasQuizStatistics::Analyzers::Base::DSL
def metric(key, &calculator)
deps = []
if key.is_a?(Hash)
deps, key = key.values.flatten, key.keys.first
end
self.metrics[question_type] << {
key: key.to_sym,
context: deps,
calculator: calculator
}
end
# You will need to do this if you're subclassing a concrete analyzer and would
# like to inherit the metric calculators it defined, as the calculators are
# scoped per question type and not the Ruby class.
#
# Example:
#
# module CanvasQuizStatistics::Analyzers
# class TrueFalse < MultipleChoice
# inherit_metrics :multiple_choice_question
# end
# end
#
def inherit_metrics(question_type)
self.metrics[self.question_type] += self.metrics[question_type].clone
end
def metrics
@@metrics ||= Hash.new { |hsh, key| hsh[key] = [] }
end
end

View File

@ -15,7 +15,7 @@
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
module CanvasQuizStatistics::AnswerAnalyzers
module CanvasQuizStatistics::Analyzers
# Generates statistics for a set of student responses to an essay question.
class Essay < Base
# @param [Array<Hash>] responses
@ -30,6 +30,9 @@ module CanvasQuizStatistics::AnswerAnalyzers
#
# @example output
# {
# // Number of students who have answered this question.
# "responses": 2,
#
# // The number of students whose responses were graded by the teacher so
# // far.
# "graded": 1,
@ -44,8 +47,8 @@ module CanvasQuizStatistics::AnswerAnalyzers
# { "score": 3, "count": 1 }
# ]
# }
def run(question_data, responses)
full_credit = question_data[:points_possible].to_f
def run(responses)
full_credit = @question_data[:points_possible].to_f
stats = {}
stats[:graded] = responses.select { |r| r[:correct] == 'defined' }.length
@ -60,11 +63,20 @@ module CanvasQuizStatistics::AnswerAnalyzers
stats[:point_distribution] = point_distribution.keys.map do |score|
{ score: score, count: point_distribution[score] }
end
end.sort_by { |v| v[:score] || -1 }
stats[:point_distribution].sort_by! { |v| v[:score] || -1 }
stats[:responses] = responses.select(&method(:answer_present?)).length
stats
end
private
# Test whether the response contains an answer to the question.
#
# Default behavior is to text whether the "text" field is populated.
def answer_present?(response)
response[:text].present?
end
end
end

View File

@ -1,39 +0,0 @@
#
# Copyright (C) 2014 Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
require 'canvas_quiz_statistics/answer_analyzers/util'
require 'canvas_quiz_statistics/answer_analyzers/base'
require 'canvas_quiz_statistics/answer_analyzers/essay'
module CanvasQuizStatistics::AnswerAnalyzers
# Convenient access to analyzer for a given question type, e.g:
#
# AnswerAnalyzers['multiple_choice_question'].new
#
# If the question type is not supported, you will be given the Base
# analyzer which really does nothing.
def self.[](question_type)
AVAILABLE_ANALYZERS[question_type] || Base
end
private
# for fast lookup without having to use #constantize or anything
AVAILABLE_ANALYZERS = {
'essay_question' => Essay
}
end

View File

@ -1,31 +0,0 @@
#
# Copyright (C) 2014 Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
module CanvasQuizStatistics::AnswerAnalyzers
class Base
def run(question_data, responses)
{}
end
# Test whether the response contains an answer to the question.
#
# Default behavior is to text whether the "text" field is populated.
def answer_present?(response)
response[:text].present?
end
end
end

View File

@ -1,24 +0,0 @@
#
# Copyright (C) 2014 Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
module CanvasQuizStatistics::AnswerAnalyzers
module Util
def self.digest(str)
Digest::MD5.hexdigest((str || '').to_s.strip)
end
end
end

View File

@ -1,61 +0,0 @@
#
# Copyright (C) 2014 Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
require 'active_support/core_ext'
class CanvasQuizStatistics::QuestionAnalyzer
attr_reader :question_data
def initialize(question_data)
@question_data = question_data
@answer_analyzer = AnswerAnalyzers[question_data[:question_type].to_s].new
end
# Gathers all types of stats from a set of student responses.
#
# The output will contain the output of an answer analyzer.
#
# @return [Hash]
# {
# // Number of students who have answered this question.
# "responses": 2,
#
# // IDs of those students.
# "user_ids": [ 1, 133 ]
# }
def run(responses)
{}.tap do |stats|
stats.merge! @answer_analyzer.run(question_data, responses)
stats.merge! count_filled_responses(responses)
end
end
private
AnswerAnalyzers = CanvasQuizStatistics::AnswerAnalyzers
# Returns the number of and IDs of students who provided any kind of answer,
# regardless of whether it's correct or not.
def count_filled_responses(responses)
answers = responses.select do |response|
@answer_analyzer.answer_present?(response)
end
{ responses: answers.size, user_ids: answers.map { |a| a[:user_id] }.uniq }
end
end

View File

@ -0,0 +1,91 @@
require 'spec_helper'
describe CanvasQuizStatistics::Analyzers::Base do
Base = CanvasQuizStatistics::Analyzers::Base
subject { described_class.new({}) }
describe 'DSL' do
def unset(*klasses)
klasses.each do |klass|
Object.send(:remove_const, klass.name.demodulize)
Base.metrics[klass.question_type] = []
end
end
describe '#metric' do
it 'should define a metric calculator' do
class Apple < Base
metric :something do |responses|
responses.size
end
end
Apple.new({}).run([ {}, {} ]).should == { something: 2 }
unset Apple
end
it 'should not conflict with other analyzer metrics' do
class Apple < Base
metric :something do |responses|
responses.size
end
end
class Orange < Base
metric :something_else do |responses|
responses.size
end
end
Apple.new({}).run([{}]).should == { something: 1 }
Orange.new({}).run([{}]).should == { something_else: 1 }
unset Apple, Orange
end
describe 'with context dependencies' do
it 'should invoke the context builder and parse dependency' do
class Apple < Base
def build_context(responses)
{ colors: responses.map { |r| r[:color] } }
end
metric something: [ :colors ] do |responses, colors|
colors.join(', ')
end
end
responses = [{ color: 'Red' }, { color: 'Green' }]
Apple.new({}).run(responses).should == { something: 'Red, Green' }
unset Apple
end
end
end
describe '#inherit_metrics' do
it 'should inherit a parent class metrics' do
class Apple < Base
metric :something do |responses|
responses.size
end
end
class Orange < Apple
inherit_metrics :apple_question
metric :something_else do |responses|
responses.size
end
end
Apple.new({}).run([{}]).should == { something: 1 }
Orange.new({}).run([{}]).should == { something: 1, something_else: 1 }
unset Apple, Orange
end
end
end
end

View File

@ -1,23 +1,43 @@
require 'spec_helper'
describe CanvasQuizStatistics::AnswerAnalyzers::Essay do
describe CanvasQuizStatistics::Analyzers::Essay do
let(:question_data) { QuestionHelpers.fixture('essay_question') }
subject { described_class.new(question_data) }
it 'should not blow up when no responses are provided' do
expect {
subject.run(question_data, []).should be_present
subject.run([]).should be_present
}.to_not raise_error
end
it "should not consider an answer to be present if it's empty" do
subject.answer_present?({ text: nil }).should be_false
subject.answer_present?({ text: '' }).should be_false
end
describe 'output [#run]' do
describe '[:responses]' do
it 'should count students who have written anything' do
subject.run([{ text: 'foo' }])[:responses].should == 1
end
it 'should not count students who have written a blank response' do
subject.run([{ }])[:responses].should == 0
subject.run([{ text: nil }])[:responses].should == 0
subject.run([{ text: '' }])[:responses].should == 0
end
end
it ':graded - should reflect the number of graded answers' do
output = subject.run([
{ correct: 'defined' }, { correct: 'undefined' }
])
output[:graded].should == 1
end
describe ':full_credit' do
let :question_data do
{ points_possible: 3 }
end
it 'should count all students who received full credit' do
output = subject.run(question_data, [
output = subject.run([
{ points: 3 }, { points: 2 }, { points: 3 }
])
@ -25,7 +45,7 @@ describe CanvasQuizStatistics::AnswerAnalyzers::Essay do
end
it 'should count students who received more than full credit' do
output = subject.run(question_data, [
output = subject.run([
{ points: 3 }, { points: 2 }, { points: 5 }
])
@ -33,25 +53,22 @@ describe CanvasQuizStatistics::AnswerAnalyzers::Essay do
end
it 'should be 0 otherwise' do
output = subject.run(question_data, [
output = subject.run([
{ points: 1 }
])
output[:full_credit].should == 0
end
end
it ':graded - should reflect the number of graded answers' do
output = subject.run(question_data, [
{ correct: 'defined' }, { correct: 'undefined' }
])
output[:graded].should == 1
it 'should count those who exceed the maximum points possible' do
output = subject.run([{ points: 5 }])
output[:full_credit].should == 1
end
end
describe ':point_distribution' do
it 'should map each score to the number of receivers' do
output = subject.run(question_data, [
output = subject.run([
{ points: 1, user_id: 1 },
{ points: 3, user_id: 2 }, { points: 3, user_id: 3 },
{ points: nil, user_id: 5 }
@ -63,7 +80,7 @@ describe CanvasQuizStatistics::AnswerAnalyzers::Essay do
end
it 'should sort them in score ascending mode' do
output = subject.run(question_data, [
output = subject.run([
{ points: 3, user_id: 2 }, { points: 3, user_id: 3 },
{ points: 1, user_id: 1 },
{ points: nil, user_id: 5 }

View File

@ -1,17 +0,0 @@
require 'spec_helper'
describe CanvasQuizStatistics::AnswerAnalyzers::Base do
describe '#run' do
it 'returns an empty set' do
subject.run({}, []).should == {}
end
end
describe '#answer_present?' do
it 'defaults to testing whether :text is present' do
subject.answer_present?({ text: 'foo' }).should be_true
subject.answer_present?({}).should be_false
subject.answer_present?({ text: nil }).should be_false
end
end
end

View File

@ -1,7 +1,7 @@
require 'spec_helper'
describe CanvasQuizStatistics::AnswerAnalyzers do
Analyzers = CanvasQuizStatistics::AnswerAnalyzers
describe CanvasQuizStatistics::Analyzers do
Analyzers = CanvasQuizStatistics::Analyzers
describe '[]' do
it 'should locate an analyzer' do

View File

@ -1,43 +0,0 @@
require 'spec_helper'
describe CanvasQuizStatistics::QuestionAnalyzer do
subject do
described_class.new(@question_data || { })
end
it 'should not break with no responses' do
expect { subject.run([]) }.to_not raise_error
end
it 'should embed the output of the answer analyzer' do
@question_data = {
question_type: 'stubbed_question'
}
responses = [{ user_id: 1, text: 'foobar' }]
answer_analysis = { some_metric: 12 }
analyzer = double
analyzer.should_receive(:run).with(@question_data, responses).and_return(answer_analysis)
analyzer.should_receive(:answer_present?).and_return(true)
analyzer_generator = double
analyzer_generator.should_receive(:new).and_return(analyzer)
CanvasQuizStatistics::AnswerAnalyzers.stub :[] => analyzer_generator
output = subject.run(responses)
output[:some_metric].should == 12
end
describe ':answered' do
it ':count - the number of students who provided an answer' do
output = subject.run([
{ text: 'hi', user_id: 1 },
{ text: 'hello', user_id: 3 }
])
# answer_analyzer.stub(:answer_present?).and_return(true)
output[:responses].should == 2
output[:user_ids].should == [1,3]
end
end
end

View File

@ -319,21 +319,19 @@ describe Quizzes::QuizStatistics::StudentAnalysis do
describe 'question statistics' do
subject { Quizzes::QuizStatistics::StudentAnalysis.new({}) }
it 'should proxy to the gem for Essay question stats' do
it 'should proxy to CanvasQuizStatistics for supported questions' do
question_data = { question_type: 'essay_question' }
responses = []
stats = { foo: 'bar' }
analyzer = stub
CanvasQuizStatistics::QuestionAnalyzer.
expects(:new).with(question_data).
returns(analyzer)
CanvasQuizStatistics.
expects(:analyze).
with(question_data, responses).
returns({ some_metric: 5 })
analyzer.expects(:run).with(responses).returns(stats)
output = subject.send(:stats_for_question, question_data, responses)
output.should == {
question_type: 'essay_question',
foo: 'bar'
some_metric: 5
}.with_indifferent_access
end
end