atomic_assessments_import 0.2.4 → 0.4.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 +4 -4
- data/README.md +21 -1
- data/docs/plans/2026-02-11-flexible-examsoft-importer-design.md +127 -0
- data/docs/plans/2026-02-11-flexible-examsoft-importer-plan.md +2635 -0
- data/lib/atomic_assessments_import/csv/converter.rb +3 -3
- data/lib/atomic_assessments_import/exam_soft/chunker/heading_split_strategy.rb +38 -0
- data/lib/atomic_assessments_import/exam_soft/chunker/horizontal_rule_split_strategy.rb +37 -0
- data/lib/atomic_assessments_import/exam_soft/chunker/metadata_marker_strategy.rb +38 -0
- data/lib/atomic_assessments_import/exam_soft/chunker/numbered_question_strategy.rb +41 -0
- data/lib/atomic_assessments_import/exam_soft/chunker/strategy.rb +22 -0
- data/lib/atomic_assessments_import/exam_soft/chunker.rb +46 -0
- data/lib/atomic_assessments_import/exam_soft/converter.rb +203 -0
- data/lib/atomic_assessments_import/exam_soft/extractor/correct_answer_detector.rb +36 -0
- data/lib/atomic_assessments_import/exam_soft/extractor/feedback_detector.rb +50 -0
- data/lib/atomic_assessments_import/exam_soft/extractor/metadata_detector.rb +37 -0
- data/lib/atomic_assessments_import/exam_soft/extractor/options_detector.rb +44 -0
- data/lib/atomic_assessments_import/exam_soft/extractor/question_stem_detector.rb +44 -0
- data/lib/atomic_assessments_import/exam_soft/extractor/question_type_detector.rb +51 -0
- data/lib/atomic_assessments_import/exam_soft/extractor.rb +96 -0
- data/lib/atomic_assessments_import/exam_soft.rb +10 -0
- data/lib/atomic_assessments_import/questions/cloze_dropdown.rb +62 -0
- data/lib/atomic_assessments_import/questions/essay.rb +20 -0
- data/lib/atomic_assessments_import/questions/fill_in_the_blank.rb +49 -0
- data/lib/atomic_assessments_import/questions/matching.rb +42 -0
- data/lib/atomic_assessments_import/questions/multiple_choice.rb +102 -0
- data/lib/atomic_assessments_import/questions/ordering.rb +53 -0
- data/lib/atomic_assessments_import/questions/question.rb +106 -0
- data/lib/atomic_assessments_import/questions/short_answer.rb +24 -0
- data/lib/atomic_assessments_import/utils.rb +21 -0
- data/lib/atomic_assessments_import/version.rb +1 -1
- data/lib/atomic_assessments_import/writer.rb +1 -1
- data/lib/atomic_assessments_import.rb +31 -12
- metadata +62 -13
- data/lib/atomic_assessments_import/csv/questions/multiple_choice.rb +0 -104
- data/lib/atomic_assessments_import/csv/questions/question.rb +0 -86
- data/lib/atomic_assessments_import/csv/utils.rb +0 -24
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AtomicAssessmentsImport
|
|
4
|
+
module ExamSoft
|
|
5
|
+
module Extractor
|
|
6
|
+
class QuestionTypeDetector
|
|
7
|
+
TYPE_LABEL_PATTERN = /Type:\s*(.+?)(?=\s*(?:Folder:|Title:|Category:|\d+[.)]|\z))/i
|
|
8
|
+
|
|
9
|
+
TYPE_MAP = {
|
|
10
|
+
/\Amcq?\z/i => "mcq",
|
|
11
|
+
/\Amultiple\s*choice\z/i => "mcq",
|
|
12
|
+
/\Ama\z/i => "ma",
|
|
13
|
+
/\Amultiple\s*(?:select|answer|response)\z/i => "ma",
|
|
14
|
+
%r{\Atrue[\s/]*false\z}i => "true_false",
|
|
15
|
+
%r{\At\s*/?\s*f\z}i => "true_false",
|
|
16
|
+
/\Aessay\z/i => "essay",
|
|
17
|
+
/\Ae\z/i => "essay",
|
|
18
|
+
/\Along\s*answer\z/i => "essay",
|
|
19
|
+
/\Ashort\s*answer\z/i => "short_answer",
|
|
20
|
+
/\Asa\z/i => "short_answer",
|
|
21
|
+
/\Afill[\s_-]*in[\s_-]*(?:the[\s_-]*)?blank\z/i => "fill_in_the_blank",
|
|
22
|
+
/\Afib\z/i => "fill_in_the_blank",
|
|
23
|
+
/\Af\z/i => "fill_in_the_blank",
|
|
24
|
+
/\Acloze\z/i => "fill_in_the_blank",
|
|
25
|
+
/\Amatching\z/i => "matching",
|
|
26
|
+
/\Aorder(?:ing)?\z/i => "ordering",
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
def initialize(nodes, has_options:)
|
|
30
|
+
@nodes = nodes
|
|
31
|
+
@has_options = has_options
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def detect
|
|
35
|
+
full_text = @nodes.map { |n| n.text.strip }.join(" ")
|
|
36
|
+
match = full_text.match(TYPE_LABEL_PATTERN)
|
|
37
|
+
|
|
38
|
+
if match
|
|
39
|
+
type_text = match[1].strip
|
|
40
|
+
TYPE_MAP.each do |pattern, type|
|
|
41
|
+
return type if type_text.match?(pattern)
|
|
42
|
+
end
|
|
43
|
+
return type_text.downcase.gsub(/\s+/, "_")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
@has_options ? "mcq" : "short_answer"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "extractor/question_stem_detector"
|
|
4
|
+
require_relative "extractor/options_detector"
|
|
5
|
+
require_relative "extractor/correct_answer_detector"
|
|
6
|
+
require_relative "extractor/metadata_detector"
|
|
7
|
+
require_relative "extractor/feedback_detector"
|
|
8
|
+
require_relative "extractor/question_type_detector"
|
|
9
|
+
|
|
10
|
+
module AtomicAssessmentsImport
|
|
11
|
+
module ExamSoft
|
|
12
|
+
module Extractor
|
|
13
|
+
SUPPORTED_TYPES = %w[mcq ma true_false essay short_answer fill_in_the_blank matching ordering].freeze
|
|
14
|
+
OPTION_TYPES = %w[mcq ma true_false].freeze
|
|
15
|
+
|
|
16
|
+
def self.extract(nodes)
|
|
17
|
+
warnings = []
|
|
18
|
+
|
|
19
|
+
# Run detectors
|
|
20
|
+
options = OptionsDetector.new(nodes).detect
|
|
21
|
+
has_options = !options.empty?
|
|
22
|
+
|
|
23
|
+
metadata = MetadataDetector.new(nodes).detect
|
|
24
|
+
question_type = QuestionTypeDetector.new(nodes, has_options: has_options).detect
|
|
25
|
+
stem = QuestionStemDetector.new(nodes).detect
|
|
26
|
+
feedback = FeedbackDetector.new(nodes).detect
|
|
27
|
+
correct_answers = has_options ? CorrectAnswerDetector.new(nodes, options).detect : []
|
|
28
|
+
|
|
29
|
+
# Determine status
|
|
30
|
+
status = "published"
|
|
31
|
+
|
|
32
|
+
unless SUPPORTED_TYPES.include?(question_type)
|
|
33
|
+
warnings << "Unsupported question type '#{question_type}'"
|
|
34
|
+
status = nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
if stem.nil?
|
|
38
|
+
warnings << "No question text found"
|
|
39
|
+
status = nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
if OPTION_TYPES.include?(question_type)
|
|
43
|
+
if options.empty?
|
|
44
|
+
warnings << "No options found for #{question_type} question"
|
|
45
|
+
end
|
|
46
|
+
if correct_answers.empty?
|
|
47
|
+
warnings << "No correct answer found"
|
|
48
|
+
status = nil
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Build row_mock
|
|
53
|
+
row = {
|
|
54
|
+
"question id" => nil,
|
|
55
|
+
"folder" => metadata[:folder],
|
|
56
|
+
"title" => metadata[:title],
|
|
57
|
+
"category" => metadata[:categories] || [],
|
|
58
|
+
"import type" => nil,
|
|
59
|
+
"description" => nil,
|
|
60
|
+
"question text" => stem,
|
|
61
|
+
"question type" => question_type,
|
|
62
|
+
"stimulus review" => nil,
|
|
63
|
+
"instructor stimulus" => nil,
|
|
64
|
+
"correct answer" => correct_answers.join("; "),
|
|
65
|
+
"scoring type" => nil,
|
|
66
|
+
"points" => nil,
|
|
67
|
+
"distractor rationale" => nil,
|
|
68
|
+
"sample answer" => nil,
|
|
69
|
+
"acknowledgements" => nil,
|
|
70
|
+
"general feedback" => feedback,
|
|
71
|
+
"correct feedback" => nil,
|
|
72
|
+
"incorrect feedback" => nil,
|
|
73
|
+
"shuffle options" => nil,
|
|
74
|
+
"template" => "block layout",
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
# Add option keys
|
|
78
|
+
options.each_with_index do |opt, index|
|
|
79
|
+
letter = ("a".ord + index).chr
|
|
80
|
+
row["option #{letter}"] = opt[:text]
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# For FITB questions, options ARE the answers (no asterisk marking)
|
|
84
|
+
if question_type == "fill_in_the_blank" && row["correct answer"].blank? && !options.empty?
|
|
85
|
+
row["correct answer"] = options.map { |opt| opt[:text] }.join("; ")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
{
|
|
89
|
+
row: row,
|
|
90
|
+
status: status,
|
|
91
|
+
warnings: warnings,
|
|
92
|
+
}
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "question"
|
|
4
|
+
|
|
5
|
+
module AtomicAssessmentsImport
|
|
6
|
+
module Questions
|
|
7
|
+
class ClozeDropdown < Question
|
|
8
|
+
CHOICE_OF_PATTERN = /\AChoice of:\s*(.+)/i
|
|
9
|
+
|
|
10
|
+
def question_type
|
|
11
|
+
"clozedropdown"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def question_data
|
|
15
|
+
parsed = parse_dropdown_options
|
|
16
|
+
super.merge(
|
|
17
|
+
stimulus: "",
|
|
18
|
+
template: build_template(parsed.size),
|
|
19
|
+
possible_responses: parsed.map { |p| p[:choices] },
|
|
20
|
+
validation: {
|
|
21
|
+
scoring_type: scoring_type,
|
|
22
|
+
valid_response: {
|
|
23
|
+
score: points,
|
|
24
|
+
value: parsed.map { |p| p[:correct] },
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def parse_dropdown_options
|
|
33
|
+
("a".."o").each_with_object([]) do |letter, acc|
|
|
34
|
+
option = @row["option #{letter}"]
|
|
35
|
+
break acc unless option
|
|
36
|
+
|
|
37
|
+
m = option.match(CHOICE_OF_PATTERN)
|
|
38
|
+
next unless m
|
|
39
|
+
|
|
40
|
+
parts = m[1].split("|").map(&:strip)
|
|
41
|
+
correct_index = parts.pop.to_i - 1
|
|
42
|
+
acc << { choices: parts, correct: parts[correct_index] }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def build_template(blank_count)
|
|
47
|
+
text = @row["question text"] || ""
|
|
48
|
+
return text if text.include?("{{response}}")
|
|
49
|
+
|
|
50
|
+
if text.match?(/__\d+__/)
|
|
51
|
+
text.gsub(/__\d+__/, "{{response}}")
|
|
52
|
+
elsif text.match?(/_____/)
|
|
53
|
+
text.gsub(/_____/, "{{response}}")
|
|
54
|
+
elsif text.match?(/\[[A-Za-z0-9]\]/)
|
|
55
|
+
text.gsub(/\[[A-Za-z0-9]\]/, "{{response}}")
|
|
56
|
+
else
|
|
57
|
+
"#{text} #{Array.new(blank_count, "{{response}}").join(" ")}"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "question"
|
|
4
|
+
|
|
5
|
+
module AtomicAssessmentsImport
|
|
6
|
+
module Questions
|
|
7
|
+
class Essay < Question
|
|
8
|
+
def question_type
|
|
9
|
+
"longtext"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def question_data
|
|
13
|
+
data = super
|
|
14
|
+
word_limit = @row["word_limit"]&.to_i
|
|
15
|
+
data[:max_length] = word_limit if word_limit&.positive?
|
|
16
|
+
data
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "question"
|
|
4
|
+
|
|
5
|
+
module AtomicAssessmentsImport
|
|
6
|
+
module Questions
|
|
7
|
+
class FillInTheBlank < Question
|
|
8
|
+
def question_type
|
|
9
|
+
"clozetext"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def question_data
|
|
13
|
+
answers = (@row["correct answer"] || "").split(";").map(&:strip)
|
|
14
|
+
super.merge(
|
|
15
|
+
stimulus: "", # Note: ExamSoft doesn't use a template like Learnosity, so we put the full question text in the template and leave the stimulus blank
|
|
16
|
+
template: build_stimulus(answers),
|
|
17
|
+
validation: {
|
|
18
|
+
scoring_type: scoring_type,
|
|
19
|
+
valid_response: {
|
|
20
|
+
score: points,
|
|
21
|
+
value: answers
|
|
22
|
+
},
|
|
23
|
+
}
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def build_stimulus(answers)
|
|
30
|
+
text = @row["question text"] || ""
|
|
31
|
+
return text if text.include?("{{response}}")
|
|
32
|
+
|
|
33
|
+
# You can indicate the blank(s) in various ways:
|
|
34
|
+
# Five underscores
|
|
35
|
+
# Number enclosed by two underscores on each side
|
|
36
|
+
# Number, uppercase letter, or lowercase letter enclosed by square brackets
|
|
37
|
+
if text.match?(/__\d+__/)
|
|
38
|
+
text.gsub(/__\d+__/, "{{response}}")
|
|
39
|
+
elsif text.match?(/_____/)
|
|
40
|
+
text.gsub(/_____/, "{{response}}")
|
|
41
|
+
elsif text.match?(/\[[A-Za-z0-9]\]/)
|
|
42
|
+
text.gsub(/\[[A-Za-z0-9]\]/, "{{response}}")
|
|
43
|
+
else
|
|
44
|
+
"#{text} #{Array.new(answers.size, "{{response}}").join(" ")}"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "question"
|
|
4
|
+
|
|
5
|
+
module AtomicAssessmentsImport
|
|
6
|
+
module Questions
|
|
7
|
+
class Matching < Question
|
|
8
|
+
INDEXES = ("a".."o").to_a.freeze
|
|
9
|
+
|
|
10
|
+
def question_type
|
|
11
|
+
"association"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def question_data
|
|
15
|
+
stimulus_list = []
|
|
16
|
+
possible_responses = []
|
|
17
|
+
valid_values = []
|
|
18
|
+
|
|
19
|
+
INDEXES.each do |letter|
|
|
20
|
+
option = @row["option #{letter}"]
|
|
21
|
+
match_val = @row["match #{letter}"]
|
|
22
|
+
break unless option
|
|
23
|
+
|
|
24
|
+
stimulus_list << option
|
|
25
|
+
possible_responses << match_val if match_val
|
|
26
|
+
valid_values << match_val if match_val
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
super.merge(
|
|
30
|
+
stimulus_list: stimulus_list,
|
|
31
|
+
possible_responses: possible_responses,
|
|
32
|
+
validation: {
|
|
33
|
+
valid_response: {
|
|
34
|
+
score: points,
|
|
35
|
+
value: valid_values,
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "question"
|
|
4
|
+
|
|
5
|
+
module AtomicAssessmentsImport
|
|
6
|
+
module Questions
|
|
7
|
+
class MultipleChoice < Question
|
|
8
|
+
QUESTION_INDEXES = ("a".."o").to_a.freeze
|
|
9
|
+
|
|
10
|
+
def question_type
|
|
11
|
+
"mcq"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def question_data
|
|
15
|
+
raise "Missing correct answer" if correct_responses.empty?
|
|
16
|
+
raise "Missing options" if options.empty?
|
|
17
|
+
|
|
18
|
+
super.deep_merge(
|
|
19
|
+
{
|
|
20
|
+
multiple_responses: multiple_responses,
|
|
21
|
+
options: options,
|
|
22
|
+
validation: {
|
|
23
|
+
scoring_type: scoring_type,
|
|
24
|
+
valid_response: {
|
|
25
|
+
score: points,
|
|
26
|
+
value: correct_responses,
|
|
27
|
+
},
|
|
28
|
+
rounding: "none",
|
|
29
|
+
penalty: 1,
|
|
30
|
+
},
|
|
31
|
+
shuffle_options: Utils.parse_boolean(@row["shuffle options"], default: false),
|
|
32
|
+
ui_style: ui_style,
|
|
33
|
+
}
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def metadata
|
|
38
|
+
super.merge(
|
|
39
|
+
{
|
|
40
|
+
distractor_rationale_response_level: distractor_rationale_response_level,
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def options
|
|
46
|
+
QUESTION_INDEXES.filter_map.with_index do |value, cnt|
|
|
47
|
+
key = "option #{value}"
|
|
48
|
+
if @row[key].present?
|
|
49
|
+
{
|
|
50
|
+
label: @row[key],
|
|
51
|
+
value: cnt.to_s,
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def correct_responses
|
|
58
|
+
correct = @row["correct answer"]&.split(";")&.map(&:strip)&.map(&:downcase) || []
|
|
59
|
+
|
|
60
|
+
correct.filter_map do |value|
|
|
61
|
+
QUESTION_INDEXES.find_index(value).to_s
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def distractor_rationale_response_level
|
|
66
|
+
QUESTION_INDEXES.map do |value|
|
|
67
|
+
key = "option #{value} feedback"
|
|
68
|
+
@row[key].presence || ""
|
|
69
|
+
end.reverse.drop_while(&:blank?).reverse
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def multiple_responses
|
|
73
|
+
case @row["template"]&.downcase
|
|
74
|
+
when "multiple response", "block layout multiple response", "choice matrix",
|
|
75
|
+
"choice matrix inline", "choice matrix labels"
|
|
76
|
+
true
|
|
77
|
+
else
|
|
78
|
+
false
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def ui_style
|
|
83
|
+
case @row["template"]&.downcase
|
|
84
|
+
when "multiple response"
|
|
85
|
+
{ type: "horizontal" }
|
|
86
|
+
when "block layout", "block layout multiple response"
|
|
87
|
+
{ choice_label: "upper-alpha", type: "block" }
|
|
88
|
+
when "choice matrix"
|
|
89
|
+
{ horizontal_lines: false, type: "table" }
|
|
90
|
+
when "choice matrix inline"
|
|
91
|
+
{ horizontal_lines: false, type: "inline" }
|
|
92
|
+
when "choice matrix labels"
|
|
93
|
+
{ stem_numeration: "upper-alpha", horizontal_lines: false, type: "table" }
|
|
94
|
+
when nil, "", "multiple choice", "standard"
|
|
95
|
+
{ type: "horizontal" }
|
|
96
|
+
else
|
|
97
|
+
raise "Unknown template: #{@row['template']}"
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "question"
|
|
4
|
+
|
|
5
|
+
module AtomicAssessmentsImport
|
|
6
|
+
module Questions
|
|
7
|
+
class Ordering < Question
|
|
8
|
+
INDEXES = ("a".."o").to_a.freeze
|
|
9
|
+
|
|
10
|
+
def question_type
|
|
11
|
+
"orderlist"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
ORDER_MARKER = /\s*---\s*(\d+)\s*\z/
|
|
15
|
+
|
|
16
|
+
def question_data
|
|
17
|
+
raw_items = INDEXES.filter_map { |letter| @row["option #{letter}"] }
|
|
18
|
+
|
|
19
|
+
if raw_items.any? { |item| item.match?(ORDER_MARKER) }
|
|
20
|
+
list, valid_values = parse_order_markers(raw_items)
|
|
21
|
+
else
|
|
22
|
+
list = raw_items
|
|
23
|
+
order = (@row["correct answer"] || "").split(";").map(&:strip).map(&:downcase)
|
|
24
|
+
valid_values = order.filter_map { |letter| INDEXES.find_index(letter) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
super.merge(
|
|
28
|
+
list: list,
|
|
29
|
+
validation: {
|
|
30
|
+
scoring_type: scoring_type,
|
|
31
|
+
valid_response: {
|
|
32
|
+
score: points,
|
|
33
|
+
value: valid_values,
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def parse_order_markers(raw_items)
|
|
42
|
+
items_with_rank = raw_items.map.with_index do |item, idx|
|
|
43
|
+
m = item.match(ORDER_MARKER)
|
|
44
|
+
{ text: item.sub(ORDER_MARKER, "").strip, rank: m ? m[1].to_i : idx + 1, original_index: idx }
|
|
45
|
+
end
|
|
46
|
+
list = items_with_rank.map { |i| i[:text] }
|
|
47
|
+
sorted = items_with_rank.sort_by { |i| i[:rank] }
|
|
48
|
+
valid_values = sorted.map { |i| i[:original_index] }
|
|
49
|
+
[list, valid_values]
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AtomicAssessmentsImport
|
|
4
|
+
module Questions
|
|
5
|
+
class Question
|
|
6
|
+
def initialize(row)
|
|
7
|
+
@row = row
|
|
8
|
+
@reference = SecureRandom.uuid
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.load(row)
|
|
12
|
+
case row["question type"]
|
|
13
|
+
when nil, "", /multiple choice/i, /mcq/i, /^ma$/i
|
|
14
|
+
MultipleChoice.new(row)
|
|
15
|
+
when /true_false/i, %r{true/false}i
|
|
16
|
+
MultipleChoice.new(row)
|
|
17
|
+
when /essay/i, /longanswer/i
|
|
18
|
+
Essay.new(row)
|
|
19
|
+
when /short_answer/i, /shorttext/i
|
|
20
|
+
ShortAnswer.new(row)
|
|
21
|
+
when /fill_in_the_blank/i, /cloze/i
|
|
22
|
+
if row["option a"]&.match?(/\AChoice of:/i)
|
|
23
|
+
ClozeDropdown.new(row)
|
|
24
|
+
else
|
|
25
|
+
FillInTheBlank.new(row)
|
|
26
|
+
end
|
|
27
|
+
when /matching/i, /association/i
|
|
28
|
+
Matching.new(row)
|
|
29
|
+
when /ordering/i, /orderlist/i
|
|
30
|
+
Ordering.new(row)
|
|
31
|
+
else
|
|
32
|
+
raise "Unknown question type #{row['question type']}"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
attr_reader :reference
|
|
37
|
+
|
|
38
|
+
def question_type
|
|
39
|
+
raise NotImplementedError
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def question_data
|
|
43
|
+
{
|
|
44
|
+
stimulus: @row["question text"],
|
|
45
|
+
type: question_type,
|
|
46
|
+
metadata: metadata,
|
|
47
|
+
**{
|
|
48
|
+
stimulus_review: @row["stimulus review"],
|
|
49
|
+
instructor_stimulus: @row["instructor stimulus"],
|
|
50
|
+
}.compact,
|
|
51
|
+
}
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def metadata
|
|
55
|
+
{
|
|
56
|
+
distractor_rationale: @row["distractor rationale"],
|
|
57
|
+
sample_answer: @row["sample answer"],
|
|
58
|
+
acknowledgements: @row["acknowledgements"],
|
|
59
|
+
general_feedback: @row["general feedback"],
|
|
60
|
+
correct_feedback: @row["correct feedback"],
|
|
61
|
+
partially_correct_feedback: @row["partially correct feedback"],
|
|
62
|
+
incorrect_feedback: @row["incorrect feedback"],
|
|
63
|
+
}.compact
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def scoring_type
|
|
67
|
+
case @row["scoring type"]
|
|
68
|
+
when nil, "", /Partial Match Per Response/i
|
|
69
|
+
"partialMatchV2"
|
|
70
|
+
when /Partial Match/i
|
|
71
|
+
"partialMatch"
|
|
72
|
+
when /Exact Match/i
|
|
73
|
+
"exactMatch"
|
|
74
|
+
else
|
|
75
|
+
raise "Unknown scoring type #{@row['scoring type']}"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def points
|
|
80
|
+
if @row["points"].blank?
|
|
81
|
+
1
|
|
82
|
+
else
|
|
83
|
+
Float(@row["points"])
|
|
84
|
+
end
|
|
85
|
+
rescue ArgumentError
|
|
86
|
+
1
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def to_learnosity
|
|
90
|
+
{
|
|
91
|
+
type: question_type,
|
|
92
|
+
widget_type: "response",
|
|
93
|
+
reference: @reference,
|
|
94
|
+
data: question_data,
|
|
95
|
+
}
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
require_relative "essay"
|
|
102
|
+
require_relative "short_answer"
|
|
103
|
+
require_relative "fill_in_the_blank"
|
|
104
|
+
require_relative "cloze_dropdown"
|
|
105
|
+
require_relative "matching"
|
|
106
|
+
require_relative "ordering"
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "question"
|
|
4
|
+
|
|
5
|
+
module AtomicAssessmentsImport
|
|
6
|
+
module Questions
|
|
7
|
+
class ShortAnswer < Question
|
|
8
|
+
def question_type
|
|
9
|
+
"shorttext"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def question_data
|
|
13
|
+
super.merge(
|
|
14
|
+
validation: {
|
|
15
|
+
valid_response: {
|
|
16
|
+
score: points,
|
|
17
|
+
value: @row["correct answer"] || "",
|
|
18
|
+
},
|
|
19
|
+
}
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/core_ext/digest/uuid"
|
|
4
|
+
|
|
5
|
+
require_relative "questions/question"
|
|
6
|
+
require_relative "questions/multiple_choice"
|
|
7
|
+
|
|
8
|
+
module AtomicAssessmentsImport
|
|
9
|
+
module Utils
|
|
10
|
+
def self.parse_boolean(value, default:)
|
|
11
|
+
case value&.downcase
|
|
12
|
+
when "true", "yes", "y", "1"
|
|
13
|
+
true
|
|
14
|
+
when "false", "no", "n", "0"
|
|
15
|
+
false
|
|
16
|
+
else
|
|
17
|
+
default
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|