human_task_agent.rb 19KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487
  1. require 'rturk'
  2. module Agents
  3. class HumanTaskAgent < Agent
  4. include LiquidInterpolatable
  5. default_schedule "every_10m"
  6. description <<-MD
  7. You can use a HumanTaskAgent to create Human Intelligence Tasks (HITs) on Mechanical Turk.
  8. HITs can be created in response to events, or on a schedule. Set `trigger_on` to either `schedule` or `event`.
  9. # Schedule
  10. 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
  11. should be submitted when in `schedule` mode, set `submission_period` to a number of hours.
  12. # Example
  13. If created with an event, all HIT fields can contain interpolated values via [liquid templating](https://github.com/cantino/huginn/wiki/Formatting-Events-using-Liquid).
  14. For example, if the incoming event was a Twitter event, you could make a HITT to rate its sentiment like this:
  15. {
  16. "expected_receive_period_in_days": 2,
  17. "trigger_on": "event",
  18. "hit": {
  19. "assignments": 1,
  20. "title": "Sentiment evaluation",
  21. "description": "Please rate the sentiment of this message: '{{message}}'",
  22. "reward": 0.05,
  23. "lifetime_in_seconds": "3600",
  24. "questions": [
  25. {
  26. "type": "selection",
  27. "key": "sentiment",
  28. "name": "Sentiment",
  29. "required": "true",
  30. "question": "Please select the best sentiment value:",
  31. "selections": [
  32. { "key": "happy", "text": "Happy" },
  33. { "key": "sad", "text": "Sad" },
  34. { "key": "neutral", "text": "Neutral" }
  35. ]
  36. },
  37. {
  38. "type": "free_text",
  39. "key": "feedback",
  40. "name": "Have any feedback for us?",
  41. "required": "false",
  42. "question": "Feedback",
  43. "default": "Type here...",
  44. "min_length": "2",
  45. "max_length": "2000"
  46. }
  47. ]
  48. }
  49. }
  50. As you can see, you configure the created HIT with the `hit` option. Required fields are `title`, which is the
  51. title of the created HIT, `description`, which is the description of the HIT, and `questions` which is an array of
  52. questions. Questions can be of `type` _selection_ or _free\\_text_. Both types require the `key`, `name`, `required`,
  53. `type`, and `question` configuration options. Additionally, _selection_ requires a `selections` array of options, each of
  54. which contain `key` and `text`. For _free\\_text_, the special configuration options are all optional, and are
  55. `default`, `min_length`, and `max_length`.
  56. # Combining answers
  57. There are a couple of ways to combine HITs that have multiple `assignments`, all of which involve setting `combination_mode` at the top level.
  58. ## Taking the majority
  59. Option 1: if all of your `questions` are of `type` _selection_, you can set `combination_mode` to `take_majority`.
  60. This will cause the Agent to automatically select the majority vote for each question across all `assignments` and return it as `majority_answer`.
  61. If all selections are numeric, an `average_answer` will also be generated.
  62. Option 2: you can have the Agent ask additional human workers to rank the `assignments` and return the most highly ranked answer.
  63. To do this, set `combination_mode` to `poll` and provide a `poll_options` object. Here is an example:
  64. {
  65. "trigger_on": "schedule",
  66. "submission_period": 12,
  67. "combination_mode": "poll",
  68. "poll_options": {
  69. "title": "Take a poll about some jokes",
  70. "instructions": "Please rank these jokes from most funny (5) to least funny (1)",
  71. "assignments": 3,
  72. "row_template": "{{joke}}"
  73. },
  74. "hit": {
  75. "assignments": 5,
  76. "title": "Tell a joke",
  77. "description": "Please tell me a joke",
  78. "reward": 0.05,
  79. "lifetime_in_seconds": "3600",
  80. "questions": [
  81. {
  82. "type": "free_text",
  83. "key": "joke",
  84. "name": "Your joke",
  85. "required": "true",
  86. "question": "Joke",
  87. "min_length": "2",
  88. "max_length": "2000"
  89. }
  90. ]
  91. }
  92. }
  93. Resulting events will have the original `answers`, as well as the `poll` results, and a field called `best_answer` that contains the best answer as determined by the poll.
  94. # Other settings
  95. `lifetime_in_seconds` is the number of seconds a HIT is left on Amazon before it's automatically closed. The default is 1 day.
  96. As with most Agents, `expected_receive_period_in_days` is required if `trigger_on` is set to `event`.
  97. MD
  98. event_description <<-MD
  99. Events look like:
  100. {
  101. "answers": [
  102. {
  103. "feedback": "Hello!",
  104. "sentiment": "happy"
  105. }
  106. ]
  107. }
  108. MD
  109. def validate_options
  110. options['hit'] ||= {}
  111. options['hit']['questions'] ||= []
  112. errors.add(:base, "'trigger_on' must be one of 'schedule' or 'event'") unless %w[schedule event].include?(options['trigger_on'])
  113. 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
  114. errors.add(:base, "'hit.title' must be provided") unless options['hit']['title'].present?
  115. errors.add(:base, "'hit.description' must be provided") unless options['hit']['description'].present?
  116. errors.add(:base, "'hit.questions' must be provided") unless options['hit']['questions'].present? && options['hit']['questions'].length > 0
  117. if options['trigger_on'] == "event"
  118. 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?
  119. elsif options['trigger_on'] == "schedule"
  120. 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
  121. end
  122. if options['hit']['questions'].any? { |question| %w[key name required type question].any? {|k| !question[k].present? } }
  123. errors.add(:base, "all questions must set 'key', 'name', 'required', 'type', and 'question'")
  124. end
  125. 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? })}
  126. errors.add(:base, "all questions of type 'selection' must have a selections array with selections that set 'key' and 'name'")
  127. end
  128. if take_majority? && options['hit']['questions'].any? { |question| question['type'] != "selection" }
  129. errors.add(:base, "all questions must be of type 'selection' to use the 'take_majority' option")
  130. end
  131. if create_poll?
  132. errors.add(:base, "poll_options is required when combination_mode is set to 'poll' and must have the keys 'title', 'instructions', 'row_template', and 'assignments'") unless options['poll_options'].is_a?(Hash) && options['poll_options']['title'].present? && options['poll_options']['instructions'].present? && options['poll_options']['row_template'].present? && options['poll_options']['assignments'].to_i > 0
  133. end
  134. end
  135. def default_options
  136. {
  137. 'expected_receive_period_in_days' => 2,
  138. 'trigger_on' => "event",
  139. 'hit' =>
  140. {
  141. 'assignments' => 1,
  142. 'title' => "Sentiment evaluation",
  143. 'description' => "Please rate the sentiment of this message: '{{message}}'",
  144. 'reward' => 0.05,
  145. 'lifetime_in_seconds' => 24 * 60 * 60,
  146. 'questions' =>
  147. [
  148. {
  149. 'type' => "selection",
  150. 'key' => "sentiment",
  151. 'name' => "Sentiment",
  152. 'required' => "true",
  153. 'question' => "Please select the best sentiment value:",
  154. 'selections' =>
  155. [
  156. { 'key' => "happy", 'text' => "Happy" },
  157. { 'key' => "sad", 'text' => "Sad" },
  158. { 'key' => "neutral", 'text' => "Neutral" }
  159. ]
  160. },
  161. {
  162. 'type' => "free_text",
  163. 'key' => "feedback",
  164. 'name' => "Have any feedback for us?",
  165. 'required' => "false",
  166. 'question' => "Feedback",
  167. 'default' => "Type here...",
  168. 'min_length' => "2",
  169. 'max_length' => "2000"
  170. }
  171. ]
  172. }
  173. }
  174. end
  175. def working?
  176. last_receive_at && last_receive_at > options['expected_receive_period_in_days'].to_i.days.ago && !recent_error_logs?
  177. end
  178. def check
  179. review_hits
  180. if options['trigger_on'] == "schedule" && (memory['last_schedule'] || 0) <= Time.now.to_i - options['submission_period'].to_i * 60 * 60
  181. memory['last_schedule'] = Time.now.to_i
  182. create_basic_hit
  183. end
  184. end
  185. def receive(incoming_events)
  186. if options['trigger_on'] == "event"
  187. incoming_events.each do |event|
  188. create_basic_hit event
  189. end
  190. end
  191. end
  192. protected
  193. def take_majority?
  194. options['combination_mode'] == "take_majority" || options['take_majority'] == "true"
  195. end
  196. def create_poll?
  197. options['combination_mode'] == "poll"
  198. end
  199. def event_for_hit(hit_id)
  200. if memory['hits'][hit_id].is_a?(Hash)
  201. Event.find_by_id(memory['hits'][hit_id]['event_id'])
  202. else
  203. nil
  204. end
  205. end
  206. def hit_type(hit_id)
  207. if memory['hits'][hit_id].is_a?(Hash) && memory['hits'][hit_id]['type']
  208. memory['hits'][hit_id]['type']
  209. else
  210. 'user'
  211. end
  212. end
  213. def review_hits
  214. reviewable_hit_ids = RTurk::GetReviewableHITs.create.hit_ids
  215. my_reviewed_hit_ids = reviewable_hit_ids & (memory['hits'] || {}).keys
  216. if reviewable_hit_ids.length > 0
  217. log "MTurk reports #{reviewable_hit_ids.length} HITs, of which I own [#{my_reviewed_hit_ids.to_sentence}]"
  218. end
  219. my_reviewed_hit_ids.each do |hit_id|
  220. hit = RTurk::Hit.new(hit_id)
  221. assignments = hit.assignments
  222. log "Looking at HIT #{hit_id}. I found #{assignments.length} assignments#{" with the statuses: #{assignments.map(&:status).to_sentence}" if assignments.length > 0}"
  223. if assignments.length == hit.max_assignments && assignments.all? { |assignment| assignment.status == "Submitted" }
  224. inbound_event = event_for_hit(hit_id)
  225. if hit_type(hit_id) == 'poll'
  226. # handle completed polls
  227. log "Handling a poll: #{hit_id}"
  228. scores = {}
  229. assignments.each do |assignment|
  230. assignment.answers.each do |index, rating|
  231. scores[index] ||= 0
  232. scores[index] += rating.to_i
  233. end
  234. end
  235. top_answer = scores.to_a.sort {|b, a| a.last <=> b.last }.first.first
  236. payload = {
  237. 'answers' => memory['hits'][hit_id]['answers'],
  238. 'poll' => assignments.map(&:answers),
  239. 'best_answer' => memory['hits'][hit_id]['answers'][top_answer.to_i - 1]
  240. }
  241. event = create_event :payload => payload
  242. log "Event emitted with answer(s) for poll", :outbound_event => event, :inbound_event => inbound_event
  243. else
  244. # handle normal completed HITs
  245. payload = { 'answers' => assignments.map(&:answers) }
  246. if take_majority?
  247. counts = {}
  248. options['hit']['questions'].each do |question|
  249. question_counts = question['selections'].inject({}) { |memo, selection| memo[selection['key']] = 0; memo }
  250. assignments.each do |assignment|
  251. answers = ActiveSupport::HashWithIndifferentAccess.new(assignment.answers)
  252. answer = answers[question['key']]
  253. question_counts[answer] += 1
  254. end
  255. counts[question['key']] = question_counts
  256. end
  257. payload['counts'] = counts
  258. majority_answer = counts.inject({}) do |memo, (key, question_counts)|
  259. memo[key] = question_counts.to_a.sort {|a, b| a.last <=> b.last }.last.first
  260. memo
  261. end
  262. payload['majority_answer'] = majority_answer
  263. if all_questions_are_numeric?
  264. average_answer = counts.inject({}) do |memo, (key, question_counts)|
  265. sum = divisor = 0
  266. question_counts.to_a.each do |num, count|
  267. sum += num.to_s.to_f * count
  268. divisor += count
  269. end
  270. memo[key] = sum / divisor.to_f
  271. memo
  272. end
  273. payload['average_answer'] = average_answer
  274. end
  275. end
  276. if create_poll?
  277. questions = []
  278. selections = 5.times.map { |i| { 'key' => i+1, 'text' => i+1 } }.reverse
  279. assignments.length.times do |index|
  280. questions << {
  281. 'type' => "selection",
  282. 'name' => "Item #{index + 1}",
  283. 'key' => index,
  284. 'required' => "true",
  285. 'question' => interpolate_string(options['poll_options']['row_template'], assignments[index].answers),
  286. 'selections' => selections
  287. }
  288. end
  289. poll_hit = create_hit 'title' => options['poll_options']['title'],
  290. 'description' => options['poll_options']['instructions'],
  291. 'questions' => questions,
  292. 'assignments' => options['poll_options']['assignments'],
  293. 'lifetime_in_seconds' => options['poll_options']['lifetime_in_seconds'],
  294. 'reward' => options['poll_options']['reward'],
  295. 'payload' => inbound_event && inbound_event.payload,
  296. 'metadata' => { 'type' => 'poll',
  297. 'original_hit' => hit_id,
  298. 'answers' => assignments.map(&:answers),
  299. 'event_id' => inbound_event && inbound_event.id }
  300. log "Poll HIT created with ID #{poll_hit.id} and URL #{poll_hit.url}. Original HIT: #{hit_id}", :inbound_event => inbound_event
  301. else
  302. event = create_event :payload => payload
  303. log "Event emitted with answer(s)", :outbound_event => event, :inbound_event => inbound_event
  304. end
  305. end
  306. assignments.each(&:approve!)
  307. hit.dispose!
  308. memory['hits'].delete(hit_id)
  309. end
  310. end
  311. end
  312. def all_questions_are_numeric?
  313. options['hit']['questions'].all? do |question|
  314. question['selections'].all? do |selection|
  315. selection['key'] == selection['key'].to_f.to_s || selection['key'] == selection['key'].to_i.to_s
  316. end
  317. end
  318. end
  319. def create_basic_hit(event = nil)
  320. hit = create_hit 'title' => options['hit']['title'],
  321. 'description' => options['hit']['description'],
  322. 'questions' => options['hit']['questions'],
  323. 'assignments' => options['hit']['assignments'],
  324. 'lifetime_in_seconds' => options['hit']['lifetime_in_seconds'],
  325. 'reward' => options['hit']['reward'],
  326. 'payload' => event && event.payload,
  327. 'metadata' => { 'event_id' => event && event.id }
  328. log "HIT created with ID #{hit.id} and URL #{hit.url}", :inbound_event => event
  329. end
  330. def create_hit(opts = {})
  331. payload = opts['payload'] || {}
  332. title = interpolate_string(opts['title'], payload).strip
  333. description = interpolate_string(opts['description'], payload).strip
  334. questions = interpolate_options(opts['questions'], payload)
  335. hit = RTurk::Hit.create(:title => title) do |hit|
  336. hit.max_assignments = (opts['assignments'] || 1).to_i
  337. hit.description = description
  338. hit.lifetime = (opts['lifetime_in_seconds'] || 24 * 60 * 60).to_i
  339. hit.question_form AgentQuestionForm.new(:title => title, :description => description, :questions => questions)
  340. hit.reward = (opts['reward'] || 0.05).to_f
  341. #hit.qualifications.add :approval_rate, { :gt => 80 }
  342. end
  343. memory['hits'] ||= {}
  344. memory['hits'][hit.id] = opts['metadata'] || {}
  345. hit
  346. end
  347. # RTurk Question Form
  348. class AgentQuestionForm < RTurk::QuestionForm
  349. needs :title, :description, :questions
  350. def question_form_content
  351. Overview do
  352. Title do
  353. text @title
  354. end
  355. Text do
  356. text @description
  357. end
  358. end
  359. @questions.each.with_index do |question, index|
  360. Question do
  361. QuestionIdentifier do
  362. text question['key'] || "question_#{index}"
  363. end
  364. DisplayName do
  365. text question['name'] || "Question ##{index}"
  366. end
  367. IsRequired do
  368. text question['required'] || 'true'
  369. end
  370. QuestionContent do
  371. Text do
  372. text question['question']
  373. end
  374. end
  375. AnswerSpecification do
  376. if question['type'] == "selection"
  377. SelectionAnswer do
  378. StyleSuggestion do
  379. text 'radiobutton'
  380. end
  381. Selections do
  382. question['selections'].each do |selection|
  383. Selection do
  384. SelectionIdentifier do
  385. text selection['key']
  386. end
  387. Text do
  388. text selection['text']
  389. end
  390. end
  391. end
  392. end
  393. end
  394. else
  395. FreeTextAnswer do
  396. if question['min_length'].present? || question['max_length'].present?
  397. Constraints do
  398. lengths = {}
  399. lengths['minLength'] = question['min_length'].to_s if question['min_length'].present?
  400. lengths['maxLength'] = question['max_length'].to_s if question['max_length'].present?
  401. Length lengths
  402. end
  403. end
  404. if question['default'].present?
  405. DefaultText do
  406. text question['default']
  407. end
  408. end
  409. end
  410. end
  411. end
  412. end
  413. end
  414. end
  415. end
  416. end
  417. end