262 行
7.4 KiB
Ruby
262 行
7.4 KiB
Ruby
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
|