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.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +21 -1
  3. data/docs/plans/2026-02-11-flexible-examsoft-importer-design.md +127 -0
  4. data/docs/plans/2026-02-11-flexible-examsoft-importer-plan.md +2635 -0
  5. data/lib/atomic_assessments_import/csv/converter.rb +3 -3
  6. data/lib/atomic_assessments_import/exam_soft/chunker/heading_split_strategy.rb +38 -0
  7. data/lib/atomic_assessments_import/exam_soft/chunker/horizontal_rule_split_strategy.rb +37 -0
  8. data/lib/atomic_assessments_import/exam_soft/chunker/metadata_marker_strategy.rb +38 -0
  9. data/lib/atomic_assessments_import/exam_soft/chunker/numbered_question_strategy.rb +41 -0
  10. data/lib/atomic_assessments_import/exam_soft/chunker/strategy.rb +22 -0
  11. data/lib/atomic_assessments_import/exam_soft/chunker.rb +46 -0
  12. data/lib/atomic_assessments_import/exam_soft/converter.rb +203 -0
  13. data/lib/atomic_assessments_import/exam_soft/extractor/correct_answer_detector.rb +36 -0
  14. data/lib/atomic_assessments_import/exam_soft/extractor/feedback_detector.rb +50 -0
  15. data/lib/atomic_assessments_import/exam_soft/extractor/metadata_detector.rb +37 -0
  16. data/lib/atomic_assessments_import/exam_soft/extractor/options_detector.rb +44 -0
  17. data/lib/atomic_assessments_import/exam_soft/extractor/question_stem_detector.rb +44 -0
  18. data/lib/atomic_assessments_import/exam_soft/extractor/question_type_detector.rb +51 -0
  19. data/lib/atomic_assessments_import/exam_soft/extractor.rb +96 -0
  20. data/lib/atomic_assessments_import/exam_soft.rb +10 -0
  21. data/lib/atomic_assessments_import/questions/cloze_dropdown.rb +62 -0
  22. data/lib/atomic_assessments_import/questions/essay.rb +20 -0
  23. data/lib/atomic_assessments_import/questions/fill_in_the_blank.rb +49 -0
  24. data/lib/atomic_assessments_import/questions/matching.rb +42 -0
  25. data/lib/atomic_assessments_import/questions/multiple_choice.rb +102 -0
  26. data/lib/atomic_assessments_import/questions/ordering.rb +53 -0
  27. data/lib/atomic_assessments_import/questions/question.rb +106 -0
  28. data/lib/atomic_assessments_import/questions/short_answer.rb +24 -0
  29. data/lib/atomic_assessments_import/utils.rb +21 -0
  30. data/lib/atomic_assessments_import/version.rb +1 -1
  31. data/lib/atomic_assessments_import/writer.rb +1 -1
  32. data/lib/atomic_assessments_import.rb +31 -12
  33. metadata +62 -13
  34. data/lib/atomic_assessments_import/csv/questions/multiple_choice.rb +0 -104
  35. data/lib/atomic_assessments_import/csv/questions/question.rb +0 -86
  36. 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,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "exam_soft/chunker"
4
+ require_relative "exam_soft/extractor"
5
+ require_relative "exam_soft/converter"
6
+
7
+ module AtomicAssessmentsImport
8
+ module ExamSoft
9
+ end
10
+ 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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AtomicAssessmentsImport
4
- VERSION = "0.2.4"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -9,7 +9,7 @@ module AtomicAssessmentsImport
9
9
  end
10
10
 
11
11
  def open
12
- @zip = Zip::File.open(@path, Zip::File::CREATE)
12
+ @zip = Zip::File.open(@path, create: true)
13
13
  yield self
14
14
  ensure
15
15
  @zip.close