diff --git a/app/controllers/reviews_controller.rb b/app/controllers/reviews_controller.rb index 63865fb..194fee6 100644 --- a/app/controllers/reviews_controller.rb +++ b/app/controllers/reviews_controller.rb @@ -5,7 +5,7 @@ def new def create @card = current_user.cards.find(review_params[:card_id]) - review = @card.review(review_params[:answer]) + review = @card.review(review_params[:answer], review_params[:quality].to_i) if review[:success] flash[:success] = t("review_success", @@ -24,6 +24,6 @@ def create protected def review_params - params.require(:review).permit(:card_id, :answer) + params.require(:review).permit(:card_id, :answer, :quality) end end diff --git a/app/models/card.rb b/app/models/card.rb index 8578200..3af8830 100644 --- a/app/models/card.rb +++ b/app/models/card.rb @@ -1,6 +1,4 @@ class Card < ActiveRecord::Base - MAX_CORRECT_ANSWERS = 5 - MAX_INCORRECT_ANSWERS = 3 MAX_LEVENSHTEIN_DISTANCE = 1 belongs_to :deck @@ -9,7 +7,7 @@ class Card < ActiveRecord::Base validate :words_different validates_associated :deck - before_validation :set_review_date, if: :new_record? + after_initialize :set_default_date, if: :new_record? has_attached_file :image, styles: { medium: "360x360", thumb: "100x100" }, default_url: "cards/missing/:style/missing.png" @@ -22,66 +20,38 @@ def self.for_review cards.offset(rand(cards.count)) end - def review(translated) + def review(translated, quality) typos = words_distanse(translated, original_text) - - if typos <= MAX_LEVENSHTEIN_DISTANCE - handle_correct_answer - else - handle_incorrect_answer - end - - { success: typos <= MAX_LEVENSHTEIN_DISTANCE, typos: typos } + success = typos <= MAX_LEVENSHTEIN_DISTANCE + update_review_date(success ? quality : 0) + { success: success, typos: typos } end protected - def handle_correct_answer - increment(:correct_answers) if correct_answers < MAX_CORRECT_ANSWERS - update_attributes(incorrect_answers: 0) - update_review_date + def update_review_date(quality) + repetition = SuperMemo2.repetition(e_factor, interval, quality, repetitions) + repetition[:review_date] = DateTime.now + repetition[:interval] + update_attributes(repetition) end - def handle_incorrect_answer - decrement(:correct_answers) if correct_answers > 0 - increment(:incorrect_answers) if incorrect_answers < MAX_INCORRECT_ANSWERS - save + def words_distanse(word1, word2) + DamerauLevenshtein.distance(normalize(word1), normalize(word2)) end + private + def normalize(str) str.squish.mb_chars.downcase.to_s end - def set_review_date + def set_default_date self.review_date = DateTime.now end - def update_review_date - case correct_answers - when 0 - offset = 0 - when 1 - offset = 12.hour - when 2 - offset = 3.day - when 3 - offset = 1.week - when 4 - offset = 2.week - else - offset = 1.month - end - - update_attributes(review_date: review_date + offset) - end - def words_different if normalize(original_text) == (translated_text) errors.add(:original_text, "can't equal translated text") end end - - def words_distanse(word1, word2) - DamerauLevenshtein.distance(normalize(word1), normalize(word2)) - end end diff --git a/app/services/super_memo2.rb b/app/services/super_memo2.rb new file mode 100644 index 0000000..a232697 --- /dev/null +++ b/app/services/super_memo2.rb @@ -0,0 +1,75 @@ +# This class implement a _SuperMemo2_ algorithm. +# See more: http://www.supermemo.com/english/ol/sm2.htm +# +# Algorithm _SM-2_ used in the computer-based variant of the SuperMemo method +# and involving the calculation of easiness factors for particular items: +# +# 1. Split the knowledge into smallest possible items. +# 2. With all items associate an E-Factor equal to 2.5. +# 3. Repeat items using the following intervals: +# I(1) = 1 +# I(2) = 6 +# for n > 2: I(n) = I(n-1) * EF +# +# where: +# *I*(*n*) - inter-repetition interval after the n-th repetition (in days), +# *EF* - E-Factor of a given item +# If interval is a fraction, round it up to the nearest integer. +# 4. After each repetition assess the quality of repetition response in 0-5 +# grade scale: +# 5 - perfect response +# 4 - correct response after a hesitation +# 3 - correct response recalled with serious difficulty +# 2 - incorrect response; where the correct one seemed easy to recall +# 1 - incorrect response; the correct one remembered +# 0 - complete blackout. +# 5. After each repetition modify the E-Factor of the recently repeated item +# according to the formula: +# E-Factror = EF + (0.1 - (5 - q) * (0.08 + (5 - q) * 0.02)) +# where: +# *E-Factor* - new value of the E-Factor, +# *EF* - old value of the E-Factor, +# *q* - quality of the response in the 0-5 grade scale. +# If *EF* is less than 1.3 then let EF be 1.3. +# 6. If the quality response was lower than 3 then start repetitions for the +# item from the beginning without changing the E-Factor (i.e. use intervals +# I(1), I(2) etc. as if the item was memorized anew). +# 7. After each repetition session of a given day repeat again all items that +# scored below four in the quality assessment. Continue the repetitions until +# all of these items score at least fou +class SuperMemo2 + MIN_E_FACTOR = 1.3 + MIN_QUALITY = 3 + + # Return repetition result + def self.repetition(e_factor, interval, quality, repetitions) + repetitions = reduce_repetitions(repetitions, quality) + { + e_factor: e_factor(e_factor, quality), + interval: interval(e_factor, interval, repetitions).days, + quality: quality, + repetitions: repetitions + 1 + } + end + + # Return interval unto the next repetition (see p.3 of class description). + def self.interval(e_factor, interval, repetitions) + case repetitions + when 0 then 1 + when 1 then 6 + else + (interval * e_factor).ceil + end + end + + # Return E-Factor according to the formula (see p.5 of class description). + def self.e_factor(e_factor, quality) + ef = e_factor + (0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02)) + [MIN_E_FACTOR, ef].max + end + + # Return count of repetitions (see p.3 of class description). + def self.reduce_repetitions(repetitions, quality) + quality < MIN_QUALITY ? 0 : repetitions + end +end diff --git a/app/views/cards/index.html.erb b/app/views/cards/index.html.erb index 6d26193..c217fa3 100644 --- a/app/views/cards/index.html.erb +++ b/app/views/cards/index.html.erb @@ -6,8 +6,7 @@ <%= Card.human_attribute_name(:translated_text) %> <%= Card.human_attribute_name(:image) %> <%= Card.human_attribute_name(:review_date) %> - <%= Card.human_attribute_name(:correct_answers) %> - <%= Card.human_attribute_name(:incorrect_answers) %> + <%= Card.human_attribute_name(:repetitions) %> <%= Deck.human_attribute_name(:title) %> @@ -16,9 +15,8 @@ <%= card.original_text %> <%= card.translated_text %> <%= image_tag card.image.url(:thumb) %> - <%= card.review_date.strftime("%d/%m/%Y %H:%M") %> - <%= card.correct_answers %> - <%= card.incorrect_answers %> + <%= card.review_date.localtime.strftime("%d/%m/%Y %H:%M") %> + <%= card.repetitions %> <%= card.deck.title %> <%= link_to t(:edit), edit_card_path(card) %> <%= link_to t(:destroy), card_path(card), method: :delete %> diff --git a/app/views/reviews/_form.html.erb b/app/views/reviews/_form.html.erb index 4d03649..3faaedc 100644 --- a/app/views/reviews/_form.html.erb +++ b/app/views/reviews/_form.html.erb @@ -5,5 +5,6 @@

<%= card.translated_text %>

<%= f.input :answer, label: (t :review_answer) %> + <%= f.input :quality, label: (t :review_quality), collection: 1..5 %> <%= f.button :submit, (t :review_check) %> <% end %> diff --git a/app/views/reviews/new.html.erb b/app/views/reviews/new.html.erb index e889673..c6ebece 100644 --- a/app/views/reviews/new.html.erb +++ b/app/views/reviews/new.html.erb @@ -1,5 +1,5 @@ <% if @card %> - <%= render 'form', card: @card %> + <%= render "form", card: @card %> <% else %>

Нет карточек для просмотра.

<% end %> diff --git a/config/locales/en.yml b/config/locales/en.yml index e2fabbf..b067d19 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -52,6 +52,7 @@ en: review_answer: "Answer" review_check: "Check" review_wrong: "Wrong! Next review: %{next_review}" + review_quality: "Answer quality" review_success: > Right! Original: %{original}, translated: %{translated}. diff --git a/config/locales/ru.yml b/config/locales/ru.yml index 6e1c4de..3df495b 100644 --- a/config/locales/ru.yml +++ b/config/locales/ru.yml @@ -14,10 +14,9 @@ ru: deck: title: "Название" card: - correct_answers: "Правильные ответы" - incorrect_answers: "Неправильные ответы" image: "Изображение" original_text: "Оригинал" + repetitions: "Повторения" review_date: "Дата просмотра" translated_text: "Перевод" user: @@ -56,6 +55,7 @@ ru: review_answer: "Ответ" review_check: "Проверить" review_wrong: "Неверно! Следующий просмотр: %{next_review}" + review_quality: "Оценка ответа" review_success: > Правильно! Оригинал: %{original}, перевод: %{translated}. Ваш ответ: %{user_answer}. Ошибок: %{typos}. diff --git a/db/migrate/20150824141658_add_super_memo_to_card.rb b/db/migrate/20150824141658_add_super_memo_to_card.rb new file mode 100644 index 0000000..e477e81 --- /dev/null +++ b/db/migrate/20150824141658_add_super_memo_to_card.rb @@ -0,0 +1,8 @@ +class AddSuperMemoToCard < ActiveRecord::Migration + def change + add_column :cards, :e_factor, :float, default: 2.5 + add_column :cards, :interval, :integer, default: 0 + add_column :cards, :quality, :integer, default: 0 + add_column :cards, :repetitions, :integer, default: 0 + end +end diff --git a/db/schema.rb b/db/schema.rb index 00c0db3..1e9f912 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20150822114606) do +ActiveRecord::Schema.define(version: 20150824141658) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -30,15 +30,19 @@ t.text "original_text" t.text "translated_text" t.datetime "review_date" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.string "image_file_name" t.string "image_content_type" t.integer "image_file_size" t.datetime "image_updated_at" - t.integer "deck_id", null: false + t.integer "deck_id", null: false t.integer "correct_answers", default: 0 t.integer "incorrect_answers", default: 0 + t.float "e_factor", default: 2.5 + t.integer "interval", default: 0 + t.integer "quality", default: 0 + t.integer "repetitions", default: 0 end add_index "cards", ["deck_id"], name: "index_cards_on_deck_id", using: :btree diff --git a/spec/models/card_spec.rb b/spec/models/card_spec.rb index aa08361..f2351f7 100644 --- a/spec/models/card_spec.rb +++ b/spec/models/card_spec.rb @@ -2,87 +2,75 @@ describe Card do context "initialize" do - let(:invalid) { build(:card, original_text: "foo", translated_text: "foo") } + context "defaults" do + let(:card) { create(:card) } + it { expect(card.e_factor).to be 2.5 } + it { expect(card.interval).to be 0 } + it { expect(card.quality).to be 0 } + it { expect(card.repetitions).to be 0 } + end - it "invalid card" do - expect(invalid).to be_invalid + context "invalid" do + let(:card) { build(:card, original_text: "foo", translated_text: "foo") } + it { expect(card).to be_invalid } end end context "check input" do - let(:card) { FactoryGirl.create(:card, original_text: "Bueno") } + let(:card) { create(:card, original_text: "Bueno") } + it "right answer" do - expect(card.review("bueno")[:typos]).to be 0 + expect(card.review("bueno", 0)[:typos]).to be 0 end it "right answer case insensitive" do - expect(card.review("BuENo")[:typos]).to be 0 + expect(card.review("BuENo", 0)[:typos]).to be 0 end it "right answer with trailing whitespaces" do - expect(card.review(" bueno ")[:typos]).to be 0 + expect(card.review(" bueno ", 0)[:typos]).to be 0 end it "right answer with typos" do - expect(card.review("byeno")[:typos]).to be 1 + expect(card.review("byeno", 0)[:typos]).to be 1 end it "wrong answer" do - expect(card.review("malo")[:typos]).to be > 1 + expect(card.review("malo", 0)[:typos]).to be > 1 end end - describe "#handle_correct_answer" do - before(:each) do - @original_date = card.review_date - card.review(card.original_text) - end + describe "#review" do + let(:time_now) { Time.parse("Aug 25 2015") } - context "after the first right review" do - let(:card) { FactoryGirl.create(:card, correct_answers: 0) } - it { expect(card.review_date).to eq @original_date + 12.hour } - end - - context "after the second right review" do - let(:card) { FactoryGirl.create(:card, correct_answers: 1) } - it { expect(card.review_date).to eq @original_date + 3.day } - end - - context "after the third right review" do - let(:card) { FactoryGirl.create(:card, correct_answers: 2) } - it { expect(card.review_date).to eq @original_date + 1.week } - end - - context "after the fourth right review" do - let(:card) { FactoryGirl.create(:card, correct_answers: 3) } - it { expect(card.review_date).to eq @original_date + 2.week } + before(:each) do + DateTime.stub(:now).and_return(time_now) + card.review(card.original_text, 4) end - context "after the fifth right review" do - let(:card) { FactoryGirl.create(:card, correct_answers: 4) } - it { expect(card.review_date).to eq @original_date + 1.month } + context "the first right review" do + let(:card) { create(:card) } + it { expect(card.review_date).to eq time_now + 1.day } end - end - describe "#handle_incorrect_answer" do - before(:each) do - @original_date = card.review_date - card.review("#{card.original_text}foo") + context "the second right review" do + let(:card) { create(:card, interval: 1, repetitions: 1) } + it { expect(card.review_date).to eq time_now + 6.day } end - context "after the first wrong review" do - let(:card) { FactoryGirl.create(:card, correct_answers: 3) } - it { expect(card.correct_answers).to eq 2 } + context "the third right review" do + let(:card) { create(:card, interval: 6, repetitions: 2) } + it { expect(card.review_date).to eq time_now + (2.5 * 6).day } end - context "after the second wrong review" do - let(:card) { FactoryGirl.create(:card, correct_answers: 2) } - it { expect(card.correct_answers).to eq 1 } + context "the fourth right review" do + let(:card) { create(:card, interval: 15, repetitions: 3) } + it { expect(card.review_date).to eq time_now + (2.5 * 15).ceil.day } end - context "after the third wrong review" do - let(:card) { FactoryGirl.create(:card, correct_answers: 1) } - it { expect(card.correct_answers).to eq 0 } + context "the fifth right review" do + let(:card) { create(:card, interval: 37, repetitions: 4) } + it { expect(card.review_date).to eq time_now + (2.5 * 37).ceil.day } end end end