class GekanatorGamesController < ApplicationController def create return head :unauthorized unless current_user guessed_post_id = params.require(:guessed_post_id) correct_post_id = params[:correct_post_id].presence answers = params.require(:answers).as_json game = GekanatorGame.new( user: current_user, guessed_post_id:, correct_post_id:, won: correct_post_id.present? && guessed_post_id.to_i == correct_post_id.to_i, question_count: answers.length, answers:) if game.invalid? render json: { errors: game.errors.full_messages }, status: :unprocessable_entity return end learned_example_count = 0 ActiveRecord::Base.transaction do game.save! learned_example_count = learn_answers_from_game!(game) end render json: { id: game.id, learned_example_count: }, status: :created rescue ActiveRecord::RecordInvalid => e render json: { errors: e.record.errors.full_messages }, status: :unprocessable_entity end def extra_questions game = find_owned_game return if performed? questions = GekanatorQuestion .accepted .includes(:gekanator_question_examples) .where(kind: 'post_similarity', source: 'user_suggested') .to_a selected = prioritized_extra_questions( questions, post_id: game.correct_post_id, user: current_user, limit: 2) render json: { questions: selected.map { |question| extra_question_json(question) } } end def extra_question_answers game = find_owned_game return if performed? answer_params = params.require(:answers) if !answer_params.is_a?(Array) return render_validation_error fields: { answers: ['配列で指定してください.'] } end answers = answer_params.map { |answer| { question_id: answer.require(:question_id).to_i, answer: answer.require(:answer) } } questions = GekanatorQuestion.where(id: answers.map { _1[:question_id] }) question_by_id = questions.index_by(&:id) if questions.length != answers.length return render_validation_error fields: { answers: ['質問が見つかりません.'] } end if questions.any? { |question| question.status != 'accepted' || question.kind != 'post_similarity' } return render_validation_error fields: { answers: ['質問が不正です.'] } end ActiveRecord::Base.transaction do answers.each do |item| question = question_by_id[item[:question_id]] example = GekanatorQuestionExample.find_or_initialize_by( gekanator_question: question, post: game.correct_post, user: current_user) example.record_answer!( answer: item[:answer], source: 'post_game_extra', gekanator_game: game) example.save! end end render json: { count: answers.length }, status: :created end private def extra_question_json question { id: question.id, text: question.text, source: question.source, priority_weight: question.priority_weight } end def prioritized_extra_questions questions, post_id:, user:, limit: answered_question_ids = GekanatorQuestionExample .where(user:, gekanator_question_id: questions.map(&:id)) .distinct .pluck(:gekanator_question_id) unanswered, answered = questions.partition { |question| !answered_question_ids.include?(question.id) } selected = weighted_sample_questions(unanswered, post_id:, limit:) return selected if selected.length >= limit selected + weighted_sample_questions( answered.reject { |question| selected.any? { _1.id == question.id } }, post_id:, limit: limit - selected.length) end def weighted_sample_questions questions, post_id:, limit: remaining = questions.uniq(&:id) selected = [] while selected.length < limit && remaining.any? weighted = remaining.map { |question| [question, selection_weight_for(question, post_id: post_id)] } total_weight = weighted.sum { |_question, weight| weight } break if total_weight <= 0 target = rand * total_weight cumulative = 0.0 chosen = weighted.find do |_question, weight| cumulative += weight cumulative >= target end&.first || weighted.first.first selected << chosen remaining.reject! { |question| question.id == chosen.id } end selected end def selection_weight_for question, post_id: sample_count = question.gekanator_question_examples.sum { |example| next 0 unless example.post_id == post_id example.sample_count.presence || 1 } question.priority_weight.to_f / (1.0 + sample_count * 0.15) end def find_owned_game return head :unauthorized unless current_user game = GekanatorGame.find_by(id: params[:id]) return head :not_found unless game if !current_user.admin? && game.user_id != current_user.id return head :not_found end game end def learn_answers_from_game! game correct_post = game.correct_post return 0 if correct_post.blank? accepted_questions = GekanatorQuestion .accepted .index_by { |question| public_question_id_for(question) } learned_count = 0 Array(game.answers).each do |answer| answer_value = answer['answer'].to_s next if answer_value.blank? || answer_value == 'unknown' question = accepted_questions[answer['question_id'].to_s] next unless learnable_game_answer_question?(question) example = GekanatorQuestionExample.find_or_initialize_by( gekanator_question: question, post: correct_post, user: current_user) example.record_answer!( answer: answer_value, source: 'post_game_answer', gekanator_game: game) example.save! learned_count += 1 end learned_count end def public_question_id_for question condition = normalize_condition(question.condition) case condition[:type] when 'tag' "tag:#{condition[:key]}" when 'source' "source:#{condition[:host]}" when 'original-year' "original-year:#{condition[:year]}" when 'original-month' "original-month:#{condition[:month]}" when 'original-month-day' "original-month-day:#{condition[:monthDay] || condition[:month_day]}" when 'title-length-at-least' "title:length-at-least:#{condition[:length]}" when 'title-length-greater-than' "title:length-at-least:#{condition[:length].to_i + 1}" when 'title-has-ascii' 'title:ascii' when 'title-contains' "title:contains:#{condition[:text]}" when 'post-similarity' "post-similarity:#{question.id}" else "catalog:#{question.id}" end end def normalize_condition condition json = condition.deep_dup.as_json if json['type'] == 'original-month-day' && json['monthDay'].blank? json['monthDay'] = json.delete('month_day') end json.deep_symbolize_keys end def learnable_game_answer_question? question return true if question.kind == 'post_similarity' return false unless question.kind == 'tag' condition = normalize_condition(question.condition) key = condition[:key].to_s !key.start_with?('nico:') end end