1
0

human_task_agent.rb 21 KB

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