コミットを比較

..

1 コミット

作成者 SHA1 メッセージ 日付
みてるぞ ffebce36b9 #371 2026-06-16 00:34:48 +09:00
10個のファイルの変更940行の追加541行の削除
+120 -7
ファイルの表示
@@ -14,11 +14,24 @@ class GekanatorGamesController < ApplicationController
question_count: answers.length, question_count: answers.length,
answers:) answers:)
if game.save if game.invalid?
render json: { id: game.id }, status: :created
else
render json: { errors: game.errors.full_messages }, status: :unprocessable_entity render json: { errors: game.errors.full_messages }, status: :unprocessable_entity
return
end 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 end
def extra_questions def extra_questions
@@ -32,10 +45,12 @@ class GekanatorGamesController < ApplicationController
.where(kind: 'post_similarity', source: 'user_suggested') .where(kind: 'post_similarity', source: 'user_suggested')
.to_a .to_a
selected = weighted_sample_questions( selected =
questions, prioritized_extra_questions(
post_id: game.correct_post_id, questions,
limit: 2) post_id: game.correct_post_id,
user: current_user,
limit: 2)
render json: { render json: {
questions: selected.map { |question| extra_question_json(question) } questions: selected.map { |question| extra_question_json(question) }
@@ -96,6 +111,23 @@ class GekanatorGamesController < ApplicationController
} }
end 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: def weighted_sample_questions questions, post_id:, limit:
remaining = questions.uniq(&:id) remaining = questions.uniq(&:id)
selected = [] selected = []
@@ -145,4 +177,85 @@ class GekanatorGamesController < ApplicationController
game game
end 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 end
+7 -1
ファイルの表示
@@ -2,7 +2,7 @@ class GekanatorPostsController < ApplicationController
def index def index
posts = posts =
Post Post
.preload(tags: :tag_name) .preload(:post_similarities, tags: :tag_name)
.with_attached_thumbnail .with_attached_thumbnail
.order(Arel.sql( .order(Arel.sql(
'COALESCE(posts.original_created_before - INTERVAL 1 MINUTE, ' \ 'COALESCE(posts.original_created_before - INTERVAL 1 MINUTE, ' \
@@ -22,6 +22,12 @@ class GekanatorPostsController < ApplicationController
thumbnail_base: post.thumbnail_base, thumbnail_base: post.thumbnail_base,
original_created_from: post.original_created_from, original_created_from: post.original_created_from,
original_created_before: post.original_created_before, original_created_before: post.original_created_before,
post_similarity_edges: post.post_similarities.map { |similarity|
{
target_post_id: similarity.target_post_id,
cos: similarity.cos.to_f
}
},
tags: post.tags.map { |tag| tag_json(tag) } tags: post.tags.map { |tag| tag_json(tag) }
} }
end end
+39
ファイルの表示
@@ -8,6 +8,35 @@ class GekanatorQuestionSuggestionsController < ApplicationController
return head :not_found return head :not_found
end end
existing_question_id = params[:existing_question_id].presence
if existing_question_id
question = GekanatorQuestion.accepted.find_by(id: existing_question_id)
return head :not_found unless question
unless learnable_existing_question?(question)
return render_validation_error fields: { existing_question_id: ['質問が不正です.'] }
end
example =
GekanatorQuestionExample.find_or_initialize_by(
gekanator_question: question,
post: game.correct_post,
user: current_user)
example.record_answer!(
answer: params.require(:answer),
source: 'post_game_extra',
gekanator_game: game)
if example.save
render json: {
id: question.id,
count: game.question_suggestions.count
}, status: :created
else
render_validation_error example
end
return
end
suggestion = GekanatorQuestionSuggestion.new( suggestion = GekanatorQuestionSuggestion.new(
gekanator_game: game, gekanator_game: game,
user: current_user, user: current_user,
@@ -53,4 +82,14 @@ class GekanatorQuestionSuggestionsController < ApplicationController
rescue NotImplementedError rescue NotImplementedError
head :not_implemented head :not_implemented
end end
private
def learnable_existing_question? question
return true if question.kind == 'post_similarity'
return false unless question.kind == 'tag'
key = question.condition.as_json['key'].to_s
!key.start_with?('nico:')
end
end end
+2 -1
ファイルの表示
@@ -16,6 +16,7 @@ class GekanatorQuestionsController < ApplicationController
def question_json question def question_json question
condition = condition_json(question.condition).deep_symbolize_keys condition = condition_json(question.condition).deep_symbolize_keys
json = { json = {
record_id: question.id,
id: question_id_for(question, condition), id: question_id_for(question, condition),
text: question_text_for(question, condition), text: question_text_for(question, condition),
kind: question.kind, kind: question.kind,
@@ -23,7 +24,7 @@ class GekanatorQuestionsController < ApplicationController
source: question.source, source: question.source,
priority_weight: question.priority_weight priority_weight: question.priority_weight
} }
if question.kind == 'post_similarity' if question.kind == 'post_similarity' || question.kind == 'tag'
json[:example_answers] = example_answers_json(question) json[:example_answers] = example_answers_json(question)
end end
json json
+2 -2
ファイルの表示
@@ -1,7 +1,7 @@
class GekanatorQuestionExample < ApplicationRecord class GekanatorQuestionExample < ApplicationRecord
ANSWERS = GekanatorQuestionSuggestion::ANSWERS ANSWERS = GekanatorQuestionSuggestion::ANSWERS
NON_UNKNOWN_ANSWERS = ANSWERS - ['unknown'] NON_UNKNOWN_ANSWERS = ANSWERS - ['unknown']
SOURCES = ['initial_suggestion', 'post_game_extra'].freeze SOURCES = ['initial_suggestion', 'post_game_answer', 'post_game_extra'].freeze
belongs_to :gekanator_question belongs_to :gekanator_question
belongs_to :post belongs_to :post
@@ -35,7 +35,7 @@ class GekanatorQuestionExample < ApplicationRecord
self.answer_counts = counts self.answer_counts = counts
self.sample_count = counts.values.sum self.sample_count = counts.values.sum
self.gekanator_game = gekanator_game if gekanator_game.present? self.gekanator_game = gekanator_game if gekanator_game.present?
self.source = source if new_record? self.source = source
apply_aggregated_answer!(preferred_answer: answer) apply_aggregated_answer!(preferred_answer: answer)
self self
-13
ファイルの表示
@@ -1,5 +1,4 @@
class GekanatorQuestionSuggestion < ApplicationRecord class GekanatorQuestionSuggestion < ApplicationRecord
MAX_QUESTIONS_PER_GAME = 3
ANSWERS = ['yes', 'no', 'partial', 'probably_no', 'unknown'].freeze ANSWERS = ['yes', 'no', 'partial', 'probably_no', 'unknown'].freeze
belongs_to :gekanator_game belongs_to :gekanator_game
@@ -10,16 +9,4 @@ class GekanatorQuestionSuggestion < ApplicationRecord
validates :question_text, presence: true, length: { maximum: 1000 } validates :question_text, presence: true, length: { maximum: 1000 }
validates :answer, presence: true, inclusion: { in: ANSWERS } validates :answer, presence: true, inclusion: { in: ANSWERS }
validates :processed, inclusion: { in: [true, false] } validates :processed, inclusion: { in: [true, false] }
validate :question_suggestion_limit_per_game, on: :create
private
def question_suggestion_limit_per_game
return if gekanator_game_id.blank?
count = GekanatorQuestionSuggestion.where(gekanator_game_id:).count
if count >= MAX_QUESTIONS_PER_GAME
errors.add(:base, '質問追加数を超えてゐます.')
end
end
end end
+10 -3
ファイルの表示
@@ -62,6 +62,7 @@ export type GekanatorExtraQuestion = {
priorityWeight: number } priorityWeight: number }
export type StoredGekanatorQuestion = { export type StoredGekanatorQuestion = {
recordId?: number
id: string id: string
text: string text: string
kind: GekanatorQuestionKind kind: GekanatorQuestionKind
@@ -71,6 +72,7 @@ export type StoredGekanatorQuestion = {
exampleAnswers?: Record<`${ number }`, GekanatorAnswerValue> } exampleAnswers?: Record<`${ number }`, GekanatorAnswerValue> }
export type GekanatorQuestion = { export type GekanatorQuestion = {
recordId?: number
id: string id: string
text: string text: string
kind: GekanatorQuestionKind kind: GekanatorQuestionKind
@@ -148,7 +150,7 @@ const directExampleAnswerFor = (
question: StoredGekanatorQuestion, question: StoredGekanatorQuestion,
post: Post, post: Post,
): GekanatorAnswerValue | null => { ): GekanatorAnswerValue | null => {
if (question.kind !== 'post_similarity') if (question.kind !== 'post_similarity' && question.kind !== 'tag')
return null return null
const direct = question.exampleAnswers?.[String (post.id) as `${ number }`] const direct = question.exampleAnswers?.[String (post.id) as `${ number }`]
@@ -348,6 +350,7 @@ export const restoreGekanatorQuestion = (
const normalizedCondition = normalizeTitleLengthCondition (question.condition) const normalizedCondition = normalizeTitleLengthCondition (question.condition)
const normalizedQuestion = { const normalizedQuestion = {
...question, ...question,
recordId: question.recordId,
id: normalizedCondition.type === 'title-length-at-least' id: normalizedCondition.type === 'title-length-at-least'
? `title:length-at-least:${ normalizedCondition.length }` ? `title:length-at-least:${ normalizedCondition.length }`
: question.id, : question.id,
@@ -367,6 +370,7 @@ export const storeGekanatorQuestion = (
id: question.condition.type === 'title-length-greater-than' id: question.condition.type === 'title-length-greater-than'
? `title:length-at-least:${ question.condition.length + 1 }` ? `title:length-at-least:${ question.condition.length + 1 }`
: question.id, : question.id,
recordId: question.recordId,
text: question.text, text: question.text,
kind: question.kind, kind: question.kind,
condition: normalizeTitleLengthCondition (question.condition), condition: normalizeTitleLengthCondition (question.condition),
@@ -581,7 +585,7 @@ export const saveGekanatorGame = async ({
guessedPostId: number guessedPostId: number
correctPostId: number correctPostId: number
answers: GekanatorAnswerLog[] answers: GekanatorAnswerLog[]
}): Promise<{ id: number }> => }): Promise<{ id: number; learnedExampleCount: number }> =>
await apiPost ('/gekanator/games', { await apiPost ('/gekanator/games', {
guessed_post_id: guessedPostId, guessed_post_id: guessedPostId,
correct_post_id: correctPostId, correct_post_id: correctPostId,
@@ -595,15 +599,18 @@ export const saveGekanatorGame = async ({
export const saveGekanatorQuestionSuggestion = async ({ export const saveGekanatorQuestionSuggestion = async ({
gekanatorGameId, gekanatorGameId,
existingQuestionId,
questionText, questionText,
answer, answer,
}: { }: {
gekanatorGameId: number gekanatorGameId: number
questionText: string existingQuestionId?: number
questionText?: string
answer: GekanatorAnswerValue answer: GekanatorAnswerValue
}): Promise<{ id: number; count: number }> => }): Promise<{ id: number; count: number }> =>
await apiPost ('/gekanator/question_suggestions', { await apiPost ('/gekanator/question_suggestions', {
gekanator_game_id: gekanatorGameId, gekanator_game_id: gekanatorGameId,
existing_question_id: existingQuestionId,
question_text: questionText, question_text: questionText,
answer }) answer })
+15
ファイルの表示
@@ -13,6 +13,16 @@ export type RecoveredCandidatePost = {
answerCountAtRecovery: number } answerCountAtRecovery: number }
const questionIsFactLikeForHardFiltering = (
question: GekanatorQuestion,
): boolean =>
!(question.kind === 'post_similarity'
|| (
question.kind === 'tag'
&& question.condition.type === 'tag'
&& !(question.condition.key.startsWith ('nico:'))))
export const candidatePostsFor = ({ export const candidatePostsFor = ({
posts, posts,
questions, questions,
@@ -46,6 +56,8 @@ export const candidatePostsFor = ({
const question = questionById.get (answer.questionId) const question = questionById.get (answer.questionId)
if (!(question)) if (!(question))
return true return true
if (!(questionIsFactLikeForHardFiltering (question)))
return true
switch (answer.answer) switch (answer.answer)
{ {
@@ -71,6 +83,9 @@ export const hardFilteredPostsForAnswer = ({
question: GekanatorQuestion question: GekanatorQuestion
answer: GekanatorAnswerValue answer: GekanatorAnswerValue
}): Post[] => { }): Post[] => {
if (!(questionIsFactLikeForHardFiltering (question)))
return posts
if (answer === 'unknown') if (answer === 'unknown')
return posts return posts
ファイル差分が大きすぎるため省略します 差分を読込み
+4
ファイルの表示
@@ -139,6 +139,10 @@ export type Post = {
title: string | null title: string | null
thumbnail: string | null thumbnail: string | null
thumbnailBase: string | null thumbnailBase: string | null
postSimilarityEdges?: {
targetPostId: number
cos: number
}[]
tags: Tag[] tags: Tag[]
parentPosts?: Post[] parentPosts?: Post[]
childPosts?: Post[] childPosts?: Post[]