ruqlcqb 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|