グカネータ / 質問パターン見直し (#41) (#365)

Reviewed-on: #365
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
このコミットはPull リクエスト #365 でマージされました.
このコミットが含まれているのは:
2026-06-12 01:35:31 +09:00
committed by みてるぞ
コミット def6870f06
14個のファイルの変更1077行の追加190行の削除
+44 -22
ファイルの表示
@@ -27,26 +27,20 @@ class GekanatorGamesController < ApplicationController
game = GekanatorGame.find_by(id: params[:id])
return head :not_found unless game
asked_ids = Array(game.answers).filter_map { |answer| answer_question_id(answer) }
existing_example_ids =
GekanatorQuestionExample.where(post_id: game.correct_post_id)
.select(:gekanator_question_id)
# Direct examples only for now; post_similarity-based expansion is deferred.
questions =
GekanatorQuestion
.accepted
.includes(:gekanator_question_examples)
.where(kind: 'post_similarity', source: 'user_suggested')
.where.not(id: existing_example_ids)
.order(priority_weight: :desc, id: :asc)
.to_a
selected = weighted_sample_questions(
questions,
post_id: game.correct_post_id,
limit: 2)
render json: {
questions: questions.filter_map { |question|
json = extra_question_json(question)
next if asked_ids.include?(json[:id].to_s)
next if asked_ids.include?("post-similarity:#{ json[:id] }")
json
}.first(2)
questions: selected.map { |question| extra_question_json(question) }
}
end
@@ -84,11 +78,10 @@ class GekanatorGamesController < ApplicationController
gekanator_question: question,
post: game.correct_post,
user: current_user)
example.assign_attributes(
gekanator_game: game,
example.record_answer!(
answer: item[:answer],
source: 'post_game_extra',
weight: 1.0)
gekanator_game: game)
example.save!
end
end
@@ -107,12 +100,41 @@ class GekanatorGamesController < ApplicationController
}
end
def answer_question_id answer
value = if answer.is_a?(Hash)
answer['question_id'].presence || answer[:question_id].presence ||
answer['questionId'].presence || answer[:questionId].presence
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
value&.to_s
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
end
+80
ファイルの表示
@@ -1,5 +1,6 @@
class GekanatorQuestionExample < ApplicationRecord
ANSWERS = GekanatorQuestionSuggestion::ANSWERS
NON_UNKNOWN_ANSWERS = ANSWERS - ['unknown']
SOURCES = ['initial_suggestion', 'post_game_extra'].freeze
belongs_to :gekanator_question
@@ -8,10 +9,89 @@ class GekanatorQuestionExample < ApplicationRecord
belongs_to :gekanator_game, optional: true
validates :answer, presence: true, inclusion: { in: ANSWERS }
validates :answer_counts, presence: true
validates :sample_count,
presence: true,
numericality: {
only_integer: true,
greater_than: 0
}
validates :source, presence: true, inclusion: { in: SOURCES }
validates :weight,
presence: true,
numericality: {
greater_than: 0
}
before_validation :normalize_learning_state
def record_answer!(answer:, source:, gekanator_game: nil)
answer = answer.to_s
raise ArgumentError, 'invalid answer' unless ANSWERS.include?(answer)
counts = normalized_answer_counts
counts[answer] += 1
self.answer_counts = counts
self.sample_count = counts.values.sum
self.gekanator_game = gekanator_game if gekanator_game.present?
self.source = source if new_record?
apply_aggregated_answer!(preferred_answer: answer)
self
end
private
def normalize_learning_state
counts = normalized_answer_counts
if counts.values.sum.zero? && answer.present?
counts[answer] = 1
end
self.answer_counts = counts
self.sample_count = counts.values.sum
apply_aggregated_answer!
end
def apply_aggregated_answer!(preferred_answer: nil)
counts = normalized_answer_counts
known_counts = counts.slice(*NON_UNKNOWN_ANSWERS)
known_total = known_counts.values.sum
if known_total.zero?
self.answer = 'unknown'
self.weight = 0.1
return
else
max_count = known_counts.values.max
candidates = known_counts.select { |_answer, count| count == max_count }.keys
self.answer =
if preferred_answer.present? && candidates.include?(preferred_answer)
preferred_answer
elsif answer.present? && candidates.include?(answer)
answer
else
candidates.first
end
end
consensus = max_count.to_f / known_total
self.weight = Math.sqrt(known_total) * consensus
end
def normalized_answer_counts
base = ANSWERS.index_with(0)
answer_counts.to_h.each do |key, value|
answer_key = key.to_s
next unless ANSWERS.include?(answer_key)
base[answer_key] = value.to_i
end
base
end
end
+9 -6
ファイルの表示
@@ -26,13 +26,16 @@ module Gekanator
},
gekanator_question_suggestion: suggestion,
created_by: user)
GekanatorQuestionExample.create!(
gekanator_question: question,
post: suggestion.gekanator_game.correct_post,
user: user,
gekanator_game: suggestion.gekanator_game,
example =
GekanatorQuestionExample.new(
gekanator_question: question,
post: suggestion.gekanator_game.correct_post,
user: user)
example.record_answer!(
answer: suggestion.answer,
source: 'initial_suggestion')
source: 'initial_suggestion',
gekanator_game: suggestion.gekanator_game)
example.save!
suggestion.update!(processed: true)
question
end
@@ -0,0 +1,40 @@
class AddAnswerStatisticsToGekanatorQuestionExamples < ActiveRecord::Migration[8.0]
class MigrationGekanatorQuestionExample < ApplicationRecord
self.table_name = 'gekanator_question_examples'
end
def up
add_column :gekanator_question_examples,
:answer_counts,
:json,
null: true
add_column :gekanator_question_examples,
:sample_count,
:integer,
null: false,
default: 1
MigrationGekanatorQuestionExample.reset_column_information
MigrationGekanatorQuestionExample.find_each do |example|
counts = {
'yes' => 0,
'no' => 0,
'partial' => 0,
'probably_no' => 0,
'unknown' => 0
}
counts[example.answer] = 1 if counts.key?(example.answer)
example.update_columns(
answer_counts: counts,
sample_count: 1)
end
change_column_null :gekanator_question_examples, :answer_counts, false
end
def down
remove_column :gekanator_question_examples, :sample_count
remove_column :gekanator_question_examples, :answer_counts
end
end
生成ファイル
+3 -1
ファイルの表示
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2026_06_10_000000) do
ActiveRecord::Schema[8.0].define(version: 2026_06_12_000000) do
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "name", null: false
t.string "record_type", null: false
@@ -85,6 +85,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_10_000000) do
t.float "weight", default: 1.0, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.json "answer_counts", null: false
t.integer "sample_count", default: 1, null: false
t.index ["gekanator_game_id"], name: "index_gekanator_question_examples_on_gekanator_game_id"
t.index ["gekanator_question_id", "post_id", "user_id"], name: "idx_gekanator_question_examples_on_question_post_user", unique: true
t.index ["gekanator_question_id"], name: "index_gekanator_question_examples_on_gekanator_question_id"
+39 -15
ファイルの表示
@@ -275,7 +275,7 @@ RSpec.describe 'Gekanator learning API', type: :request do
end
describe 'GET /gekanator/games/:id/extra_questions' do
it 'returns at most two accepted user_suggested post_similarity questions' do
it 'returns at most two accepted user_suggested post_similarity questions without duplicates' do
sign_in_as admin
low = create_post_similarity_question!(
@@ -295,15 +295,14 @@ RSpec.describe 'Gekanator learning API', type: :request do
expect(response).to have_http_status(:ok)
expect(json['questions'].length).to eq(2)
expect(json['questions'].map { _1['id'] }).to eq([high.id, middle.id])
expect(json['questions'].map { _1['id'] }).not_to include(low.id)
expect(json['questions'].map { _1['id'] }.uniq.length).to eq(2)
expect(json['questions'].map { _1['id'] }).to all(be_in([low.id, high.id, middle.id]))
end
it 'does not return questions that already have an example for the correct post' do
it 'can return questions that already have an example for the correct post' do
sign_in_as admin
existing = create_post_similarity_question!(text: 'already learned?')
fresh = create_post_similarity_question!(text: 'fresh?')
GekanatorQuestionExample.create!(
gekanator_question: existing,
@@ -317,15 +316,13 @@ RSpec.describe 'Gekanator learning API', type: :request do
get "/gekanator/games/#{game.id}/extra_questions"
expect(response).to have_http_status(:ok)
expect(json['questions'].map { _1['id'] }).to include(fresh.id)
expect(json['questions'].map { _1['id'] }).not_to include(existing.id)
expect(json['questions'].map { _1['id'] }).to include(existing.id)
end
it 'does not return questions already asked in the game using snake_case question_id' do
it 'can return questions already asked in the game using snake_case question_id' do
sign_in_as admin
asked = create_post_similarity_question!(text: 'already asked?')
fresh = create_post_similarity_question!(text: 'fresh?')
game.update!(
answers: [
{
@@ -338,15 +335,13 @@ RSpec.describe 'Gekanator learning API', type: :request do
get "/gekanator/games/#{game.id}/extra_questions"
expect(response).to have_http_status(:ok)
expect(json['questions'].map { _1['id'] }).to include(fresh.id)
expect(json['questions'].map { _1['id'] }).not_to include(asked.id)
expect(json['questions'].map { _1['id'] }).to include(asked.id)
end
it 'does not return questions already asked in the game using camelCase questionId' do
it 'can return questions already asked in the game using camelCase questionId' do
sign_in_as admin
asked = create_post_similarity_question!(text: 'already asked?')
fresh = create_post_similarity_question!(text: 'fresh?')
game.update!(
answers: [
{
@@ -359,8 +354,7 @@ RSpec.describe 'Gekanator learning API', type: :request do
get "/gekanator/games/#{game.id}/extra_questions"
expect(response).to have_http_status(:ok)
expect(json['questions'].map { _1['id'] }).to include(fresh.id)
expect(json['questions'].map { _1['id'] }).not_to include(asked.id)
expect(json['questions'].map { _1['id'] }).to include(asked.id)
end
it 'does not return non-accepted, non-user_suggested, or non-post_similarity questions' do
@@ -584,5 +578,35 @@ RSpec.describe 'Gekanator learning API', type: :request do
other_post.id.to_s => 'no'
)
end
it 'normalizes legacy title length questions' do
sign_in_as admin
GekanatorQuestion.create!(
text: '題名が長めの投稿?',
kind: 'title',
source: 'admin_curated',
status: 'accepted',
priority_weight: 1.0,
condition: {
type: 'title-length-greater-than',
length: 20
},
created_by: admin
)
get '/gekanator/questions'
expect(response).to have_http_status(:ok)
question_json = json['questions'].find { _1['id'] == 'title:length-at-least:21' }
expect(question_json).to include(
'text' => 'タイトルは 21 文字以上?',
'kind' => 'title'
)
expect(question_json['condition']).to include(
'type' => 'title-length-at-least',
'length' => 21
)
end
end
end