ruqlcqb 0.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/bin/ruqlcqb +95 -0
- data/lib/ruql.rb +23 -0
- data/lib/ruql/answer.rb +28 -0
- data/lib/ruql/fill_in.rb +15 -0
- data/lib/ruql/multiple_choice.rb +11 -0
- data/lib/ruql/question.rb +73 -0
- data/lib/ruql/quiz.rb +105 -0
- data/lib/ruql/renderer.rb +20 -0
- data/lib/ruql/renderers/auto_qcm_renderer.rb +85 -0
- data/lib/ruql/renderers/edxml_renderer.rb +74 -0
- data/lib/ruql/renderers/html5_renderer.rb +166 -0
- data/lib/ruql/renderers/html_form_renderer.rb +198 -0
- data/lib/ruql/renderers/json_renderer.rb +15 -0
- data/lib/ruql/renderers/qualtrics_renderer.rb +63 -0
- data/lib/ruql/renderers/xml_renderer.rb +148 -0
- data/lib/ruql/select_multiple.rb +7 -0
- data/lib/ruql/tex_output.rb +27 -0
- data/lib/ruql/true_false.rb +17 -0
- data/templates/autoqcm.tex.erb +1 -0
- data/templates/html5.html.erb +42 -0
- data/templates/htmlform.html.erb +44 -0
- metadata +94 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 4d81b97a766f2c8045e559d68b15f609fa8ba1c1
|
4
|
+
data.tar.gz: 243f8d96f129e17672957bdda0895a83f641d046
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 9f62bd2e0b3c1f1cff9e89850ada70ef0e5dff7397ee0c5db2d6f995e5da6b205018807834f51a0aa20e288f14389d1f4877895d960acda559d4a6e99483ebc7
|
7
|
+
data.tar.gz: 0c88b4a37c4aaa43c7f3a0eb7961e1e757f06dd7a83a8958eff8504cef2246f61b268d8ae3119a9301f9476706be28266837ba7647e03209013d5ecc94ed9e60
|
data/bin/ruqlcqb
ADDED
@@ -0,0 +1,95 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require 'ruql'
|
4
|
+
require 'getopt/long'
|
5
|
+
|
6
|
+
def usage
|
7
|
+
name = File.basename $0
|
8
|
+
STDERR.puts <<eos
|
9
|
+
Usage: #{name} filename.rb renderer [options]
|
10
|
+
filename.rb contains questions expressed in RuQL
|
11
|
+
|
12
|
+
renderer choices are:
|
13
|
+
Html5 - HTML 5 suitable for Web display/printing
|
14
|
+
HtmlForm - HTML forms
|
15
|
+
EdXml - XML for OpenEdX platform in-course questions
|
16
|
+
AutoQCM - LaTeX for use with AutoQCM (http://home.gna.org/auto-qcm)
|
17
|
+
Coursera - [obsolete] Coursera HTML/XML format for online auto-grading
|
18
|
+
JSON - [partially implemented] JSON format
|
19
|
+
Qualtrics -[partially implemented] Qualtrics survey (txt) format
|
20
|
+
|
21
|
+
Global options:
|
22
|
+
-l <loglevel>, --log=<loglevel>
|
23
|
+
In increasing verbosity, they are 'error' (nonfatal), 'warn', 'info',
|
24
|
+
'debug'; default is 'warn'
|
25
|
+
-p <penalty>, --penalty <penalty>
|
26
|
+
The penalty for WRONG answers, as a fraction of the penalty for
|
27
|
+
RIGHT answers. For a 2-point question, penalty=0.25 means 1/2 point
|
28
|
+
(0.25 * 2) deducted for WRONG answer. Default is 0. Not all
|
29
|
+
output formats are able to do something useful with this.
|
30
|
+
|
31
|
+
The EdXML renderer supports these options:
|
32
|
+
-n <name>, --name=<name>
|
33
|
+
Only render the question(s) that have :name => 'name'.
|
34
|
+
NOTE: Some markup that is legal in RuQL questions will break the EdX parser.
|
35
|
+
Manually check your questions as you enter them into EdX. Code seems to
|
36
|
+
be particularly troublesome.
|
37
|
+
NOTE: The 'points' and 'randomize' attributes of questions are not honored by
|
38
|
+
some systems.
|
39
|
+
|
40
|
+
The HTML5 and HTML Forms renderers supports these options:
|
41
|
+
-c <href>, --css=<href>
|
42
|
+
embed <href> for stylesheet into generated HTML5
|
43
|
+
-j <src>, --js=<src>
|
44
|
+
embed <src> for JavaScrips
|
45
|
+
-t <file.html.erb>, --template=<file.html.erb>
|
46
|
+
Use file.html.erb as HTML template rather than generating our own file.
|
47
|
+
file.html.erb should have <%= yield %> where questions should go.
|
48
|
+
An example is in the templates/ directory of RuQL.
|
49
|
+
The following local variables will be replaced with their values in
|
50
|
+
the template:
|
51
|
+
<%= quiz.title %> - the quiz title
|
52
|
+
<%= quiz.num_questions %> - total number of questions
|
53
|
+
<%= quiz.points %> - total number of points for whole quiz
|
54
|
+
-s, --solutions
|
55
|
+
generate solutions (showing correct answers and explanations)
|
56
|
+
NOTE: If there is more than one quiz (collection of questions) in the file,
|
57
|
+
a complete <html>...</html> block is produced in the output for EACH quiz.
|
58
|
+
|
59
|
+
The AutoQCM renderer supports these options:
|
60
|
+
-t <file.tex.erb>, --template=<file.tex.erb>
|
61
|
+
MANDATORY: Use file.tex.erb as LaTeX/AutoQCM template.
|
62
|
+
The file should have <%= yield %> where questions should go.
|
63
|
+
See the description of template under HTML5 renderer for variable
|
64
|
+
substitutions that can occur in the quiz body.
|
65
|
+
|
66
|
+
The JSON renderer currently supports no options
|
67
|
+
|
68
|
+
The Qualtrics renderer supports these options:
|
69
|
+
-t <file.txt.erb>, --template=<file.txt.erb>
|
70
|
+
The file should have <%= yield %> where questions should go. Since this just creates survey questions, grading information is ignored.Currently supports the same type of questions as the AutoQCM renderer.
|
71
|
+
|
72
|
+
eos
|
73
|
+
exit
|
74
|
+
end
|
75
|
+
|
76
|
+
def main
|
77
|
+
filename = ARGV.shift
|
78
|
+
raise "Cannot read #{filename}" unless File.readable? filename
|
79
|
+
renderer = ARGV.shift
|
80
|
+
raise "Unknown renderer '#{renderer}'" unless Quiz.get_renderer(renderer)
|
81
|
+
opts = Getopt::Long.getopts(
|
82
|
+
['-c', '--css', Getopt::REQUIRED],
|
83
|
+
['-j', '--js', Getopt::REQUIRED],
|
84
|
+
['-t', '--template', Getopt::REQUIRED],
|
85
|
+
['-s', '--solutions', Getopt::BOOLEAN],
|
86
|
+
['-n', '--name', Getopt::REQUIRED],
|
87
|
+
['-l', '--log', Getopt::REQUIRED],
|
88
|
+
)
|
89
|
+
Quiz.instance_eval "#{IO.read(filename)}"
|
90
|
+
Quiz.quizzes.each { |quiz| puts quiz.render_with(renderer, opts) }
|
91
|
+
end
|
92
|
+
|
93
|
+
usage if ARGV.length < 2
|
94
|
+
main
|
95
|
+
|
data/lib/ruql.rb
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
# basic gems/libs we rely on
|
2
|
+
require 'builder'
|
3
|
+
require 'logger'
|
4
|
+
|
5
|
+
$LOAD_PATH.unshift File.expand_path(File.join(File.dirname(__FILE__)))
|
6
|
+
|
7
|
+
# renderers
|
8
|
+
require 'ruql/renderers/xml_renderer'
|
9
|
+
require 'ruql/renderers/html5_renderer'
|
10
|
+
require 'ruql/renderers/html_form_renderer'
|
11
|
+
require 'ruql/renderers/edxml_renderer'
|
12
|
+
require 'ruql/renderers/auto_qcm_renderer'
|
13
|
+
require 'ruql/renderers/json_renderer'
|
14
|
+
require 'ruql/renderers/qualtrics_renderer'
|
15
|
+
|
16
|
+
# question types
|
17
|
+
require 'ruql/quiz'
|
18
|
+
require 'ruql/question'
|
19
|
+
require 'ruql/answer'
|
20
|
+
require 'ruql/multiple_choice'
|
21
|
+
require 'ruql/select_multiple'
|
22
|
+
require 'ruql/true_false'
|
23
|
+
require 'ruql/fill_in'
|
data/lib/ruql/answer.rb
ADDED
@@ -0,0 +1,28 @@
|
|
1
|
+
class Answer
|
2
|
+
include Comparable
|
3
|
+
attr_accessor :answer_text, :explanation, :correct
|
4
|
+
attr_reader :correct
|
5
|
+
attr_reader :builder
|
6
|
+
attr_reader :question
|
7
|
+
|
8
|
+
def <=>(other) ; self.answer_text <=> other.answer_text ; end
|
9
|
+
def correct? ; !!correct ; end
|
10
|
+
def has_explanation? ; !!explanation ; end
|
11
|
+
def initialize(answer_text = '', correct = false, explanation=nil)
|
12
|
+
@answer_text = answer_text
|
13
|
+
@correct = !!correct # ensure boolean
|
14
|
+
@explanation = explanation
|
15
|
+
end
|
16
|
+
|
17
|
+
def to_JSON
|
18
|
+
Hash[instance_variables.collect { |var| [var.to_s.delete('@'), instance_variable_get(var)] }]
|
19
|
+
end
|
20
|
+
|
21
|
+
def self.from_JSON(hash)
|
22
|
+
answer = Answer.new
|
23
|
+
hash.each do |key, value|
|
24
|
+
answer.send((key + '=').to_sym, value)
|
25
|
+
end
|
26
|
+
answer
|
27
|
+
end
|
28
|
+
end
|
data/lib/ruql/fill_in.rb
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
class FillIn < Question
|
2
|
+
|
3
|
+
attr_accessor :order
|
4
|
+
attr_accessor :case_sensitive
|
5
|
+
|
6
|
+
def initialize(text='', opts={})
|
7
|
+
super
|
8
|
+
self.question_text = text
|
9
|
+
self.order = !!opts[:order]
|
10
|
+
self.case_sensitive = !!opts[:case_sensitive]
|
11
|
+
end
|
12
|
+
|
13
|
+
def multiple ; false ; end
|
14
|
+
|
15
|
+
end
|
@@ -0,0 +1,73 @@
|
|
1
|
+
class Question
|
2
|
+
attr_accessor :question_text, :answers, :randomize, :points, :name, :question_tags, :question_comment, :raw
|
3
|
+
|
4
|
+
def initialize(*args)
|
5
|
+
options = if args[-1].kind_of?(Hash) then args[-1] else {} end
|
6
|
+
@answers = options[:answers] || []
|
7
|
+
@points = [options[:points].to_i, 1].max
|
8
|
+
@raw = options[:raw]
|
9
|
+
@name = options[:name]
|
10
|
+
@question_tags = []
|
11
|
+
@question_comment = ''
|
12
|
+
end
|
13
|
+
|
14
|
+
def raw? ; !!@raw ; end
|
15
|
+
|
16
|
+
def text(s) ; @question_text = s ; end
|
17
|
+
|
18
|
+
def explanation(text)
|
19
|
+
@answers.each { |answer| answer.explanation ||= text }
|
20
|
+
end
|
21
|
+
|
22
|
+
def answer(text, opts={})
|
23
|
+
@answers << Answer.new(text, correct=true, opts[:explanation])
|
24
|
+
end
|
25
|
+
|
26
|
+
def distractor(text, opts={})
|
27
|
+
@answers << Answer.new(text, correct=false, opts[:explanation])
|
28
|
+
end
|
29
|
+
|
30
|
+
# these are ignored but legal for now:
|
31
|
+
def tags(*args) # string or array of strings
|
32
|
+
if args.length > 1
|
33
|
+
@question_tags += args.map(&:to_s)
|
34
|
+
else
|
35
|
+
@question_tags << args.first.to_s
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def comment(str = '')
|
40
|
+
@question_comment = str.to_s
|
41
|
+
end
|
42
|
+
|
43
|
+
def correct_answer ; @answers.detect(&:correct?) ; end
|
44
|
+
|
45
|
+
def correct_answers ; @answers.collect(&:correct?) ; end
|
46
|
+
|
47
|
+
def answer_helper(obj)
|
48
|
+
if obj.is_a? Array and obj.size and obj[0].is_a? Answer
|
49
|
+
return obj.map {|answer| answer.to_JSON}
|
50
|
+
end
|
51
|
+
obj
|
52
|
+
end
|
53
|
+
|
54
|
+
#creates a JSON hash of the object with its object name. we should convert this to a mixin for answer and question. aaron
|
55
|
+
def to_JSON
|
56
|
+
h = Hash[instance_variables.collect { |var| [var.to_s.delete('@'), answer_helper(instance_variable_get(var))] }]
|
57
|
+
h['question_type'] = self.class.to_s
|
58
|
+
return h
|
59
|
+
end
|
60
|
+
|
61
|
+
#factory method to return correct type of question
|
62
|
+
def self.from_JSON(hash_str)
|
63
|
+
hash = JSON.parse(hash_str)
|
64
|
+
#create the appropriate class of the object from the hash's class name
|
65
|
+
question = Object.const_get(hash.fetch('question_type')).new()
|
66
|
+
hash.reject{|key| key == 'answers' or key == 'question_type'}.each do |key, value|
|
67
|
+
question.send((key + '=').to_sym, value)
|
68
|
+
end
|
69
|
+
question.answers = hash['answers'].map{|answer_hash| Answer.from_JSON(answer_hash)}
|
70
|
+
question
|
71
|
+
end
|
72
|
+
|
73
|
+
end
|
data/lib/ruql/quiz.rb
ADDED
@@ -0,0 +1,105 @@
|
|
1
|
+
|
2
|
+
class Quiz
|
3
|
+
@@quizzes = []
|
4
|
+
def self.quizzes ; @@quizzes ; end
|
5
|
+
@@default_options =
|
6
|
+
{
|
7
|
+
:open_time => Time.now,
|
8
|
+
:soft_close_time => Time.now + 24*60*60,
|
9
|
+
:hard_close_time => Time.now + 24*60*60,
|
10
|
+
:maximum_submissions => 1,
|
11
|
+
:duration => 3600,
|
12
|
+
:retry_delay => 600,
|
13
|
+
:parameters => {
|
14
|
+
:show_explanations => {
|
15
|
+
:question => 'before_soft_close_time',
|
16
|
+
:option => 'before_soft_close_time',
|
17
|
+
:score => 'before_soft_close_time',
|
18
|
+
}
|
19
|
+
},
|
20
|
+
:maximum_score => 1,
|
21
|
+
}
|
22
|
+
|
23
|
+
attr_reader :renderer
|
24
|
+
attr_reader :questions
|
25
|
+
attr_reader :options
|
26
|
+
attr_reader :output
|
27
|
+
attr_reader :seed
|
28
|
+
attr_reader :logger
|
29
|
+
attr_accessor :title
|
30
|
+
|
31
|
+
def initialize(title, options={})
|
32
|
+
@output = ''
|
33
|
+
@questions = options[:questions] || []
|
34
|
+
@title = title
|
35
|
+
@options = @@default_options.merge(options)
|
36
|
+
@seed = srand
|
37
|
+
@logger = Logger.new(STDERR)
|
38
|
+
@logger.level = Logger.const_get (options.delete('l') ||
|
39
|
+
options.delete('log') || 'warn').upcase
|
40
|
+
end
|
41
|
+
|
42
|
+
def self.get_renderer(renderer)
|
43
|
+
Object.const_get(renderer.to_s + 'Renderer') rescue nil
|
44
|
+
end
|
45
|
+
|
46
|
+
def render_with(renderer,options={})
|
47
|
+
srand @seed
|
48
|
+
@renderer = Quiz.get_renderer(renderer).send(:new,self,options)
|
49
|
+
@renderer.render_quiz
|
50
|
+
@output = @renderer.output
|
51
|
+
end
|
52
|
+
|
53
|
+
def points ; questions.map(&:points).inject { |sum,points| sum + points } ; end
|
54
|
+
|
55
|
+
def num_questions ; questions.length ; end
|
56
|
+
|
57
|
+
def random_seed(num)
|
58
|
+
@seed = num.to_i
|
59
|
+
end
|
60
|
+
|
61
|
+
# this should really be done using mixins.
|
62
|
+
def choice_answer(*args, &block)
|
63
|
+
if args.first.is_a?(Hash) # no question text
|
64
|
+
q = MultipleChoice.new('',*args)
|
65
|
+
else
|
66
|
+
text = args.shift
|
67
|
+
q = MultipleChoice.new(text, *args)
|
68
|
+
end
|
69
|
+
q.instance_eval(&block)
|
70
|
+
@questions << q
|
71
|
+
end
|
72
|
+
|
73
|
+
def select_multiple(*args, &block)
|
74
|
+
if args.first.is_a?(Hash) # no question text
|
75
|
+
q = SelectMultiple.new('',*args)
|
76
|
+
else
|
77
|
+
text = args.shift
|
78
|
+
q = SelectMultiple.new(text, *args)
|
79
|
+
end
|
80
|
+
q.instance_eval(&block)
|
81
|
+
@questions << q
|
82
|
+
end
|
83
|
+
|
84
|
+
def truefalse(*args)
|
85
|
+
q = TrueFalse.new(*args)
|
86
|
+
@questions << q
|
87
|
+
end
|
88
|
+
|
89
|
+
def fill_in(*args, &block)
|
90
|
+
if args.first.is_a?(Hash) # no question text
|
91
|
+
q = FillIn.new('', *args)
|
92
|
+
else
|
93
|
+
text = args.shift
|
94
|
+
q = FillIn.new(text, *args)
|
95
|
+
end
|
96
|
+
q.instance_eval(&block)
|
97
|
+
@questions << q
|
98
|
+
end
|
99
|
+
|
100
|
+
def self.quiz(*args,&block)
|
101
|
+
quiz = Quiz.new(*args)
|
102
|
+
quiz.instance_eval(&block)
|
103
|
+
@@quizzes << quiz
|
104
|
+
end
|
105
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
class Renderer
|
2
|
+
|
3
|
+
attr_reader :quiz
|
4
|
+
def initialize(quiz)
|
5
|
+
@quiz = quiz
|
6
|
+
end
|
7
|
+
|
8
|
+
@@render_handlers = {}
|
9
|
+
def self.render(thing, &block)
|
10
|
+
@@render_handlers[thing.to_sym] = block
|
11
|
+
end
|
12
|
+
def self.renderer_for(thing)
|
13
|
+
@@render_handlers[thing.to_sym]
|
14
|
+
end
|
15
|
+
|
16
|
+
def render_quiz!
|
17
|
+
self.instance_eval@@render_handlers[:quiz].call(self.quiz)
|
18
|
+
end
|
19
|
+
|
20
|
+
end
|
@@ -0,0 +1,85 @@
|
|
1
|
+
require 'erb'
|
2
|
+
require 'ruql/tex_output'
|
3
|
+
|
4
|
+
class AutoQCMRenderer
|
5
|
+
include TexOutput
|
6
|
+
attr_reader :output
|
7
|
+
|
8
|
+
def initialize(quiz, options={})
|
9
|
+
@output = ''
|
10
|
+
@quiz = quiz
|
11
|
+
@template = options.delete('t') ||
|
12
|
+
options.delete('template') ||
|
13
|
+
File.join(Gem.loaded_specs['ruql'].full_gem_path, 'templates/autoqcm.tex.erb')
|
14
|
+
@penalty = (options.delete('p') || options.delete('penalty') || '0').to_f
|
15
|
+
@show_solutions = options.delete('s') || options.delete('solutions')
|
16
|
+
|
17
|
+
end
|
18
|
+
|
19
|
+
def render_quiz
|
20
|
+
quiz = @quiz # make quiz object available in template's scope
|
21
|
+
with_erb_template(IO.read(File.expand_path @template)) do
|
22
|
+
output = ''
|
23
|
+
render_random_seed
|
24
|
+
@quiz.questions.each_with_index do |q,i|
|
25
|
+
next_question = render_question q,i
|
26
|
+
output << next_question
|
27
|
+
end
|
28
|
+
output
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def with_erb_template(template)
|
33
|
+
# template will 'yield' back to render_quiz to render the questions
|
34
|
+
@output = ERB.new(template).result(binding)
|
35
|
+
end
|
36
|
+
|
37
|
+
def render_question(q,index)
|
38
|
+
case q
|
39
|
+
when SelectMultiple,TrueFalse then render(q, index, 'mult') # These are subclasses of MultipleChoice, should go first
|
40
|
+
when MultipleChoice then render(q, index)
|
41
|
+
else
|
42
|
+
@quiz.logger.error "Question #{index} (#{q.question_text[0,15]}...): AutoQCM can only handle multiple_choice, truefalse, or select_multiple questions"
|
43
|
+
''
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def render_random_seed
|
48
|
+
seed = @quiz.seed
|
49
|
+
@output << "\n%% Random seed: #{seed}\n"
|
50
|
+
@output << "\\AMCrandomseed{#{seed}}\n\n"
|
51
|
+
end
|
52
|
+
|
53
|
+
def render(question, index, type='')
|
54
|
+
output = ''
|
55
|
+
output << "\\begin{question#{type}}{q#{index}}\n"
|
56
|
+
output << " \\scoring{b=#{question.points},m=#{@penalty*question.points}}\n"
|
57
|
+
if type == 'mult'
|
58
|
+
question.question_text = "Select ALL that apply. " + question.question_text
|
59
|
+
elsif type == ''
|
60
|
+
question.question_text = "Choose ONE answer. " + question.question_text
|
61
|
+
end
|
62
|
+
output << " " << to_tex(question.question_text) << "\n"
|
63
|
+
|
64
|
+
# answers - ignore randomization
|
65
|
+
|
66
|
+
output << " \\begin{choices}\n"
|
67
|
+
question.answers.each do |answer|
|
68
|
+
answer_text = to_tex(answer.answer_text)
|
69
|
+
answer_type = if answer.correct? then 'correct' else 'wrong' end
|
70
|
+
output << " \\#{answer_type}choice{#{answer_text}}\n"
|
71
|
+
if @show_solutions and answer.explanation
|
72
|
+
explanation = to_tex(answer.explanation)
|
73
|
+
if answer_type == 'wrong'
|
74
|
+
output << "{\\color{red}\\tab #{explanation}}"
|
75
|
+
else
|
76
|
+
output << "{\\color[rgb]{0,.5,0}\\tab #{explanation}}"
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
output << " \\end{choices}\n"
|
81
|
+
output << "\\end{question#{type}}\n\n"
|
82
|
+
output
|
83
|
+
end
|
84
|
+
|
85
|
+
end
|