Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

Recommendable 2.0 - please read the CHANGELOG

Recommendable has been rewritten from the ground up. Major changes:

 * No longer require Rails (still require ActiveSupport) (fixes #36)
 * Add support for Mongoid, MongoMapper, and DataMapper (fixes #5)
 * Rename the concept of "Ignoring" items to "Hiding" items
 * Rename the concept of "Stashing" items to "Bookmarking" items
 * Store Likes/Dislikes/Hidden Items/Bookmarks in Redis as opposed to
   using Models
 * Add a Configuration class
 * Likes/Dislikes/Hidden Items/Bookmarks can now all have counters
   (fixes #42)
 * Enable support for Ruby 1.8.7 (fixes #32)
 * Greatly improve speed and performance

Signed-off-by: David Celis <david@davidcelis.com>
  • Loading branch information...
commit 7c0d92f103e5175aab059fc2070cf2806d40cb3d 1 parent 18f939d
@davidcelis authored
Showing with 2,492 additions and 3,066 deletions.
  1. +3 −48 .gitignore
  2. +2 −0  .travis.yml
  3. +75 −7 CHANGELOG.markdown → CHANGELOG.md
  4. +1 −2  Gemfile
  5. +9 −14 Gemfile.lock
  6. 0  LICENSE.txt → LICENSE
  7. +0 −138 README.markdown
  8. +143 −0 README.md
  9. +7 −18 Rakefile
  10. +0 −5 TODO
  11. +0 −19 app/models/recommendable/dislike.rb
  12. +0 −19 app/models/recommendable/ignore.rb
  13. +0 −19 app/models/recommendable/like.rb
  14. +0 −19 app/models/recommendable/stash.rb
  15. +0 −17 app/workers/recommendable/delayed_job_worker.rb
  16. +0 −17 app/workers/recommendable/rails_worker.rb
  17. +0 −14 app/workers/recommendable/resque_worker.rb
  18. +0 −14 app/workers/recommendable/sidekiq_priority_worker.rb
  19. +0 −14 app/workers/recommendable/sidekiq_worker.rb
  20. +0 −3  config/routes.rb
  21. +0 −17 db/migrate/20120124193723_create_likes.rb
  22. +0 −17 db/migrate/20120124193728_create_dislikes.rb
  23. +0 −17 db/migrate/20120127092558_create_ignores.rb
  24. +0 −17 db/migrate/20120131173909_create_stashes.rb
  25. +0 −8 lib/generators/recommendable/USAGE
  26. +0 −37 lib/generators/recommendable/install_generator.rb
  27. +0 −23 lib/generators/recommendable/templates/initializer.rb
  28. +35 −29 lib/recommendable.rb
  29. +0 −170 lib/recommendable/acts_as_recommendable.rb
  30. +0 −819 lib/recommendable/acts_as_recommended_to.rb
  31. +35 −7 lib/recommendable/configuration.rb
  32. +0 −14 lib/recommendable/engine.rb
  33. +0 −4 lib/recommendable/exceptions.rb
  34. +3 −9 lib/recommendable/helpers.rb
  35. +150 −0 lib/recommendable/helpers/calculations.rb
  36. +23 −0 lib/recommendable/helpers/queriers.rb
  37. +29 −0 lib/recommendable/helpers/redis_key_mapper.rb
  38. +6 −0 lib/recommendable/orm/active_record.rb
  39. +7 −0 lib/recommendable/orm/data_mapper.rb
  40. +8 −0 lib/recommendable/orm/mongo_mapper.rb
  41. +7 −0 lib/recommendable/orm/mongoid.rb
  42. +0 −6 lib/recommendable/railtie.rb
  43. +83 −0 lib/recommendable/ratable.rb
  44. +26 −0 lib/recommendable/ratable/dislikable.rb
  45. +26 −0 lib/recommendable/ratable/likable.rb
  46. +109 −0 lib/recommendable/rater.rb
  47. +120 −0 lib/recommendable/rater/bookmarker.rb
  48. +122 −0 lib/recommendable/rater/disliker.rb
  49. +120 −0 lib/recommendable/rater/hider.rb
  50. +122 −0 lib/recommendable/rater/liker.rb
  51. +68 −0 lib/recommendable/rater/recommender.rb
  52. +5 −4 lib/recommendable/version.rb
  53. +16 −0 lib/recommendable/workers/delayed_job.rb
  54. +16 −0 lib/recommendable/workers/rails.rb
  55. +13 −0 lib/recommendable/workers/resque.rb
  56. +13 −0 lib/recommendable/workers/sidekiq.rb
  57. +0 −1  lib/tasks/recommendable_tasks.rake
  58. +21 −21 recommendable.gemspec
  59. +0 −8 script/rails
  60. +0 −9 spec/configuration_spec.rb
  61. +0 −261 spec/dummy/README.rdoc
  62. +0 −15 spec/dummy/app/assets/javascripts/application.js
  63. +0 −13 spec/dummy/app/assets/stylesheets/application.css
  64. +0 −3  spec/dummy/app/controllers/application_controller.rb
  65. +0 −2  spec/dummy/app/helpers/application_helper.rb
  66. 0  spec/dummy/app/mailers/.gitkeep
  67. 0  spec/dummy/app/models/.gitkeep
  68. +0 −2  spec/dummy/app/models/bully.rb
  69. +0 −2  spec/dummy/app/models/php_framework.rb
  70. +0 −3  spec/dummy/app/models/user.rb
  71. +0 −14 spec/dummy/app/views/layouts/application.html.erb
  72. +0 −10 spec/dummy/config/boot.rb
  73. +0 −21 spec/dummy/config/initializers/recommendable.rb
  74. +0 −5 spec/dummy/config/locales/en.yml
  75. +0 −4 spec/dummy/config/routes.rb
  76. +0 −18 spec/dummy/db/migrate/20120128005553_create_likes.recommendable.rb
  77. +0 −18 spec/dummy/db/migrate/20120128005554_create_dislikes.recommendable.rb
  78. +0 −18 spec/dummy/db/migrate/20120128005555_create_ignores.recommendable.rb
  79. +0 −9 spec/dummy/db/migrate/20120128024632_create_php_frameworks.rb
  80. +0 −9 spec/dummy/db/migrate/20120128024804_create_bullies.rb
  81. +0 −19 spec/dummy/db/migrate/20120131195416_create_stashes.recommendable.rb
  82. +0 −93 spec/dummy/db/schema.rb
  83. 0  spec/dummy/lib/assets/.gitkeep
  84. +0 −26 spec/dummy/public/404.html
  85. +0 −26 spec/dummy/public/422.html
  86. +0 −25 spec/dummy/public/500.html
  87. 0  spec/dummy/public/favicon.ico
  88. BIN  spec/dummy/recommendable_dummy_development
  89. BIN  spec/dummy/recommendable_dummy_test
  90. +0 −16 spec/factories.rb
  91. +0 −44 spec/models/dislike_spec.rb
  92. +0 −27 spec/models/ignore_spec.rb
  93. +0 −45 spec/models/like_spec.rb
  94. +0 −82 spec/models/movie_spec.rb
  95. +0 −27 spec/models/stash_spec.rb
  96. +0 −49 spec/models/user_benchmark_spec.rb
  97. +0 −494 spec/models/user_spec.rb
  98. +0 −28 spec/spec_helper.rb
  99. 0  {spec → test}/dummy/Rakefile
  100. +3 −0  test/dummy/app/models/book.rb
  101. +1 −0  {spec → test}/dummy/app/models/movie.rb
  102. +3 −0  test/dummy/app/models/rock.rb
  103. +4 −0 test/dummy/app/models/user.rb
  104. 0  {spec → test}/dummy/config.ru
  105. +10 −4 {spec → test}/dummy/config/application.rb
  106. +6 −0 test/dummy/config/boot.rb
  107. +3 −3 {spec → test}/dummy/config/database.yml
  108. 0  {spec → test}/dummy/config/environment.rb
  109. 0  {spec → test}/dummy/config/environments/development.rb
  110. +1 −1  {spec → test}/dummy/config/environments/production.rb
  111. 0  {spec → test}/dummy/config/environments/test.rb
  112. 0  {spec → test}/dummy/config/initializers/backtrace_silencers.rb
  113. 0  {spec → test}/dummy/config/initializers/inflections.rb
  114. 0  {spec → test}/dummy/config/initializers/mime_types.rb
  115. +6 −0 test/dummy/config/initializers/recommendable.rb
  116. +1 −1  {spec → test}/dummy/config/initializers/secret_token.rb
  117. 0  {spec → test}/dummy/config/initializers/session_store.rb
  118. 0  {spec → test}/dummy/config/initializers/wrap_parameters.rb
  119. +58 −0 test/dummy/config/routes.rb
  120. BIN  test/dummy/db/development.sqlite3
  121. +1 −3 ...dummy/db/migrate/20120128020228_create_users.rb → test/dummy/db/migrate/20121006052300_create_users.rb
  122. +0 −2  ...mmy/db/migrate/20120128020413_create_movies.rb → test/dummy/db/migrate/20121006052339_create_movies.rb
  123. +9 −0 test/dummy/db/migrate/20121007212545_create_rocks.rb
  124. +10 −0 test/dummy/db/migrate/20121007213144_create_books.rb
  125. +42 −0 test/dummy/db/schema.rb
  126. +7 −0 test/dummy/db/seeds.rb
  127. BIN  test/dummy/db/test.sqlite3
  128. 0  {spec → test}/dummy/log/.gitkeep
  129. 0  {spec → test}/dummy/script/rails
  130. +17 −0 test/factories.rb
  131. +46 −0 test/recommendable/helpers/calculations_test.rb
  132. +40 −0 test/recommendable/helpers/redis_key_mapper_test.rb
  133. +31 −0 test/recommendable/ratable/dislikable_test.rb
  134. +31 −0 test/recommendable/ratable/likable_test.rb
  135. +78 −0 test/recommendable/ratable_test.rb
  136. +122 −0 test/recommendable/rater/bookmarker_test.rb
  137. +122 −0 test/recommendable/rater/disliker_test.rb
  138. +122 −0 test/recommendable/rater/hider_test.rb
  139. +122 −0 test/recommendable/rater/liker_test.rb
  140. +119 −0 test/recommendable/rater/recommender_test.rb
  141. +29 −0 test/recommendable/rater_test.rb
  142. +25 −0 test/test_helper.rb
View
51 .gitignore
@@ -1,57 +1,12 @@
-# RVM / rbenv version files
-.rvmrc
-.rbenv-version
-
-# rcov generated
-coverage
-
-# rdoc generated
-rdoc
-
-# yard generated
-doc
-.yardoc
-
# bundler
.bundle
vendor/bundle
-# jeweler generated
-pkg
-
# logging
*.log
-# Have editor/IDE/OS specific files you need to ignore? Consider using a global gitignore:
-#
-# * Create a file at ~/.gitignore
-# * Include files you want ignored
-# * Run: git config --global core.excludesfile ~/.gitignore
-#
-# After doing this, these files will be ignored in all your git projects,
-# saving you from having to 'pollute' every project you touch with them
-#
-# Not sure what to needs to be ignored for particular editors/OSes? Here's some ideas to get you started. (Remember, remove the leading # of the line)
-#
-# For MacOS:
+# sqlite3
+*.sqlite3-journal
+# For MacOS:
.DS_Store
-
-# For TextMate
-*.tmproj
-tmtags
-
-# For emacs:
-#*~
-#\#*
-#.\#*
-
-# For vim:
-*.swp
-
-# For redcar:
-#.redcar
-
-# For rubinius:
-#*.rbc
-.rake_tasks*
View
2  .travis.yml
@@ -1,3 +1,5 @@
rvm:
+ - 1.8.7
- 1.9.2
- 1.9.3
+script: bundle exec rake test
View
82 CHANGELOG.markdown → CHANGELOG.md
@@ -1,13 +1,81 @@
Changelog
=========
-_Future Release_
-----------------
-* Introduce a new configuration method. Please regenerate your intializer: `rails g recommendable:install --no-migrate`
-* Expire non-persistent redis sets after a certain time rather than explicitly tearing them down. Configure this in the initializer
-* Allow configuration of the Sidekiq/Resque queue name in the initializer
-* Allow configuration of whether or not to automatically enqueue users in the initializer
-* Fix a bug where `Float::NAN` was a possible prediction
+2.0.0 (Current version)
+-----------------------
+**IMPORTANT**: This is a MAJOR version bump that should greatly improve the performance of Recommendable. Most of the library has been rewritten from the ground up and, as such, there are steps you must take to make your application compatibile with this version.
+
+1. Flush your Redis database. Yes, you'll have to regenerate any recommendations at the end. Sorry.
+2. Please make a migration like the following. This migration will be irreversible if you uncomment the bit to drop the tables. If you do want to drop the tables, you should probably make a backup of your database first. I am not responsible for lost data, angry users, broken marriages, _et cetera_.
+
+```ruby
+# Require the model that receives recommendations so Recommendable detects it
+require Rails.root.join('app', 'models', 'user')
+
+class UpdateRecommendable < ActiveRecord::Migration
+ def up
+ Recommendable.redis.flushdb # Step 1
+
+ connection = ActiveRecord::Base.connection
+ # Transfer likes
+ result = connection.execute('SELECT * FROM recommendable_likes')
+ result.each do |row|
+ liked_set = Recommendable::Helpers::RedisKeyMapper.liked_set_for(row['likeable_type'], row['user_id'])
+ liked_by_set = Recommendable::Helpers::RedisKeyMapper.liked_by_set_for(row['likeable_type'], row['likeable_id'])
+ Recommendable.redis.sadd(liked_set, row['likeable_id'])
+ Recommendable.redis.sadd(liked_by_set, row['user_id'])
+ end
+
+ # Transfer dislikes
+ result = connection.execute('SELECT * FROM recommendable_dislikes')
+ result.each do |row|
+ disliked_set = Recommendable::Helpers::RedisKeyMapper.disliked_set_for(row['dislikeable_type'], row['user_id'])
+ disliked_by_set = Recommendable::Helpers::RedisKeyMapper.disliked_by_set_for(row['dislikeable_type'], row['dislikeable_id'])
+ Recommendable.redis.sadd(disliked_set, row['dislikeable_id'])
+ Recommendable.redis.sadd(disliked_by_set, row['user_id'])
+ end
+
+ # Transfer hidden items
+ result = connection.execute('SELECT * FROM recommendable_ignores')
+ result.each do |row|
+ set = Recommendable::Helpers::RedisKeyMapper.hidden_set_for(row['ignorable_type'], row['user_id'])
+ Recommendable.redis.sadd(set, row['ignorable_id'])
+ end
+
+ # Transfer bookmarks
+ result = connection.execute('SELECT * FROM recommendable_stashes')
+ result.each do |row|
+ set = Recommendable::Helpers::RedisKeyMapper.bookmarked_set_for(row['stashable_type'], row['user_id'])
+ Recommendable.redis.sadd(set, row['stashable_id'])
+ end
+
+ # Recalculate scores
+ Recommendable.config.ratable_classes.each do |klass|
+ klass.pluck(:id).each { |id| Recommendable::Helpers::Calculations.update_score_for(klass, id) }
+ end
+
+ # Remove old tables. Uncomment this if you're feeling confident. Please
+ # back up your database before doing this. Thanks.
+ # drop_table :recommendable_likes
+ # drop_table :recommendable_dislikes
+ # drop_table :recommendable_ignores
+ # drop_Table :recommendable_stashes
+ end
+
+ def down
+ # Recreate the tables from previous migrations, if you must.
+ end
+end
+
+```
+
+* Recommendable no longer requires Rails (it does, however, still require ActiveSupport)
+* Add support for more ORMs: DataMapper, Mongoid, and MongoMapper
+* Renamed the concept of Ignoring to Hiding
+* Renamed the concept of Stashing to Bookmarking
+* Likes, Dislikes, Hidden Items, and Bookmarked items are no longer stored as models. They are, instead, permanently stored in Redis.
+* Added a Configuration class
+* Enable Ruby 1.8.7 support
1.1.7 (Current version)
-----------------------
View
3  Gemfile
@@ -1,3 +1,2 @@
-source 'http://rubygems.org'
-# Add dependencies required to use your gem here.
+source :rubygems
gemspec
View
23 Gemfile.lock
@@ -1,9 +1,9 @@
PATH
remote: .
specs:
- recommendable (1.1.7)
+ recommendable (2.0.0)
+ activesupport (>= 3.0.0)
hooks (>= 0.2.1)
- rails (>= 3.0.0)
redis (>= 2.2.0)
GEM
@@ -38,6 +38,7 @@ GEM
multi_json (~> 1.0)
arel (3.0.2)
builder (3.0.3)
+ database_cleaner (0.8.0)
erubis (2.7.0)
hike (1.2.1)
hooks (0.2.1)
@@ -51,7 +52,7 @@ GEM
mime-types (1.19)
miniskirt (1.2.1)
activesupport
- minitest (4.0.0)
+ minitest (4.1.0)
multi_json (1.3.6)
polyglot (0.3.3)
rack (1.4.1)
@@ -79,13 +80,7 @@ GEM
rake (0.9.2.2)
rdoc (3.12)
json (~> 1.4)
- redis (3.0.1)
- shoulda (3.1.1)
- shoulda-context (~> 1.0)
- shoulda-matchers (~> 1.2)
- shoulda-context (1.0.0)
- shoulda-matchers (1.3.0)
- activesupport (>= 3.0.0)
+ redis (3.0.2)
sprockets (2.1.3)
hike (~> 1.2)
rack (~> 1.0)
@@ -97,16 +92,16 @@ GEM
polyglot
polyglot (>= 0.3.1)
tzinfo (0.3.33)
- yard (0.6.8)
+ yard (0.8.2.1)
PLATFORMS
ruby
DEPENDENCIES
- bundler
+ database_cleaner
miniskirt
minitest
+ rails (>= 3.1.0)
recommendable!
- shoulda
sqlite3
- yard (~> 0.6.0)
+ yard
View
0  LICENSE.txt → LICENSE
File renamed without changes
View
138 README.markdown
@@ -1,138 +0,0 @@
-# Recommendable [![Build Status](https://secure.travis-ci.org/davidcelis/recommendable.png)](http://travis-ci.org/davidcelis/recommendable)
-
-Recommendable is an engine for Rails 3 applications to quickly add the ability for your users to Like/Dislike items and receive recommendations for new items. It uses Redis to store your recommendations and keep them sorted by how good the recommendation is.
-
-Requirements
-------------
-* Ruby 1.9.x
-* Rails 3.x or 4.x (and, currently, ActiveRecord)
-* Sidekiq or Resque (or DelayedJob)
-
-Bundling one of the queueing systems above is highly recommended to avoid having to manually refresh users' recommendations. If running on Rails 4, the built-in queueing system is supported. If you bundle [Sidekiq][sidekiq], [Resque][resque], or [DelayedJob][delayed_job], Recommendable will use your bundled queueing system instead. If bundling Resque, you should also include ['resque-loner'][resque-loner] in your Gemfile to ensure your users only get queued once (Sidekiq does this by default, and there is no current way to avoid duplicate jobs in DelayedJob).
-
-Installation
-------------
-
-Add the following to your Rails application's `Gemfile`:
-
-``` ruby
- gem 'recommendable'
-```
-
-After bundling, run the installation generator:
-
-``` bash
-$ rails g recommendable:install
-```
-
-Double check `config/initializers/recommendable.rb` for options on configuring Recommendable. After a user likes or dislikes something new, they are placed in a queue to have their recommendations updated. If you're using the basic Rails 4.0 queue, you don't need to do anything explicit. If using Sidekiq, Resque, or DelayedJob, start your workers from the command line:
-
-``` bash
-# sidekiq
-$ bundle exec sidekiq -q recommendable
-# resque
-$ QUEUE=recommendable rake environment resque:work
-# delayed_job
-$ rake jobs:work
-```
-
-If you're using Sidekiq, I recommend also bundling [sidekiq-middleware][sidekiq-middleware] to make your jobs unique. There's no reason for a user to be processed more than once at a time. If using Resque, use [resque-loner][resque-loner] for the same purpose. Bundling one of these gems is enough; the jobs will automatically become unique.
-
-Usage
------
-
-In your Rails model that will be receiving recommendations:
-
-``` ruby
-class User < ActiveRecord::Base
- recommends :movies, :shows, :other_things
-
- # ...
-end
-```
-
-That's it! Please note, however, that you may only do this in one model at this time.
-
-For more details on how to use Recommendable once it's installed and configured, [check out the more detailed README][recommendable] or see the [documentation][documentation].
-
-Installing Redis
-----------------
-
-Recommendable requires Redis to deliver recommendations. The collaborative filtering logic is based almost entirely on set math, and Redis is blazing fast for this. _NOTE: Your redis database MUST be persistent._
-
-### Mac OS X
-
-For Mac OS X users, homebrew is by far the easiest way to install Redis. Make sure to read the caveats after installation!
-
-``` bash
-$ brew install redis
-```
-
-### Linux
-
-For Linux users, there is a package on apt-get.
-
-``` bash
-$ sudo apt-get install redis-server
-$ redis-server
-```
-
-Redis will now be running on localhost:6379. After a second, you can hit `ctrl-\` to detach and keep Redis running in the background.
-
-### Redis problems?
-
-Oops, did you kill your Redis database? Not to worry. Likes, Dislikes, Ignores,
-and StashedItems are stored as models in your regular database. As long as these
-still exist, you can regenerate the similarity values and recommendations on the
-fly. But try not to have to do it!
-
-``` ruby
-User.all.each do |user|
- user.send :update_similarities
- user.send :update_recommendations
-end
-```
-
-Why not stars?
---------------
-I'll let Randall Munroe of [XKCD](http://xkcd.com/) take this one for me:
-
-[![I got lost and wandered into the world's creepiest cemetery, where the headstones just had names and star ratings. Freaked me out. When I got home I tried to leave the cemetery a bad review on Yelp, but as my hand hovered over the 'one star' button I felt this distant chill ...](http://imgs.xkcd.com/comics/star_ratings.png)](http://xkcd.com/1098/)
-
-Contributing to recommendable
------------------------------
-
-Once you've made your great commits:
-
-1. [Fork][forking] recommendable
-2. Create a feature branch
-3. Write your code (and tests please)
-4. Push to your branch's origin
-5. Create a [Pull Request][pull requests] from your branch
-6. That's it!
-
-Links
------
-* Code: `git clone git://github.com/davidcelis/recommendable.git`
-* Home: <http://github.com/davidcelis/recommendable>
-* Docs: <http://rubydoc.info/gems/recommendable/frames>
-* Bugs: <http://github.com/davidcelis/recommendable/issues>
-* Gems: <http://rubygems.org/gems/recommendable>
-
-Copyright
----------
-
-Copyright © 2012 David Celis. See LICENSE.txt for
-further details.
-
-[stars]: http://davidcelis.com/blog/2012/02/01/why-i-hate-five-star-ratings/
-[sidekiq]: https://github.com/mperham/sidekiq
-[sidekiq-middleware]: https://github.com/krasnoukhov/sidekiq-middleware
-[delayed_job]: https://github.com/tobi/delayed_job
-[resque]: https://github.com/defunkt/resque
-[resque-loner]: https://github.com/jayniz/resque-loner
-[forking]: http://help.github.com/forking/
-[pull requests]: http://help.github.com/pull-requests/
-[collaborative filtering]: http://davidcelis.com/blog/2012/02/07/collaborative-filtering-with-likes-and-dislikes/
-[recommendable]: http://davidcelis.github.com/recommendable/
-[documentation]: http://rubydoc.info/gems/recommendable/frames
View
143 README.md
@@ -0,0 +1,143 @@
+# Recommendable [![Build Status](https://secure.travis-ci.org/davidcelis/recommendable.png)](http://travis-ci.org/davidcelis/recommendable)
+
+Recommendable is a gem that allows you to quickly add a recommendation engine for Likes and Dislikes to your Ruby application using my version of [Jaccardian similarity and memory-based collaborative filtering][collaborative filtering].
+
+## Requirements
+
+* Ruby 1.8.7 or 1.9.x
+* ActiveRecord, DataMapper, Mongoid, or MongoMapper (your models must have an `id` field)
+* Sidekiq, Resque, DelayedJob (optional but highly recommended)
+
+Bundling one of the queueing systems above is highly recommended to avoid having to manually refresh users' recommendations. If running on Rails 4, the built-in queueing system is supported. If you bundle [Sidekiq][sidekiq], [Resque][resque], or [DelayedJob][delayed_job], Recommendable will use your bundled queueing system instead. If bundling Sidekiq, you should also include ['sidekiq-middleware'][sidekiq-middleware] in your Gemfile to ensure that a user will not get enqueued more than once at a time. If bundling Resque, you should include ['resque-loner'][resque-loner] for this. As far as I know, there is no current way to avoid duplicate jobs in DelayedJob.
+
+## Installation
+
+Add the following to your application's `Gemfile`:
+
+``` ruby
+ gem 'recommendable'
+```
+
+For correct detection of your ORM, you may need to place Recommendable below your ORM in the Gemfile.
+
+After bundling, you should configure Recommendable. Do this somewhere after you've required it, but before it's actually used. For example, Rails users would create an initializer (`config/initializers/recommendable.rb`):
+
+``` ruby
+require 'redis'
+
+Recommendable.configure do |config|
+ # Recommendable's connection to Redis
+ config.redis = Redis.new(:host => 'localhost', :port => 6379, :db => 0)
+
+ # A prefix for all keys Recommendable uses
+ config.redis_namespace = :recommendable
+
+ # Whether or not to automatically enqueue users to have their recommendations
+ # refreshed after they like/dislike an item
+ config.auto_enqueue = true
+
+ # The name of the queue that background jobs will be placed in
+ config.queue_name = :recommendable
+
+ # The number of nearest neighbors (k-NN) to check when updating
+ # recommendations for a user. Set to `nil` if you want to check all
+ # other users as opposed to a subset of the nearest ones.
+ config.nearest_neighbors = nil
+end
+```
+
+The values listed above are the defaults. I recommend playing around with the `nearest_neighbors` setting. A higher value will provide more accurate recommendations at the cost of more time spent generating them.
+
+## Usage
+
+In your model that will be receiving recommendations:
+
+``` ruby
+class User
+ recommends :movies, :books, :minerals, :other_things
+
+ # ...
+end
+```
+
+To ensure that users' recommendations are processed after they rate items, make sure your bundled queue system is running:
+
+``` bash
+# sidekiq
+$ [bundle exec] sidekiq -q recommendable
+# resque
+$ QUEUE=recommendable [bundle exec] rake environment resque:work
+# delayed_job
+$ [bundle exec] rake jobs:work
+```
+
+That's it! Please note, however, that currently only one model may receive recommendations.
+
+For more details on how to use Recommendable in your application, [check out the detailed guide][recommendable] or see the [documentation][documentation].
+
+## Installing Redis
+
+Recommendable requires Redis to deliver recommendations. The collaborative filtering logic is based almost entirely on set math, and Redis is blazing fast for this.
+
+_NOTE: Your Redis database **MUST** be persistent. All ratings are stored permanently in Redis. If you're worried about Redis losing data, keep backups._
+
+### Mac OS X
+
+For Mac OS X users, homebrew is by far the easiest way to install Redis. Make sure to read the caveats after installation!
+
+``` bash
+$ brew install redis
+```
+
+### Linux
+
+For Linux users, there is a package on apt-get.
+
+``` bash
+$ sudo apt-get install redis-server
+$ redis-server
+```
+
+Redis will now be running on localhost:6379. After a second, you can hit `ctrl-\` to detach and keep Redis running in the background.
+
+## Why not stars?
+
+I'll let Randall Munroe of [XKCD](http://xkcd.com/) take this one for me:
+
+[![I got lost and wandered into the world's creepiest cemetery, where the headstones just had names and star ratings. Freaked me out. When I got home I tried to leave the cemetery a bad review on Yelp, but as my hand hovered over the 'one star' button I felt this distant chill ...](http://imgs.xkcd.com/comics/star_ratings.png)](http://xkcd.com/1098/)
+
+## Contributing to recommendable
+
+Once you've made your great commits:
+
+1. [Fork][forking] recommendable
+2. Create a feature branch
+3. Write your code (and tests please)
+4. Push to your branch's origin
+5. Create a [Pull Request][pull requests] from your branch
+6. That's it!
+
+## Links
+
+* Code: `git clone git://github.com/davidcelis/recommendable.git`
+* Home: <http://github.com/davidcelis/recommendable>
+* Docs: <http://rubydoc.info/gems/recommendable/frames>
+* Bugs: <http://github.com/davidcelis/recommendable/issues>
+* Gems: <http://rubygems.org/gems/recommendable>
+
+## Copyright
+
+Copyright © 2012 David Celis. See LICENSE.txt for
+further details.
+
+[stars]: http://davidcelis.com/blog/2012/02/01/why-i-hate-five-star-ratings/
+[sidekiq]: https://github.com/mperham/sidekiq
+[sidekiq-middleware]: https://github.com/krasnoukhov/sidekiq-middleware
+[delayed_job]: https://github.com/tobi/delayed_job
+[resque]: https://github.com/defunkt/resque
+[resque-loner]: https://github.com/jayniz/resque-loner
+[forking]: http://help.github.com/forking/
+[pull requests]: http://help.github.com/pull-requests/
+[collaborative filtering]: http://davidcelis.com/blog/2012/02/07/collaborative-filtering-with-likes-and-dislikes/
+[recommendable]: http://davidcelis.github.com/recommendable/
+[documentation]: http://rubydoc.info/gems/recommendable/frames
View
25 Rakefile
@@ -1,23 +1,12 @@
-# encoding: utf-8
-
-require 'rubygems'
-require 'bundler'
-
-begin
- Bundler.setup(:default, :development)
-rescue Bundler::BundlerError => e
- $stderr.puts e.message
- $stderr.puts "Run `bundle install` to install missing gems"
- exit e.status_code
-end
+#!/usr/bin/env rake
+require 'rake/testtask'
-require 'rake'
+task :default => :test
-require 'rake/testtask'
-Rake::TestTask.new(:test) do |test|
- test.libs << 'lib' << 'spec'
- test.pattern = 'spec/**/*_spec.rb'
- test.verbose = true
+Rake::TestTask.new(:test) do |t|
+ t.libs << 'test'
+ t.pattern = 'test/**/*_test.rb'
+ t.verbose = true
end
task :default => :test
View
5 TODO
@@ -1,5 +0,0 @@
-= TODO
-
-* Support Mongoid (and potentially other ORMs)
-* Allow the option NOT to queue up on like/dislike/ignore?
-* Don't destroy sets. Can't do that with concurrency. Shit.
View
19 app/models/recommendable/dislike.rb
@@ -1,19 +0,0 @@
-module Recommendable
- class Dislike < ActiveRecord::Base
- self.table_name = 'recommendable_dislikes'
- attr_accessible :user_id, :dislikeable_id, :dislikeable_type
-
- belongs_to :user, :class_name => Recommendable.user_class.to_s, :foreign_key => :user_id, :counter_cache => :dislikes_count
- belongs_to :dislikeable, :polymorphic => true, :counter_cache => :dislikes_count
-
- validates :user_id, :uniqueness => { :scope => [:dislikeable_id, :dislikeable_type],
- :message => "has already disliked this item" },
- :presence => true
- validates_presence_of :dislikeable_id
- validates_presence_of :dislikeable_type
-
- def dislikeable_type=(sType)
- super sType.to_s.classify.constantize.base_class.to_s
- end
- end
-end
View
19 app/models/recommendable/ignore.rb
@@ -1,19 +0,0 @@
-module Recommendable
- class Ignore < ActiveRecord::Base
- self.table_name = 'recommendable_ignores'
- attr_accessible :user_id, :ignorable_id, :ignorable_type
-
- belongs_to :user, :class_name => Recommendable.user_class.to_s, :foreign_key => :user_id
- belongs_to :ignorable, :polymorphic => true
-
- validates :user_id, :uniqueness => { :scope => [:ignorable_id, :ignorable_type],
- :message => "has already ignored this item" },
- :presence => true
- validates_presence_of :ignorable_id
- validates_presence_of :ignorable_type
-
- def ignorable_type=(sType)
- super sType.to_s.classify.constantize.base_class.to_s
- end
- end
-end
View
19 app/models/recommendable/like.rb
@@ -1,19 +0,0 @@
-module Recommendable
- class Like < ActiveRecord::Base
- self.table_name = 'recommendable_likes'
- attr_accessible :user_id, :likeable_id, :likeable_type
-
- belongs_to :user, :class_name => Recommendable.user_class.to_s, :foreign_key => :user_id, :counter_cache => :likes_count
- belongs_to :likeable, :polymorphic => true, :foreign_key => :likeable_id, :counter_cache => :likes_count
-
- validates :user_id, :uniqueness => { :scope => [:likeable_id, :likeable_type],
- :message => "has already liked this item" },
- :presence => true
- validates_presence_of :likeable_id
- validates_presence_of :likeable_type
-
- def likeable_type=(sType)
- super sType.to_s.classify.constantize.base_class.to_s
- end
- end
-end
View
19 app/models/recommendable/stash.rb
@@ -1,19 +0,0 @@
-module Recommendable
- class Stash < ActiveRecord::Base
- self.table_name = 'recommendable_stashes'
- attr_accessible :user_id, :stashable_id, :stashable_type
-
- belongs_to :user, :class_name => Recommendable.user_class.to_s, :foreign_key => :user_id
- belongs_to :stashable, :polymorphic => :true
-
- validates :user_id, :uniqueness => { :scope => [:stashable_id, :stashable_type],
- :message => "has already stashed this item" },
- :presence => true
- validates_presence_of :stashable_id
- validates_presence_of :stashable_type
-
- def stashable_type=(sType)
- super sType.to_s.classify.constantize.base_class.to_s
- end
- end
-end
View
17 app/workers/recommendable/delayed_job_worker.rb
@@ -1,17 +0,0 @@
-module Recommendable
- if defined?(Delayed::Job)
- class DelayedJobWorker
- attr_accessor :user_id
-
- def initialize(user_id)
- @user_id = user_id
- end
-
- def perform
- user = Recommendable.user_class.find(self.user_id)
- user.send :update_similarities
- user.send :update_recommendations
- end
- end
- end
-end
View
17 app/workers/recommendable/rails_worker.rb
@@ -1,17 +0,0 @@
-module Recommendable
- if defined?(Rails::Queueing)
- class RailsWorker
- attr_accessor :user_id
-
- def initialize(user_id)
- @user_id = user_id
- end
-
- def run
- user = Recommendable.user_class.find(self.user_id)
- user.send :update_similarities
- user.send :update_recommendations
- end
- end
- end
-end
View
14 app/workers/recommendable/resque_worker.rb
@@ -1,14 +0,0 @@
-module Recommendable
- if defined?(Resque)
- class ResqueWorker
- include Resque::Plugins::UniqueJob if defined?(Resque::Plugins::UniqueJob)
- @queue = :recommendable
-
- def self.perform(user_id)
- user = Recommendable.user_class.find(user_id)
- user.send :update_similarities
- user.send :update_recommendations
- end
- end
- end
-end
View
14 app/workers/recommendable/sidekiq_priority_worker.rb
@@ -1,14 +0,0 @@
-module Recommendable
- if defined?(Sidekiq)
- class SidekiqPriorityWorker
- include ::Sidekiq::Worker
- sidekiq_options :unique => true, :queue => :recommendable_priority
-
- def perform(user_id)
- user = Recommendable.user_class.find(user_id)
- user.send :update_similarities, :priority => true
- user.send :update_recommendations, :priority => true
- end
- end
- end
-end
View
14 app/workers/recommendable/sidekiq_worker.rb
@@ -1,14 +0,0 @@
-module Recommendable
- if defined?(Sidekiq)
- class SidekiqWorker
- include ::Sidekiq::Worker
- sidekiq_options :unique => true, :queue => :recommendable
-
- def perform(user_id)
- user = Recommendable.user_class.find(user_id)
- user.send :update_similarities
- user.send :update_recommendations
- end
- end
- end
-end
View
3  config/routes.rb
@@ -1,3 +0,0 @@
-Recommendable::Engine.routes.draw do
-
-end
View
17 db/migrate/20120124193723_create_likes.rb
@@ -1,17 +0,0 @@
-class CreateLikes < ActiveRecord::Migration
- def up
- create_table :recommendable_likes do |t|
- t.references :user
- t.references :likeable, :polymorphic => true
- t.timestamps
- end
-
- add_index :recommendable_likes, :likeable_id
- add_index :recommendable_likes, :likeable_type
- add_index :recommendable_likes, [:user_id, :likeable_id, :likeable_type], :unique => true, :name => "user_like_constraint"
- end
-
- def down
- drop_table :recommendable_likes
- end
-end
View
17 db/migrate/20120124193728_create_dislikes.rb
@@ -1,17 +0,0 @@
-class CreateDislikes < ActiveRecord::Migration
- def up
- create_table :recommendable_dislikes do |t|
- t.references :user
- t.references :dislikeable, :polymorphic => true
- t.timestamps
- end
-
- add_index :recommendable_dislikes, :dislikeable_id
- add_index :recommendable_dislikes, :dislikeable_type
- add_index :recommendable_dislikes, [:user_id, :dislikeable_id, :dislikeable_type], :unique => true, :name => "user_dislike_constraint"
- end
-
- def down
- drop_table :recommendable_dislikes
- end
-end
View
17 db/migrate/20120127092558_create_ignores.rb
@@ -1,17 +0,0 @@
-class CreateIgnores < ActiveRecord::Migration
- def up
- create_table :recommendable_ignores do |t|
- t.references :user
- t.references :ignorable, :polymorphic => true
- t.timestamps
- end
-
- add_index :recommendable_ignores, :ignorable_id
- add_index :recommendable_ignores, :ignorable_type
- add_index :recommendable_ignores, [:user_id, :ignorable_id, :ignorable_type], :unique => true, :name => "user_ignore_constraint"
- end
-
- def down
- drop_table :recommendable_ignores
- end
-end
View
17 db/migrate/20120131173909_create_stashes.rb
@@ -1,17 +0,0 @@
-class CreateStashes < ActiveRecord::Migration
- def up
- create_table :recommendable_stashes do |t|
- t.references :user
- t.references :stashable, :polymorphic => true
- t.timestamps
- end
-
- add_index :recommendable_stashes, :stashable_id
- add_index :recommendable_stashes, :stashable_type
- add_index :recommendable_stashes, [:user_id, :stashable_id, :stashable_type], :unique => true, :name => "user_stashed_constraint"
- end
-
- def down
- drop_table :recommendable_stashes
- end
-end
View
8 lib/generators/recommendable/USAGE
@@ -1,8 +0,0 @@
-Description:
- This generator will install Recommendable's initializer.rb file and migrate the Like and Dislike tables into your database unless specified.
-
-Example:
- rails generate recommendable:install
-
- This will create:
- config/initializers/recommendable.rb
View
37 lib/generators/recommendable/install_generator.rb
@@ -1,37 +0,0 @@
-require 'rails/generators'
-
-module Recommendable
- module Generators
- class InstallGenerator < Rails::Generators::Base
- class_option :no_migrate, :type => :boolean, :default => false, :desc => "Skip migrations. The Like and Dislike tables will not be created."
-
- source_root File.expand_path("../templates", __FILE__)
-
- def add_recommendable_initializer
- path = "#{Rails.root}/config/initializers/recommendable.rb"
- if File.exists?(path)
- puts "Skipping config/initializers/recommendable.rb creation; file already exists!"
- else
- puts "Adding Recommendable initializer (config/initializers/recommendable.rb)"
- template "initializer.rb", path
- end
- end
-
- def install_migrations
- puts "Copying migrations..."
- Dir.chdir(Rails.root) { puts `rake recommendable:install:migrations` }
- end
-
- def run_migrations
- unless options[:no_migrate]
- puts "Running rake db:migrate"
- puts `rake db:migrate`
- end
- end
-
- def finished
- puts "Done! Recommendable has been successfully installed. Please configure it in config/intializers/recommendable.rb"
- end
- end
- end
-end
View
23 lib/generators/recommendable/templates/initializer.rb
@@ -1,23 +0,0 @@
-require "redis"
-
-Recommendable.configure do |config|
- # Recommendable requires a connection to a running redis-server. Create a new
- # instance based on a host/port or UNIX socket, or pass in an existing client.
- config.redis = Redis.new(:host => 'localhost', :port => 6379)
-
- # Tell Recommendable how long to wait until unnecessary keys are expired.
- # These keys point to sets that Recommendable uses internally; expiration is
- # to avoid a set being destroyed while another background worker is using it.
- # You should set this value to how long it takes for a worker process to complete.
- # Set to false if you do not wish to expire these keys. Set to :destroy if they
- # can immediately be torn down (you do not have concurrent workers)
- config.expire_keys_in = 1.hour
-
- # Configure the name of your Sidekiq or Resque queue
- config.queue_name = :recommendable
-
- # Automatically enqueue users to have their recommendations refreshed after
- # liking/disliking an object. To manually do this, you can use
- # Recommendable.enqueue(user_id)
- config.auto_enqueue = true
-end
View
64 lib/recommendable.rb
@@ -1,40 +1,46 @@
+require 'active_support'
+
+require 'recommendable/version'
require 'recommendable/configuration'
-require 'recommendable/engine'
require 'recommendable/helpers'
-require 'recommendable/acts_as_recommended_to'
-require 'recommendable/acts_as_recommendable'
-require 'recommendable/exceptions'
-require 'recommendable/railtie' if defined?(Rails)
-require 'recommendable/version'
-require 'hooks'
+
+require 'recommendable/rater'
+require 'recommendable/ratable'
module Recommendable
- mattr_writer :recommendable_classes
- mattr_accessor :user_class
+ class << self
+ def redis() config.redis end
- def self.recommendable_classes
- @@recommendable_classes ||= []
- end
+ def query(klass, ids)
+ Recommendable::Helpers::Queriers.send(Recommendable.config.orm, klass, ids)
+ rescue NoMethodError
+ warn 'Your ORM is not currently supported. Please open an issue at https://github.com/davidcelis/recommendable'
+ end
- def self.enqueue(user_id, options={})
- defaults = { :priority => false }
- options = defaults.merge(options)
+ def enqueue(user_id)
+ user_id = user_id.id if user_id.is_a?(Recommendable.config.user_class)
- if defined? Sidekiq
- if options[:priority]
- SidekiqPriorityWorker.perform_async user_id
- else
- SidekiqWorker.perform_async user_id
- end
- elsif defined? Resque
- Resque.enqueue ResqueWorker, user_id
- elsif defined? Delayed::Job
- Delayed::Job.enqueue DelayedJobWorker.new(user_id)
- elsif defined? Rails::Queueing
- unless Rails.queue.any? { |w| w.user_id == user_id }
- Rails.queue.push RailsWorker.new(user_id)
- Rails.application.queue_consumer.start
+ if defined?(::Sidekiq)
+ require 'recommendable/workers/sidekiq'
+ Recommendable::Workers::Sidekiq.perform_async(user_id)
+ elsif defined?(::Resque)
+ require 'recommendable/workers/resque'
+ Resque.enqueue(Recommendable::Workers::Resque, user_id)
+ elsif defined?(::Delayed::Job)
+ require 'recommendable/workers/delayed_job'
+ Delayed::Job.enqueue(Recommendable::Workers::DelayedJob.new(user_id))
+ elsif defined?(::Rails::Queueing)
+ require 'recommendable/workers/rails'
+ unless Rails.queue.any? { |w| w.user_id == user_id }
+ Rails.queue.push(Recommendable::Workers::Rails.new(user_idid))
+ Rails.application.queue_consumer.start
+ end
end
end
end
end
+
+require 'recommendable/orm/active_record' if defined?(ActiveRecord::Base)
+require 'recommendable/orm/data_mapper' if defined?(DataMapper::Resource)
+require 'recommendable/orm/mongoid' if defined?(Mongoid::Document)
+require 'recommendable/orm/mongo_mapper' if defined?(MongoMapper::Document)
View
170 lib/recommendable/acts_as_recommendable.rb
@@ -1,170 +0,0 @@
-module Recommendable
- module ActsAsRecommendable
- extend ActiveSupport::Concern
-
- module ClassMethods
- def acts_as_recommendable
- class_eval do
- Recommendable.recommendable_classes << self
-
- has_many :recommendable_likes, :as => :likeable, :dependent => :destroy,
- :class_name => "Recommendable::Like"
- has_many :recommendable_dislikes, :as => :dislikeable, :dependent => :destroy,
- :class_name => "Recommendable::Dislike"
- has_many :recommendable_ignores, :as => :ignorable, :dependent => :destroy,
- :class_name => "Recommendable::Ignore"
- has_many :recommendable_stashes, :as => :stashable, :dependent => :destroy,
- :class_name => "Recommendable::Stash"
-
- has_many :liked_by, :through => :recommendable_likes, :source => :user,
- :foreign_key => :user_id, :class_name => Recommendable.user_class.to_s
- has_many :disliked_by, :through => :recommendable_dislikes, :source => :user,
- :foreign_key => :user_id, :class_name => Recommendable.user_class.to_s
-
- include LikeableMethods
- include DislikeableMethods
-
- before_destroy :remove_from_scores, :remove_from_recommendations
-
- def self.acts_as_recommendable?() true end
-
- def been_rated?
- likes_count + dislikes_count > 0
- end
-
- # Returns an array of users that have liked or disliked this item.
- # @return [Array] an array of users
- def rated_by
- liked_by + disliked_by
- end
-
- def self.top count = 1, options = {}
- defaults = { :offset => 0 }
- options = defaults.merge options
-
- ids = Recommendable.configuration.redis.zrevrange(self.score_set, options[:offset], options[:offset] + count - 1).map(&:to_i)
-
- items = self.find ids
- return items.first if count == 1
-
- return items.sort_by { |item| ids.index(item.id) }
- end
-
- private
-
- def update_score
- return 0 unless been_rated?
-
- z = 1.96
- n = likes_count + dislikes_count
-
- phat = likes_count / n.to_f
- begin
- score = (phat + z*z/(2*n) - z * Math.sqrt((phat*(1-phat)+z*z/(4*n))/n))/(1+z*z/n)
- rescue Math::DomainError
- score = 0
- end
-
- Recommendable.configuration.redis.zadd self.class.score_set, score, self.id
- true
- end
-
- def remove_from_scores
- Recommendable.configuration.redis.zrem self.class.score_set, self.id
- true
- end
-
- def remove_from_recommendations
- Recommendable.user_class.find_each do |user|
- user.send :completely_unrecommend, self
- end
- end
-
- # Used for setup purposes. Calls convenience methods to create sets
- # in redis of users that both like and dislike this object.
- # @return [Array] an array containing the liked_by set and the disliked_by set
- # @private
- def create_recommendable_sets
- [create_liked_by_set, create_disliked_by_set]
- end
-
- # Used for teardown purposes. Destroys the sets in redis created by
- # {#create_recommendable_sets}
- # @private
- def destroy_recommendable_sets(force = false)
- return unless Recommendable.configuration.expire_keys_in == :destroy || force
- Recommendable.configuration.redis.del "#{self.class.base_class}:#{id}:liked_by"
- Recommendable.configuration.redis.del "#{self.class.base_class}:#{id}:disliked_by"
- end
-
- # Returns an array of IDs of users that have liked or disliked this item.
- # @return [Array] an array of user IDs
- # @private
- def rates_by
- recommendable_likes.map(&:user_id) + recommendable_dislikes.map(&:user_id)
- end
-
- def self.score_set
- "#{self}:sorted"
- end
-
- private :recommendable_likes, :recommendable_dislikes,
- :recommendable_ignores, :recommendable_stashes
- end
- end
-
- def acts_as_recommendable?() false end
-
- def sti?
- self.base_class != self && self.base_class.table_name == self.table_name
- end
-
- private
- end
-
- # Instance methods.
- def recommendable?() self.class.acts_as_recommendable? end
-
- def redis_key() "#{self.class.base_class}:#{id}" end
-
- protected :redis_key
-
- module LikeableMethods
- private
-
- # Used for setup purposes. Creates a set in redis containing users that
- # have liked this object.
- # @private
- # @return [String] the key in Redis pointing to the set
- def create_liked_by_set
- set = "#{redis_key}:liked_by"
- liked_by.each { |rater| Recommendable.configuration.redis.sadd set, rater.id }
-
- if Recommendable.configuration.expire_keys_in.is_a?(Numeric)
- Recommendable.configuration.redis.expire(set, Recommendable.configuration.expire_keys_in)
- end
-
- return set
- end
- end
-
- module DislikeableMethods
- private
-
- # Used for setup purposes. Creates a set in redis containing users that
- # have disliked this object.
- # @private
- # @return [String] the key in Redis pointing to the set
- def create_disliked_by_set
- set = "#{redis_key}:disliked_by"
- disliked_by.each { |rater| Recommendable.configuration.redis.sadd set, rater.id }
-
- if Recommendable.configuration.expire_keys_in.is_a?(Numeric)
- Recommendable.configuration.redis.expire(set, Recommendable.configuration.expire_keys_in)
- end
-
- return set
- end
- end
- end
-end
View
819 lib/recommendable/acts_as_recommended_to.rb
@@ -1,819 +0,0 @@
-require 'active_support/concern'
-
-module Recommendable
- module ActsAsRecommendedTo
- include Recommendable::Helpers
- extend ActiveSupport::Concern
-
- module ClassMethods
- def recommends *things
- acts_as_recommended_to
- things.each { |thing| thing.to_s.classify.constantize.acts_as_recommendable }
- end
-
- def acts_as_recommended_to
- class_eval do
- Recommendable.user_class = self
-
- has_many :likes, :class_name => "Recommendable::Like", :dependent => :destroy, :foreign_key => :user_id
- has_many :dislikes, :class_name => "Recommendable::Dislike", :dependent => :destroy, :foreign_key => :user_id
- has_many :ignores, :class_name => "Recommendable::Ignore", :dependent => :destroy, :foreign_key => :user_id
- has_many :stashed_items, :class_name => "Recommendable::Stash", :dependent => :destroy, :foreign_key => :user_id
-
- include LikeMethods
- include DislikeMethods
- include StashMethods
- include IgnoreMethods
- include RecommendationMethods
- include Hooks
-
- before_destroy :remove_from_similarities, :remove_recommendations
-
- define_hooks :before_like, :after_like, :before_unlike, :after_unlike,
- :before_dislike, :after_dislike, :before_undislike, :after_undislike,
- :before_stash, :after_stash, :before_unstash, :after_unstash,
- :before_ignore, :after_ignore, :before_unignore, :after_unignore
-
- %w(like dislike ignore).each do |action|
- send "before_#{action}", lambda { |obj| completely_unrecommend obj }
- end
-
- %w(like unlike dislike undislike).each do |action|
- send "after_#{action}", lambda { |obj|
- obj.reload.send(:update_score)
- if Recommendable.configuration.auto_enqueue
- if (likes_count + dislikes_count) < 10
- Recommendable.enqueue(self.id, :priority => true)
- else
- Recommendable.enqueue(self.id)
- end
- end
- }
- end
-
- before_stash { |obj| unignore(obj) and unpredict(obj) }
-
- def method_missing method, *args, &block
- if method.to_s =~ /^(liked|disliked)_(.+)_in_common_with$/
- begin
- super unless $2.classify.constantize.acts_as_recommendable?
-
- self.send "#{$1}_in_common_with", *args, { :class => $2.classify.constantize }
- rescue NameError
- super
- end
- elsif method.to_s =~ /^(liked|disliked|ignored|stashed|recommended)_(.+)$/
- begin
- super unless $2.classify.constantize.acts_as_recommendable?
-
- self.send "#{$1}_for", $2.classify.constantize, *args
- rescue NameError
- super
- end
- else
- super
- end
- end
-
- def respond_to? method, include_private = false
- if method.to_s =~ /^(liked|disliked|ignored|stashed|recommended)_(.+)$/ || method.to_s =~ /^common_(liked|disliked)_(.+)_with$/
- begin
- $2.classify.constantize.acts_as_recommendable?
- rescue NameError
- false
- end
- else
- super
- end
- end
-
- private :likes, :dislikes, :ignores, :stashed_items
- end
- end
- end
-
- module LikeMethods
- # Creates a Recommendable::Like to associate self to a passed object. If
- # self is currently found to have disliked object, the corresponding
- # Recommendable::Dislike will be destroyed. It will also be removed from
- # the user's stash or ignores.
- #
- # @param [Object] object the object you want self to like.
- # @return true if object has been liked
- # @raise [UnrecommendableError] if you have not declared the passed object's model to `act_as_recommendable`
- def like object
- raise UnrecommendableError unless object.recommendable?
- return if likes? object
-
- run_hook :before_like, object
- likes.create! :likeable_id => object.id, :likeable_type => object.class
- run_hook :after_like, object
-
- true
- end
-
- # Checks to see if self has already liked a passed object.
- #
- # @param [Object] object the object you want to check
- # @return true if self likes object, false if not
- def likes? object
- likes.exists? :likeable_id => object.id, :likeable_type => object.class.base_class.to_s
- end
-
- # Destroys a Recommendable::Like currently associating self with object
- #
- # @param [Object] object the object you want to remove from self's likes
- # @return true if object is unliked, nil if nothing happened
- def unlike object
- like = likes.where(:likeable_id => object.id, :likeable_type => object.class.base_class.to_s)
- if like.exists?
- run_hook :before_unlike, object
- like.first.destroy
- run_hook :after_unlike, object
- true
- end
- end
-
- # Get a list of records that self currently likes
-
- # @return [Array] an array of ActiveRecord objects that self has liked
- def liked
- Recommendable.recommendable_classes.map { |klass| liked_for klass }.flatten
- end
-
- private
-
- # Get a list of records belonging to a passed class that self currently
- # likes.
- #
- # @param [Class, String, Symbol] klass the class of records. Can be the class constant, or a String/Symbol representation of the class name.
- # @return [ActiveRecord::Relation] an ActiveRecord::Relation of records that self has liked
- def liked_for klass
- ids = if klass.sti?
- likes.joins(manual_join(klass, 'like')).map(&:likeable_id)
- else
- likes.where(:likeable_type => klass.to_s).map(&:likeable_id)
- end
-
- klass.where('ID IN (?)', ids)
- end
-
- # Get a list of Recommendable::Likes with a `#likeable_type` of the passed
- # class.
- #
- # @param [Class, String, Symbol] klass the class for which you would like to return self's likes. Can be the class constant, or a String/Symbol representation of the class name.
- # @note You should not need to use this method. (see {#liked_for})
- # @private
- def likes_for klass
- if klass.sti?
- likes.joins manual_join(klass, 'like')
- else
- likes.where(:likeable_type => klass.to_s)
- end
- end
- end
-
- module DislikeMethods
- # Creates a Recommendable::Dislike to associate self to a passed object. If
- # self is currently found to have liked object, the corresponding
- # Recommendable::Like will be destroyed. It will also be removed from the
- # user's stash or list of ignores.
- #
- # @param [Object] object the object you want self to dislike.
- # @return true if object has been disliked
- # @raise [UnrecommendableError] if you have not declared the passed object's model to `act_as_recommendable`
- def dislike object
- raise UnrecommendableError unless object.recommendable?
- return if dislikes? object
-
- run_hook :before_dislike, object
- dislikes.create! :dislikeable_id => object.id, :dislikeable_type => object.class
- run_hook :after_dislike, object
-
- true
- end
-
- # Checks to see if self has already disliked a passed object.
- #
- # @param [Object] object the object you want to check
- # @return true if self dislikes object, false if not
- def dislikes? object
- dislikes.exists? :dislikeable_id => object.id, :dislikeable_type => object.class.base_class.to_s
- end
-
- # Destroys a Recommendable::Dislike currently associating self with object
- #
- # @param [Object] object the object you want to remove from self's dislikes
- # @return true if object is removed from self's dislikes, nil if nothing happened
- def undislike object
- dislike = dislikes.where(:dislikeable_id => object.id, :dislikeable_type => object.class.base_class.to_s)
- if dislike.exists?
- run_hook :before_undislike, object
- dislike.first.destroy
- run_hook :after_undislike, object
- true
- end
- end
-
- # Get a list of records that self currently dislikes
-
- # @return [Array] an array of ActiveRecord objects that self has disliked
- def disliked
- Recommendable.recommendable_classes.map { |klass| disliked_for klass }.flatten
- end
-
- private
-
- # Get a list of records belonging to a passed class that self currently
- # dislikes.
- #
- # @param [Class, String, Symbol] klass the class of records. Can be the class constant, or a String/Symbol representation of the class name.
- # @return [ActiveRecord::Relation] an ActiveRecord::Relation of records that self has disliked
- def disliked_for klass
- ids = if klass.sti?
- dislikes.joins(manual_join(klass, 'dislike')).map(&:dislikeable_id)
- else
- dislikes.where(:dislikeable_type => klass.to_s).map(&:dislikeable_id)
- end
-
- klass.where('ID IN (?)', ids)
- end
-
- # Get a list of Recommendable::Dislikes with a `#dislikeable_type` of the
- # passed class.
- #
- # @param [Class, String, Symbol] klass the class for which you would like to return self's dislikes. Can be the class constant, or a String/Symbol representation of the class name.
- # @note You should not need to use this method. (see {#disliked_for})
- # @private
- def dislikes_for klass
- if klass.sti?
- dislikes.joins manual_join(klass, 'dislike')
- else
- dislikes.where(:dislikeable_type => klass.to_s)
- end
- end
- end
-
- module StashMethods
- # Creates a Recommendable::Stash to associate self to a passed object.
- # This will remove the item from this user's recommendations.
- # If self is currently found to have liked or disliked the object, nothing
- # will happen. It will, however, be unignored.
- #
- # @param [Object] object the object you want self to stash.
- # @return true if object has been stashed
- # @raise [UnrecommendableError] if you have not declared the passed object's model to `act_as_recommendable`
- def stash object
- raise UnrecommendableError unless object.recommendable?
- return if rated?(object) || stashed?(object)
-
- run_hook :before_stash, object
- stashed_items.create! :stashable_id => object.id, :stashable_type => object.class
- run_hook :after_stash, object
-
- true
- end
-
- # Checks to see if self has already stashed a passed object for later.
- #
- # @param [Object] object the object you want to check
- # @return true if self has stashed object, false if not
- def stashed? object
- stashed_items.exists? :stashable_id => object.id, :stashable_type => object.class.base_class.to_s
- end
-
- # Destroys a Recommendable::Stash currently associating self with object
- #
- # @param [Object] object the object you want to remove from self's stash
- # @return true if object is stashed, nil if nothing happened
- def unstash object
- stash = stashed_items.where(:stashable_id => object.id, :stashable_type => object.class.base_class.to_s)
- if stash.exists?
- run_hook :before_unstash, object
- stash.first.destroy
- run_hook :after_unstash, object
- true
- end
- end
-
- # Get a list of records that self has currently stashed for later
- #
- # @return [Array] an array of ActiveRecord objects that self has stashed
- def stashed
- Recommendable.recommendable_classes.map { |klass| stashed_for klass }.flatten
- end
-
- private
-
- # Get a list of records belonging to a passed class that self currently
- # has stashed away for later.
- #
- # @param [Class, String, Symbol] klass the class of records. Can be the class constant, or a String/Symbol representation of the class name.
- # @return [ActiveRecord::Relation] an ActiveRecord::Relation of records that self has stashed
- def stashed_for klass
- ids = if klass.sti?
- stashed_items.joins(manual_join(klass, 'stash')).map(&:stashable_id)
- else
- stashed_items.where(:stashable_type => klass.to_s).map(&:stashable_id)
- end
-
- klass.where('ID IN (?)', ids)
- end
- end
-
- module IgnoreMethods
- # Creates a Recommendable::Ignore to associate self to a passed object. If
- # self is currently found to have liked or dislikedobject, the
- # corresponding Recommendable::Like or Recommendable::Dislike will be
- # destroyed.
- #
- # @param [Object] object the object you want self to ignore.
- # @return true if object has been ignored
- # @raise [UnrecommendableError] if you have not declared the passed object's model to `act_as_recommendable`
- def ignore object
- raise UnrecommendableError unless object.recommendable?
- return if ignored? object
-
- run_hook :before_ignore, object
- ignores.create! :ignorable_id => object.id, :ignorable_type => object.class
- run_hook :after_ignore, object
-
- true
- end
-
- # Checks to see if self has already ignored a passed object.
- #
- # @param [Object] object the object you want to check
- # @return true if self has ignored object, false if not
- def ignored? object
- ignores.exists? :ignorable_id => object.id, :ignorable_type => object.class.base_class.to_s
- end
-
- # Destroys a Recommendable::Ignore currently associating self with object
- #
- # @param [Object] object the object you want to remove from self's ignores
- # @return true if object is removed from self's ignores, nil if nothing happened
- def unignore object
- ignore = ignores.where(:ignorable_id => object.id, :ignorable_type => object.class.base_class.to_s)
- if ignore.exists?
- run_hook :before_unignore, object
- ignore.first.destroy
- run_hook :after_unignore, object
- true
- end
- end
-
- # Get a list of records that self is currently ignoring
-
- # @return [Array] an array of ActiveRecord objects that self has ignored
- def ignored
- Recommendable.recommendable_classes.map { |klass| ignored_for klass }.flatten
- end
-
- private
-
- # Get a list of records belonging to a passed class that self is
- # currently ignoring.
- #
- # @param [Class, String, Symbol] klass the class of records. Can be the class constant, or a String/Symbol representation of the class name.
- # @return [ActiveRecord::Relation] an ActiveRecord::Relation of records that self has ignored
- def ignored_for klass
- ids = if klass.sti?
- ignores.joins(manual_join(klass, 'ignore')).map(&:ignorable_id)
- else
- ignores.where(:ignorable_type => klass.to_s).map(&:ignorable_id)
- end
-
- klass.where('ID IN (?)', ids)
- end
- end
-
- module RecommendationMethods
- # Checks to see if self has already liked or disliked a passed object.
- #
- # @param [Object] object the object you want to check
- # @return true if self has liked or disliked object, false if not
- def rated? object
- likes?(object) || dislikes?(object)
- end
-
- # Checks to see if self has liked or disliked any objects yet.
- #
- # @return true if self has liked or disliked anything, false if not
- def rated_anything?
- likes.any? || dislikes.any?
- end
-
- # Get a list of raters that have been found to be the most similar to
- # self. They are sorted in a descending fashion with the most similar
- # rater in the first index.
- #
- # @param [Hash] options the options for this query
- # @option options [Fixnum] :count (10) The number of raters to return
- # @return [Array] An array of instances of your user class
- def similar_raters options = {}
- defaults = { :count => 10 }
- options = defaults.merge options
-
- rater_ids = Recommendable.configuration.redis.zrevrange(similarity_set, 0, options[:count] - 1).map(&:to_i)
- raters = Recommendable.user_class.find rater_ids
-
- # The query loses the ordering, so...
- return raters.sort_by { |rater| rater_ids.index(rater.id) }
- end
-
- def liked_in_common_with rater, options = {}
- options.merge!({ :return_records => true })
- create_recommended_to_sets and rater.create_recommended_to_sets
- liked = common_likes_with rater, options
-
- send :destroy_recommended_to_sets
- rater.send :destroy_recommended_to_sets
-
- return liked
- end
-
- def disliked_in_common_with rater, options = {}
- options.merge!({ :return_records => true })
- create_recommended_to_sets and rater.create_recommended_to_sets
- disliked = common_dislikes_with rater, options
-
- send :destroy_recommended_to_sets
- rater.send :destroy_recommended_to_sets
-
- return disliked
- end
-
- def disagreed_on_with rater, options = {}
- options.merge!({ :return_records => true })
- create_recommended_to_sets and rater.create_recommended_to_sets
- disagreements = disagreements_with rater, options
-
- send :destroy_recommended_to_sets
- rater.send :destroy_recommended_to_sets
-
- return disagreements
- end
-
- # Get a list of recommendations for self. The whole point of this gem!
- # Recommendations are returned in a descending order with the first index
- # being the object that self has been found most likely to enjoy.
- #
- # @param [Fixnum] count the number of recmomendations to return
- # @return [Array] an array of ActiveRecord objects that are recommendable
- def recommendations count = 10
- return [] if likes.count + dislikes.count == 0
-
- unioned_predictions = "#{self.class}:#{id}:predictions"
- Recommendable.configuration.redis.zunionstore unioned_predictions, Recommendable.recommendable_classes.map { |klass| predictions_set_for klass }
-
- recommendations = Recommendable.configuration.redis.zrevrange(unioned_predictions, 0, count - 1).map do |object|
- klass, id = object.split(":")
- klass.constantize.find(id)
- end
-
- recommendations = recommendations.first if count == 1
-
- Recommendable.configuration.redis.del(unioned_predictions) and return recommendations
- end
-
- # Get a list of recommendations for self on a single recommendable type.
- # Recommendations are returned in a descending order with the first index
- # being the object that self has been found most likely to enjoy.
- #
- # @param [Class, String, Symbol] klass the class to receive recommendations for. Can be the class constant, or a String/Symbol representation of the class name.
- # @return [ActiveRecord::Relation] an ActiveRecord::Relation of recommendations
- def recommended_for klass, count = 10
- if likes_for(klass.base_class).count + dislikes_for(klass.base_class).count == 0 || Recommendable.configuration.redis.zcard(predictions_set_for(klass)) == 0
- return klass.to_s.classify.constantize.where('ID IN (?)', [])
- end
-
- ids = []
-
- (0...count).each do |i|
- prediction = Recommendable.configuration.redis.zrevrange(predictions_set_for(klass), i, i).first
- break unless prediction
-
- ids << prediction.split(":").last
- end
-
- return klass.to_s.classify.constantize.where('ID IN (?)', ids)
- end
-
- # Return the value calculated by {#predict} on self for a passed object.
- #
- # @param [Object] object the object to fetch the probability for
- # @return [Float] the likelihood of self liking the passed object
- def probability_of_liking object
- Recommendable.configuration.redis.zscore predictions_set_for(object.class), object.redis_key
- end
-
- # Return the negation of the value calculated by {#predict} on self
- # for a passed object.
- #
- # @param [Object] object the object to fetch the probability for
- # @return [Float] the likelihood of self disliking the passed object
- # @see {#probability_of_liking}
- def probability_of_disliking object
- -probability_of_liking(object)
- end
-
- protected
-
- # Makes a call to Redis and intersects the sets of likes belonging to self
- # and rater.
- #
- # @param [Object] rater the person whose set of likes you wish to intersect with that of self
- # @param [Hash] options the options for this intersection
- # @option options [Class, String, Symbol] :class ('nil') Restrict the intersection to a single recommendable type. By default, all recomendable types are considered
- # @option options [true, false] :return_records (true) Return the actual Model instances
- # @return [Array] Typically, an array of ActiveRecord objects (unless :return_records is false)
- def common_likes_with rater, options = {}
- defaults = { :class => nil, :return_records => false }
- options = defaults.merge options
-
- if options[:class]
- in_common = Recommendable.configuration.redis.sinter likes_set_for(options[:class]), rater.likes_set_for(options[:class])
- in_common = options[:class].to_s.classify.constantize.where('ID IN (?)', in_common) if options[:return_records]
- else
- in_common = Recommendable.recommendable_classes.map do |klass|
- things = Recommendable.configuration.redis.sinter(likes_set_for(klass), rater.likes_set_for(klass))
-
- if options[:return_records]
- klass.to_s.classify.constantize.find things
- else
- things.map { |id| "#{klass.to_s.classify}:#{id}" }
- end
- end
-
- in_common.flatten!
- end
-
- return in_common
- end
-
- # Makes a call to Redis and intersects the sets of dislikes belonging to
- # self and rater.
- #
- # @param [Object] rater the person whose set of dislikes you wish to intersect with that of self
- # @param [Hash] options the options for this intersection
- # @option options [Class, String, Symbol] :class ('nil') Restrict the intersection to a single recommendable type. By default, all recomendable types are considered
- # @option options [true, false] :return_records (true) Return the actual Model instances
- # @return [Array] Typically, an array of ActiveRecord objects (unless :return_records is false)
- def common_dislikes_with rater, options = {}
- defaults = { :class => nil, :return_records => false }
- options = defaults.merge options
-
- if options[:class]
- in_common = Recommendable.configuration.redis.sinter dislikes_set_for(options[:class]), rater.dislikes_set_for(options[:class])
- in_common = options[:class].to_s.classify.constantize.where('ID IN (?)', in_common) if options[:return_records]
- else
- in_common = Recommendable.recommendable_classes.map do |klass|
- things = Recommendable.configuration.redis.sinter(dislikes_set_for(klass), rater.dislikes_set_for(klass))
-
- if options[:return_records]
- klass.to_s.classify.constantize.find(things)
- else
- things.map { |id| "#{klass.to_s.classify}:#{id}" }
- end
- end
-
- in_common.flatten!
- end
-
- in_common
- end
-
- # Makes a call to Redis and intersects self's set of likes with rater's
- # set of dislikes and vise versa. The idea here is that if self likes
- # an object that rater dislikes, it is a disagreement and should count
- # negatively towards their similarity.
- #
- # @param [Object] rater the person whose sets you wish to intersect with those of self
- # @param [Hash] options the options for this intersection
- # @option options [Class, String, Symbol] :class ('nil') Restrict the intersections to a single recommendable type. By default, all recomendable types are considered
- # @option options [true, false] :return_records (true) Return the actual Model instances
- # @return [Array] Typically, an array of ActiveRecord objects (unless :return_records is false)
- def disagreements_with rater, options = {}
- defaults = { :class => nil, :return_records => false }
- options = defaults.merge options
-
- if options[:class]
- disagreements = Recommendable.configuration.redis.sinter(likes_set_for(options[:class]), rater.dislikes_set_for(options[:class]))
- disagreements += Recommendable.configuration.redis.sinter(dislikes_set_for(options[:class]), rater.likes_set_for(options[:class]))
- disagreements = options[:class].to_s.classify.constantize.where('ID IN (?)', disagreements) if options[:return_records]
- else
- disagreements = Recommendable.recommendable_classes.map do |klass|
- things = Recommendable.configuration.redis.sinter(likes_set_for(klass), rater.dislikes_set_for(klass))
- things += Recommendable.configuration.redis.sinter(dislikes_set_for(klass), rater.likes_set_for(klass))
-
- if options[:return_records]
- klass.to_s.classify.constantize.find(things)
- else
- things.map { |id| "#{options[:class].to_s.classify}:#{id}" }
- end
- end
-
- disagreements.flatten!
- end
-
- disagreements
- end
-
- # Used internally during liking/disliking/stashing/ignoring objects. This
- # will prep an object to be liked, disliked, etc. by making sure that self
- # doesn't already have this item in their list of likes, dislikes, stashed
- # items or ignored items.
- #
- # param [Object] object the object to destroy Recommendable models for
- # @private
- def completely_unrecommend object
- unlike(object) || undislike(object) || unstash(object) || unignore(object)
- unpredict(object)
- end
-
- # @private
- def likes_set_for klass
- "#{self.class}:#{id}:likes:#{klass}"
- end
-
- # @private
- def dislikes_set_for klass
- "#{self.class}:#{id}:dislikes:#{klass}"
- end
-
- # Used for setup purposes. Creates and populates sets in redis containing
- # self's likes and dislikes.
- # @private
- def create_recommended_to_sets
- Recommendable.recommendable_classes.each do |klass|
- likes_for(klass).each { |like| Recommendable.configuration.redis.sadd likes_set_for(klass), like.likeable_id }
- dislikes_for(klass).each { |dislike| Recommendable.configuration.redis.sadd dislikes_set_for(klass), dislike.dislikeable_id }
-
- if Recommendable.configuration.expire_keys_in.is_a?(Numeric)
- Recommendable.configuration.redis.expire(likes_set_for(klass), Recommendable.configuration.expire_keys_in)
- Recommendable.configuration.redis.expire(dislikes_set_for(klass), Recommendable.configuration.expire_keys_in)
- end
- end
- end
-
- private
-
- # Used for teardown purposes. Destroys the redis sets containing self's
- # likes and dislikes, as they are only used during the process of
- # updating recommendations and similarity values.
- # @private
- def destroy_recommended_to_sets(force = false)
- return unless Recommendable.configuration.expire_keys_in == :destroy || force
-
- Recommendable.recommendable_classes.each do |klass|
- Recommendable.configuration.redis.del likes_set_for(klass)
- Recommendable.configuration.redis.del dislikes_set_for(klass)
- end
- end
-
- # Checks how similar a passed rater is with self. This method calculates
- # a numeric similarity value that can fall between -1.0 and 1.0. A value of
- # 1.0 indicates that rater has the exact same likes and dislikes as self
- # while a value of -1.0 indicates that rater dislikes every object that self
- # likes and likes every object that self dislikes. A value of 0.0 would
- # indicate that the two users share no likes or dislikes.
- #
- # @param [Object] rater an ActiveRecord object declared to `act_as_recommendable_to`
- # @return [Float] the numeric similarity between self and rater
- # @note The returned value relies on which user the method is called on. current_user.similarity_with(rater) will not equal rater.similarity_with(current_user) unless their sets of likes and dislikes are identical. current_user.similarity_with(rater) will return 1.0 even if rater has several likes/dislikes that `current_user` does not.
- # @private
- def similarity_with(rater)
- rater.send :create_recommended_to_sets
- agreements = common_likes_with(rater, :return_records => false).size
- agreements += common_dislikes_with(rater, :return_records => false).size
- disagreements = disagreements_with(rater, :return_records => false).size
-
- similarity = (agreements - disagreements).to_f / (likes.count + dislikes.count)
-
- rater.send :destroy_recommended_to_sets
-
- return similarity
- end
-
- # Used internally to update self's prediction values across all
- # recommendable types. This is called in the Resque job to refresh
- # recommendations.
- #
- # @private
- def update_recommendations(options={})
- defaults = { :priority => false }
- options = defaults.merge(options)
-
- send :create_recommended_to_sets
- Recommendable.recommendable_classes.each { |klass| update_recommendations_for(klass, options) }
- send :destroy_recommended_to_sets
- true
- end
-
- # Used internally to update self's prediction values across a single
- # recommendable type. Convenience method for {#update_recommendations}
- #
- # @param [Class] klass the recommendable type to update predictions for
- # @private
- def update_recommendations_for klass, options = {}
- defaults = { :priority => false }
- options = defaults.merge(options)
-
- klass = klass.order('likes_count + dislikes_count DESC').limit(1000) if options[:priority]
-
- klass.find_each do |object|
- next if rated?(object) || !object.been_rated? || ignored?(object) || stashed?(object)
-
- prediction = predict object
-
- Recommendable.configuration.redis.zadd(predictions_set_for(object.class), prediction, object.redis_key)
- end
- end
-
- # Predict how likely it is that self will like a passed in object. This
- # probability is not based on percentage. 0.0 indicates that self will
- # neither like nor dislike the passed object. Values that approach Infinity
- # indicate a rising probability of liking the passed object while values
- # approaching -Infinity indicate a rising probability of disliking the
- # passed object.
- #
- # @param [Object] object the object to check the likeliness of liking
- # @return [Float] the probability that self will like object
- # @private
- def predict object
- liked_by, disliked_by = object.send :create_recommendable_sets
- rated_by = Recommendable.configuration.redis.scard(liked_by) + Recommendable.configuration.redis.scard(disliked_by)
- similarity_sum = 0.0
- prediction = 0.0
-
- Recommendable.configuration.redis.smembers(liked_by).inject(similarity_sum) { |sum, r| sum += Recommendable.configuration.redis.zscore(similarity_set, r).to_f }
- Recommendable.configuration.redis.smembers(disliked_by).inject(similarity_sum) { |sum, r| sum -= Recommendable.configuration.redis.zscore(similarity_set, r).to_f }
-
- prediction = similarity_sum / rated_by.to_f
-
- object.send :destroy_recommendable_sets
-
- return prediction.finite? ? prediction : 0.0
- end
-
- # Used internally to update the similarity values between self and all
- # other users. This is called in the Resque job to refresh recommendations.
- #
- # @private
- def update_similarities options = {}
- defaults = { :priority => false }
- options = defaults.merge(options)
-
- return unless rated_anything?
- send :create_recommended_to_sets
-
- klass = Recommendable.user_class
- klass = klass.order('likes_count + dislikes_count DESC').limit(1000) if options[:priority]
-
- klass.find_each do |rater|
- next if self == rater || !rater.rated_anything?
- Recommendable.configuration.redis.zadd similarity_set, similarity_with(rater), rater.id
- end
-
- send :destroy_recommended_to_sets
- true
- end
-
- # @private
- def remove_from_similarities
- Recommendable.configuration.redis.del similarity_set
-
- Recommendable.user_class.find_each do |user|
- Recommendable.configuration.redis.zrem user.send(:similarity_set), self.id
- end
-
- true
- end
-
- # @private
- def remove_recommendations
- Recommendable.recommendable_classes.each do |klass|
- Recommendable.configuration.redis.del predictions_set_for(klass)
- end
-
- true
- end
-
- # @private
- def unpredict object
- Recommendable.configuration.redis.zrem predictions_set_for(object.class), object.redis_key
- end
-
- # @private
- def similarity_set
- "#{self.class}:#{id}:similarities"
- end
-
- # @private
- def predictions_set_for klass
- "#{self.class}:#{id}:predictions:#{klass}"
- end
- end
- end
-end
View
42 lib/recommendable/configuration.rb
@@ -1,19 +1,47 @@
+require 'redis'
+
module Recommendable
class Configuration
- # False if you want to kee
+ # The ORM you are using. Currently supported: `:activerecord`, `:mongoid`, and `:datamapper`
+ attr_accessor :orm
+
+ # Recommendable's connection to Redis
attr_accessor :redis
- attr_accessor :expire_keys_in
+
+ # A prefix for all keys Recommendable uses
+ attr_accessor :redis_namespace
+
+ # Whether or not to automatically enqueue users to have their recommendations
+ # refreshed after they like/dislike an item
attr_accessor :auto_enqueue
+
+ # The name of the queue that background jobs will be placed in
attr_accessor :queue_name
+
+ # The number of nearest neighbors (k-NN) to check when updating
+ # recommendations for a user. Set to `nil` if you want to check all
+ # neighbors as opposed to a subset of the nearest ones.
+ attr_accessor :nearest_neighbors
+
+ attr_accessor :ratable_classes, :user_class
+
+ # Default values
+ def initialize
+ @redis = Redis.new
+ @redis_namespace = :recommendable
+ @auto_enqueue = true
+ @queue_name = :recommendable
+ @ratable_classes = []
+ @nearest_neighbors = nil
+ end
end
class << self
- def configure
- yield configuration
- end
+ attr_accessor :config
- def configuration
- @configuration ||= Configuration.new
+ def configure
+ @config ||= Configuration.new
+ yield @config
end
end
end
View
14 lib/recommendable/engine.rb
@@ -1,14 +0,0 @@
-module Recommendable
- class Engine < ::Rails::Engine
- isolate_namespace Recommendable
- engine_name "recommendable"
-
- class << self
- attr_accessor :root
-
- def root
- @root ||= Pathname.new File.expand_path('../../', __FILE__)
- end
- end
- end
-end
View
4 lib/recommendable/exceptions.rb
@@ -1,4 +0,0 @@
-module Recommendable
- class UnrecommendableError < StandardError
- end
-end
View
12 lib/recommendable/helpers.rb
@@ -1,9 +1,3 @@
-module Recommendable
- module Helpers
- def manual_join(klass, action)
- table = klass.base_class.table_name
- inheritance_column = klass.base_class.inheritance_column
- "JOIN #{table} ON recommendable_#{action.pluralize}.#{action}able_id = #{table}.id AND #{table}.#{inheritance_column} = '#{klass}'"
- end
- end
-end
+require 'recommendable/helpers/redis_key_mapper'
+require 'recommendable/helpers/calculations'
+require 'recommendable/helpers/queriers'
View
150 lib/recommendable/helpers/calculations.rb
@@ -0,0 +1,150 @@
+module Recommendable
+ module Helpers
+ module Calculations
+ class << self
+ # Calculate a numeric similarity value that can fall between -1.0 and 1.0.
+ # A value of 1.0 indicates that both users have rated the same items in
+ # the same ways. A value of -1.0 indicates that both users have rated the
+ # same items in opposite ways.
+ #
+ # @param [Fixnum, String] user_id the ID of the first user
+ # @param [Fixnum, String] other_user_id the ID of another user
+ # @return [Float] the numeric similarity between this user and the passed user
+ # @note Similarity values are asymmetrical. `Calculations.similarity_between(user_id, other_user_id)` will not necessarily equal `Calculations.similarity_between(other_user_id, user_id)`
+ def similarity_between(user_id, other_user_id)
+ similarity = liked_count = disliked_count = 0
+ in_common = Recommendable.config.ratable_classes.each do |klass|
+ liked_set = Recommendable::Helpers::RedisKeyMapper.liked_set_for(klass, user_id)
+ other_liked_set = Recommendable::Helpers::RedisKeyMapper.liked_set_for(klass, other_user_id)
+ disliked_set = Recommendable::Helpers::RedisKeyMapper.disliked_set_for(klass, user_id)
+ other_disliked_set = Recommendable::Helpers::RedisKeyMapper.disliked_set_for(klass, other_user_id)
+
+ # Agreements
+ similarity += Recommendable.redis.sinter(liked_set, other_liked_set).size
+ similarity += Recommendable.redis.sinter(disliked_set, other_disliked_set).size
+
+ # Disagreements
+ similarity -= Recommendable.redis.sinter(liked_set, other_disliked_set).size
+ similarity -= Recommendable.redis.sinter(disliked_set, other_liked_set).size
+
+ liked_count += Recommendable.redis.scard(liked_set)
+ disliked_count += Recommendable.redis.scard(disliked_set)
+ end
+
+ similarity / (liked_count + disliked_count).to_f
+ end
+
+ # Used internally to update the similarity values between this user and all
+ # other users. This is called by the background worker.
+ def update_similarities_for(user_id)
+ user_id = user_id.to_s # For comparison. Redis returns all set members as strings.
+ similarity_set = Recommendable::Helpers::RedisKeyMapper.similarity_set_for(user_id)
+
+ # Only calculate similarities for users who have rated the items that
+ # this user has rated
+ relevant_user_ids = Recommendable.config.ratable_classes.inject([]) do |memo, klass|
+ liked_set = Recommendable::Helpers::RedisKeyMapper.liked_set_for(klass, user_id)
+ disliked_set = Recommendable::Helpers::RedisKeyMapper.disliked_set_for(klass, user_id)
+ item_ids = Recommendable.redis.sunion(liked_set, disliked_set)
+
+ unless item_ids.empty?
+ sets = item_ids.map do |id|
+ liked_by_set = Recommendable::Helpers::RedisKeyMapper.liked_by_set_for(klass, id)
+ disliked_by_set = Recommendable::Helpers::RedisKeyMapper.disliked_by_set_for(klass, id)
+
+ [liked_by_set, disliked_by_set]
+ end
+
+ memo | Recommendable.redis.sunion(sets.flatten)
+ else
+ memo
+ end
+ end
+
+ relevant_user_ids.each do |id|
+ next if id == user_id # Skip comparing with self.
+ Recommendable.redis.zadd(similarity_set, similarity_between(user_id, id), id)
+ end
+
+ true
+ end
+
+ # Used internally to update this user's prediction values across all
+ # recommendable types. This is called by the background worker.
+ #
+ # @private
+ def update_recommendations_for(user_id)
+ nearest_neighbors = Recommendable.config.nearest_neighbors || Recommendable.config.user_class.count
+ Recommendable.config.ratable_classes.each do |klass|
+ similarity_set = Recommendable::Helpers::RedisKeyMapper.similarity_set_for(user_id)
+ recommended_set = Recommendable::Helpers::RedisKeyMapper.recommended_set_for(klass, user_id)
+ similar_user_ids = Recommendable.redis.zrevrange(similarity_set, 0, nearest_neighbors - 1)
+
+ sets_to_union = similar_user_ids.inject([]) do |sets, id|
+ sets << Recommendable::Helpers::RedisKeyMapper.liked_set_for(klass, id)
+ end
+
+ next if sets_to_union.empty?
+ scores = Recommendable.redis.sunion(sets_to_union).map { |id| [predict_for(user_id, klass, id), id] }
+ next if scores.empty?
+ Recommendable.redis.zadd(recommended_set, scores)
+ end
+
+ true
+ end
+
+ # Predict how likely it is that a user will like an item. This probability
+ # is not based on percentage. 0.0 indicates that the user will neither like
+ # nor dislike the item. Values that approach Infinity indicate a rising
+ # likelihood of liking the item while values approaching -Infinity
+ # indicate a rising probability of disliking the item.
+ #
+ # @param [Fixnum, String] user_id the user's ID
+ # @param [Class] klass the item's class
+ # @param [Fixnum, String] item_id the item's ID
+ # @return [Float] the probability that the user will like the item
+ def predict_for(user_id, klass, item_id)
+ similarity_set = Recommendable::Helpers::RedisKeyMapper.similarity_set_for(user_id)
+ liked_by_set = Recommendable::Helpers::RedisKeyMapper.liked_by_set_for(klass, item_id)
+ disliked_by_set = Recommendable::Helpers::RedisKeyMapper.disliked_by_set_for(klass, item_id)
+ similarity_sum = 0.0
+
+ Recommendable.redis.smembers(liked_by_set).inject(similarity_sum) do |sum, id|
+ sum += Recommendable.redis.zscore(similarity_set, id).to_f
+ end
+ Recommendable.redis.smembers(disliked_by_set).inject(similarity_sum) do |sum, id|
+ sum -= Recommendable.redis.zscore(similarity_set, id).to_f
+ end
+
+ liked_by_count = Recommendable.redis.scard(liked_by_set)
+ disliked_by_count = Recommendable.redis.scard(disliked_by_set)
+ prediction = similarity_sum / (liked_by_count + disliked_by_count).to_f
+ prediction.finite? ? prediction : 0.0
+ end
+
+ def update_score_for(klass, id)
+ score_set = Recommendable::Helpers::RedisKeyMapper.score_set_for(klass)
+ liked_by_set = Recommendable::Helpers::RedisKeyMapper.liked_by_set_for(klass, id)
+ disliked_by_set = Recommendable::Helpers::RedisKeyMapper.disliked_by_set_for(klass, id)
+ liked_by_count = Recommendable.redis.scard(liked_by_set)
+ disliked_by_count = Recommendable.redis.scard(disliked_by_set)
+
+ return 0.0 unless liked_by_count + disliked_by_count > 0
+
+ z = 1.96
+ n = liked_by_count + disliked_by_count
+ phat = liked_by_count / n.to_f
+
+ begin
+ score = (phat + z*z/(2*n) - z * Math.sqrt((phat*(1-phat)+z*z/(4*n))/n))/(1+z*z/n)
+ rescue Math::DomainError
+ score = 0
+ end
+
+ Recommendable.redis.zadd(score_set, score, id)
+ true
+ end
+ end
+ end
+ end
+end
View
23 lib/recommendable/helpers/queriers.rb
@@ -0,0 +1,23 @@
+module Recommendable
+ module Helpers
+ module Queriers
+ class << self
+ def active_record(klass, ids)
+ klass.where(:id => ids)
+ end
+
+ def data_mapper(klass, ids)
+ klass.all(:id => ids)
+ end
+
+ def mongoid(klass, ids)
+ klass.where(:id => ids)
+ end
+
+ def mongo_mapper(klass, ids)
+ klass.where(:id => ids)
+ end
+ end
+ end
+ end
+end
View
29 lib/recommendable/helpers/redis_key_mapper.rb
@@ -0,0 +1,29 @@
+module Recommendable
+ module Helpers
+ module RedisKeyMapper
+ class << self
+ %w[liked disliked hidden bookmarked recommended].each do |action|
+ define_method "#{action}_set_for" do |klass, id|
+ [Recommendable.config.redis_namespace, Recommendable.config.user_class.to_s.tableize, id, "#{action}_#{klass.to_s.tableize}"].compact.join(':')
+ end
+ end
+
+ def similarity_set_for(id)
+ [Recommendable.config.redis_namespace, Recommendable.config.user_class.to_s.tableize, id, 'similarities'].compact.join(':')
+ end
+
+ def liked_by_set_for(klass, id)
+ [Recommendable.config.redis_namespace, klass.to_s.tableize, id, "liked_by"].compact.join(':')
+ end
+
+ def disliked_by_set_for(klass, id)
+ [Recommendable.config.redis_namespace, klass.to_s.tableize, id, "disliked_by"].compact.join(':')
+ end
+
+ def score_set_for(klass)
+ [Recommendable.config.redis_namespace, klass.to_s.tableize, 'scores'].join(':')