question_kit 0.2.0 → 0.3.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/lib/question_kit/answer_explanations.rb +25 -0
- data/lib/question_kit/question_importer.rb +18 -0
- data/lib/question_kit/railtie.rb +7 -0
- data/lib/question_kit/titled_start.rb +43 -0
- data/lib/question_kit/uploaded_question.rb +84 -0
- data/lib/question_kit/uploaded_questions.rb +15 -0
- data/lib/question_kit/version.rb +1 -1
- data/lib/question_kit.rb +16 -1
- data/the_local/agents/question_kit-develop.md +125 -19
- data/the_local/agents/question_kit-info.md +51 -13
- data/the_local/agents/question_kit-install.md +14 -3
- data/the_local/interface.yml +16 -0
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c5639e96431bb3c4754d3655dee3b8805ff685db6946f720688708a3dea83768
|
|
4
|
+
data.tar.gz: 54d2beff617fcab5f988fe30fdef1680db0e68e9750be6de1455bc158639c9a6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 37c3a74f4a42956916a4d8fd9cdeedb62c7e77405cee84b280aca71b097a11505d89f486485a83da333c41719bb94d7c04a60abc7439b4b679b2e9bc2d58a5f4
|
|
7
|
+
data.tar.gz: 2a8adaeeba7b2db1bdbbd9e63683bf33e8e74c9e8b0c2b57cb3c51f9446edcadfb3465a926ce5e24570bc7a309a085eb91090228623454972cd12b935533744e
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module QuestionKit
|
|
2
|
+
module AnswerExplanations
|
|
3
|
+
TEXT = {
|
|
4
|
+
"question" => {
|
|
5
|
+
"answers.info" => "Extra detail saved with this answer, which the question page does not show to a player.",
|
|
6
|
+
"answers.hint" => "A smaller line a player sees under this answer's text.",
|
|
7
|
+
"answers.label" => "The text a player sees for this answer in place of its value.",
|
|
8
|
+
"answers.value" => "The text saved when a player picks this answer, which the player also sees when the label is blank or the flow shows answer values.",
|
|
9
|
+
"answers.weight" => "The points a player scores by picking this answer, and any weight above zero makes it a right answer."
|
|
10
|
+
},
|
|
11
|
+
"multiple_select" => {
|
|
12
|
+
"answers.label" => "The text a player sees for this answer in place of its value.",
|
|
13
|
+
"answers.value" => "The text saved when a player ticks this answer, which the player also sees when the label is blank.",
|
|
14
|
+
"answers.weight" => "Any weight above zero makes this a right answer, whatever its size, and the question scores one point only when a player ticks every right answer and no others."
|
|
15
|
+
},
|
|
16
|
+
"fill_in_the_blank" => {
|
|
17
|
+
"accepted_answers.value" => "A typed answer that matches this text, ignoring capitals and the spaces around it, is right and scores one point."
|
|
18
|
+
}
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
def explanation_for(step_type, setting)
|
|
22
|
+
super || TEXT.dig(step_type.to_s, setting.to_s)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module QuestionKit
|
|
2
|
+
class QuestionImporter < Ixport::Importer
|
|
3
|
+
field :type, label: "Type"
|
|
4
|
+
field :question, label: "Question"
|
|
5
|
+
field :category, label: "Category"
|
|
6
|
+
UploadedQuestion::ANSWER_FIELDS.each.with_index(1) { |key, number| field key, label: "Answer #{number}" }
|
|
7
|
+
field :right_answer, label: "Right answer"
|
|
8
|
+
|
|
9
|
+
def records
|
|
10
|
+
UploadedQuestions.new(target)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.allows?(account, quiz)
|
|
14
|
+
host = EasyFlow.host_named(QuestionKit.flow_host)
|
|
15
|
+
quiz.is_a?(EasyFlow::Definition) && quiz.host == QuestionKit.flow_host.to_s && (host.owner_method.nil? || quiz.owner == account)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
data/lib/question_kit/railtie.rb
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
module QuestionKit
|
|
2
2
|
class Railtie < ::Rails::Railtie
|
|
3
|
+
initializer "question_kit.answer_explanations" do |app|
|
|
4
|
+
app.config.to_prepare { QuestionKit.explain_answer_fields }
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
initializer "question_kit.question_importer" do |app|
|
|
8
|
+
app.config.to_prepare { require "question_kit/question_importer" if defined?(Ixport::Importer) }
|
|
9
|
+
end
|
|
3
10
|
end
|
|
4
11
|
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
module QuestionKit
|
|
2
|
+
class TitledStart
|
|
3
|
+
FALLBACK_SLUG = "quiz"
|
|
4
|
+
MADE_SLUG_LENGTH = 100
|
|
5
|
+
TRIES_FOR_A_FREE_SLUG = 3
|
|
6
|
+
SLUGS_THE_ROUTES_USE = %w[manage pages runs].freeze
|
|
7
|
+
|
|
8
|
+
def initialize(flow)
|
|
9
|
+
@flow = flow
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def save
|
|
13
|
+
return refuse_blank_title if @flow.title.blank?
|
|
14
|
+
|
|
15
|
+
tries = 0
|
|
16
|
+
begin
|
|
17
|
+
@flow.slug = free_slug
|
|
18
|
+
@flow.save
|
|
19
|
+
rescue ActiveRecord::RecordNotUnique
|
|
20
|
+
retry if (tries += 1) < TRIES_FOR_A_FREE_SLUG
|
|
21
|
+
raise
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
def refuse_blank_title
|
|
27
|
+
@flow.errors.add(:title, :blank)
|
|
28
|
+
false
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def free_slug
|
|
32
|
+
made = @flow.title.parameterize.first(MADE_SLUG_LENGTH).chomp("-").presence || FALLBACK_SLUG
|
|
33
|
+
numbered = (2..).lazy.map { |number| "#{made}-#{number}" }
|
|
34
|
+
taken = unavailable
|
|
35
|
+
|
|
36
|
+
[ made ].chain(numbered).find { |candidate| taken.exclude?(candidate) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def unavailable
|
|
40
|
+
EasyFlow::Definition.where(host: @flow.host, owner: @flow.owner).pluck(:slug) + SLUGS_THE_ROUTES_USE
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
module QuestionKit
|
|
2
|
+
class UploadedQuestion
|
|
3
|
+
include ActiveModel::Model
|
|
4
|
+
include ActiveModel::Attributes
|
|
5
|
+
|
|
6
|
+
attr_accessor :flow
|
|
7
|
+
|
|
8
|
+
attribute :type, :string
|
|
9
|
+
attribute :question, :string
|
|
10
|
+
attribute :category, :string
|
|
11
|
+
attribute :right_answer, :string
|
|
12
|
+
ANSWER_FIELDS = (1..6).map { |number| :"answer_#{number}" }
|
|
13
|
+
ANSWER_FIELDS.each { |field| attribute field, :string }
|
|
14
|
+
|
|
15
|
+
KINDS = %w[truefalse multiplechoice].freeze
|
|
16
|
+
|
|
17
|
+
validates :question, presence: true
|
|
18
|
+
validate :kind_is_offered
|
|
19
|
+
validate :choices_are_offered, if: -> { kind == "multiplechoice" }
|
|
20
|
+
validate :true_or_false_is_right, if: -> { kind == "truefalse" }
|
|
21
|
+
|
|
22
|
+
def save!
|
|
23
|
+
before = document.to_h
|
|
24
|
+
flow.edit_history.record(appended, :added, [ step["id"] ], before)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
def choices_are_offered
|
|
29
|
+
errors.add(:answers, "must be at least two for a multiple choice question") if filled_answers.size < 2
|
|
30
|
+
errors.add(:right_answer, "must be the number of one of its answers") if answers.none? { |answer| answer["weight"] == 1 }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def true_or_false_is_right
|
|
34
|
+
errors.add(:right_answer, "must be true or false") if answers.none? { |answer| answer["weight"] == 1 }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def kind_is_offered
|
|
38
|
+
errors.add(:type, "must be true/false or multiple choice") unless KINDS.include?(kind)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def document
|
|
42
|
+
EasyFlow::Document.new(flow.document || flow.definition || {})
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def appended
|
|
46
|
+
leaving = document.edges_from(last_question).first
|
|
47
|
+
return document.insert(step, on: [ last_question, leaving.to ]) if leaving
|
|
48
|
+
|
|
49
|
+
document.add(step).connect(from: last_question, to: step["id"])
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def last_question
|
|
53
|
+
reached = document.reachable
|
|
54
|
+
reached.reverse.find { |id| document.node(id)&.type == "question" } || reached.last
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def step
|
|
58
|
+
@step ||= { "id" => next_id, "type" => "question", "question" => question, "category" => category.presence, "answers" => answers }.compact
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def answers
|
|
62
|
+
return true_false_answers if kind == "truefalse"
|
|
63
|
+
|
|
64
|
+
filled_answers.map { |number, text| { "value" => text, "weight" => number.to_s == right_answer.to_s.strip ? 1 : 0 } }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def true_false_answers
|
|
68
|
+
%w[True False].map { |text| { "value" => text, "weight" => text.casecmp?(right_answer.to_s.strip) ? 1 : 0 } }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def kind
|
|
72
|
+
type.to_s.downcase.gsub(/[^a-z]/, "")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def filled_answers
|
|
76
|
+
ANSWER_FIELDS.each_with_index.filter_map { |field, index| [ index + 1, public_send(field).strip ] if public_send(field).present? }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def next_id
|
|
80
|
+
taken = document.nodes.map(&:id)
|
|
81
|
+
(1..).lazy.map { |number| "question_#{number}" }.find { |id| !taken.include?(id) }
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
data/lib/question_kit/version.rb
CHANGED
data/lib/question_kit.rb
CHANGED
|
@@ -4,7 +4,22 @@ require "question_kit/railtie"
|
|
|
4
4
|
require "question_kit/score"
|
|
5
5
|
require "question_kit/answer_key"
|
|
6
6
|
require "question_kit/time_taken"
|
|
7
|
+
require "question_kit/titled_start"
|
|
8
|
+
require "question_kit/uploaded_question"
|
|
9
|
+
require "question_kit/uploaded_questions"
|
|
10
|
+
require "question_kit/answer_explanations"
|
|
7
11
|
|
|
8
12
|
module QuestionKit
|
|
9
|
-
|
|
13
|
+
class << self
|
|
14
|
+
attr_reader :flow_host
|
|
15
|
+
|
|
16
|
+
def flow_host=(name)
|
|
17
|
+
@flow_host = name
|
|
18
|
+
explain_answer_fields
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def explain_answer_fields
|
|
22
|
+
EasyFlow.host_named(flow_host).extend(AnswerExplanations) if flow_host
|
|
23
|
+
end
|
|
24
|
+
end
|
|
10
25
|
end
|
|
@@ -1,29 +1,38 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: question_kit-develop
|
|
3
|
-
description: Use PROACTIVELY for scoring an easy_flow quiz — working out what one answer to a question, multiple-select or fill-in-the-blank step is worth, adding up a run's total score, listing a question's right answers, showing a question's answer key or every accepted spelling of a fill-in-the-blank answer, timing how long a visitor took over a quiz run, and reading which question_kit version is loaded — MUST BE USED instead of summing answer weights by hand, comparing typed answers to accepted answers in host code, picking out right answers from a step's answer list,
|
|
3
|
+
description: Use PROACTIVELY for scoring an easy_flow quiz — working out what one answer to a question, multiple-select or fill-in-the-blank step is worth, adding up a run's total score, listing a question's right answers, showing a question's answer key or every accepted spelling of a fill-in-the-blank answer, timing how long a visitor took over a quiz run — and for building quizzes — starting a new quiz from its title with a free address slug, finding which easy_flow host runs the quizzes, letting a builder upload a CSV file of true/false and multiple choice questions into a quiz through ixport, limiting that upload to the builder's own quizzes, adding one uploaded question to a quiz, reading or replacing the explanations an admin sees beside a question's answer settings, and reading which question_kit version is loaded — MUST BE USED instead of summing answer weights by hand, comparing typed answers to accepted answers in host code, picking out right answers from a step's answer list, subtracting run timestamps, making a quiz slug from its title, writing a CSV question importer or a loop that adds question steps to a flow, or writing a permission check for uploading into a quiz.
|
|
4
4
|
tools: Bash, Read, Write, Edit, Grep
|
|
5
5
|
scope: shared question and answer rules for easy_flow quizzes — what an answer to a question step is worth, which answers are right, and how a quiz is built
|
|
6
6
|
---
|
|
7
7
|
|
|
8
|
-
This local writes host app code that scores, keys and
|
|
8
|
+
This local writes host app code that scores, keys, times and builds easy_flow quizzes through question_kit's declared interface only, and follows the steps below in order.
|
|
9
9
|
|
|
10
10
|
## What question_kit is
|
|
11
11
|
|
|
12
|
-
A Rails gem for apps that run quizzes as easy_flow flows. It holds the rules those apps share: what an answer to a question step is worth, which answers are right,
|
|
12
|
+
A Rails gem for apps that run quizzes as easy_flow flows on one easy_flow host, the flow host. It holds the rules those apps share: what an answer to a question step is worth, which answers are right, how long a visitor took, how a new quiz gets its slug, how a CSV file of questions becomes question steps, and the explanations an admin sees beside a question's answer settings. The scoring, key and timing methods take plain step hashes and answer values, or an easy_flow run, and write nothing. Use this local whenever host code needs a score, an answer key, a time taken, a new quiz, or a question upload. Flows, step types, runs and answers in general belong to the easy_flow locals, and ixport's own pages and configuration belong to the ixport locals.
|
|
13
13
|
|
|
14
14
|
## Interface
|
|
15
15
|
|
|
16
|
-
- `QuestionKit::VERSION` — a string such as `"0.
|
|
16
|
+
- `QuestionKit::VERSION` — a string such as `"0.2.0"` naming the question_kit release loaded in the host app.
|
|
17
17
|
- `QuestionKit::Score.worth(step, value)` — returns the integer one answer `value` to the step hash `step` is worth.
|
|
18
18
|
- `QuestionKit::Score.total(answers, steps)` — returns the integer sum of the worth of every answer in a run.
|
|
19
19
|
- `QuestionKit::Score.right_answers(step)` — returns the list of answer values on a choice step that carry a positive weight.
|
|
20
20
|
- `QuestionKit::AnswerKey.chosen(step)` — returns the answer key to show for a step: one value, a list of values, or `nil`.
|
|
21
21
|
- `QuestionKit::AnswerKey.accepted(step)` — returns every non-blank accepted answer on a fill-in-the-blank step.
|
|
22
22
|
- `QuestionKit::TimeTaken.seconds(run)` — returns the whole seconds from a run's start to its last answer, or `nil`.
|
|
23
|
+
- `QuestionKit.flow_host` — returns the name of the easy_flow host that runs the app's quizzes, exactly as the install set it, or `nil` when none is set.
|
|
24
|
+
- `QuestionKit::TitledStart.new(flow).save` — gives an unsaved quiz a slug made from its title and saves it, returning `true` or `false`.
|
|
25
|
+
- `QuestionKit::UploadedQuestion` — one row of a question upload: validates it and, once valid, adds it to the quiz as a question step.
|
|
26
|
+
- `QuestionKit::UploadedQuestion::ANSWER_FIELDS` — the six answer field names, `:answer_1` to `:answer_6`, a row may fill.
|
|
27
|
+
- `QuestionKit::UploadedQuestions.new(flow)` — the set of uploaded rows for one quiz, which builds an `UploadedQuestion` bound to that quiz from a row's values.
|
|
28
|
+
- `QuestionKit::QuestionImporter` — the ixport importer class that turns each row of a CSV file into a question step in the quiz the upload targets.
|
|
29
|
+
- `QuestionKit::QuestionImporter.allows?(account, quiz)` — returns whether `account` may upload questions into `quiz`.
|
|
30
|
+
- `QuestionKit::AnswerExplanations` — the explanations question_kit adds to the flow host's answer settings, which an admin sees beside each field.
|
|
31
|
+
- `QuestionKit::AnswerExplanations::TEXT` — a frozen hash from step type to setting to the explanation text question_kit shows.
|
|
23
32
|
|
|
24
33
|
## The step hash
|
|
25
34
|
|
|
26
|
-
Every method that takes a `step` takes the step's settings as a hash with string keys, as easy_flow stores them in a flow's document. Pass the hash, not a record.
|
|
35
|
+
Every scoring, key and timing method that takes a `step` takes the step's settings as a hash with string keys, as easy_flow stores them in a flow's document. Pass the hash, not a record.
|
|
27
36
|
|
|
28
37
|
- `"type"` decides how it is read: `"multiple_select"`, `"fill_in_the_blank"`, or any other value (or none) for a single-choice question.
|
|
29
38
|
- A choice step lists its answers under `"answers"` or `"options"`, each a hash with a `"value"` and an optional `"weight"`. A weight may be an integer or a string of digits, and a missing weight counts as 0.
|
|
@@ -31,6 +40,8 @@ Every method that takes a `step` takes the step's settings as a hash with string
|
|
|
31
40
|
|
|
32
41
|
## How to use it
|
|
33
42
|
|
|
43
|
+
### Before any of it
|
|
44
|
+
|
|
34
45
|
1. Confirm question_kit is in the host app's `Gemfile.lock`. If it is not, hand the work to `question_kit-install` and stop.
|
|
35
46
|
2. At the top of the file that calls question_kit, require it explicitly:
|
|
36
47
|
|
|
@@ -39,36 +50,131 @@ Every method that takes a `step` takes the step's settings as a hash with string
|
|
|
39
50
|
```
|
|
40
51
|
|
|
41
52
|
This also loads easy_flow, so no separate `require "easy_flow"` is needed.
|
|
42
|
-
3.
|
|
43
|
-
|
|
53
|
+
3. Confirm `QuestionKit.flow_host` returns a host name in the app, for example with `bin/rails runner 'p QuestionKit.flow_host'`. Starting a quiz, uploading questions and the answer explanations all depend on it. If it prints `nil`, hand the work to `question_kit-install` and stop.
|
|
54
|
+
|
|
55
|
+
### Scoring, keying and timing a run
|
|
56
|
+
|
|
57
|
+
4. Put the calls in host code that reads a finished or stored run, such as a results page's controller, a model method on the app's own quiz record, or a job that records a score. Ask the developer where the score is used and whether it is shown only or also stored; do not pick.
|
|
58
|
+
5. Build the arguments from the flow version the run was taken on, not from the flow's live version, so a later edit does not change an old run's score:
|
|
44
59
|
- `steps` is a hash from step id (string) to that step's settings hash.
|
|
45
60
|
- `answers` is a hash from step id to the value the visitor gave. Ids may be strings or symbols.
|
|
46
|
-
|
|
47
|
-
- Single-choice: `value` is the chosen answer's value, and the result is that answer's weight, or 0 when it carries none or matches no answer.
|
|
61
|
+
6. Score one answer with `QuestionKit::Score.worth(step, value)`:
|
|
62
|
+
- Single-choice: `value` is the chosen answer's value, matched exactly, including letter case, and the result is that answer's weight, or 0 when it carries none or matches no answer.
|
|
48
63
|
- Multiple-select: `value` is the list of ticked values, and the result is 1 when they are exactly the right answers in any order and 0 otherwise. Partial credit is not given.
|
|
49
64
|
- Fill-in-the-blank: `value` is the typed text, and the result is 1 when it matches an accepted answer after removing outer spaces and ignoring letter case, and 0 otherwise.
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
65
|
+
7. Score a whole run with `QuestionKit::Score.total(answers, steps)`. An answer whose step id is not in `steps` counts 0, so steps that are not questions, such as info or branch steps, can be left in `answers` or taken out.
|
|
66
|
+
8. Ask the developer whether a score of 0 for a single-choice question with no weights set is correct for their quizzes, or whether every quiz must set weights before it is published. question_kit cannot tell an unweighted question from a wrong answer.
|
|
67
|
+
9. Show what was right:
|
|
53
68
|
- `QuestionKit::Score.right_answers(step)` for the full list of values with a positive weight on a choice step.
|
|
54
|
-
- `QuestionKit::AnswerKey.chosen(step)` for the single thing to display as the key: the list of right values for multiple-select, the first accepted answer for fill-in-the-blank, and the first right value for any other question.
|
|
69
|
+
- `QuestionKit::AnswerKey.chosen(step)` for the single thing to display as the key: the list of right values for multiple-select, the first accepted answer for fill-in-the-blank, and the first right value for any other question. With no right or accepted answer it returns an empty list for multiple-select and `nil` for every other type, so render both cases.
|
|
55
70
|
- `QuestionKit::AnswerKey.accepted(step)` for every accepted spelling on a fill-in-the-blank step, with blank entries left out.
|
|
56
|
-
|
|
57
|
-
|
|
71
|
+
10. Time a run with `QuestionKit::TimeTaken.seconds(run)`, passing the easy_flow run record. It returns `nil` when the run's flow saves answers only once the flow ends, and when no step has been completed yet; render both cases, for example as "not timed".
|
|
72
|
+
|
|
73
|
+
### Starting a new quiz
|
|
74
|
+
|
|
75
|
+
11. In the host app's action that creates a quiz, build an unsaved `EasyFlow::Definition` with the flow host, the owner and the title the builder typed, and save it through `TitledStart` instead of calling `save` on it:
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
quiz = EasyFlow::Definition.new(host: QuestionKit.flow_host.to_s, owner: current_owner, title: params[:title])
|
|
79
|
+
|
|
80
|
+
if QuestionKit::TitledStart.new(quiz).save
|
|
81
|
+
redirect_to ...
|
|
82
|
+
else
|
|
83
|
+
render :new, status: :unprocessable_entity
|
|
84
|
+
end
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`current_owner` stands for whatever record owns the host's flows; ask the developer which method returns it, or pass no owner when the host keeps none.
|
|
88
|
+
12. Read the result:
|
|
89
|
+
- A blank title is refused: `save` returns `false` and `quiz.errors` holds a blank error on `:title`. Render the form with that error.
|
|
90
|
+
- Otherwise the quiz takes a slug made from its title, lower case with hyphens, at most 100 characters and never ending on a hyphen. A title with no usable letters or digits gets `quiz`.
|
|
91
|
+
- When the same host and owner already have that slug, or the slug is `manage`, `pages` or `runs`, the quiz takes the first free numbered slug, such as `morning-routine-2`.
|
|
92
|
+
- `save` then returns what saving the flow returns, so any other validation error on the flow comes back as `false` with its errors.
|
|
93
|
+
- When another quiz takes the same slug at the same moment, it tries the next free slug, and after three lost tries it raises `ActiveRecord::RecordNotUnique`. Ask the developer whether to let that error reach the error page or rescue it and show the form again.
|
|
94
|
+
13. Never set `slug` yourself when using `TitledStart`; it replaces any slug already on the flow.
|
|
95
|
+
|
|
96
|
+
### Uploading questions into a quiz
|
|
97
|
+
|
|
98
|
+
`QuestionKit::QuestionImporter` is defined only when ixport is loaded in the app. In an app without ixport, it does not exist, and nothing in this section applies. If the developer asks for question uploads and ixport is missing, hand adding ixport to `ixport-install`.
|
|
99
|
+
|
|
100
|
+
14. Register the upload with ixport in an initializer that runs after the ixport configuration, naming the importer by its string and limiting it with `allows?`:
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
Ixport.register_import :quiz_questions, title: "Quiz questions", importer: "QuestionKit::QuestionImporter", refuse_whole_file: true,
|
|
104
|
+
allow: ->(_person, account, quiz) { QuestionKit::QuestionImporter.allows?(account, quiz) }
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Ask the developer for the key and the title the builder sees. Ask whether one bad row refuses the whole file (`refuse_whole_file: true`) or only that row; refusing the whole file is the setting that keeps a quiz from gaining half a file of questions.
|
|
108
|
+
15. `allows?(account, quiz)` returns `true` only when `quiz` is an easy_flow flow on the flow host and, when the flow host keeps owners, `quiz.owner` is `account`. A flow host with no owner method lets any account upload into any of its quizzes. ixport answers a refused upload page with not found.
|
|
109
|
+
16. Link a builder to the upload page from the quiz's own page, passing the quiz as the target by its global id. Hand the link and ixport's pages to `ixport-develop`:
|
|
110
|
+
|
|
111
|
+
```ruby
|
|
112
|
+
ixport.new_import_path(key: :quiz_questions, target: quiz.to_global_id)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
17. Tell the developer what the file must hold, since the builder's columns are matched to these fields on ixport's column-matching page:
|
|
116
|
+
- `Type` — `True/false` or `Multiple choice`. Letter case, spaces, slashes and other non-letters are ignored, so `truefalse` and `multiple-choice` also match. Any other type refuses the row.
|
|
117
|
+
- `Question` — the question text. A blank question refuses the row.
|
|
118
|
+
- `Category` — optional; saved as the question's category when filled.
|
|
119
|
+
- `Answer 1` to `Answer 6` — the answers of a multiple choice question, of which at least two must be filled. A true/false question ignores them and always offers `True` and `False`.
|
|
120
|
+
- `Right answer` — for multiple choice, the number of the answer column that is right, such as `2` for `Answer 2`, counted by column even when an earlier column is blank; for true/false, `true` or `false` in any letter case. Outer spaces are ignored. A right answer that names no filled answer refuses the row.
|
|
121
|
+
- Each answer text is saved with its outer spaces removed, and the question text is saved as given.
|
|
122
|
+
18. Each accepted row becomes a single-choice question step whose right answer has weight 1 and every other answer weight 0, so `Score.worth` scores it 1 or 0. Every row is added as a new question; an upload never matches or updates an existing question.
|
|
123
|
+
19. Each question is added to the quiz's draft, after the last question a visitor can reach and before whatever step followed it, and is recorded in the quiz's edit history as an added step. Visitors are asked the uploaded questions only once the quiz is published. Tell the developer that the builder must publish after uploading.
|
|
124
|
+
|
|
125
|
+
### Adding one question outside an upload
|
|
126
|
+
|
|
127
|
+
20. To add a single question from host code in the same way an upload row does, build it through `UploadedQuestions` and check it before saving, since `save!` does not validate:
|
|
128
|
+
|
|
129
|
+
```ruby
|
|
130
|
+
question = QuestionKit::UploadedQuestions.new(quiz).new(type: "Multiple choice", question: "Which planet is largest?", category: "Space",
|
|
131
|
+
answer_1: "Mars", answer_2: "Jupiter", right_answer: "2")
|
|
132
|
+
|
|
133
|
+
if question.valid?
|
|
134
|
+
question.save!
|
|
135
|
+
else
|
|
136
|
+
question.errors.full_messages
|
|
137
|
+
end
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
The accepted keys are `type`, `question`, `category`, `right_answer` and each name in `QuestionKit::UploadedQuestion::ANSWER_FIELDS`. The rules in step 17 and the placement in step 19 apply. A refused question carries its errors on `:question`, `:type`, `:answers` or `:right_answer`. Build the answer keys from `ANSWER_FIELDS` rather than typing `answer_1` to `answer_6`.
|
|
141
|
+
|
|
142
|
+
### Answer explanations
|
|
143
|
+
|
|
144
|
+
21. question_kit adds its explanations to the flow host's answer settings by itself once the install sets the flow host, so an admin sees them beside each answer field on the canvas. Write no code to turn them on.
|
|
145
|
+
22. They cover the `question` step's answer value, label, hint, info and weight, the `multiple_select` step's answer value, label and weight, and the `fill_in_the_blank` step's accepted answer value. Read the exact text from `QuestionKit::AnswerExplanations::TEXT`, for example `QuestionKit::AnswerExplanations::TEXT["question"]["answers.weight"]`.
|
|
146
|
+
23. To show different words for a field, set it in the flow host's own setting explanations, keyed by step type and then by the same setting name `TEXT` uses. The host app's text replaces question_kit's for that field only, and question_kit's text stays on every other field:
|
|
147
|
+
|
|
148
|
+
```ruby
|
|
149
|
+
EasyFlow.host_named(QuestionKit.flow_host).setting_explanations = { question: { "answers.weight" => "Points for this answer." } }
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Ask the developer which fields to reword and the words to use. Other easy_flow hosts get none of question_kit's explanations.
|
|
153
|
+
|
|
154
|
+
### Versions and tests
|
|
155
|
+
|
|
156
|
+
24. When code depends on a release, compare versions with `Gem::Version`, never as strings:
|
|
58
157
|
|
|
59
158
|
```ruby
|
|
60
159
|
Gem::Version.new(QuestionKit::VERSION) >= Gem::Version.new("0.2.0")
|
|
61
160
|
```
|
|
62
161
|
|
|
63
162
|
Ask the developer what the app does when the loaded version is older: raise at boot, log a warning, or skip the feature.
|
|
64
|
-
|
|
163
|
+
25. Write host app tests that assert the app's own behaviour with the score, key, time, new quiz or upload, such as what the results page shows or that a builder cannot reach another owner's upload page, and run them. Do not test question_kit's own scoring, slug or row rules.
|
|
65
164
|
|
|
66
165
|
## Conventions
|
|
67
166
|
|
|
68
167
|
- Never compute a score, a right answer or a time taken in host code. Call question_kit so every app scores the same way.
|
|
69
168
|
- Never compare a typed answer to an accepted answer in host code; `Score.worth` already ignores case and outer spaces.
|
|
70
169
|
- Never mutate the step hash passed in.
|
|
170
|
+
- Never make a quiz slug in host code; save a new quiz through `TitledStart`.
|
|
171
|
+
- Never write a question importer, a CSV parser for questions or a loop that adds question steps; register `QuestionKit::QuestionImporter` with ixport.
|
|
172
|
+
- Always pass `allows?` as the upload's access rule, so a builder cannot upload into another owner's quiz or a flow on another host.
|
|
173
|
+
- Never call `UploadedQuestion#save!` on a row that has not passed `valid?`.
|
|
174
|
+
- Never edit `QuestionKit::AnswerExplanations::TEXT` or extend a host with `AnswerExplanations` yourself; reword a field through the host's setting explanations.
|
|
175
|
+
- Compare `QuestionKit.flow_host` with a flow's host as a string, with `.to_s`.
|
|
71
176
|
- Never hard-code or redefine `QuestionKit::VERSION`.
|
|
72
|
-
- question_kit does not
|
|
73
|
-
- Adding or upgrading the gem
|
|
74
|
-
- Flows, step types, runs and answers belong to the easy_flow locals.
|
|
177
|
+
- question_kit does not store scores, add step types, or upload multiple-select or fill-in-the-blank questions. If the developer asks for that, say so and ask whether to build it in the host app or wait for question_kit to add it.
|
|
178
|
+
- Adding or upgrading the gem and setting the flow host belong to `question_kit-install`.
|
|
179
|
+
- Flows, step types, runs, publishing and answers belong to the easy_flow locals.
|
|
180
|
+
- Adding ixport and its pages, links and configuration belong to the ixport locals.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: question_kit-info
|
|
3
|
-
description: Use to learn what question_kit offers — shared rules for easy_flow quizzes, covering what an answer to a question step is worth, which answers are right, the answer key a quiz shows for each question,
|
|
3
|
+
description: Use to learn what question_kit offers — shared rules for easy_flow quizzes, covering what an answer to a question step is worth, which answers are right, the answer key a quiz shows for each question, how long a visitor took on a quiz, starting a quiz from its title, adding questions to a quiz from an uploaded CSV file, and the explanations an admin sees beside a question's answer settings.
|
|
4
4
|
tools: Read
|
|
5
5
|
scope: shared question and answer rules for easy_flow quizzes — what an answer to a question step is worth, which answers are right, and how a quiz is built
|
|
6
6
|
---
|
|
@@ -18,36 +18,47 @@ Reach for it when an app runs easy_flow quizzes and needs to:
|
|
|
18
18
|
- score a visitor's answer to one question, or add up a whole quiz;
|
|
19
19
|
- say which answers to a question are right;
|
|
20
20
|
- show the answer key for a question once the quiz is over;
|
|
21
|
-
- report how long a visitor took to answer the quiz
|
|
21
|
+
- report how long a visitor took to answer the quiz;
|
|
22
|
+
- start a new quiz from the title an admin types;
|
|
23
|
+
- add questions to a quiz from a CSV file an admin uploads;
|
|
24
|
+
- explain to an admin, beside each answer setting on the canvas, what that
|
|
25
|
+
setting does to the score.
|
|
22
26
|
|
|
23
|
-
It reads the steps and runs easy_flow already stores. It adds
|
|
24
|
-
|
|
27
|
+
It reads and writes the flows, steps and runs easy_flow already stores. It adds
|
|
28
|
+
no tables and no step types of its own. The CSV upload uses ixport's import
|
|
29
|
+
pages, and is only there in an app that has ixport.
|
|
25
30
|
|
|
26
31
|
## Interface
|
|
27
32
|
|
|
28
33
|
This local documents no commands. The other two locals cover everything the
|
|
29
34
|
gem exposes:
|
|
30
35
|
|
|
31
|
-
- The install local, `question_kit-install`, adds the gem to a host app
|
|
32
|
-
|
|
33
|
-
|
|
36
|
+
- The install local, `question_kit-install`, adds the gem to a host app and
|
|
37
|
+
names the easy_flow host whose flows are quizzes.
|
|
38
|
+
- The develop local, `question_kit-develop`, covers scoring, the answer key,
|
|
39
|
+
time taken, starting a quiz from its title, the question upload and the
|
|
40
|
+
answer setting explanations.
|
|
34
41
|
|
|
35
42
|
Anything about building or running the flows themselves, such as the canvas,
|
|
36
|
-
step types, runs and recorded answers, belongs to easy_flow
|
|
37
|
-
locals cover
|
|
43
|
+
step types, runs and recorded answers, belongs to easy_flow, and the easy_flow
|
|
44
|
+
locals cover it. The import pages themselves belong to ixport, and the ixport
|
|
45
|
+
locals cover them.
|
|
38
46
|
|
|
39
47
|
## How to use it
|
|
40
48
|
|
|
41
49
|
- To add question_kit to an app, use `question_kit-install`.
|
|
42
|
-
- To score answers, show an answer key
|
|
43
|
-
`question_kit-develop`.
|
|
44
|
-
- To build the quiz flow or a question step, use the easy_flow locals.
|
|
50
|
+
- To score answers, show an answer key, time a quiz, start a quiz from its
|
|
51
|
+
title or offer the question upload, use `question_kit-develop`.
|
|
52
|
+
- To build the quiz flow or a question step by hand, use the easy_flow locals.
|
|
45
53
|
|
|
46
54
|
## Conventions
|
|
47
55
|
|
|
48
56
|
- **Quiz** — an easy_flow flow whose steps ask the visitor questions.
|
|
49
57
|
- **Host app** — the Rails app that installs question_kit and easy_flow and runs
|
|
50
58
|
the quizzes.
|
|
59
|
+
- **Quiz host** — the one easy_flow host in the host app whose flows are
|
|
60
|
+
quizzes. The answer setting explanations and the question upload apply to
|
|
61
|
+
that host only.
|
|
51
62
|
- **Question step** — one easy_flow step that asks one question and records the
|
|
52
63
|
visitor's answer. The rules know three kinds:
|
|
53
64
|
- a **single choice** question, where the visitor picks one of the step's
|
|
@@ -58,7 +69,8 @@ locals cover that.
|
|
|
58
69
|
- **Right answer** — on a choice question, an answer whose weight is above
|
|
59
70
|
zero.
|
|
60
71
|
- **Accepted answer** — on a fill-in-the-blank question, a text the admin lists
|
|
61
|
-
as correct.
|
|
72
|
+
as correct. The list of accepted answers a quiz shows leaves out any left
|
|
73
|
+
blank.
|
|
62
74
|
- **Worth** — what one answer counts toward the quiz's result:
|
|
63
75
|
- a single choice answer is worth the weight of the answer picked, and zero
|
|
64
76
|
when it has none;
|
|
@@ -75,3 +87,29 @@ locals cover that.
|
|
|
75
87
|
answer they gave. A run with no answers has none. A flow that saves answers
|
|
76
88
|
only when the visitor finishes, rather than after each step, records no
|
|
77
89
|
time per answer, so its runs have no time taken.
|
|
90
|
+
- **Titled start** — saving a new quiz from its title alone. A blank title is
|
|
91
|
+
refused. The quiz's address is made from the title, cut to 100 characters,
|
|
92
|
+
and is `quiz` when the title holds nothing usable. When another quiz of the
|
|
93
|
+
same host and owner already has that address, a number is added, starting at
|
|
94
|
+
2. The words `manage`, `pages` and `runs` are never used as an address.
|
|
95
|
+
- **Uploaded question** — one row of a CSV file an admin uploads to a quiz. Its
|
|
96
|
+
columns are Type, Question, Category, Answer 1 to Answer 6, and Right answer.
|
|
97
|
+
- Type is true/false or multiple choice, read without regard to capitals,
|
|
98
|
+
spaces or punctuation.
|
|
99
|
+
- Category is saved on the question when it is filled in.
|
|
100
|
+
- A true/false row gets the answers True and False, and its Right answer is
|
|
101
|
+
true or false.
|
|
102
|
+
- A multiple choice row needs at least two answers, and its Right answer is
|
|
103
|
+
the number of one of them.
|
|
104
|
+
- The right answer gets a weight of one and every other answer zero.
|
|
105
|
+
- A row with no question, an unknown type or no right answer is refused.
|
|
106
|
+
- **Where uploaded questions go** — each one is added as a single choice
|
|
107
|
+
question after the last question a visitor can reach, and is recorded in the
|
|
108
|
+
quiz's edit history as an added step. It is added to the quiz's unpublished
|
|
109
|
+
changes, so visitors are not asked it until the quiz is published.
|
|
110
|
+
- **Who may upload** — only to a quiz of the quiz host, and, when that host's
|
|
111
|
+
flows each belong to an owner, only by the account that owns the quiz.
|
|
112
|
+
- **Answer setting explanation** — the line an admin sees beside a question's
|
|
113
|
+
answer settings on the canvas, saying what the setting shows a player and
|
|
114
|
+
what it does to the score. An explanation the host app gives itself is shown
|
|
115
|
+
in place of question_kit's.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: question_kit-install
|
|
3
|
-
description: Use to hook question_kit into a project — adding the gem to the host app's Gemfile and
|
|
3
|
+
description: Use to hook question_kit into a project — adding the gem to the host app's Gemfile alongside easy_flow, and naming the easy_flow host whose flows are the app's quizzes.
|
|
4
4
|
tools: Bash, Read, Edit
|
|
5
5
|
scope: shared question and answer rules for easy_flow quizzes — what an answer to a question step is worth, which answers are right, and how a quiz is built
|
|
6
6
|
---
|
|
@@ -14,6 +14,7 @@ A Rails gem holding the shared quiz rules for apps that run quizzes as easy_flow
|
|
|
14
14
|
## Interface
|
|
15
15
|
|
|
16
16
|
- `gem "question_kit"` — the Gemfile line that adds question_kit, and with it easy_flow, to the host app.
|
|
17
|
+
- `QuestionKit.flow_host = name` — names the easy_flow host whose flows are the app's quizzes, so that host's admin settings for answers carry question_kit's explanations and quiz questions are uploaded only into that host's flows.
|
|
17
18
|
|
|
18
19
|
## How to use it
|
|
19
20
|
|
|
@@ -33,12 +34,22 @@ A Rails gem holding the shared quiz rules for apps that run quizzes as easy_flow
|
|
|
33
34
|
|
|
34
35
|
This edits `Gemfile.lock` and brings in easy_flow if the app did not have it.
|
|
35
36
|
5. Check whether easy_flow is already hooked into the app: its migrations copied and run, its engine mounted, and its hosts declared. If any of that is missing, hand the work to `easy_flow-install`, and ask the developer which hosts to declare rather than picking them.
|
|
37
|
+
6. Find the easy_flow host declarations in the app's initializers, the `EasyFlow.host(:name)` lines. Ask the developer which declared host runs the quizzes. Never pick one, even when only one host is declared.
|
|
38
|
+
7. Set that host as question_kit's flow host, in `config/initializers/question_kit.rb`:
|
|
36
39
|
|
|
37
|
-
|
|
40
|
+
```ruby
|
|
41
|
+
QuestionKit.flow_host = :quizzes
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Use the host's name exactly as its `EasyFlow.host` line spells it. The line must run after that `EasyFlow.host` line. Rails runs initializers in alphabetical order by file name, so when the host is declared in a file that sorts after `question_kit.rb`, put the line in that file, directly below the declaration, instead of in a file of its own.
|
|
45
|
+
|
|
46
|
+
question_kit has no generator, no migrations and no routes of its own. Requiring the gem loads easy_flow, so the host app needs no separate `require "easy_flow"`.
|
|
38
47
|
|
|
39
48
|
## Conventions
|
|
40
49
|
|
|
41
50
|
- After install, run `bundle list | grep -E "question_kit|easy_flow"` and confirm both gems are listed.
|
|
51
|
+
- After install, boot the app with `bin/rails runner 'puts QuestionKit.flow_host'` and confirm it prints the host name chosen in step 6.
|
|
52
|
+
- One app has one quiz host. To move the quizzes to another host, change the name in the `QuestionKit.flow_host` line and nowhere else.
|
|
42
53
|
- After a `bundle update question_kit`, check `Gemfile.lock` again for the easy_flow version it brought in, and run `easy_flow-install` for any new easy_flow migrations.
|
|
43
54
|
- Setting up easy_flow itself, its hosts, step types and admin pages belongs to `easy_flow-install`.
|
|
44
|
-
-
|
|
55
|
+
- Registering the quiz question upload with ixport, and writing code against question_kit, belong to `question_kit-develop`.
|
data/the_local/interface.yml
CHANGED
|
@@ -2,6 +2,7 @@ scope: shared question and answer rules for easy_flow quizzes — what an answer
|
|
|
2
2
|
|
|
3
3
|
install:
|
|
4
4
|
- gem "question_kit"
|
|
5
|
+
- QuestionKit.flow_host = name
|
|
5
6
|
|
|
6
7
|
develop:
|
|
7
8
|
- QuestionKit::VERSION
|
|
@@ -11,11 +12,26 @@ develop:
|
|
|
11
12
|
- QuestionKit::AnswerKey.chosen(step)
|
|
12
13
|
- QuestionKit::AnswerKey.accepted(step)
|
|
13
14
|
- QuestionKit::TimeTaken.seconds(run)
|
|
15
|
+
- QuestionKit.flow_host
|
|
16
|
+
- QuestionKit::TitledStart.new(flow).save
|
|
17
|
+
- QuestionKit::UploadedQuestion
|
|
18
|
+
- QuestionKit::UploadedQuestion::ANSWER_FIELDS
|
|
19
|
+
- QuestionKit::UploadedQuestions.new(flow)
|
|
20
|
+
- QuestionKit::QuestionImporter
|
|
21
|
+
- QuestionKit::QuestionImporter.allows?(account, quiz)
|
|
22
|
+
- QuestionKit::AnswerExplanations
|
|
23
|
+
- QuestionKit::AnswerExplanations::TEXT
|
|
14
24
|
|
|
15
25
|
sources:
|
|
16
26
|
- question_kit.gemspec
|
|
17
27
|
- lib/question_kit.rb
|
|
18
28
|
- lib/question_kit/version.rb
|
|
29
|
+
- lib/question_kit/railtie.rb
|
|
19
30
|
- lib/question_kit/score.rb
|
|
20
31
|
- lib/question_kit/answer_key.rb
|
|
21
32
|
- lib/question_kit/time_taken.rb
|
|
33
|
+
- lib/question_kit/titled_start.rb
|
|
34
|
+
- lib/question_kit/uploaded_question.rb
|
|
35
|
+
- lib/question_kit/uploaded_questions.rb
|
|
36
|
+
- lib/question_kit/question_importer.rb
|
|
37
|
+
- lib/question_kit/answer_explanations.rb
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: question_kit
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- tylercschneider
|
|
@@ -50,10 +50,15 @@ files:
|
|
|
50
50
|
- README.md
|
|
51
51
|
- Rakefile
|
|
52
52
|
- lib/question_kit.rb
|
|
53
|
+
- lib/question_kit/answer_explanations.rb
|
|
53
54
|
- lib/question_kit/answer_key.rb
|
|
55
|
+
- lib/question_kit/question_importer.rb
|
|
54
56
|
- lib/question_kit/railtie.rb
|
|
55
57
|
- lib/question_kit/score.rb
|
|
56
58
|
- lib/question_kit/time_taken.rb
|
|
59
|
+
- lib/question_kit/titled_start.rb
|
|
60
|
+
- lib/question_kit/uploaded_question.rb
|
|
61
|
+
- lib/question_kit/uploaded_questions.rb
|
|
57
62
|
- lib/question_kit/version.rb
|
|
58
63
|
- lib/tasks/question_kit_tasks.rake
|
|
59
64
|
- the_local/agents/question_kit-develop.md
|