@@ -7,7 +7,10 @@ APP_SECRET_TOKEN=REPLACE_ME_NOW! |
||
| 7 | 7 |
# for development, but it needs to be changed when you deploy to a production environment. |
| 8 | 8 |
DOMAIN=localhost:3000 |
| 9 | 9 |
|
| 10 |
-# Database Setup |
|
| 10 |
+############################ |
|
| 11 |
+# Database Setup # |
|
| 12 |
+############################ |
|
| 13 |
+ |
|
| 11 | 14 |
DATABASE_ADAPTER=mysql2 |
| 12 | 15 |
DATABASE_ENCODING=utf8 |
| 13 | 16 |
DATABASE_RECONNECT=true |
@@ -24,6 +27,10 @@ DATABASE_PASSWORD="" |
||
| 24 | 27 |
# Configure Rails environment. This should only be needed in production and may cause errors in development. |
| 25 | 28 |
# RAILS_ENV=production |
| 26 | 29 |
|
| 30 |
+############################# |
|
| 31 |
+# Email Configuration # |
|
| 32 |
+############################# |
|
| 33 |
+ |
|
| 27 | 34 |
# Outgoing email settings. To use Gmail or Google Apps, put your Google Apps domain or gmail.com |
| 28 | 35 |
# as the SMTP_DOMAIN and your Gmail username and password as the SMTP_USER_NAME and SMTP_PASSWORD. |
| 29 | 36 |
SMTP_DOMAIN=your-domain-here.com |
@@ -37,9 +44,28 @@ SMTP_ENABLE_STARTTLS_AUTO=true |
||
| 37 | 44 |
# The address from which system emails will appear to be sent. |
| 38 | 45 |
EMAIL_FROM_ADDRESS=from_address@gmail.com |
| 39 | 46 |
|
| 47 |
+############################ |
|
| 48 |
+# Allowing Signups # |
|
| 49 |
+############################ |
|
| 50 |
+ |
|
| 40 | 51 |
# This invitation code will be required for users to signup with your Huginn installation. |
| 41 | 52 |
# You can see its use in user.rb. |
| 42 | 53 |
INVITATION_CODE=try-huginn |
| 43 | 54 |
|
| 55 |
+########################### |
|
| 56 |
+# Agent Logging # |
|
| 57 |
+########################### |
|
| 58 |
+ |
|
| 44 | 59 |
# Number of lines of log messages to keep per Agent |
| 45 |
-AGENT_LOG_LENGTH=100 |
|
| 60 |
+AGENT_LOG_LENGTH=200 |
|
| 61 |
+ |
|
| 62 |
+############################# |
|
| 63 |
+# AWS and Mechanical Turk # |
|
| 64 |
+############################# |
|
| 65 |
+ |
|
| 66 |
+# AWS Credentials for MTurk |
|
| 67 |
+AWS_ACCESS_KEY_ID="your aws access key id" |
|
| 68 |
+AWS_ACCESS_KEY="your aws access key" |
|
| 69 |
+ |
|
| 70 |
+# Set AWS_SANDBOX to true if you're developing Huginn code. |
|
| 71 |
+AWS_SANDBOX=false |
@@ -32,6 +32,7 @@ gem 'kramdown' |
||
| 32 | 32 |
gem "typhoeus" |
| 33 | 33 |
gem 'nokogiri' |
| 34 | 34 |
gem 'wunderground' |
| 35 |
+gem 'rturk' |
|
| 35 | 36 |
|
| 36 | 37 |
gem "twitter" |
| 37 | 38 |
gem 'twitter-stream', '>=0.1.16' |
@@ -74,6 +74,8 @@ GEM |
||
| 74 | 74 |
http_parser.rb (>= 0.5.3) |
| 75 | 75 |
em-socksify (0.3.0) |
| 76 | 76 |
eventmachine (>= 1.0.0.beta.4) |
| 77 |
+ erector (0.9.0) |
|
| 78 |
+ treetop (>= 1.2.3) |
|
| 77 | 79 |
erubis (2.7.0) |
| 78 | 80 |
ethon (0.5.12) |
| 79 | 81 |
ffi (>= 1.3.0) |
@@ -118,7 +120,7 @@ GEM |
||
| 118 | 120 |
mime-types (~> 1.16) |
| 119 | 121 |
treetop (~> 1.4.8) |
| 120 | 122 |
method_source (0.8.1) |
| 121 |
- mime-types (1.23) |
|
| 123 |
+ mime-types (1.24) |
|
| 122 | 124 |
mini_portile (0.5.1) |
| 123 | 125 |
multi_json (1.7.9) |
| 124 | 126 |
multi_xml (0.5.5) |
@@ -182,6 +184,10 @@ GEM |
||
| 182 | 184 |
rspec-core (~> 2.14.0) |
| 183 | 185 |
rspec-expectations (~> 2.14.0) |
| 184 | 186 |
rspec-mocks (~> 2.14.0) |
| 187 |
+ rturk (2.11.0) |
|
| 188 |
+ erector |
|
| 189 |
+ nokogiri |
|
| 190 |
+ rest-client |
|
| 185 | 191 |
rufus-scheduler (2.0.22) |
| 186 | 192 |
tzinfo (>= 0.3.23) |
| 187 | 193 |
safe_yaml (0.9.5) |
@@ -205,7 +211,7 @@ GEM |
||
| 205 | 211 |
system_timer (1.2.4) |
| 206 | 212 |
thor (0.18.1) |
| 207 | 213 |
tilt (1.4.1) |
| 208 |
- treetop (1.4.14) |
|
| 214 |
+ treetop (1.4.15) |
|
| 209 | 215 |
polyglot |
| 210 | 216 |
polyglot (>= 0.3.1) |
| 211 | 217 |
twilio-ruby (3.10.0) |
@@ -269,6 +275,7 @@ DEPENDENCIES |
||
| 269 | 275 |
rr |
| 270 | 276 |
rspec |
| 271 | 277 |
rspec-rails |
| 278 |
+ rturk |
|
| 272 | 279 |
rufus-scheduler |
| 273 | 280 |
sass-rails (~> 3.2.3) |
| 274 | 281 |
select2-rails |
@@ -8,7 +8,7 @@ $ -> |
||
| 8 | 8 |
|
| 9 | 9 |
if json.pending? && json.pending > 0 |
| 10 | 10 |
tooltipOptions = {
|
| 11 |
- title: "#{json.pending} pending, #{json.awaiting_retry} awaiting retry, and #{json.recent_failures} recent failures"
|
|
| 11 |
+ title: "#{json.pending} jobs pending, #{json.awaiting_retry} awaiting retry, and #{json.recent_failures} recent failures"
|
|
| 12 | 12 |
delay: 0 |
| 13 | 13 |
placement: "bottom" |
| 14 | 14 |
trigger: "hover" |
@@ -1,4 +1,6 @@ |
||
| 1 | 1 |
class EventsController < ApplicationController |
| 2 |
+ before_filter :load_event, :except => :index |
|
| 3 |
+ |
|
| 2 | 4 |
def index |
| 3 | 5 |
if params[:agent] |
| 4 | 6 |
@agent = current_user.agents.find(params[:agent]) |
@@ -14,21 +16,29 @@ class EventsController < ApplicationController |
||
| 14 | 16 |
end |
| 15 | 17 |
|
| 16 | 18 |
def show |
| 17 |
- @event = current_user.events.find(params[:id]) |
|
| 18 |
- |
|
| 19 | 19 |
respond_to do |format| |
| 20 | 20 |
format.html |
| 21 | 21 |
format.json { render json: @event }
|
| 22 | 22 |
end |
| 23 | 23 |
end |
| 24 | 24 |
|
| 25 |
+ def reemit |
|
| 26 |
+ @event.reemit! |
|
| 27 |
+ redirect_to :back, :notice => "Event re-emitted" |
|
| 28 |
+ end |
|
| 29 |
+ |
|
| 25 | 30 |
def destroy |
| 26 |
- event = current_user.events.find(params[:id]) |
|
| 27 |
- event.destroy |
|
| 31 |
+ @event.destroy |
|
| 28 | 32 |
|
| 29 | 33 |
respond_to do |format| |
| 30 | 34 |
format.html { redirect_to events_path }
|
| 31 | 35 |
format.json { head :no_content }
|
| 32 | 36 |
end |
| 33 | 37 |
end |
| 38 |
+ |
|
| 39 |
+ private |
|
| 40 |
+ |
|
| 41 |
+ def load_event |
|
| 42 |
+ @event = current_user.events.find(params[:id]) |
|
| 43 |
+ end |
|
| 34 | 44 |
end |
@@ -159,6 +159,7 @@ class Agent < ActiveRecord::Base |
||
| 159 | 159 |
end |
| 160 | 160 |
|
| 161 | 161 |
def log(message, options = {})
|
| 162 |
+ puts "Agent##{id}: #{message}" unless Rails.env.test?
|
|
| 162 | 163 |
AgentLog.log_for_agent(self, message, options) |
| 163 | 164 |
end |
| 164 | 165 |
|
@@ -66,16 +66,10 @@ module Agents |
||
| 66 | 66 |
!recent_error_logs? |
| 67 | 67 |
end |
| 68 | 68 |
|
| 69 |
- def value_constructor(value, payload) |
|
| 70 |
- value.gsub(/<[^>]+>/).each { |jsonpath|
|
|
| 71 |
- Utils.values_at(payload, jsonpath[1..-2]).first.to_s |
|
| 72 |
- } |
|
| 73 |
- end |
|
| 74 |
- |
|
| 75 | 69 |
def receive(incoming_events) |
| 76 | 70 |
incoming_events.each do |event| |
| 77 | 71 |
formatted_event = options[:mode].to_s == "merge" ? event.payload : {}
|
| 78 |
- options[:instructions].each_pair {|key, value| formatted_event[key] = value_constructor value, event.payload }
|
|
| 72 |
+ options[:instructions].each_pair {|key, value| formatted_event[key] = Utils.interpolate_jsonpaths(value, event.payload) }
|
|
| 79 | 73 |
formatted_event[:agent] = Agent.find(event.agent_id).type.slice!(8..-1) unless options[:skip_agent].to_s == "true" |
| 80 | 74 |
formatted_event[:created_at] = event.created_at unless options[:skip_created_at].to_s == "true" |
| 81 | 75 |
create_event :payload => formatted_event |
@@ -0,0 +1,332 @@ |
||
| 1 |
+require 'rturk' |
|
| 2 |
+ |
|
| 3 |
+module Agents |
|
| 4 |
+ class HumanTaskAgent < Agent |
|
| 5 |
+ default_schedule "every_10m" |
|
| 6 |
+ |
|
| 7 |
+ description <<-MD |
|
| 8 |
+ You can use a HumanTaskAgent to create Human Intelligence Tasks (HITs) on Mechanical Turk. |
|
| 9 |
+ |
|
| 10 |
+ HITs can be created in response to events, or on a schedule. Set `trigger_on` to either `schedule` or `event`. |
|
| 11 |
+ |
|
| 12 |
+ The schedule of this Agent is how often it should check for completed HITs, __NOT__ how often to submit one. To configure how often a new HIT |
|
| 13 |
+ should be submitted when in `schedule` mode, set `submission_period` to a number of hours. |
|
| 14 |
+ |
|
| 15 |
+ If created with an event, all HIT fields can contain interpolated values via [JSONPaths](http://goessner.net/articles/JsonPath/) placed between < and > characters. |
|
| 16 |
+ For example, if the incoming event was a Twitter event, you could make a HITT to rate its sentiment like this: |
|
| 17 |
+ |
|
| 18 |
+ {
|
|
| 19 |
+ "expected_receive_period_in_days": 2, |
|
| 20 |
+ "trigger_on": "event", |
|
| 21 |
+ "hit": {
|
|
| 22 |
+ "assignments": 1, |
|
| 23 |
+ "title": "Sentiment evaluation", |
|
| 24 |
+ "description": "Please rate the sentiment of this message: '<$.message>'", |
|
| 25 |
+ "reward": 0.05, |
|
| 26 |
+ "questions": [ |
|
| 27 |
+ {
|
|
| 28 |
+ "type": "selection", |
|
| 29 |
+ "key": "sentiment", |
|
| 30 |
+ "name": "Sentiment", |
|
| 31 |
+ "required": "true", |
|
| 32 |
+ "question": "Please select the best sentiment value:", |
|
| 33 |
+ "selections": [ |
|
| 34 |
+ { "key": "happy", "text": "Happy" },
|
|
| 35 |
+ { "key": "sad", "text": "Sad" },
|
|
| 36 |
+ { "key": "neutral", "text": "Neutral" }
|
|
| 37 |
+ ] |
|
| 38 |
+ }, |
|
| 39 |
+ {
|
|
| 40 |
+ "type": "free_text", |
|
| 41 |
+ "key": "feedback", |
|
| 42 |
+ "name": "Have any feedback for us?", |
|
| 43 |
+ "required": "false", |
|
| 44 |
+ "question": "Feedback", |
|
| 45 |
+ "default": "Type here...", |
|
| 46 |
+ "min_length": "2", |
|
| 47 |
+ "max_length": "2000" |
|
| 48 |
+ } |
|
| 49 |
+ ] |
|
| 50 |
+ } |
|
| 51 |
+ } |
|
| 52 |
+ |
|
| 53 |
+ As you can see, you configure the created HIT with the `hit` option. Required fields are `title`, which is the |
|
| 54 |
+ title of the created HIT, `description`, which is the description of the HIT, and `questions` which is an array of |
|
| 55 |
+ questions. Questions can be of `type` _selection_ or _free\\_text_. Both types require the `key`, `name`, `required`, |
|
| 56 |
+ `type`, and `question` configuration options. Additionally, _selection_ requires a `selections` array of options, each of |
|
| 57 |
+ which contain `key` and `text`. For _free\\_text_, the special configuration options are all optional, and are |
|
| 58 |
+ `default`, `min_length`, and `max_length`. |
|
| 59 |
+ |
|
| 60 |
+ If all of the `questions` are of `type` _selection_, you can set `take_majority` to _true_ at the top level to |
|
| 61 |
+ automatically select the majority vote for each question across all `assignments`. If all selections are numeric, an `average_answer` will also be generated. |
|
| 62 |
+ |
|
| 63 |
+ As with most Agents, `expected_receive_period_in_days` is required if `trigger_on` is set to `event`. |
|
| 64 |
+ MD |
|
| 65 |
+ |
|
| 66 |
+ event_description <<-MD |
|
| 67 |
+ Events look like: |
|
| 68 |
+ |
|
| 69 |
+ {
|
|
| 70 |
+ } |
|
| 71 |
+ MD |
|
| 72 |
+ |
|
| 73 |
+ def validate_options |
|
| 74 |
+ options[:hit] ||= {}
|
|
| 75 |
+ options[:hit][:questions] ||= [] |
|
| 76 |
+ |
|
| 77 |
+ errors.add(:base, "'trigger_on' must be one of 'schedule' or 'event'") unless %w[schedule event].include?(options[:trigger_on]) |
|
| 78 |
+ errors.add(:base, "'hit.assignments' should specify the number of HIT assignments to create") unless options[:hit][:assignments].present? && options[:hit][:assignments].to_i > 0 |
|
| 79 |
+ errors.add(:base, "'hit.title' must be provided") unless options[:hit][:title].present? |
|
| 80 |
+ errors.add(:base, "'hit.description' must be provided") unless options[:hit][:description].present? |
|
| 81 |
+ errors.add(:base, "'hit.questions' must be provided") unless options[:hit][:questions].present? && options[:hit][:questions].length > 0 |
|
| 82 |
+ |
|
| 83 |
+ if options[:trigger_on] == "event" |
|
| 84 |
+ errors.add(:base, "'expected_receive_period_in_days' is required when 'trigger_on' is set to 'event'") unless options[:expected_receive_period_in_days].present? |
|
| 85 |
+ elsif options[:trigger_on] == "schedule" |
|
| 86 |
+ errors.add(:base, "'submission_period' must be set to a positive number of hours when 'trigger_on' is set to 'schedule'") unless options[:submission_period].present? && options[:submission_period].to_i > 0 |
|
| 87 |
+ end |
|
| 88 |
+ |
|
| 89 |
+ if options[:hit][:questions].any? { |question| [:key, :name, :required, :type, :question].any? {|k| !question[k].present? } }
|
|
| 90 |
+ errors.add(:base, "all questions must set 'key', 'name', 'required', 'type', and 'question'") |
|
| 91 |
+ end |
|
| 92 |
+ |
|
| 93 |
+ if options[:hit][:questions].any? { |question| question[:type] == "selection" && (!question[:selections].present? || question[:selections].length == 0 || !question[:selections].all? {|s| s[:key].present? } || !question[:selections].all? { |s| s[:text].present? })}
|
|
| 94 |
+ errors.add(:base, "all questions of type 'selection' must have a selections array with selections that set 'key' and 'name'") |
|
| 95 |
+ end |
|
| 96 |
+ |
|
| 97 |
+ if options[:take_majority] == "true" && options[:hit][:questions].any? { |question| question[:type] != "selection" }
|
|
| 98 |
+ errors.add(:base, "all questions must be of type 'selection' to use the 'take_majority' option") |
|
| 99 |
+ end |
|
| 100 |
+ end |
|
| 101 |
+ |
|
| 102 |
+ def default_options |
|
| 103 |
+ {
|
|
| 104 |
+ :expected_receive_period_in_days => 2, |
|
| 105 |
+ :trigger_on => "event", |
|
| 106 |
+ :hit => |
|
| 107 |
+ {
|
|
| 108 |
+ :assignments => 1, |
|
| 109 |
+ :title => "Sentiment evaluation", |
|
| 110 |
+ :description => "Please rate the sentiment of this message: '<$.message>'", |
|
| 111 |
+ :reward => 0.05, |
|
| 112 |
+ :questions => |
|
| 113 |
+ [ |
|
| 114 |
+ {
|
|
| 115 |
+ :type => "selection", |
|
| 116 |
+ :key => "sentiment", |
|
| 117 |
+ :name => "Sentiment", |
|
| 118 |
+ :required => "true", |
|
| 119 |
+ :question => "Please select the best sentiment value:", |
|
| 120 |
+ :selections => |
|
| 121 |
+ [ |
|
| 122 |
+ { :key => "happy", :text => "Happy" },
|
|
| 123 |
+ { :key => "sad", :text => "Sad" },
|
|
| 124 |
+ { :key => "neutral", :text => "Neutral" }
|
|
| 125 |
+ ] |
|
| 126 |
+ }, |
|
| 127 |
+ {
|
|
| 128 |
+ :type => "free_text", |
|
| 129 |
+ :key => "feedback", |
|
| 130 |
+ :name => "Have any feedback for us?", |
|
| 131 |
+ :required => "false", |
|
| 132 |
+ :question => "Feedback", |
|
| 133 |
+ :default => "Type here...", |
|
| 134 |
+ :min_length => "2", |
|
| 135 |
+ :max_length => "2000" |
|
| 136 |
+ } |
|
| 137 |
+ ] |
|
| 138 |
+ } |
|
| 139 |
+ } |
|
| 140 |
+ end |
|
| 141 |
+ |
|
| 142 |
+ def working? |
|
| 143 |
+ last_receive_at && last_receive_at > options[:expected_receive_period_in_days].to_i.days.ago && !recent_error_logs? |
|
| 144 |
+ end |
|
| 145 |
+ |
|
| 146 |
+ def check |
|
| 147 |
+ setup! |
|
| 148 |
+ review_hits |
|
| 149 |
+ |
|
| 150 |
+ if options[:trigger_on] == "schedule" && (memory[:last_schedule] || 0) <= Time.now.to_i - options[:submission_period].to_i * 60 * 60 |
|
| 151 |
+ memory[:last_schedule] = Time.now.to_i |
|
| 152 |
+ create_hit |
|
| 153 |
+ end |
|
| 154 |
+ end |
|
| 155 |
+ |
|
| 156 |
+ def receive(incoming_events) |
|
| 157 |
+ if options[:trigger_on] == "event" |
|
| 158 |
+ setup! |
|
| 159 |
+ |
|
| 160 |
+ incoming_events.each do |event| |
|
| 161 |
+ create_hit event |
|
| 162 |
+ end |
|
| 163 |
+ end |
|
| 164 |
+ end |
|
| 165 |
+ |
|
| 166 |
+ # To be moved either into an initilizer or a per-agent setting. |
|
| 167 |
+ def setup! |
|
| 168 |
+ RTurk::logger.level = Logger::DEBUG |
|
| 169 |
+ RTurk.setup(ENV['AWS_ACCESS_KEY_ID'], ENV['AWS_ACCESS_KEY'], :sandbox => ENV['AWS_SANDBOX'] == "true") unless Rails.env.test? |
|
| 170 |
+ end |
|
| 171 |
+ |
|
| 172 |
+ protected |
|
| 173 |
+ |
|
| 174 |
+ def review_hits |
|
| 175 |
+ reviewable_hit_ids = RTurk::GetReviewableHITs.create.hit_ids |
|
| 176 |
+ my_reviewed_hit_ids = reviewable_hit_ids & (memory[:hits] || {}).keys.map(&:to_s)
|
|
| 177 |
+ log "MTurk reports the following HITs [#{reviewable_hit_ids.to_sentence}], of which I own [#{my_reviewed_hit_ids.to_sentence}]"
|
|
| 178 |
+ my_reviewed_hit_ids.each do |hit_id| |
|
| 179 |
+ hit = RTurk::Hit.new(hit_id) |
|
| 180 |
+ assignments = hit.assignments |
|
| 181 |
+ |
|
| 182 |
+ log "Looking at HIT #{hit_id}. I found #{assignments.length} assignments#{" with the statuses: #{assignments.map(&:status).to_sentence}" if assignments.length > 0}"
|
|
| 183 |
+ if assignments.length == hit.max_assignments && assignments.all? { |assignment| assignment.status == "Submitted" }
|
|
| 184 |
+ payload = { :answers => assignments.map(&:answers) }
|
|
| 185 |
+ |
|
| 186 |
+ if options[:take_majority] == "true" |
|
| 187 |
+ counts = {}
|
|
| 188 |
+ options[:hit][:questions].each do |question| |
|
| 189 |
+ question_counts = question[:selections].inject({}) { |memo, selection| memo[selection[:key]] = 0; memo }
|
|
| 190 |
+ assignments.each do |assignment| |
|
| 191 |
+ answers = ActiveSupport::HashWithIndifferentAccess.new(assignment.answers) |
|
| 192 |
+ answer = answers[question[:key]] |
|
| 193 |
+ question_counts[answer] += 1 |
|
| 194 |
+ end |
|
| 195 |
+ counts[question[:key]] = question_counts |
|
| 196 |
+ end |
|
| 197 |
+ payload[:counts] = counts |
|
| 198 |
+ |
|
| 199 |
+ majority_answer = counts.inject({}) do |memo, (key, question_counts)|
|
|
| 200 |
+ memo[key] = question_counts.to_a.sort {|a, b| a.last <=> b.last }.last.first
|
|
| 201 |
+ memo |
|
| 202 |
+ end |
|
| 203 |
+ payload[:majority_answer] = majority_answer |
|
| 204 |
+ |
|
| 205 |
+ if all_questions_are_numeric? |
|
| 206 |
+ average_answer = counts.inject({}) do |memo, (key, question_counts)|
|
|
| 207 |
+ sum = divisor = 0 |
|
| 208 |
+ question_counts.to_a.each do |num, count| |
|
| 209 |
+ sum += num.to_s.to_f * count |
|
| 210 |
+ divisor += count |
|
| 211 |
+ end |
|
| 212 |
+ memo[key] = sum / divisor.to_f |
|
| 213 |
+ memo |
|
| 214 |
+ end |
|
| 215 |
+ payload[:average_answer] = average_answer |
|
| 216 |
+ end |
|
| 217 |
+ end |
|
| 218 |
+ |
|
| 219 |
+ event = create_event :payload => payload |
|
| 220 |
+ log "Event emitted with answer(s)", :outbound_event => event, :inbound_event => Event.find_by_id(memory[:hits][hit_id.to_sym]) |
|
| 221 |
+ |
|
| 222 |
+ assignments.each(&:approve!) |
|
| 223 |
+ |
|
| 224 |
+ memory[:hits].delete(hit_id.to_sym) |
|
| 225 |
+ end |
|
| 226 |
+ end |
|
| 227 |
+ end |
|
| 228 |
+ |
|
| 229 |
+ def all_questions_are_numeric? |
|
| 230 |
+ options[:hit][:questions].all? do |question| |
|
| 231 |
+ question[:selections].all? do |selection| |
|
| 232 |
+ selection[:key] == selection[:key].to_f.to_s || selection[:key] == selection[:key].to_i.to_s |
|
| 233 |
+ end |
|
| 234 |
+ end |
|
| 235 |
+ end |
|
| 236 |
+ |
|
| 237 |
+ def create_hit(event = nil) |
|
| 238 |
+ payload = event ? event.payload : {}
|
|
| 239 |
+ title = Utils.interpolate_jsonpaths(options[:hit][:title], payload).strip |
|
| 240 |
+ description = Utils.interpolate_jsonpaths(options[:hit][:description], payload).strip |
|
| 241 |
+ questions = Utils.recursively_interpolate_jsonpaths(options[:hit][:questions], payload) |
|
| 242 |
+ hit = RTurk::Hit.create(:title => title) do |hit| |
|
| 243 |
+ hit.max_assignments = (options[:hit][:assignments] || 1).to_i |
|
| 244 |
+ hit.description = description |
|
| 245 |
+ hit.question_form AgentQuestionForm.new(:title => title, :description => description, :questions => questions) |
|
| 246 |
+ hit.reward = (options[:hit][:reward] || 0.05).to_f |
|
| 247 |
+ #hit.qualifications.add :approval_rate, { :gt => 80 }
|
|
| 248 |
+ end |
|
| 249 |
+ memory[:hits] ||= {}
|
|
| 250 |
+ memory[:hits][hit.id] = event && event.id |
|
| 251 |
+ log "HIT created with ID #{hit.id} and URL #{hit.url}", :inbound_event => event
|
|
| 252 |
+ end |
|
| 253 |
+ |
|
| 254 |
+ # RTurk Question Form |
|
| 255 |
+ |
|
| 256 |
+ class AgentQuestionForm < RTurk::QuestionForm |
|
| 257 |
+ needs :title, :description, :questions |
|
| 258 |
+ |
|
| 259 |
+ def question_form_content |
|
| 260 |
+ Overview do |
|
| 261 |
+ Title do |
|
| 262 |
+ text @title |
|
| 263 |
+ end |
|
| 264 |
+ Text do |
|
| 265 |
+ text @description |
|
| 266 |
+ end |
|
| 267 |
+ end |
|
| 268 |
+ |
|
| 269 |
+ @questions.each.with_index do |question, index| |
|
| 270 |
+ Question do |
|
| 271 |
+ QuestionIdentifier do |
|
| 272 |
+ text question[:key] || "question_#{index}"
|
|
| 273 |
+ end |
|
| 274 |
+ DisplayName do |
|
| 275 |
+ text question[:name] || "Question ##{index}"
|
|
| 276 |
+ end |
|
| 277 |
+ IsRequired do |
|
| 278 |
+ text question[:required] || 'true' |
|
| 279 |
+ end |
|
| 280 |
+ QuestionContent do |
|
| 281 |
+ Text do |
|
| 282 |
+ text question[:question] |
|
| 283 |
+ end |
|
| 284 |
+ end |
|
| 285 |
+ AnswerSpecification do |
|
| 286 |
+ if question[:type] == "selection" |
|
| 287 |
+ |
|
| 288 |
+ SelectionAnswer do |
|
| 289 |
+ StyleSuggestion do |
|
| 290 |
+ text 'radiobutton' |
|
| 291 |
+ end |
|
| 292 |
+ Selections do |
|
| 293 |
+ question[:selections].each do |selection| |
|
| 294 |
+ Selection do |
|
| 295 |
+ SelectionIdentifier do |
|
| 296 |
+ text selection[:key] |
|
| 297 |
+ end |
|
| 298 |
+ Text do |
|
| 299 |
+ text selection[:text] |
|
| 300 |
+ end |
|
| 301 |
+ end |
|
| 302 |
+ end |
|
| 303 |
+ end |
|
| 304 |
+ end |
|
| 305 |
+ |
|
| 306 |
+ else |
|
| 307 |
+ |
|
| 308 |
+ FreeTextAnswer do |
|
| 309 |
+ if question[:min_length].present? || question[:max_length].present? |
|
| 310 |
+ Constraints do |
|
| 311 |
+ lengths = {}
|
|
| 312 |
+ lengths[:minLength] = question[:min_length].to_s if question[:min_length].present? |
|
| 313 |
+ lengths[:maxLength] = question[:max_length].to_s if question[:max_length].present? |
|
| 314 |
+ Length lengths |
|
| 315 |
+ end |
|
| 316 |
+ end |
|
| 317 |
+ |
|
| 318 |
+ if question[:default].present? |
|
| 319 |
+ DefaultText do |
|
| 320 |
+ text question[:default] |
|
| 321 |
+ end |
|
| 322 |
+ end |
|
| 323 |
+ end |
|
| 324 |
+ |
|
| 325 |
+ end |
|
| 326 |
+ end |
|
| 327 |
+ end |
|
| 328 |
+ end |
|
| 329 |
+ end |
|
| 330 |
+ end |
|
| 331 |
+ end |
|
| 332 |
+end |
@@ -17,4 +17,8 @@ class Event < ActiveRecord::Base |
||
| 17 | 17 |
def symbolize_payload |
| 18 | 18 |
self.payload = payload.recursively_symbolize_keys if payload.is_a?(Hash) |
| 19 | 19 |
end |
| 20 |
+ |
|
| 21 |
+ def reemit! |
|
| 22 |
+ agent.create_event :payload => payload, :lat => lat, :lng => lng |
|
| 23 |
+ end |
|
| 20 | 24 |
end |
@@ -24,6 +24,7 @@ |
||
| 24 | 24 |
<td> |
| 25 | 25 |
<div class="btn-group"> |
| 26 | 26 |
<%= link_to 'Show', event_path(event), class: "btn btn-mini" %> |
| 27 |
+ <%= link_to 'Re-emit', reemit_event_path(event), method: :post, data: { confirm: 'Are you sure you want to duplicate this event and emit the new one now?' }, class: "btn btn-mini" %>
|
|
| 27 | 28 |
<%= link_to 'Delete', event_path(event), method: :delete, data: { confirm: 'Are you sure?' }, class: "btn btn-mini" %>
|
| 28 | 29 |
</div> |
| 29 | 30 |
</td> |
@@ -19,7 +19,13 @@ Huginn::Application.routes.draw do |
||
| 19 | 19 |
end |
| 20 | 20 |
end |
| 21 | 21 |
end |
| 22 |
- resources :events, :only => [:index, :show, :destroy] |
|
| 22 |
+ |
|
| 23 |
+ resources :events, :only => [:index, :show, :destroy] do |
|
| 24 |
+ member do |
|
| 25 |
+ post :reemit |
|
| 26 |
+ end |
|
| 27 |
+ end |
|
| 28 |
+ |
|
| 23 | 29 |
match "/worker_status" => "worker_status#show" |
| 24 | 30 |
|
| 25 | 31 |
post "/users/:user_id/update_location/:secret" => "user_location_updates#create" |
@@ -32,6 +32,25 @@ module Utils |
||
| 32 | 32 |
end |
| 33 | 33 |
end |
| 34 | 34 |
|
| 35 |
+ def self.interpolate_jsonpaths(value, data) |
|
| 36 |
+ value.gsub(/<[^>]+>/).each { |jsonpath|
|
|
| 37 |
+ Utils.values_at(data, jsonpath[1..-2]).first.to_s |
|
| 38 |
+ } |
|
| 39 |
+ end |
|
| 40 |
+ |
|
| 41 |
+ def self.recursively_interpolate_jsonpaths(struct, data) |
|
| 42 |
+ case struct |
|
| 43 |
+ when Hash |
|
| 44 |
+ struct.inject({}) {|memo, (key, value)| memo[key] = recursively_interpolate_jsonpaths(value, data); memo }
|
|
| 45 |
+ when Array |
|
| 46 |
+ struct.map {|elem| recursively_interpolate_jsonpaths(elem, data) }
|
|
| 47 |
+ when String |
|
| 48 |
+ interpolate_jsonpaths(struct, data) |
|
| 49 |
+ else |
|
| 50 |
+ struct |
|
| 51 |
+ end |
|
| 52 |
+ end |
|
| 53 |
+ |
|
| 35 | 54 |
def self.value_at(data, path) |
| 36 | 55 |
values_at(data, path).first |
| 37 | 56 |
end |
@@ -7,7 +7,7 @@ describe EventsController do |
||
| 7 | 7 |
end |
| 8 | 8 |
|
| 9 | 9 |
describe "GET index" do |
| 10 |
- it "only returns Agents for the current user" do |
|
| 10 |
+ it "only returns Events created by Agents of the current user" do |
|
| 11 | 11 |
sign_in users(:bob) |
| 12 | 12 |
get :index |
| 13 | 13 |
assigns(:events).all? {|i| i.user.should == users(:bob) }.should be_true
|
@@ -37,6 +37,28 @@ describe EventsController do |
||
| 37 | 37 |
end |
| 38 | 38 |
end |
| 39 | 39 |
|
| 40 |
+ describe "POST reemit" do |
|
| 41 |
+ before do |
|
| 42 |
+ request.env["HTTP_REFERER"] = "/events" |
|
| 43 |
+ sign_in users(:bob) |
|
| 44 |
+ end |
|
| 45 |
+ |
|
| 46 |
+ it "clones and re-emits events" do |
|
| 47 |
+ lambda {
|
|
| 48 |
+ post :reemit, :id => events(:bob_website_agent_event).to_param |
|
| 49 |
+ }.should change { Event.count }.by(1)
|
|
| 50 |
+ Event.last.payload.should == events(:bob_website_agent_event).payload |
|
| 51 |
+ Event.last.agent.should == events(:bob_website_agent_event).agent |
|
| 52 |
+ Event.last.created_at.should be_within(1).of(Time.now) |
|
| 53 |
+ end |
|
| 54 |
+ |
|
| 55 |
+ it "can only re-emit Events for the current user" do |
|
| 56 |
+ lambda {
|
|
| 57 |
+ post :reemit, :id => events(:jane_website_agent_event).to_param |
|
| 58 |
+ }.should raise_error(ActiveRecord::RecordNotFound) |
|
| 59 |
+ end |
|
| 60 |
+ end |
|
| 61 |
+ |
|
| 40 | 62 |
describe "DELETE destroy" do |
| 41 | 63 |
it "only deletes events for the current user" do |
| 42 | 64 |
sign_in users(:bob) |
@@ -27,6 +27,36 @@ describe Utils do |
||
| 27 | 27 |
end |
| 28 | 28 |
end |
| 29 | 29 |
|
| 30 |
+ describe "#interpolate_jsonpaths" do |
|
| 31 |
+ it "interpolates jsonpath expressions between matching <>'s" do |
|
| 32 |
+ Utils.interpolate_jsonpaths("hello <$.there.world> this <escape works>", { :there => { :world => "WORLD" }, :works => "should work" }).should == "hello WORLD this should+work"
|
|
| 33 |
+ end |
|
| 34 |
+ end |
|
| 35 |
+ |
|
| 36 |
+ describe "#recursively_interpolate_jsonpaths" do |
|
| 37 |
+ it "interpolates all string values in a structure" do |
|
| 38 |
+ struct = {
|
|
| 39 |
+ :int => 5, |
|
| 40 |
+ :string => "this <escape $.works>", |
|
| 41 |
+ :array => ["<works>", "now", "<$.there.world>"], |
|
| 42 |
+ :deep => {
|
|
| 43 |
+ :string => "hello <there.world>", |
|
| 44 |
+ :hello => :world |
|
| 45 |
+ } |
|
| 46 |
+ } |
|
| 47 |
+ data = { :there => { :world => "WORLD" }, :works => "should work" }
|
|
| 48 |
+ Utils.recursively_interpolate_jsonpaths(struct, data).should == {
|
|
| 49 |
+ :int => 5, |
|
| 50 |
+ :string => "this should+work", |
|
| 51 |
+ :array => ["should work", "now", "WORLD"], |
|
| 52 |
+ :deep => {
|
|
| 53 |
+ :string => "hello WORLD", |
|
| 54 |
+ :hello => :world |
|
| 55 |
+ } |
|
| 56 |
+ } |
|
| 57 |
+ end |
|
| 58 |
+ end |
|
| 59 |
+ |
|
| 30 | 60 |
describe "#value_at" do |
| 31 | 61 |
it "returns the value at a JSON path" do |
| 32 | 62 |
Utils.value_at({ :foo => { :bar => :baz }}.to_json, "foo.bar").should == "baz"
|
@@ -0,0 +1,429 @@ |
||
| 1 |
+require 'spec_helper' |
|
| 2 |
+ |
|
| 3 |
+describe Agents::HumanTaskAgent do |
|
| 4 |
+ before do |
|
| 5 |
+ @checker = Agents::HumanTaskAgent.new(:name => "my human task agent") |
|
| 6 |
+ @checker.options = @checker.default_options |
|
| 7 |
+ @checker.user = users(:bob) |
|
| 8 |
+ @checker.save! |
|
| 9 |
+ |
|
| 10 |
+ @event = Event.new |
|
| 11 |
+ @event.agent = agents(:bob_rain_notifier_agent) |
|
| 12 |
+ @event.payload = { :foo => { "bar" => { :baz => "a2b" } },
|
|
| 13 |
+ :name => "Joe" } |
|
| 14 |
+ @event.id = 345 |
|
| 15 |
+ |
|
| 16 |
+ @checker.should be_valid |
|
| 17 |
+ end |
|
| 18 |
+ |
|
| 19 |
+ describe "validations" do |
|
| 20 |
+ it "validates that trigger_on is 'schedule' or 'event'" do |
|
| 21 |
+ @checker.options[:trigger_on] = "foo" |
|
| 22 |
+ @checker.should_not be_valid |
|
| 23 |
+ end |
|
| 24 |
+ |
|
| 25 |
+ it "requires expected_receive_period_in_days when trigger_on is set to 'event'" do |
|
| 26 |
+ @checker.options[:trigger_on] = "event" |
|
| 27 |
+ @checker.options[:expected_receive_period_in_days] = nil |
|
| 28 |
+ @checker.should_not be_valid |
|
| 29 |
+ @checker.options[:expected_receive_period_in_days] = 2 |
|
| 30 |
+ @checker.should be_valid |
|
| 31 |
+ end |
|
| 32 |
+ |
|
| 33 |
+ it "requires a positive submission_period when trigger_on is set to 'schedule'" do |
|
| 34 |
+ @checker.options[:trigger_on] = "schedule" |
|
| 35 |
+ @checker.options[:submission_period] = nil |
|
| 36 |
+ @checker.should_not be_valid |
|
| 37 |
+ @checker.options[:submission_period] = 2 |
|
| 38 |
+ @checker.should be_valid |
|
| 39 |
+ end |
|
| 40 |
+ |
|
| 41 |
+ it "requires a hit.title" do |
|
| 42 |
+ @checker.options[:hit][:title] = "" |
|
| 43 |
+ @checker.should_not be_valid |
|
| 44 |
+ end |
|
| 45 |
+ |
|
| 46 |
+ it "requires a hit.description" do |
|
| 47 |
+ @checker.options[:hit][:description] = "" |
|
| 48 |
+ @checker.should_not be_valid |
|
| 49 |
+ end |
|
| 50 |
+ |
|
| 51 |
+ it "requires hit.assignments" do |
|
| 52 |
+ @checker.options[:hit][:assignments] = "" |
|
| 53 |
+ @checker.should_not be_valid |
|
| 54 |
+ @checker.options[:hit][:assignments] = 0 |
|
| 55 |
+ @checker.should_not be_valid |
|
| 56 |
+ @checker.options[:hit][:assignments] = "moose" |
|
| 57 |
+ @checker.should_not be_valid |
|
| 58 |
+ @checker.options[:hit][:assignments] = "2" |
|
| 59 |
+ @checker.should be_valid |
|
| 60 |
+ end |
|
| 61 |
+ |
|
| 62 |
+ it "requires hit.questions" do |
|
| 63 |
+ old_questions = @checker.options[:hit][:questions] |
|
| 64 |
+ @checker.options[:hit][:questions] = nil |
|
| 65 |
+ @checker.should_not be_valid |
|
| 66 |
+ @checker.options[:hit][:questions] = [] |
|
| 67 |
+ @checker.should_not be_valid |
|
| 68 |
+ @checker.options[:hit][:questions] = [old_questions[0]] |
|
| 69 |
+ @checker.should be_valid |
|
| 70 |
+ end |
|
| 71 |
+ |
|
| 72 |
+ it "requires that all questions have key, name, required, type, and question" do |
|
| 73 |
+ old_questions = @checker.options[:hit][:questions] |
|
| 74 |
+ @checker.options[:hit][:questions].first[:key] = "" |
|
| 75 |
+ @checker.should_not be_valid |
|
| 76 |
+ |
|
| 77 |
+ @checker.options[:hit][:questions] = old_questions |
|
| 78 |
+ @checker.options[:hit][:questions].first[:name] = "" |
|
| 79 |
+ @checker.should_not be_valid |
|
| 80 |
+ |
|
| 81 |
+ @checker.options[:hit][:questions] = old_questions |
|
| 82 |
+ @checker.options[:hit][:questions].first[:required] = nil |
|
| 83 |
+ @checker.should_not be_valid |
|
| 84 |
+ |
|
| 85 |
+ @checker.options[:hit][:questions] = old_questions |
|
| 86 |
+ @checker.options[:hit][:questions].first[:type] = "" |
|
| 87 |
+ @checker.should_not be_valid |
|
| 88 |
+ |
|
| 89 |
+ @checker.options[:hit][:questions] = old_questions |
|
| 90 |
+ @checker.options[:hit][:questions].first[:question] = "" |
|
| 91 |
+ @checker.should_not be_valid |
|
| 92 |
+ end |
|
| 93 |
+ |
|
| 94 |
+ it "requires that all questions of type 'selection' have a selections array with keys and text" do |
|
| 95 |
+ @checker.options[:hit][:questions][0][:selections] = [] |
|
| 96 |
+ @checker.should_not be_valid |
|
| 97 |
+ @checker.options[:hit][:questions][0][:selections] = [{}]
|
|
| 98 |
+ @checker.should_not be_valid |
|
| 99 |
+ @checker.options[:hit][:questions][0][:selections] = [{ :key => "", :text => "" }]
|
|
| 100 |
+ @checker.should_not be_valid |
|
| 101 |
+ @checker.options[:hit][:questions][0][:selections] = [{ :key => "", :text => "hi" }]
|
|
| 102 |
+ @checker.should_not be_valid |
|
| 103 |
+ @checker.options[:hit][:questions][0][:selections] = [{ :key => "hi", :text => "" }]
|
|
| 104 |
+ @checker.should_not be_valid |
|
| 105 |
+ @checker.options[:hit][:questions][0][:selections] = [{ :key => "hi", :text => "hi" }]
|
|
| 106 |
+ @checker.should be_valid |
|
| 107 |
+ @checker.options[:hit][:questions][0][:selections] = [{ :key => "hi", :text => "hi" }, {}]
|
|
| 108 |
+ @checker.should_not be_valid |
|
| 109 |
+ end |
|
| 110 |
+ |
|
| 111 |
+ it "requires that all questions be of type 'selection' when `take_majority` is `true`" do |
|
| 112 |
+ @checker.options[:take_majority] = "true" |
|
| 113 |
+ @checker.should_not be_valid |
|
| 114 |
+ @checker.options[:hit][:questions][1][:type] = "selection" |
|
| 115 |
+ @checker.options[:hit][:questions][1][:selections] = @checker.options[:hit][:questions][0][:selections] |
|
| 116 |
+ @checker.should be_valid |
|
| 117 |
+ end |
|
| 118 |
+ end |
|
| 119 |
+ |
|
| 120 |
+ describe "when 'trigger_on' is set to 'schedule'" do |
|
| 121 |
+ before do |
|
| 122 |
+ @checker.options[:trigger_on] = "schedule" |
|
| 123 |
+ @checker.options[:submission_period] = "2" |
|
| 124 |
+ @checker.options.delete(:expected_receive_period_in_days) |
|
| 125 |
+ end |
|
| 126 |
+ |
|
| 127 |
+ it "should check for reviewable HITs frequently" do |
|
| 128 |
+ mock(@checker).review_hits.twice |
|
| 129 |
+ mock(@checker).create_hit.once |
|
| 130 |
+ @checker.check |
|
| 131 |
+ @checker.check |
|
| 132 |
+ end |
|
| 133 |
+ |
|
| 134 |
+ it "should create HITs every 'submission_period' hours" do |
|
| 135 |
+ now = Time.now |
|
| 136 |
+ stub(Time).now { now }
|
|
| 137 |
+ mock(@checker).review_hits.times(3) |
|
| 138 |
+ mock(@checker).create_hit.twice |
|
| 139 |
+ @checker.check |
|
| 140 |
+ now += 1 * 60 * 60 |
|
| 141 |
+ @checker.check |
|
| 142 |
+ now += 1 * 60 * 60 |
|
| 143 |
+ @checker.check |
|
| 144 |
+ end |
|
| 145 |
+ |
|
| 146 |
+ it "should ignore events" do |
|
| 147 |
+ mock(@checker).create_hit(anything).times(0) |
|
| 148 |
+ @checker.receive([events(:bob_website_agent_event)]) |
|
| 149 |
+ end |
|
| 150 |
+ end |
|
| 151 |
+ |
|
| 152 |
+ describe "when 'trigger_on' is set to 'event'" do |
|
| 153 |
+ it "should not create HITs during check but should check for reviewable HITs" do |
|
| 154 |
+ @checker.options[:submission_period] = "2" |
|
| 155 |
+ now = Time.now |
|
| 156 |
+ stub(Time).now { now }
|
|
| 157 |
+ mock(@checker).review_hits.times(3) |
|
| 158 |
+ mock(@checker).create_hit.times(0) |
|
| 159 |
+ @checker.check |
|
| 160 |
+ now += 1 * 60 * 60 |
|
| 161 |
+ @checker.check |
|
| 162 |
+ now += 1 * 60 * 60 |
|
| 163 |
+ @checker.check |
|
| 164 |
+ end |
|
| 165 |
+ |
|
| 166 |
+ it "should create HITs based on events" do |
|
| 167 |
+ mock(@checker).create_hit(events(:bob_website_agent_event)).times(1) |
|
| 168 |
+ @checker.receive([events(:bob_website_agent_event)]) |
|
| 169 |
+ end |
|
| 170 |
+ end |
|
| 171 |
+ |
|
| 172 |
+ describe "creating hits" do |
|
| 173 |
+ it "can create HITs based on events, interpolating their values" do |
|
| 174 |
+ @checker.options[:hit][:title] = "Hi <.name>" |
|
| 175 |
+ @checker.options[:hit][:description] = "Make something for <.name>" |
|
| 176 |
+ @checker.options[:hit][:questions][0][:name] = "<.name> Question 1" |
|
| 177 |
+ |
|
| 178 |
+ question_form = nil |
|
| 179 |
+ hitInterface = OpenStruct.new |
|
| 180 |
+ hitInterface.id = 123 |
|
| 181 |
+ mock(hitInterface).question_form(instance_of Agents::HumanTaskAgent::AgentQuestionForm) { |agent_question_form_instance| question_form = agent_question_form_instance }
|
|
| 182 |
+ mock(RTurk::Hit).create(:title => "Hi Joe").yields(hitInterface) { hitInterface }
|
|
| 183 |
+ |
|
| 184 |
+ @checker.send :create_hit, @event |
|
| 185 |
+ |
|
| 186 |
+ hitInterface.max_assignments.should == @checker.options[:hit][:assignments] |
|
| 187 |
+ hitInterface.reward.should == @checker.options[:hit][:reward] |
|
| 188 |
+ hitInterface.description.should == "Make something for Joe" |
|
| 189 |
+ |
|
| 190 |
+ xml = question_form.to_xml |
|
| 191 |
+ xml.should include("<Title>Hi Joe</Title>")
|
|
| 192 |
+ xml.should include("<Text>Make something for Joe</Text>")
|
|
| 193 |
+ xml.should include("<DisplayName>Joe Question 1</DisplayName>")
|
|
| 194 |
+ |
|
| 195 |
+ @checker.memory[:hits][123].should == @event.id |
|
| 196 |
+ end |
|
| 197 |
+ |
|
| 198 |
+ it "works without an event too" do |
|
| 199 |
+ @checker.options[:hit][:title] = "Hi <.name>" |
|
| 200 |
+ hitInterface = OpenStruct.new |
|
| 201 |
+ hitInterface.id = 123 |
|
| 202 |
+ mock(hitInterface).question_form(instance_of Agents::HumanTaskAgent::AgentQuestionForm) |
|
| 203 |
+ mock(RTurk::Hit).create(:title => "Hi").yields(hitInterface) { hitInterface }
|
|
| 204 |
+ @checker.send :create_hit |
|
| 205 |
+ hitInterface.max_assignments.should == @checker.options[:hit][:assignments] |
|
| 206 |
+ hitInterface.reward.should == @checker.options[:hit][:reward] |
|
| 207 |
+ end |
|
| 208 |
+ end |
|
| 209 |
+ |
|
| 210 |
+ describe "reviewing HITs" do |
|
| 211 |
+ class FakeHit |
|
| 212 |
+ def initialize(options = {})
|
|
| 213 |
+ @options = options |
|
| 214 |
+ end |
|
| 215 |
+ |
|
| 216 |
+ def assignments |
|
| 217 |
+ @options[:assignments] || [] |
|
| 218 |
+ end |
|
| 219 |
+ |
|
| 220 |
+ def max_assignments |
|
| 221 |
+ @options[:max_assignments] || 1 |
|
| 222 |
+ end |
|
| 223 |
+ end |
|
| 224 |
+ |
|
| 225 |
+ class FakeAssignment |
|
| 226 |
+ attr_accessor :approved |
|
| 227 |
+ |
|
| 228 |
+ def initialize(options = {})
|
|
| 229 |
+ @options = options |
|
| 230 |
+ end |
|
| 231 |
+ |
|
| 232 |
+ def answers |
|
| 233 |
+ @options[:answers] || {}
|
|
| 234 |
+ end |
|
| 235 |
+ |
|
| 236 |
+ def status |
|
| 237 |
+ @options[:status] || "" |
|
| 238 |
+ end |
|
| 239 |
+ |
|
| 240 |
+ def approve! |
|
| 241 |
+ @approved = true |
|
| 242 |
+ end |
|
| 243 |
+ end |
|
| 244 |
+ |
|
| 245 |
+ it "should work on multiple HITs" do |
|
| 246 |
+ event2 = Event.new |
|
| 247 |
+ event2.agent = agents(:bob_rain_notifier_agent) |
|
| 248 |
+ event2.payload = { :foo2 => { "bar2" => { :baz2 => "a2b2" } },
|
|
| 249 |
+ :name2 => "Joe2" } |
|
| 250 |
+ event2.id = 3452 |
|
| 251 |
+ |
|
| 252 |
+ # It knows about two HITs from two different events. |
|
| 253 |
+ @checker.memory[:hits] = {}
|
|
| 254 |
+ @checker.memory[:hits][:"JH3132836336DHG"] = @event.id |
|
| 255 |
+ @checker.memory[:hits][:"JH39AA63836DHG"] = event2.id |
|
| 256 |
+ |
|
| 257 |
+ hit_ids = %w[JH3132836336DHG JH39AA63836DHG JH39AA63836DH12345] |
|
| 258 |
+ mock(RTurk::GetReviewableHITs).create { mock!.hit_ids { hit_ids } } # It sees 3 HITs.
|
|
| 259 |
+ |
|
| 260 |
+ # It looksup the two HITs that it owns. Neither are ready yet. |
|
| 261 |
+ mock(RTurk::Hit).new("JH3132836336DHG") { FakeHit.new }
|
|
| 262 |
+ mock(RTurk::Hit).new("JH39AA63836DHG") { FakeHit.new }
|
|
| 263 |
+ |
|
| 264 |
+ @checker.send :review_hits |
|
| 265 |
+ end |
|
| 266 |
+ |
|
| 267 |
+ it "shouldn't do anything if an assignment isn't ready" do |
|
| 268 |
+ @checker.memory[:hits] = { :"JH3132836336DHG" => @event.id }
|
|
| 269 |
+ mock(RTurk::GetReviewableHITs).create { mock!.hit_ids { %w[JH3132836336DHG JH39AA63836DHG JH39AA63836DH12345] } }
|
|
| 270 |
+ assignments = [ |
|
| 271 |
+ FakeAssignment.new(:status => "Accepted", :answers => {}),
|
|
| 272 |
+ FakeAssignment.new(:status => "Submitted", :answers => {"sentiment"=>"happy", "feedback"=>"Take 2"})
|
|
| 273 |
+ ] |
|
| 274 |
+ hit = FakeHit.new(:max_assignments => 2, :assignments => assignments) |
|
| 275 |
+ mock(RTurk::Hit).new("JH3132836336DHG") { hit }
|
|
| 276 |
+ |
|
| 277 |
+ # One of the assignments isn't set to "Submitted", so this should get skipped for now. |
|
| 278 |
+ mock.any_instance_of(FakeAssignment).answers.times(0) |
|
| 279 |
+ |
|
| 280 |
+ @checker.send :review_hits |
|
| 281 |
+ |
|
| 282 |
+ assignments.all? {|a| a.approved == true }.should be_false
|
|
| 283 |
+ @checker.memory[:hits].should == { :"JH3132836336DHG" => @event.id }
|
|
| 284 |
+ end |
|
| 285 |
+ |
|
| 286 |
+ it "shouldn't do anything if an assignment is missing" do |
|
| 287 |
+ @checker.memory[:hits] = { :"JH3132836336DHG" => @event.id }
|
|
| 288 |
+ mock(RTurk::GetReviewableHITs).create { mock!.hit_ids { %w[JH3132836336DHG JH39AA63836DHG JH39AA63836DH12345] } }
|
|
| 289 |
+ assignments = [ |
|
| 290 |
+ FakeAssignment.new(:status => "Submitted", :answers => {"sentiment"=>"happy", "feedback"=>"Take 2"})
|
|
| 291 |
+ ] |
|
| 292 |
+ hit = FakeHit.new(:max_assignments => 2, :assignments => assignments) |
|
| 293 |
+ mock(RTurk::Hit).new("JH3132836336DHG") { hit }
|
|
| 294 |
+ |
|
| 295 |
+ # One of the assignments hasn't shown up yet, so this should get skipped for now. |
|
| 296 |
+ mock.any_instance_of(FakeAssignment).answers.times(0) |
|
| 297 |
+ |
|
| 298 |
+ @checker.send :review_hits |
|
| 299 |
+ |
|
| 300 |
+ assignments.all? {|a| a.approved == true }.should be_false
|
|
| 301 |
+ @checker.memory[:hits].should == { :"JH3132836336DHG" => @event.id }
|
|
| 302 |
+ end |
|
| 303 |
+ |
|
| 304 |
+ it "should create events when all assignments are ready" do |
|
| 305 |
+ @checker.memory[:hits] = { :"JH3132836336DHG" => @event.id }
|
|
| 306 |
+ mock(RTurk::GetReviewableHITs).create { mock!.hit_ids { %w[JH3132836336DHG JH39AA63836DHG JH39AA63836DH12345] } }
|
|
| 307 |
+ assignments = [ |
|
| 308 |
+ FakeAssignment.new(:status => "Submitted", :answers => {"sentiment"=>"neutral", "feedback"=>""}),
|
|
| 309 |
+ FakeAssignment.new(:status => "Submitted", :answers => {"sentiment"=>"happy", "feedback"=>"Take 2"})
|
|
| 310 |
+ ] |
|
| 311 |
+ hit = FakeHit.new(:max_assignments => 2, :assignments => assignments) |
|
| 312 |
+ mock(RTurk::Hit).new("JH3132836336DHG") { hit }
|
|
| 313 |
+ |
|
| 314 |
+ lambda {
|
|
| 315 |
+ @checker.send :review_hits |
|
| 316 |
+ }.should change { Event.count }.by(1)
|
|
| 317 |
+ |
|
| 318 |
+ assignments.all? {|a| a.approved == true }.should be_true
|
|
| 319 |
+ |
|
| 320 |
+ @checker.events.last.payload[:answers].should == [ |
|
| 321 |
+ {:sentiment => "neutral", :feedback => ""},
|
|
| 322 |
+ {:sentiment => "happy", :feedback => "Take 2"}
|
|
| 323 |
+ ] |
|
| 324 |
+ |
|
| 325 |
+ @checker.memory[:hits].should == {}
|
|
| 326 |
+ end |
|
| 327 |
+ |
|
| 328 |
+ describe "taking majority votes" do |
|
| 329 |
+ before do |
|
| 330 |
+ @checker.options[:take_majority] = "true" |
|
| 331 |
+ @checker.memory[:hits] = { :"JH3132836336DHG" => @event.id }
|
|
| 332 |
+ mock(RTurk::GetReviewableHITs).create { mock!.hit_ids { %w[JH3132836336DHG JH39AA63836DHG JH39AA63836DH12345] } }
|
|
| 333 |
+ end |
|
| 334 |
+ |
|
| 335 |
+ it "should take the majority votes of all questions" do |
|
| 336 |
+ @checker.options[:hit][:questions][1] = {
|
|
| 337 |
+ :type => "selection", |
|
| 338 |
+ :key => "age_range", |
|
| 339 |
+ :name => "Age Range", |
|
| 340 |
+ :required => "true", |
|
| 341 |
+ :question => "Please select your age range:", |
|
| 342 |
+ :selections => |
|
| 343 |
+ [ |
|
| 344 |
+ { :key => "<50", :text => "50 years old or younger" },
|
|
| 345 |
+ { :key => ">50", :text => "Over 50 years old" }
|
|
| 346 |
+ ] |
|
| 347 |
+ } |
|
| 348 |
+ |
|
| 349 |
+ assignments = [ |
|
| 350 |
+ FakeAssignment.new(:status => "Submitted", :answers => {"sentiment"=>"sad", "age_range"=>"<50"}),
|
|
| 351 |
+ FakeAssignment.new(:status => "Submitted", :answers => {"sentiment"=>"neutral", "age_range"=>">50"}),
|
|
| 352 |
+ FakeAssignment.new(:status => "Submitted", :answers => {"sentiment"=>"happy", "age_range"=>">50"}),
|
|
| 353 |
+ FakeAssignment.new(:status => "Submitted", :answers => {"sentiment"=>"happy", "age_range"=>">50"})
|
|
| 354 |
+ ] |
|
| 355 |
+ hit = FakeHit.new(:max_assignments => 4, :assignments => assignments) |
|
| 356 |
+ mock(RTurk::Hit).new("JH3132836336DHG") { hit }
|
|
| 357 |
+ |
|
| 358 |
+ lambda {
|
|
| 359 |
+ @checker.send :review_hits |
|
| 360 |
+ }.should change { Event.count }.by(1)
|
|
| 361 |
+ |
|
| 362 |
+ assignments.all? {|a| a.approved == true }.should be_true
|
|
| 363 |
+ |
|
| 364 |
+ @checker.events.last.payload[:answers].should == [ |
|
| 365 |
+ { :sentiment => "sad", :age_range => "<50" },
|
|
| 366 |
+ { :sentiment => "neutral", :age_range => ">50" },
|
|
| 367 |
+ { :sentiment => "happy", :age_range => ">50" },
|
|
| 368 |
+ { :sentiment => "happy", :age_range => ">50" }
|
|
| 369 |
+ ] |
|
| 370 |
+ |
|
| 371 |
+ @checker.events.last.payload[:counts].should == { :sentiment => { :happy => 2, :sad => 1, :neutral => 1 }, :age_range => { :">50" => 3, :"<50" => 1 } }
|
|
| 372 |
+ @checker.events.last.payload[:majority_answer].should == { :sentiment => "happy", :age_range => ">50" }
|
|
| 373 |
+ @checker.events.last.payload.should_not have_key(:average_answer) |
|
| 374 |
+ |
|
| 375 |
+ @checker.memory[:hits].should == {}
|
|
| 376 |
+ end |
|
| 377 |
+ |
|
| 378 |
+ it "should also provide an average answer when all questions are numeric" do |
|
| 379 |
+ @checker.options[:hit][:questions] = [ |
|
| 380 |
+ {
|
|
| 381 |
+ :type => "selection", |
|
| 382 |
+ :key => "rating", |
|
| 383 |
+ :name => "Rating", |
|
| 384 |
+ :required => "true", |
|
| 385 |
+ :question => "Please select a rating:", |
|
| 386 |
+ :selections => |
|
| 387 |
+ [ |
|
| 388 |
+ { :key => "1", :text => "One" },
|
|
| 389 |
+ { :key => "2", :text => "Two" },
|
|
| 390 |
+ { :key => "3", :text => "Three" },
|
|
| 391 |
+ { :key => "4", :text => "Four" },
|
|
| 392 |
+ { :key => "5.1", :text => "Five Point One" }
|
|
| 393 |
+ ] |
|
| 394 |
+ } |
|
| 395 |
+ ] |
|
| 396 |
+ |
|
| 397 |
+ assignments = [ |
|
| 398 |
+ FakeAssignment.new(:status => "Submitted", :answers => { "rating"=>"1" }),
|
|
| 399 |
+ FakeAssignment.new(:status => "Submitted", :answers => { "rating"=>"3" }),
|
|
| 400 |
+ FakeAssignment.new(:status => "Submitted", :answers => { "rating"=>"5.1" }),
|
|
| 401 |
+ FakeAssignment.new(:status => "Submitted", :answers => { "rating"=>"2" }),
|
|
| 402 |
+ FakeAssignment.new(:status => "Submitted", :answers => { "rating"=>"2" })
|
|
| 403 |
+ ] |
|
| 404 |
+ hit = FakeHit.new(:max_assignments => 5, :assignments => assignments) |
|
| 405 |
+ mock(RTurk::Hit).new("JH3132836336DHG") { hit }
|
|
| 406 |
+ |
|
| 407 |
+ lambda {
|
|
| 408 |
+ @checker.send :review_hits |
|
| 409 |
+ }.should change { Event.count }.by(1)
|
|
| 410 |
+ |
|
| 411 |
+ assignments.all? {|a| a.approved == true }.should be_true
|
|
| 412 |
+ |
|
| 413 |
+ @checker.events.last.payload[:answers].should == [ |
|
| 414 |
+ { :rating => "1" },
|
|
| 415 |
+ { :rating => "3" },
|
|
| 416 |
+ { :rating => "5.1" },
|
|
| 417 |
+ { :rating => "2" },
|
|
| 418 |
+ { :rating => "2" }
|
|
| 419 |
+ ] |
|
| 420 |
+ |
|
| 421 |
+ @checker.events.last.payload[:counts].should == { :rating => { :"1" => 1, :"2" => 2, :"3" => 1, :"4" => 0, :"5.1" => 1 } }
|
|
| 422 |
+ @checker.events.last.payload[:majority_answer].should == { :rating => "2" }
|
|
| 423 |
+ @checker.events.last.payload[:average_answer].should == { :rating => (1 + 2 + 2 + 3 + 5.1) / 5.0 }
|
|
| 424 |
+ |
|
| 425 |
+ @checker.memory[:hits].should == {}
|
|
| 426 |
+ end |
|
| 427 |
+ end |
|
| 428 |
+ end |
|
| 429 |
+end |
@@ -1,14 +1,14 @@ |
||
| 1 | 1 |
require 'spec_helper' |
| 2 | 2 |
|
| 3 | 3 |
describe Agents::PostAgent do |
| 4 |
- before do |
|
| 5 |
- @valid_params = {
|
|
| 6 |
- :name => "somename", |
|
| 7 |
- :options => {
|
|
| 8 |
- :post_url => "http://www.example.com", |
|
| 9 |
- :expected_receive_period_in_days => 1 |
|
| 10 |
- } |
|
| 11 |
- } |
|
| 4 |
+ before do |
|
| 5 |
+ @valid_params = {
|
|
| 6 |
+ :name => "somename", |
|
| 7 |
+ :options => {
|
|
| 8 |
+ :post_url => "http://www.example.com", |
|
| 9 |
+ :expected_receive_period_in_days => 1 |
|
| 10 |
+ } |
|
| 11 |
+ } |
|
| 12 | 12 |
|
| 13 | 13 |
@checker = Agents::PostAgent.new(@valid_params) |
| 14 | 14 |
@checker.user = users(:jane) |
@@ -17,55 +17,55 @@ describe Agents::PostAgent do |
||
| 17 | 17 |
@event = Event.new |
| 18 | 18 |
@event.agent = agents(:jane_weather_agent) |
| 19 | 19 |
@event.payload = {
|
| 20 |
- :somekey => "somevalue", |
|
| 21 |
- :someotherkey => {
|
|
| 22 |
- :somekey => "value" |
|
| 23 |
- } |
|
| 20 |
+ :somekey => "somevalue", |
|
| 21 |
+ :someotherkey => {
|
|
| 22 |
+ :somekey => "value" |
|
| 23 |
+ } |
|
| 24 | 24 |
} |
| 25 | 25 |
|
| 26 | 26 |
@sent_messages = [] |
| 27 |
- stub.any_instance_of(Agents::PostAgent).post_event { |uri,event| @sent_messages << event}
|
|
| 28 |
- end |
|
| 27 |
+ stub.any_instance_of(Agents::PostAgent).post_event { |uri, event| @sent_messages << event }
|
|
| 28 |
+ end |
|
| 29 | 29 |
|
| 30 |
- describe "#receive" do |
|
| 31 |
- it "checks if it can handle multiple events" do |
|
| 32 |
- event1 = Event.new |
|
| 33 |
- event1.agent = agents(:bob_weather_agent) |
|
| 34 |
- event1.payload = {
|
|
| 35 |
- :xyz => "value1", |
|
| 36 |
- :message => "value2" |
|
| 37 |
- } |
|
| 30 |
+ describe "#receive" do |
|
| 31 |
+ it "checks if it can handle multiple events" do |
|
| 32 |
+ event1 = Event.new |
|
| 33 |
+ event1.agent = agents(:bob_weather_agent) |
|
| 34 |
+ event1.payload = {
|
|
| 35 |
+ :xyz => "value1", |
|
| 36 |
+ :message => "value2" |
|
| 37 |
+ } |
|
| 38 | 38 |
|
| 39 |
- lambda {
|
|
| 40 |
- @checker.receive([@event,event1]) |
|
| 41 |
- }.should change { @sent_messages.length }.by(2)
|
|
| 42 |
- end |
|
| 39 |
+ lambda {
|
|
| 40 |
+ @checker.receive([@event, event1]) |
|
| 41 |
+ }.should change { @sent_messages.length }.by(2)
|
|
| 43 | 42 |
end |
| 43 |
+ end |
|
| 44 | 44 |
|
| 45 |
- describe "#working?" do |
|
| 46 |
- it "checks if events have been received within expected receive period" do |
|
| 47 |
- @checker.should_not be_working |
|
| 48 |
- Agents::PostAgent.async_receive @checker.id, [@event.id] |
|
| 49 |
- @checker.reload.should be_working |
|
| 50 |
- two_days_from_now = 2.days.from_now |
|
| 51 |
- stub(Time).now { two_days_from_now }
|
|
| 52 |
- @checker.reload.should_not be_working |
|
| 53 |
- end |
|
| 45 |
+ describe "#working?" do |
|
| 46 |
+ it "checks if events have been received within expected receive period" do |
|
| 47 |
+ @checker.should_not be_working |
|
| 48 |
+ Agents::PostAgent.async_receive @checker.id, [@event.id] |
|
| 49 |
+ @checker.reload.should be_working |
|
| 50 |
+ two_days_from_now = 2.days.from_now |
|
| 51 |
+ stub(Time).now { two_days_from_now }
|
|
| 52 |
+ @checker.reload.should_not be_working |
|
| 54 | 53 |
end |
| 54 |
+ end |
|
| 55 | 55 |
|
| 56 |
- describe "validation" do |
|
| 57 |
- before do |
|
| 58 |
- @checker.should be_valid |
|
| 59 |
- end |
|
| 56 |
+ describe "validation" do |
|
| 57 |
+ before do |
|
| 58 |
+ @checker.should be_valid |
|
| 59 |
+ end |
|
| 60 | 60 |
|
| 61 |
- it "should validate presence of post_url" do |
|
| 62 |
- @checker.options[:post_url] = "" |
|
| 63 |
- @checker.should_not be_valid |
|
| 64 |
- end |
|
| 61 |
+ it "should validate presence of post_url" do |
|
| 62 |
+ @checker.options[:post_url] = "" |
|
| 63 |
+ @checker.should_not be_valid |
|
| 64 |
+ end |
|
| 65 | 65 |
|
| 66 |
- it "should validate presence of expected_receive_period_in_days" do |
|
| 67 |
- @checker.options[:expected_receive_period_in_days] = "" |
|
| 68 |
- @checker.should_not be_valid |
|
| 69 |
- end |
|
| 66 |
+ it "should validate presence of expected_receive_period_in_days" do |
|
| 67 |
+ @checker.options[:expected_receive_period_in_days] = "" |
|
| 68 |
+ @checker.should_not be_valid |
|
| 70 | 69 |
end |
| 70 |
+ end |
|
| 71 | 71 |
end |
@@ -1,4 +1,19 @@ |
||
| 1 | 1 |
require 'spec_helper' |
| 2 | 2 |
|
| 3 | 3 |
describe Event do |
| 4 |
+ describe "#reemit" do |
|
| 5 |
+ it "creates a new event identical to itself" do |
|
| 6 |
+ events(:bob_website_agent_event).lat = 2 |
|
| 7 |
+ events(:bob_website_agent_event).lng = 3 |
|
| 8 |
+ events(:bob_website_agent_event).created_at = 2.weeks.ago |
|
| 9 |
+ lambda {
|
|
| 10 |
+ events(:bob_website_agent_event).reemit! |
|
| 11 |
+ }.should change { Event.count }.by(1)
|
|
| 12 |
+ Event.last.payload.should == events(:bob_website_agent_event).payload |
|
| 13 |
+ Event.last.agent.should == events(:bob_website_agent_event).agent |
|
| 14 |
+ Event.last.lat.should == 2 |
|
| 15 |
+ Event.last.lng.should == 3 |
|
| 16 |
+ Event.last.created_at.should be_within(1).of(Time.now) |
|
| 17 |
+ end |
|
| 18 |
+ end |
|
| 4 | 19 |
end |