ruql 0.0.4 → 0.0.8
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/ruql +15 -3
- data/lib/ruql/dropdown.rb +19 -0
- data/lib/ruql/open_assessment/criterion.rb +45 -0
- data/lib/ruql/open_assessment/open_assessment.rb +141 -0
- data/lib/ruql/open_assessment/option.rb +34 -0
- data/lib/ruql/open_assessment/training.rb +21 -0
- data/lib/ruql/open_assessment/training_criterion.rb +15 -0
- data/lib/ruql/question.rb +4 -4
- data/lib/ruql/quiz.rb +42 -10
- data/lib/ruql/renderer.rb +8 -0
- data/lib/ruql/renderers/auto_qcm_renderer.rb +18 -3
- data/lib/ruql/renderers/edxml_renderer.rb +145 -20
- data/lib/ruql/renderers/qualtrics_renderer.rb +63 -0
- data/lib/ruql/tex_output.rb +10 -1
- data/lib/ruql.rb +8 -0
- metadata +33 -32
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 997beabb8740224d399921151286761f842799d9
|
4
|
+
data.tar.gz: ead3b2482c09f2f2b494fff9b5fb19f41e841931
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: c681825fc87751977bb73d8fc403860f8591588a11f809b3dc5ddbf8b1cc9699441b1ec3f67e8ca07d54dd5fd0fb493dcbbb794e517206d99c4ec6aba6e35e63
|
7
|
+
data.tar.gz: 08054ec2bb224f2e86be37d8e5e69ce51872150eaa47827f52fa238c8c465415dcccba861339fdd587e36b1357c06dca4f44f79ce830262a7865380c23a9631b
|
data/bin/ruql
CHANGED
@@ -1,6 +1,7 @@
|
|
1
1
|
#!/usr/bin/env ruby
|
2
2
|
|
3
|
-
require 'ruql'
|
3
|
+
# require 'ruql'
|
4
|
+
require_relative '../lib/ruql'
|
4
5
|
require 'getopt/long'
|
5
6
|
|
6
7
|
def usage
|
@@ -16,6 +17,7 @@ renderer choices are:
|
|
16
17
|
AutoQCM - LaTeX for use with AutoQCM (http://home.gna.org/auto-qcm)
|
17
18
|
Coursera - [obsolete] Coursera HTML/XML format for online auto-grading
|
18
19
|
JSON - [partially implemented] JSON format
|
20
|
+
Qualtrics -[partially implemented] Qualtrics survey (txt) format
|
19
21
|
|
20
22
|
Global options:
|
21
23
|
-l <loglevel>, --log=<loglevel>
|
@@ -36,6 +38,9 @@ The EdXML renderer supports these options:
|
|
36
38
|
NOTE: The 'points' and 'randomize' attributes of questions are not honored by
|
37
39
|
some systems.
|
38
40
|
|
41
|
+
-y <file.yml>, --yaml=<file.yml>
|
42
|
+
Render the questions with a specific yaml file.
|
43
|
+
|
39
44
|
The HTML5 and HTML Forms renderers supports these options:
|
40
45
|
-c <href>, --css=<href>
|
41
46
|
embed <href> for stylesheet into generated HTML5
|
@@ -46,7 +51,7 @@ The HTML5 and HTML Forms renderers supports these options:
|
|
46
51
|
file.html.erb should have <%= yield %> where questions should go.
|
47
52
|
An example is in the templates/ directory of RuQL.
|
48
53
|
The following local variables will be replaced with their values in
|
49
|
-
the template:
|
54
|
+
the template:
|
50
55
|
<%= quiz.title %> - the quiz title
|
51
56
|
<%= quiz.num_questions %> - total number of questions
|
52
57
|
<%= quiz.points %> - total number of points for whole quiz
|
@@ -64,6 +69,10 @@ The AutoQCM renderer supports these options:
|
|
64
69
|
|
65
70
|
The JSON renderer currently supports no options
|
66
71
|
|
72
|
+
The Qualtrics renderer supports these options:
|
73
|
+
-t <file.txt.erb>, --template=<file.txt.erb>
|
74
|
+
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.
|
75
|
+
|
67
76
|
eos
|
68
77
|
exit
|
69
78
|
end
|
@@ -73,6 +82,7 @@ def main
|
|
73
82
|
raise "Cannot read #{filename}" unless File.readable? filename
|
74
83
|
renderer = ARGV.shift
|
75
84
|
raise "Unknown renderer '#{renderer}'" unless Quiz.get_renderer(renderer)
|
85
|
+
|
76
86
|
opts = Getopt::Long.getopts(
|
77
87
|
['-c', '--css', Getopt::REQUIRED],
|
78
88
|
['-j', '--js', Getopt::REQUIRED],
|
@@ -80,7 +90,9 @@ def main
|
|
80
90
|
['-s', '--solutions', Getopt::BOOLEAN],
|
81
91
|
['-n', '--name', Getopt::REQUIRED],
|
82
92
|
['-l', '--log', Getopt::REQUIRED],
|
83
|
-
|
93
|
+
['-y', '--yaml', Getopt::REQUIRED]
|
94
|
+
)
|
95
|
+
Quiz.set_yaml_file opts.delete("y") || opts.delete("yaml")
|
84
96
|
Quiz.instance_eval "#{IO.read(filename)}"
|
85
97
|
Quiz.quizzes.each { |quiz| puts quiz.render_with(renderer, opts) }
|
86
98
|
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
class Dropdown < Question
|
2
|
+
|
3
|
+
DropdownChoice = Struct.new(:correct, :list)
|
4
|
+
DropdownText = Struct.new(:text)
|
5
|
+
|
6
|
+
def choice(correct, list)
|
7
|
+
@choices << DropdownChoice.new(correct, list)
|
8
|
+
end
|
9
|
+
def label(str)
|
10
|
+
@choices << DropdownChoice.new(0, [str])
|
11
|
+
end
|
12
|
+
|
13
|
+
attr_accessor :choices
|
14
|
+
|
15
|
+
def initialize(opts={})
|
16
|
+
super
|
17
|
+
self.choices = []
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
class Criterion
|
2
|
+
|
3
|
+
attr_accessor :options, :feedback, :criterion_name,
|
4
|
+
:criterion_label, :criterion_prompt
|
5
|
+
|
6
|
+
##
|
7
|
+
# Initializes a criterion
|
8
|
+
def initialize(options={})
|
9
|
+
@options = []
|
10
|
+
@feedback = options[:feedback] || "required"
|
11
|
+
end
|
12
|
+
|
13
|
+
##
|
14
|
+
# Sets the criterion name
|
15
|
+
def name(name) ; @criterion_name = name ; end
|
16
|
+
|
17
|
+
##
|
18
|
+
# Sets the criterion label
|
19
|
+
def label(label) ; @criterion_label = label ; end
|
20
|
+
|
21
|
+
##
|
22
|
+
# Sets the criterion prompt
|
23
|
+
def prompt(prompt) ; @criterion_prompt = prompt ; end
|
24
|
+
|
25
|
+
##
|
26
|
+
# Adds an option to the block and evaluates the proc bloc
|
27
|
+
def option(*args, &block)
|
28
|
+
option = Option.new(*args)
|
29
|
+
option.instance_eval(&block)
|
30
|
+
raise "Missing option parameters" if option.missing_parameters?
|
31
|
+
options << option
|
32
|
+
end
|
33
|
+
|
34
|
+
##
|
35
|
+
# Adds an already initialized option
|
36
|
+
def add_option(option)
|
37
|
+
options << option
|
38
|
+
end
|
39
|
+
|
40
|
+
##
|
41
|
+
# Validation to make sure that all the required fields are in
|
42
|
+
def missing_parameters?
|
43
|
+
@criterion_name.nil? || @criterion_label.nil? || @criterion_prompt.nil?
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,141 @@
|
|
1
|
+
require 'securerandom'
|
2
|
+
class OpenAssessment
|
3
|
+
|
4
|
+
attr_accessor :question_title, :prompts, :criterions, :name,
|
5
|
+
:url_name, :self_assessment, :peer_review,
|
6
|
+
:must_grade, :graded_by
|
7
|
+
attr_accessor :allow_file_upload, :allow_latex
|
8
|
+
attr_accessor :submission_start, :submission_due,
|
9
|
+
:submission_start_time, :submission_due_time,
|
10
|
+
:self_assessment_start, :self_assessment_due,
|
11
|
+
:self_assessment_start_time, :self_assessment_due_time,
|
12
|
+
:peer_review_start, :peer_review_due,
|
13
|
+
:peer_review_start_time, :peer_review_due_time
|
14
|
+
attr_accessor :question_feedback_prompt, :question_feedback_default_text
|
15
|
+
attr_accessor :yaml
|
16
|
+
attr_accessor :trainings
|
17
|
+
attr_accessor :answer, :question_scoring_guideline
|
18
|
+
|
19
|
+
@@single_question_scores =
|
20
|
+
[[5, "Excellent", "You got all of the question correct"],
|
21
|
+
[4, "Great", "You got most of the question correct"],
|
22
|
+
[3, "Good", "You got half of the question correct"],
|
23
|
+
[2, "Fair", "You got parts of the question correct"],
|
24
|
+
[1, "OK", "You got bits of the question correct"],
|
25
|
+
[0, "Poor", "You got none of the question correct"]]
|
26
|
+
|
27
|
+
@@DATE_REGEX = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/
|
28
|
+
|
29
|
+
##
|
30
|
+
# Initializes the open assessment question
|
31
|
+
def initialize(options={}, yaml={})
|
32
|
+
@peer_review = options[:peer_review] || false
|
33
|
+
@self_assessment = options[:self_assessment]
|
34
|
+
@self_assessment = true if @self_assessment.nil?
|
35
|
+
|
36
|
+
@prompts = []
|
37
|
+
@criterions = []
|
38
|
+
@trainings = []
|
39
|
+
|
40
|
+
@url_name = options[:url_name] || SecureRandom.hex
|
41
|
+
@yaml = yaml
|
42
|
+
|
43
|
+
@must_grade = @yaml["must_grade"] || 5
|
44
|
+
@graded_by = @yaml["graded_by"] || 3
|
45
|
+
|
46
|
+
@allow_file_upload = options[:allow_file_upload] || false
|
47
|
+
@allow_latex = options[:allow_latex] || false
|
48
|
+
|
49
|
+
# Parsing start/due dates
|
50
|
+
start_date = @yaml["submission_start"] || Time.now.to_s
|
51
|
+
end_date = @yaml["submission_due"] || (Time.now + 14).to_s
|
52
|
+
|
53
|
+
peer_review_start = @yaml["peer_review_start"] || start_date
|
54
|
+
peer_review_due = @yaml["peer_review_due"] || end_date
|
55
|
+
|
56
|
+
self_assessment_start = @yaml["self_assessment_start"] || start_date
|
57
|
+
self_assessment_due = @yaml["self_assessment_due"] || end_date
|
58
|
+
|
59
|
+
@submission_start = Date.parse(start_date)
|
60
|
+
@submission_due = Date.parse(end_date)
|
61
|
+
@submission_start_time = @yaml["submission_start_time"] || "00:00"
|
62
|
+
@submission_due_time = @yaml["submission_due_time"] || "00:00"
|
63
|
+
|
64
|
+
@peer_review_start = Date.parse(peer_review_start)
|
65
|
+
@peer_review_due = Date.parse(peer_review_due)
|
66
|
+
@peer_review_start_time = @yaml["peer_review_start_time"] || "00:00"
|
67
|
+
@peer_review_due_time = @yaml["peer_review_due_time"] || "00:00"
|
68
|
+
|
69
|
+
@self_assessment_start = Date.parse(self_assessment_start)
|
70
|
+
@self_assessment_due = Date.parse(self_assessment_due)
|
71
|
+
@self_assessment_start_time = @yaml["self_assessment_start_time"] || "00:00"
|
72
|
+
@self_assessment_due_time = @yaml["self_assessment_due_time"] || "00:00"
|
73
|
+
|
74
|
+
# Default feedback settings
|
75
|
+
@question_feedback_prompt = "Leave feedback"
|
76
|
+
@question_feedback_default_text = "Let them know how they did"
|
77
|
+
end
|
78
|
+
|
79
|
+
##
|
80
|
+
# Sets the title of the question
|
81
|
+
def title(title) ; @question_title = title ; end
|
82
|
+
|
83
|
+
##
|
84
|
+
# Adds a prompt to the question - you must have at least one
|
85
|
+
def prompt(prompt) ; @prompts << prompt ; end
|
86
|
+
|
87
|
+
##
|
88
|
+
# Sets the answers for a simple_open_assessment question
|
89
|
+
def answer(answer) ; @question_answer = answer ; end
|
90
|
+
|
91
|
+
##
|
92
|
+
# Sets the scoring guidelines for a simple_open_assesment question
|
93
|
+
def scoring_guideline(score_array) ; @question_scoring_guideline = score_array ; end
|
94
|
+
|
95
|
+
##
|
96
|
+
# Sets the feedback prompt if you want students to leave feedback
|
97
|
+
def feedback_prompt(fb_prompt) ; @question_feedback_prompt = fb_prompt ; end
|
98
|
+
|
99
|
+
##
|
100
|
+
# Sets the default text for the feedback textarea
|
101
|
+
def feedback_default_text(fb_text) ; @question_feedback_default_text = fb_text ; end
|
102
|
+
|
103
|
+
##
|
104
|
+
# Adds a criterion and evaluates its proc block.
|
105
|
+
def criterion(*args, &block)
|
106
|
+
criterion = Criterion.new(*args)
|
107
|
+
criterion.instance_eval(&block)
|
108
|
+
|
109
|
+
raise "Missing criterion parameters" if criterion.missing_parameters?
|
110
|
+
|
111
|
+
@criterions << criterion
|
112
|
+
end
|
113
|
+
|
114
|
+
##
|
115
|
+
# Adds fields for a simple_open_assessment question
|
116
|
+
def add_simple_question
|
117
|
+
criterion = Criterion.new
|
118
|
+
criterion.name("How'd you do?")
|
119
|
+
criterion.label("Scoring Rubric")
|
120
|
+
|
121
|
+
raise "Must have answer for question" if @question_answer.nil?
|
122
|
+
criterion.prompt(@question_answer)
|
123
|
+
|
124
|
+
criterions << criterion
|
125
|
+
scoring_guidelines = @question_scoring_guideline || @@single_question_scores
|
126
|
+
scoring_guidelines.each do |score_array|
|
127
|
+
option = Option.new({ points: score_array[0] })
|
128
|
+
option.add_params(score_array)
|
129
|
+
criterion.add_option(option)
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
##
|
134
|
+
# Adds a student training question - only used with peer review enabled questions
|
135
|
+
def student_training(*args, &block)
|
136
|
+
return unless @peer_review
|
137
|
+
training = Training.new(*args)
|
138
|
+
training.instance_eval(&block)
|
139
|
+
@trainings << training
|
140
|
+
end
|
141
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
class Option
|
2
|
+
attr_accessor :points, :opt_name, :opt_label, :opt_explanation
|
3
|
+
|
4
|
+
##
|
5
|
+
# Initializes an option
|
6
|
+
def initialize(options={})
|
7
|
+
@points = options[:points] || 0
|
8
|
+
end
|
9
|
+
|
10
|
+
##
|
11
|
+
# Adds name to option
|
12
|
+
def name(name) ; @opt_name = name ; end
|
13
|
+
|
14
|
+
##
|
15
|
+
# Adds label to option
|
16
|
+
def label(label) ; @opt_label = label ; end
|
17
|
+
|
18
|
+
##
|
19
|
+
# Adds explanation to option
|
20
|
+
def explanation(explanation) ; @opt_explanation = explanation ; end
|
21
|
+
|
22
|
+
##
|
23
|
+
# Sets preset option paramters
|
24
|
+
def add_params(score_array)
|
25
|
+
_, @opt_label, @opt_explanation = score_array
|
26
|
+
@opt_name = @opt_label
|
27
|
+
end
|
28
|
+
|
29
|
+
##
|
30
|
+
# Validation to make sure that all the required fields are in
|
31
|
+
def missing_parameters?
|
32
|
+
@opt_name.nil? || @opt_label.nil? || @opt_explanation.nil?
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
class Training
|
2
|
+
|
3
|
+
attr_accessor :training_criterions, :training_answer
|
4
|
+
|
5
|
+
##
|
6
|
+
# Initializes a training response
|
7
|
+
def initialize(options={})
|
8
|
+
@training_criterions = []
|
9
|
+
end
|
10
|
+
|
11
|
+
##
|
12
|
+
# Sets the answer for the training response
|
13
|
+
def answer(s) ; @training_answer = s ; end
|
14
|
+
|
15
|
+
##
|
16
|
+
# Adds a training criterion and evaluates to proc block
|
17
|
+
def training_criterion(*args)
|
18
|
+
training = TrainingCriterion.new(*args)
|
19
|
+
@training_criterions << training
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
class TrainingCriterion
|
2
|
+
attr_accessor :criterion, :option
|
3
|
+
|
4
|
+
##
|
5
|
+
# Initializes a training criterion for a training response
|
6
|
+
def initialize(options={})
|
7
|
+
@criterion = options[:criterion]
|
8
|
+
@option = options[:option]
|
9
|
+
|
10
|
+
# Validation
|
11
|
+
if @criterion.nil? || @option.nil?
|
12
|
+
raise "Must include criterion and option."
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
data/lib/ruql/question.rb
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
class Question
|
2
2
|
attr_accessor :question_text, :answers, :randomize, :points, :name, :question_tags, :question_comment
|
3
|
-
|
3
|
+
|
4
4
|
def initialize(*args)
|
5
5
|
options = if args[-1].kind_of?(Hash) then args[-1] else {} end
|
6
6
|
@answers = options[:answers] || []
|
@@ -12,9 +12,9 @@ class Question
|
|
12
12
|
end
|
13
13
|
|
14
14
|
def raw? ; !!@raw ; end
|
15
|
-
|
15
|
+
|
16
16
|
def text(s) ; @question_text = s ; end
|
17
|
-
|
17
|
+
|
18
18
|
def explanation(text)
|
19
19
|
@answers.each { |answer| answer.explanation ||= text }
|
20
20
|
end
|
@@ -28,7 +28,7 @@ class Question
|
|
28
28
|
end
|
29
29
|
|
30
30
|
# these are ignored but legal for now:
|
31
|
-
def tags(*args) # string or array of strings
|
31
|
+
def tags(*args) # string or array of strings
|
32
32
|
if args.length > 1
|
33
33
|
@question_tags += args.map(&:to_s)
|
34
34
|
else
|
data/lib/ruql/quiz.rb
CHANGED
@@ -1,8 +1,9 @@
|
|
1
|
-
|
2
1
|
class Quiz
|
3
2
|
@@quizzes = []
|
3
|
+
@@yaml_file = nil
|
4
|
+
@quiz_yaml = {}
|
4
5
|
def self.quizzes ; @@quizzes ; end
|
5
|
-
@@default_options =
|
6
|
+
@@default_options =
|
6
7
|
{
|
7
8
|
:open_time => Time.now,
|
8
9
|
:soft_close_time => Time.now + 24*60*60,
|
@@ -28,7 +29,7 @@ class Quiz
|
|
28
29
|
attr_reader :logger
|
29
30
|
attr_accessor :title
|
30
31
|
|
31
|
-
def initialize(title, options={})
|
32
|
+
def initialize(title, yaml, options={})
|
32
33
|
@output = ''
|
33
34
|
@questions = options[:questions] || []
|
34
35
|
@title = title
|
@@ -36,12 +37,13 @@ class Quiz
|
|
36
37
|
@seed = srand
|
37
38
|
@logger = Logger.new(STDERR)
|
38
39
|
@logger.level = Logger.const_get (options.delete('l') ||
|
39
|
-
|
40
|
+
options.delete('log') || 'warn').upcase
|
41
|
+
@quiz_yaml = yaml
|
40
42
|
end
|
41
43
|
|
42
44
|
def self.get_renderer(renderer)
|
43
45
|
Object.const_get(renderer.to_s + 'Renderer') rescue nil
|
44
|
-
end
|
46
|
+
end
|
45
47
|
|
46
48
|
def render_with(renderer,options={})
|
47
49
|
srand @seed
|
@@ -49,7 +51,11 @@ class Quiz
|
|
49
51
|
@renderer.render_quiz
|
50
52
|
@output = @renderer.output
|
51
53
|
end
|
52
|
-
|
54
|
+
|
55
|
+
def self.set_yaml_file(file)
|
56
|
+
@@yaml_file = file && YAML::load_file(file)
|
57
|
+
end
|
58
|
+
|
53
59
|
def points ; questions.map(&:points).inject { |sum,points| sum + points } ; end
|
54
60
|
|
55
61
|
def num_questions ; questions.length ; end
|
@@ -57,7 +63,7 @@ class Quiz
|
|
57
63
|
def random_seed(num)
|
58
64
|
@seed = num.to_i
|
59
65
|
end
|
60
|
-
|
66
|
+
|
61
67
|
# this should really be done using mixins.
|
62
68
|
def choice_answer(*args, &block)
|
63
69
|
if args.first.is_a?(Hash) # no question text
|
@@ -72,7 +78,7 @@ class Quiz
|
|
72
78
|
|
73
79
|
def select_multiple(*args, &block)
|
74
80
|
if args.first.is_a?(Hash) # no question text
|
75
|
-
q = SelectMultiple.new(''
|
81
|
+
q = SelectMultiple.new('', *args)
|
76
82
|
else
|
77
83
|
text = args.shift
|
78
84
|
q = SelectMultiple.new(text, *args)
|
@@ -86,6 +92,32 @@ class Quiz
|
|
86
92
|
@questions << q
|
87
93
|
end
|
88
94
|
|
95
|
+
def dropdown(*args, &block)
|
96
|
+
q = Dropdown.new(*args)
|
97
|
+
q.instance_eval(&block)
|
98
|
+
@questions << q
|
99
|
+
end
|
100
|
+
|
101
|
+
def open_assessment(*args, &block)
|
102
|
+
q = get_open_assessment(*args, &block)
|
103
|
+
@questions << q
|
104
|
+
end
|
105
|
+
|
106
|
+
def simple_open_assessment(*args, &block)
|
107
|
+
q = get_open_assessment(*args, &block)
|
108
|
+
q.add_simple_question
|
109
|
+
@questions << q
|
110
|
+
end
|
111
|
+
|
112
|
+
def get_open_assessment(*args, &block)
|
113
|
+
y = @quiz_yaml.shift
|
114
|
+
raise "Cannot continue - You must have a yaml block for each peer evaluation question" if y.nil?
|
115
|
+
yaml = y[1][0]
|
116
|
+
q = OpenAssessment.new(*args, yaml)
|
117
|
+
q.instance_eval(&block)
|
118
|
+
q
|
119
|
+
end
|
120
|
+
|
89
121
|
def fill_in(*args, &block)
|
90
122
|
if args.first.is_a?(Hash) # no question text
|
91
123
|
q = FillIn.new('', *args)
|
@@ -97,8 +129,8 @@ class Quiz
|
|
97
129
|
@questions << q
|
98
130
|
end
|
99
131
|
|
100
|
-
def self.quiz(*args
|
101
|
-
quiz = Quiz.new(*args)
|
132
|
+
def self.quiz(*args, &block)
|
133
|
+
quiz = Quiz.new(*args, (@@yaml_file.shift if @@yaml_file))
|
102
134
|
quiz.instance_eval(&block)
|
103
135
|
@@quizzes << quiz
|
104
136
|
end
|
data/lib/ruql/renderer.rb
CHANGED
@@ -17,4 +17,12 @@ class Renderer
|
|
17
17
|
self.instance_eval@@render_handlers[:quiz].call(self.quiz)
|
18
18
|
end
|
19
19
|
|
20
|
+
# Override these methods in each renderer:
|
21
|
+
|
22
|
+
%w(fill_in multiple_choice select_multiple dropdown open_assessment true_false).each do |q|
|
23
|
+
define_method "render_#{q}" do |question|
|
24
|
+
STDERR.puts "Question type '#{q}' not yet implemented for this renderer"
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
20
28
|
end
|
@@ -12,6 +12,8 @@ class AutoQCMRenderer
|
|
12
12
|
options.delete('template') ||
|
13
13
|
File.join(Gem.loaded_specs['ruql'].full_gem_path, 'templates/autoqcm.tex.erb')
|
14
14
|
@penalty = (options.delete('p') || options.delete('penalty') || '0').to_f
|
15
|
+
@show_solutions = options.delete('s') || options.delete('solutions')
|
16
|
+
|
15
17
|
end
|
16
18
|
|
17
19
|
def render_quiz
|
@@ -34,8 +36,8 @@ class AutoQCMRenderer
|
|
34
36
|
|
35
37
|
def render_question(q,index)
|
36
38
|
case q
|
39
|
+
when SelectMultiple,TrueFalse then render(q, index, 'mult') # These are subclasses of MultipleChoice, should go first
|
37
40
|
when MultipleChoice then render(q, index)
|
38
|
-
when SelectMultiple,TrueFalse then render(q, index, 'mult')
|
39
41
|
else
|
40
42
|
@quiz.logger.error "Question #{index} (#{q.question_text[0,15]}...): AutoQCM can only handle multiple_choice, truefalse, or select_multiple questions"
|
41
43
|
''
|
@@ -48,10 +50,15 @@ class AutoQCMRenderer
|
|
48
50
|
@output << "\\AMCrandomseed{#{seed}}\n\n"
|
49
51
|
end
|
50
52
|
|
51
|
-
def render(question, index, type='')
|
53
|
+
def render(question, index, type='')
|
52
54
|
output = ''
|
53
55
|
output << "\\begin{question#{type}}{q#{index}}\n"
|
54
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
|
55
62
|
output << " " << to_tex(question.question_text) << "\n"
|
56
63
|
|
57
64
|
# answers - ignore randomization
|
@@ -61,9 +68,17 @@ class AutoQCMRenderer
|
|
61
68
|
answer_text = to_tex(answer.answer_text)
|
62
69
|
answer_type = if answer.correct? then 'correct' else 'wrong' end
|
63
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
|
64
79
|
end
|
65
80
|
output << " \\end{choices}\n"
|
66
|
-
output << "\\end{question}\n\n"
|
81
|
+
output << "\\end{question#{type}}\n\n"
|
67
82
|
output
|
68
83
|
end
|
69
84
|
|
@@ -1,7 +1,10 @@
|
|
1
|
+
require 'builder'
|
2
|
+
require 'yaml'
|
3
|
+
|
1
4
|
class EdXmlRenderer
|
2
|
-
require 'builder'
|
3
5
|
|
4
6
|
attr_reader :output
|
7
|
+
attr_accessor :file, :yaml_file
|
5
8
|
def initialize(quiz,options={})
|
6
9
|
@only_question = options.delete('n') || options.delete('name')
|
7
10
|
@output = ''
|
@@ -13,11 +16,13 @@ class EdXmlRenderer
|
|
13
16
|
case thing
|
14
17
|
when MultipleChoice,SelectMultiple,TrueFalse then render_multiple_choice(thing)
|
15
18
|
when FillIn then render_fill_in(thing)
|
19
|
+
when OpenAssessment then render_open_assessment(thing)
|
20
|
+
when Dropdown then render_dropdown(thing)
|
16
21
|
else
|
17
22
|
raise "Unknown question type: #{thing}"
|
18
23
|
end
|
19
24
|
end
|
20
|
-
|
25
|
+
|
21
26
|
def render_quiz
|
22
27
|
# entire quiz can be in one question group, as long as we specify
|
23
28
|
# that ALL question from the group must be used to make the quiz.
|
@@ -29,11 +34,16 @@ class EdXmlRenderer
|
|
29
34
|
@output
|
30
35
|
end
|
31
36
|
|
32
|
-
def render_fill_in(question)
|
33
|
-
raise "Not yet implemented for edXML"
|
34
|
-
end
|
35
|
-
|
36
37
|
def render_multiple_choice(question)
|
38
|
+
# the OLX for select-multiple and select-one are frustratingly different in arbitrary ways
|
39
|
+
# single choice has <multiplechoiceresponse> element containing a <choicegroup> with <choice>s
|
40
|
+
# select-mult has <choiceresponse> element containing a <checkboxgroup> with <choice>s
|
41
|
+
|
42
|
+
question_type, answer_type =
|
43
|
+
if question.kind_of?(SelectMultiple)
|
44
|
+
then ['choiceresponse', 'checkboxgroup']
|
45
|
+
else ['multiplechoiceresponse', 'choicegroup']
|
46
|
+
end
|
37
47
|
@b.problem do
|
38
48
|
# if question text has explicit newlines, use them to separate <p>'s
|
39
49
|
if question.raw?
|
@@ -43,32 +53,147 @@ class EdXmlRenderer
|
|
43
53
|
if question.raw? then @b.p { |p| p << line } else @b.p(line) end
|
44
54
|
end
|
45
55
|
end
|
46
|
-
|
47
|
-
|
56
|
+
|
57
|
+
@b.__send__(question_type) do
|
58
|
+
@b.__send__(answer_type, :type => 'MultipleChoice') do
|
48
59
|
question.answers.each do |answer|
|
49
60
|
if question.raw?
|
50
|
-
@b.choice(:correct => answer.correct?)
|
51
|
-
choice << answer.answer_text
|
52
|
-
choice << "\n"
|
53
|
-
end
|
61
|
+
@b.choice(:correct => answer.correct?) { @b << answer.answer_text.chomp }
|
54
62
|
else
|
55
63
|
@b.choice answer.answer_text, :correct => answer.correct?
|
56
64
|
end
|
57
65
|
end
|
58
66
|
end
|
59
67
|
end
|
60
|
-
|
61
|
-
@b.
|
62
|
-
@b.
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
68
|
+
if (ans = question.correct_answer.explanation)
|
69
|
+
@b.solution do
|
70
|
+
@b.div :class => 'detailed-solution' do
|
71
|
+
@b.p 'Explanation'
|
72
|
+
if question.raw?
|
73
|
+
@b.p { |p| p << ans }
|
74
|
+
else
|
75
|
+
@b.p ans
|
76
|
+
end
|
67
77
|
end
|
68
78
|
end
|
69
79
|
end
|
70
80
|
end
|
71
81
|
end
|
72
82
|
|
73
|
-
|
83
|
+
def render_dropdown(question)
|
84
|
+
@b.problem do
|
85
|
+
if question.raw?
|
86
|
+
@b.p { |p| p << question.question_text }
|
87
|
+
else
|
88
|
+
question.question_text.lines.map(&:chomp).each do |line|
|
89
|
+
if question.raw? then @b.p { |p| p << line } else @b.p(line) end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
question.choices.each do |choice|
|
93
|
+
idx = choice.correct
|
94
|
+
menu_opts = choice.list
|
95
|
+
if menu_opts.length == 1 # this is actually a label
|
96
|
+
@b.span menu_opts[0]
|
97
|
+
else
|
98
|
+
@b.optionresponse do
|
99
|
+
debugger if menu_opts[idx].nil?
|
100
|
+
@b.optioninput :options => options_list_for_attribute(menu_opts),
|
101
|
+
:correct => escape_doublequotes(menu_opts[idx])
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
def render_open_assessment(question)
|
109
|
+
@b.openassessment url_name: question.url_name,
|
110
|
+
submission_start: "#{question.submission_start.to_s}T"\
|
111
|
+
"#{question.submission_start_time}:00+00:00",
|
112
|
+
submission_due: "#{question.submission_due.to_s}T"\
|
113
|
+
"#{question.submission_due_time}:00+00:00",
|
114
|
+
allow_file_upload: question.allow_file_upload.to_s.capitalize,
|
115
|
+
allow_latex: question.allow_latex.to_s.capitalize do
|
116
|
+
|
117
|
+
@b.title question.question_title
|
74
118
|
|
119
|
+
# Oh good lord my eyes
|
120
|
+
@b.assessments do
|
121
|
+
if question.trainings.size > 0
|
122
|
+
@b.assessment name: "student-training" do
|
123
|
+
question.trainings.each do |training|
|
124
|
+
@b.example do
|
125
|
+
@b.answer do
|
126
|
+
@b.part training.training_answer
|
127
|
+
end
|
128
|
+
training.training_criterions.each do |criterion|
|
129
|
+
@b.select criterion: criterion.criterion,
|
130
|
+
option: criterion.option
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
if question.peer_review
|
138
|
+
@b.assessment name: "peer-assessment",
|
139
|
+
must_grade: question.must_grade,
|
140
|
+
must_be_graded_by: question.graded_by,
|
141
|
+
start: "#{question.peer_review_start.to_s}T" \
|
142
|
+
"#{question.peer_review_start_time}:00+00:00",
|
143
|
+
due: "#{question.peer_review_due.to_s}T"\
|
144
|
+
"#{question.peer_review_due_time}:00+00:00"
|
145
|
+
|
146
|
+
end
|
147
|
+
|
148
|
+
if question.self_assessment
|
149
|
+
@b.assessment name: "self-assessment",
|
150
|
+
start: "#{question.self_assessment_start.to_s}T"\
|
151
|
+
"#{question.self_assessment_start_time}:00+00:00",
|
152
|
+
due: "#{question.self_assessment_due.to_s}T"\
|
153
|
+
"#{question.self_assessment_due_time}:00+00:00"
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
@b.prompts do
|
158
|
+
question.prompts.each do |description|
|
159
|
+
@b.prompt do
|
160
|
+
@b.description description
|
161
|
+
end
|
162
|
+
end
|
163
|
+
end
|
164
|
+
|
165
|
+
@b.rubric do
|
166
|
+
question.criterions.each do |criterion|
|
167
|
+
@b.criterion feedback: criterion.feedback do
|
168
|
+
@b.name criterion.criterion_name
|
169
|
+
@b.label criterion.criterion_label
|
170
|
+
@b.prompt criterion.criterion_prompt
|
171
|
+
|
172
|
+
criterion.options.each do |option|
|
173
|
+
@b.option points: option.points do
|
174
|
+
@b.name option.opt_name
|
175
|
+
@b.label option.opt_label
|
176
|
+
@b.explanation option.opt_explanation
|
177
|
+
end
|
178
|
+
end
|
179
|
+
end
|
180
|
+
end
|
181
|
+
@b.feedbackprompt question.question_feedback_prompt
|
182
|
+
@b.feedback_default_text question.question_feedback_default_text
|
183
|
+
end
|
184
|
+
end
|
185
|
+
end
|
186
|
+
|
187
|
+
private
|
188
|
+
|
189
|
+
def escape_doublequotes(str)
|
190
|
+
str.gsub(/"/, '"')
|
191
|
+
end
|
192
|
+
|
193
|
+
def options_list_for_attribute(list)
|
194
|
+
# takes a list of strings, places single quotes around each element
|
195
|
+
# and HTML-escapes doublequotes within any element. Because OLX.
|
196
|
+
attr = list.map { |e| "'" << escape_doublequotes(e) << "'" }.join(',')
|
197
|
+
"(#{attr})"
|
198
|
+
end
|
199
|
+
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
class QualtricsRenderer
|
2
|
+
|
3
|
+
attr_reader :output
|
4
|
+
|
5
|
+
def initialize(quiz, options={})
|
6
|
+
@output = ''
|
7
|
+
@quiz = quiz
|
8
|
+
@template = options.delete('t') || options.delete('template')
|
9
|
+
end
|
10
|
+
|
11
|
+
def render_quiz
|
12
|
+
quiz = @quiz # make quiz object available in template's scope
|
13
|
+
with_erb_template(IO.read(File.expand_path @template)) do
|
14
|
+
output = ''
|
15
|
+
@quiz.questions.each_with_index do |q,i|
|
16
|
+
next_question = render_question q,i
|
17
|
+
output << next_question
|
18
|
+
end
|
19
|
+
output
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def with_erb_template(template)
|
24
|
+
# template will 'yield' back to render_quiz to render the questions
|
25
|
+
@output = ERB.new(template).result(binding)
|
26
|
+
end
|
27
|
+
|
28
|
+
def render_question(q,index)
|
29
|
+
case q
|
30
|
+
when SelectMultiple,TrueFalse then render(q, index, 'Multiple') # These are subclasses of MultipleChoice, should go first
|
31
|
+
when MultipleChoice then render(q, index, 'Single')
|
32
|
+
else
|
33
|
+
@quiz.logger.error "Question #{index} (#{q.question_text[0,15]}...): Only written to handle multiple_choice, truefalse, or select_multiple questions at this time."
|
34
|
+
''
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def render(question, index, type='')
|
39
|
+
output = ''
|
40
|
+
output << "[[Question:MC:#{type}Answer]]\n"
|
41
|
+
output << "[[ID:#{index}]]\n"
|
42
|
+
if type == 'Multiple'
|
43
|
+
question.question_text = "Select ALL that apply. " + question.question_text
|
44
|
+
elsif type == 'Single'
|
45
|
+
question.question_text = "Choose ONE answer. " + question.question_text
|
46
|
+
end
|
47
|
+
output << question.question_text << "\n"
|
48
|
+
|
49
|
+
# answers - ignore randomization
|
50
|
+
|
51
|
+
output << "[[AdvancedChoices]]\n"
|
52
|
+
question.answers.each do |answer|
|
53
|
+
output << "[[Choice]]\n"
|
54
|
+
output << "#{answer.answer_text}\n"
|
55
|
+
end
|
56
|
+
if type == 'Multiple'
|
57
|
+
output << "[[Choice]]\n"
|
58
|
+
output << "<i>None of these answers are correct.</i>\n"
|
59
|
+
end
|
60
|
+
output
|
61
|
+
end
|
62
|
+
|
63
|
+
end
|
data/lib/ruql/tex_output.rb
CHANGED
@@ -1,15 +1,24 @@
|
|
1
1
|
module TexOutput
|
2
2
|
|
3
|
+
def add_newlines(str)
|
4
|
+
str.gsub(Regexp.new('<pre>(.*?)</pre>', Regexp::MULTILINE | Regexp::IGNORECASE) ) do |code_section|
|
5
|
+
code_section.gsub!("\n"){"\\\\"}
|
6
|
+
code_section.gsub!(" "){"\\hspace*{2em}"}
|
7
|
+
end
|
8
|
+
end
|
9
|
+
|
3
10
|
@@tex_replace = {
|
4
11
|
/_/ => '\textunderscore{}',
|
5
12
|
Regexp.new('<tt>([^<]+)</tt>', Regexp::IGNORECASE) => "\\texttt{\\1}",
|
6
|
-
Regexp.new('<pre>(.*?)</pre>', Regexp::MULTILINE | Regexp::IGNORECASE) => "\\
|
13
|
+
Regexp.new('<pre>(.*?)</pre>', Regexp::MULTILINE | Regexp::IGNORECASE) => "\\texttt{\\1}",
|
7
14
|
}
|
8
15
|
|
9
16
|
@@tex_escape = Regexp.new '\$|&|%|#|\{|\}'
|
10
17
|
|
11
18
|
def to_tex(str)
|
12
19
|
str = str.gsub(@@tex_escape) { |match| "\\#{match}" }
|
20
|
+
str = add_newlines(str)
|
21
|
+
|
13
22
|
@@tex_replace.each_pair do |match, replace|
|
14
23
|
str = str.gsub(match, replace)
|
15
24
|
end
|
data/lib/ruql.rb
CHANGED
@@ -1,6 +1,7 @@
|
|
1
1
|
# basic gems/libs we rely on
|
2
2
|
require 'builder'
|
3
3
|
require 'logger'
|
4
|
+
require 'date'
|
4
5
|
|
5
6
|
$LOAD_PATH.unshift File.expand_path(File.join(File.dirname(__FILE__)))
|
6
7
|
|
@@ -11,6 +12,7 @@ require 'ruql/renderers/html_form_renderer'
|
|
11
12
|
require 'ruql/renderers/edxml_renderer'
|
12
13
|
require 'ruql/renderers/auto_qcm_renderer'
|
13
14
|
require 'ruql/renderers/json_renderer'
|
15
|
+
require 'ruql/renderers/qualtrics_renderer'
|
14
16
|
|
15
17
|
# question types
|
16
18
|
require 'ruql/quiz'
|
@@ -20,3 +22,9 @@ require 'ruql/multiple_choice'
|
|
20
22
|
require 'ruql/select_multiple'
|
21
23
|
require 'ruql/true_false'
|
22
24
|
require 'ruql/fill_in'
|
25
|
+
require 'ruql/dropdown'
|
26
|
+
require 'ruql/open_assessment/open_assessment'
|
27
|
+
require 'ruql/open_assessment/criterion'
|
28
|
+
require 'ruql/open_assessment/option'
|
29
|
+
require 'ruql/open_assessment/training'
|
30
|
+
require 'ruql/open_assessment/training_criterion'
|
metadata
CHANGED
@@ -1,48 +1,43 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: ruql
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0.
|
5
|
-
prerelease:
|
4
|
+
version: 0.0.8
|
6
5
|
platform: ruby
|
7
6
|
authors:
|
8
7
|
- Armando Fox
|
9
8
|
autorequire:
|
10
9
|
bindir: bin
|
11
10
|
cert_chain: []
|
12
|
-
date:
|
11
|
+
date: 2016-02-13 00:00:00.000000000 Z
|
13
12
|
dependencies:
|
14
13
|
- !ruby/object:Gem::Dependency
|
15
|
-
name: builder
|
16
14
|
requirement: !ruby/object:Gem::Requirement
|
17
|
-
none: false
|
18
15
|
requirements:
|
19
|
-
- -
|
16
|
+
- - ~>
|
20
17
|
- !ruby/object:Gem::Version
|
21
|
-
version: '0'
|
22
|
-
type: :runtime
|
18
|
+
version: '3.0'
|
23
19
|
prerelease: false
|
20
|
+
name: builder
|
24
21
|
version_requirements: !ruby/object:Gem::Requirement
|
25
|
-
none: false
|
26
22
|
requirements:
|
27
|
-
- -
|
23
|
+
- - ~>
|
28
24
|
- !ruby/object:Gem::Version
|
29
|
-
version: '0'
|
25
|
+
version: '3.0'
|
26
|
+
type: :runtime
|
30
27
|
- !ruby/object:Gem::Dependency
|
31
|
-
name: getopt
|
32
28
|
requirement: !ruby/object:Gem::Requirement
|
33
|
-
none: false
|
34
29
|
requirements:
|
35
|
-
- -
|
30
|
+
- - '='
|
36
31
|
- !ruby/object:Gem::Version
|
37
|
-
version:
|
38
|
-
type: :runtime
|
32
|
+
version: 1.4.2
|
39
33
|
prerelease: false
|
34
|
+
name: getopt
|
40
35
|
version_requirements: !ruby/object:Gem::Requirement
|
41
|
-
none: false
|
42
36
|
requirements:
|
43
|
-
- -
|
37
|
+
- - '='
|
44
38
|
- !ruby/object:Gem::Version
|
45
|
-
version:
|
39
|
+
version: 1.4.2
|
40
|
+
type: :runtime
|
46
41
|
description: Ruby-embedded DSL for creating short-answer quiz questions
|
47
42
|
email: fox@cs.berkeley.edu
|
48
43
|
executables:
|
@@ -50,49 +45,55 @@ executables:
|
|
50
45
|
extensions: []
|
51
46
|
extra_rdoc_files: []
|
52
47
|
files:
|
48
|
+
- bin/ruql
|
53
49
|
- lib/ruql.rb
|
54
|
-
- lib/ruql/quiz.rb
|
55
50
|
- lib/ruql/answer.rb
|
56
|
-
- lib/ruql/
|
57
|
-
- lib/ruql/renderer.rb
|
58
|
-
- lib/ruql/select_multiple.rb
|
51
|
+
- lib/ruql/dropdown.rb
|
59
52
|
- lib/ruql/fill_in.rb
|
60
53
|
- lib/ruql/multiple_choice.rb
|
61
|
-
- lib/ruql/
|
62
|
-
- lib/ruql/
|
54
|
+
- lib/ruql/open_assessment/criterion.rb
|
55
|
+
- lib/ruql/open_assessment/open_assessment.rb
|
56
|
+
- lib/ruql/open_assessment/option.rb
|
57
|
+
- lib/ruql/open_assessment/training.rb
|
58
|
+
- lib/ruql/open_assessment/training_criterion.rb
|
59
|
+
- lib/ruql/question.rb
|
60
|
+
- lib/ruql/quiz.rb
|
61
|
+
- lib/ruql/renderer.rb
|
63
62
|
- lib/ruql/renderers/auto_qcm_renderer.rb
|
64
63
|
- lib/ruql/renderers/edxml_renderer.rb
|
65
64
|
- lib/ruql/renderers/html5_renderer.rb
|
66
65
|
- lib/ruql/renderers/html_form_renderer.rb
|
67
66
|
- lib/ruql/renderers/json_renderer.rb
|
67
|
+
- lib/ruql/renderers/qualtrics_renderer.rb
|
68
68
|
- lib/ruql/renderers/xml_renderer.rb
|
69
|
+
- lib/ruql/select_multiple.rb
|
70
|
+
- lib/ruql/tex_output.rb
|
71
|
+
- lib/ruql/true_false.rb
|
69
72
|
- templates/autoqcm.tex.erb
|
70
73
|
- templates/html5.html.erb
|
71
74
|
- templates/htmlform.html.erb
|
72
|
-
- bin/ruql
|
73
75
|
homepage: http://github.com/saasbook/ruql
|
74
76
|
licenses:
|
75
77
|
- CC By-SA
|
78
|
+
metadata: {}
|
76
79
|
post_install_message:
|
77
80
|
rdoc_options: []
|
78
81
|
require_paths:
|
79
82
|
- lib
|
80
83
|
required_ruby_version: !ruby/object:Gem::Requirement
|
81
|
-
none: false
|
82
84
|
requirements:
|
83
|
-
- -
|
85
|
+
- - '>='
|
84
86
|
- !ruby/object:Gem::Version
|
85
87
|
version: '0'
|
86
88
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
87
|
-
none: false
|
88
89
|
requirements:
|
89
|
-
- -
|
90
|
+
- - '>='
|
90
91
|
- !ruby/object:Gem::Version
|
91
92
|
version: '0'
|
92
93
|
requirements: []
|
93
94
|
rubyforge_project:
|
94
|
-
rubygems_version:
|
95
|
+
rubygems_version: 2.2.5
|
95
96
|
signing_key:
|
96
|
-
specification_version:
|
97
|
+
specification_version: 4
|
97
98
|
summary: Ruby question language
|
98
99
|
test_files: []
|