グカネータ公開 (#361) (#368)

Reviewed-on: #368
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
このコミットはPull リクエスト #368 でマージされました.
このコミットが含まれているのは:
2026-06-14 05:33:39 +09:00
committed by みてるぞ
コミット 7ab46f907f
23個のファイルの変更3573行の追加466行の削除
+17 -9
ファイルの表示
@@ -1,6 +1,6 @@
class GekanatorGamesController < ApplicationController
def create
return head :not_found unless current_user&.admin?
return head :unauthorized unless current_user
guessed_post_id = params.require(:guessed_post_id)
correct_post_id = params[:correct_post_id].presence
@@ -22,10 +22,8 @@ class GekanatorGamesController < ApplicationController
end
def extra_questions
return head :not_found unless current_user&.admin?
game = GekanatorGame.find_by(id: params[:id])
return head :not_found unless game
game = find_owned_game
return if performed?
questions =
GekanatorQuestion
@@ -45,10 +43,8 @@ class GekanatorGamesController < ApplicationController
end
def extra_question_answers
return head :not_found unless current_user&.admin?
game = GekanatorGame.find_by(id: params[:id])
return head :not_found unless game
game = find_owned_game
return if performed?
answer_params = params.require(:answers)
if !answer_params.is_a?(Array)
@@ -137,4 +133,16 @@ class GekanatorGamesController < ApplicationController
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
end
-2
ファイルの表示
@@ -1,7 +1,5 @@
class GekanatorPostsController < ApplicationController
def index
return head :not_found unless current_user&.admin?
posts =
Post
.preload(tags: :tag_name)
+4 -1
ファイルの表示
@@ -1,9 +1,12 @@
class GekanatorQuestionSuggestionsController < ApplicationController
def create
return head :not_found unless current_user&.admin?
return head :unauthorized unless current_user
game = GekanatorGame.find_by(id: params.require(:gekanator_game_id))
return head :not_found unless game
if !current_user.admin? && game.user_id != current_user.id
return head :not_found
end
suggestion = GekanatorQuestionSuggestion.new(
gekanator_game: game,
+4 -2
ファイルの表示
@@ -1,7 +1,5 @@
class GekanatorQuestionsController < ApplicationController
def index
return head :not_found unless current_user&.admin?
questions =
GekanatorQuestion
.accepted
@@ -49,6 +47,8 @@ class GekanatorQuestionsController < ApplicationController
"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
@@ -77,6 +77,8 @@ class GekanatorQuestionsController < ApplicationController
case condition[:type]
when 'title-length-at-least'
"タイトルは #{ condition[:length] } 文字以上?"
when 'title-contains'
"題名に「#{ condition[:text] }」が含まれる?"
else
question.text
end
+151 -1
ファイルの表示
@@ -1,5 +1,15 @@
module Gekanator
class QuestionSuggestionAiConverter
# Temporary heuristic converter for #361.
# This creates pending ai_generated questions without external LLM calls;
# accepted questions are still distributed only after admin approval.
TITLE_LENGTH_RE = /\Aタイトルは\s*(\d+)\s*文字以上[??]\z/
ORIGINAL_YEAR_RE = /\Aオリジナルの投稿年は\s*(\d{4})\s*年[??]\z/
ORIGINAL_MONTH_RE = /\Aオリジナルの投稿月は\s*(\d{1,2})\s*月[??]\z/
ORIGINAL_MONTH_DAY_RE = /\Aオリジナルの投稿日は\s*(\d{1,2})\s*月\s*(\d{1,2})\s*日[??]\z/
TITLE_CONTAINS_RE = /\A題名に「(.+?)」が含まれる[??]\z/
SOURCE_RE = /\A(.+?)\s+の投稿を思[ひい]浮かべて[ゐい]る[??]\z/
def self.call(...) = new(...).call
def initialize suggestion:, user:
@@ -8,11 +18,151 @@ module Gekanator
end
def call
raise NotImplementedError, 'AI question conversion is not implemented yet.'
suggestion.with_lock do
existing = existing_generated_question
return existing if existing
run = suggestion.gekanator_ai_runs.create!(
model: 'heuristic_converter_v1',
status: 'running',
input_tokens: 0,
output_tokens: 0,
estimated_cost_jpy: 0)
question_attributes = build_question
question =
question_attributes &&
GekanatorQuestion.create!(
**question_attributes,
source: 'ai_generated',
status: 'pending',
gekanator_question_suggestion: suggestion,
created_by: user)
run.update!(status: question ? 'succeeded' : 'failed')
question
end
rescue => error
run&.update!(status: 'failed') if run&.persisted? && run.status != 'failed'
raise error
end
private
attr_reader :suggestion, :user
def existing_generated_question
suggestion
.gekanator_questions
.where(source: 'ai_generated')
.order(id: :desc)
.first
end
def build_question
text = normalized_text
return nil if text.blank?
structured_question_for(text) || post_similarity_question_for(text)
end
def normalized_text
suggestion.question_text.to_s.gsub(/[[:space:]]+/, ' ').strip
end
def structured_question_for text
case text
when TITLE_LENGTH_RE
length = Regexp.last_match(1).to_i
return nil if length <= 0
{
text:,
kind: 'title',
condition: {
type: 'title-length-at-least',
length:
},
priority_weight: 0.95
}
when /\A題名に英数字が混じって[ゐい]る[??]\z/
{
text: '題名に英数字が混じってゐる?',
kind: 'title',
condition: { type: 'title-has-ascii' },
priority_weight: 0.95
}
when ORIGINAL_YEAR_RE
year = Regexp.last_match(1).to_i
{
text:,
kind: 'original_date',
condition: { type: 'original-year', year: },
priority_weight: 0.95
}
when ORIGINAL_MONTH_RE
month = Regexp.last_match(1).to_i
return nil unless month.between?(1, 12)
{
text:,
kind: 'original_date',
condition: { type: 'original-month', month: },
priority_weight: 0.95
}
when ORIGINAL_MONTH_DAY_RE
month = Regexp.last_match(1).to_i
day = Regexp.last_match(2).to_i
return nil unless month.between?(1, 12) && day.between?(1, 31)
{
text:,
kind: 'original_date',
condition: {
type: 'original-month-day',
monthDay: "#{ month }-#{ day }"
},
priority_weight: 0.95
}
when TITLE_CONTAINS_RE
title_text = Regexp.last_match(1).to_s.strip
return nil if title_text.blank?
{
text: "題名に「#{ title_text }」が含まれる?",
kind: 'title',
condition: { type: 'title-contains', text: title_text },
priority_weight: 0.95
}
when SOURCE_RE
host = Regexp.last_match(1).to_s.strip
return nil if host.blank?
{
text:,
kind: 'source',
condition: { type: 'source', host: },
priority_weight: 0.95
}
else
nil
end
end
def post_similarity_question_for text
return nil if suggestion.answer == 'unknown'
{
text:,
kind: 'post_similarity',
condition: {
type: 'post-similarity',
postId: suggestion.gekanator_game.correct_post_id,
answer: suggestion.answer,
threshold: 0.65
},
priority_weight: 1.0
}
end
end
end
+5 -4
ファイルの表示
@@ -52,16 +52,16 @@ RSpec.describe 'Gekanator games API', type: :request do
expect(response).to have_http_status(:unprocessable_entity)
end
it 'returns not found without an admin user' do
it 'returns unauthorized without a user' do
post '/gekanator/games', params: {
guessed_post_id: guessed_post.id,
correct_post_id: guessed_post.id,
answers: [] }
expect(response).to have_http_status(:not_found)
expect(response).to have_http_status(:unauthorized)
end
it 'returns not found for a non-admin user' do
it 'stores a game for a non-admin user' do
sign_in_as user
post '/gekanator/games', params: {
@@ -69,7 +69,8 @@ RSpec.describe 'Gekanator games API', type: :request do
correct_post_id: guessed_post.id,
answers: [{ question_id: 'tag:1', answer: 'yes' }] }
expect(response).to have_http_status(:not_found)
expect(response).to have_http_status(:created)
expect(GekanatorGame.find(json['id']).user).to eq(user)
end
end
end
+182 -8
ファイルの表示
@@ -129,7 +129,7 @@ RSpec.describe 'Gekanator learning API', type: :request do
expect(response).to have_http_status(:unprocessable_entity)
end
it 'returns not found for a non-admin user' do
it 'stores a game result for a non-admin user' do
sign_in_as member
post '/gekanator/games', params: {
@@ -138,7 +138,18 @@ RSpec.describe 'Gekanator learning API', type: :request do
answers: []
}
expect(response).to have_http_status(:not_found)
expect(response).to have_http_status(:created)
expect(GekanatorGame.find(json['id']).user).to eq(member)
end
it 'returns unauthorized without a user' do
post '/gekanator/games', params: {
guessed_post_id: guessed_post.id,
correct_post_id: correct_post.id,
answers: []
}
expect(response).to have_http_status(:unauthorized)
end
end
@@ -261,17 +272,57 @@ RSpec.describe 'Gekanator learning API', type: :request do
expect(response).to have_http_status(:unprocessable_entity)
end
it 'returns not found for a non-admin user' do
it 'allows a non-admin user to suggest a question for their own game' do
member_game = GekanatorGame.create!(
user: member,
guessed_post: guessed_post,
correct_post: correct_post,
won: false,
question_count: 1,
answers: [{ 'question_id' => 'tag:1', 'answer' => 'yes' }]
)
sign_in_as member
post '/gekanator/question_suggestions', params: {
gekanator_game_id: game.id,
question_text: 'member question?',
answer: 'yes'
}
expect {
post '/gekanator/question_suggestions', params: {
gekanator_game_id: member_game.id,
question_text: 'member question?',
answer: 'yes'
}
}.to change { GekanatorQuestionSuggestion.count }.by(1)
expect(response).to have_http_status(:created)
expect(GekanatorQuestionSuggestion.last).to have_attributes(
gekanator_game_id: member_game.id,
user_id: member.id
)
end
it 'returns not found for another user game' do
sign_in_as member
expect {
post '/gekanator/question_suggestions', params: {
gekanator_game_id: game.id,
question_text: 'member question?',
answer: 'yes'
}
}.not_to change { GekanatorQuestionSuggestion.count }
expect(response).to have_http_status(:not_found)
end
it 'returns unauthorized without a user' do
expect {
post '/gekanator/question_suggestions', params: {
gekanator_game_id: game.id,
question_text: 'member question?',
answer: 'yes'
}
}.not_to change { GekanatorQuestionSuggestion.count }
expect(response).to have_http_status(:unauthorized)
end
end
describe 'GET /gekanator/games/:id/extra_questions' do
@@ -377,6 +428,38 @@ RSpec.describe 'Gekanator learning API', type: :request do
expect(response).to have_http_status(:ok)
expect(json['questions'].map { _1['id'] }).to contain_exactly(accepted.id)
end
it 'allows a non-admin user to fetch extra questions for their own game' do
member_game = GekanatorGame.create!(
user: member,
guessed_post: guessed_post,
correct_post: correct_post,
won: false,
question_count: 1,
answers: [{ 'question_id' => 'tag:1', 'answer' => 'yes' }]
)
accepted = create_post_similarity_question!(text: 'accepted?')
sign_in_as member
get "/gekanator/games/#{member_game.id}/extra_questions"
expect(response).to have_http_status(:ok)
expect(json['questions'].map { _1['id'] }).to include(accepted.id)
end
it 'returns not found for another user game' do
sign_in_as member
get "/gekanator/games/#{game.id}/extra_questions"
expect(response).to have_http_status(:not_found)
end
it 'returns unauthorized without a user' do
get "/gekanator/games/#{game.id}/extra_questions"
expect(response).to have_http_status(:unauthorized)
end
end
describe 'POST /gekanator/games/:id/extra_question_answers' do
@@ -503,6 +586,69 @@ RSpec.describe 'Gekanator learning API', type: :request do
expect(response).to have_http_status(:unprocessable_entity)
end
it 'allows a non-admin user to answer extra questions for their own game' do
member_game = GekanatorGame.create!(
user: member,
guessed_post: guessed_post,
correct_post: correct_post,
won: false,
question_count: 1,
answers: [{ 'question_id' => 'tag:1', 'answer' => 'yes' }]
)
question = create_post_similarity_question!(text: 'extra?')
sign_in_as member
expect {
post "/gekanator/games/#{member_game.id}/extra_question_answers", params: {
answers: [
{
question_id: question.id,
answer: 'yes'
}
]
}
}.to change { GekanatorQuestionExample.count }.by(1)
expect(response).to have_http_status(:created)
expect(GekanatorQuestionExample.last).to have_attributes(
user_id: member.id,
gekanator_game_id: member_game.id
)
end
it 'returns not found for another user game' do
question = create_post_similarity_question!(text: 'extra?')
sign_in_as member
expect {
post "/gekanator/games/#{game.id}/extra_question_answers", params: {
answers: [
{
question_id: question.id,
answer: 'yes'
}
]
}
}.not_to change { GekanatorQuestionExample.count }
expect(response).to have_http_status(:not_found)
end
it 'returns unauthorized without a user' do
question = create_post_similarity_question!(text: 'extra?')
post "/gekanator/games/#{game.id}/extra_question_answers", params: {
answers: [
{
question_id: question.id,
answer: 'yes'
}
]
}
expect(response).to have_http_status(:unauthorized)
end
end
describe 'GET /gekanator/questions' do
@@ -608,5 +754,33 @@ RSpec.describe 'Gekanator learning API', type: :request do
'length' => 21
)
end
it 'returns title-contains questions without authentication' do
GekanatorQuestion.create!(
text: '題名に「結束バンド」が含まれる?',
kind: 'title',
source: 'ai_generated',
status: 'accepted',
priority_weight: 0.95,
condition: {
type: 'title-contains',
text: '結束バンド'
},
created_by: admin
)
get '/gekanator/questions'
expect(response).to have_http_status(:ok)
question_json = json['questions'].find { _1['id'] == 'title:contains:結束バンド' }
expect(question_json).to include(
'text' => '題名に「結束バンド」が含まれる?',
'kind' => 'title'
)
expect(question_json['condition']).to include(
'type' => 'title-contains',
'text' => '結束バンド'
)
end
end
end
+112
ファイルの表示
@@ -0,0 +1,112 @@
require 'rails_helper'
RSpec.describe Gekanator::QuestionSuggestionAiConverter do
let(:user) { create(:user, :member) }
let(:guessed_post) { Post.create!(title: 'guess', url: 'https://example.com/guess') }
let(:correct_post) { Post.create!(title: 'correct', url: 'https://example.com/correct') }
let(:game) do
GekanatorGame.create!(
user: user,
guessed_post: guessed_post,
correct_post: correct_post,
won: false,
question_count: 1,
answers: [{ 'question_id' => 'tag:1', 'answer' => 'yes' }]
)
end
def create_suggestion!(question_text:, answer: 'yes')
GekanatorQuestionSuggestion.create!(
gekanator_game: game,
user: user,
question_text: question_text,
answer: answer
)
end
it 'converts title-contains suggestions to pending ai-generated questions' do
suggestion = create_suggestion!(question_text: '題名に「結束バンド」が含まれる?')
expect {
described_class.call(suggestion: suggestion, user: user)
}.to change { GekanatorQuestion.count }.by(1)
.and change { GekanatorAiRun.count }.by(1)
question = GekanatorQuestion.last
expect(question).to have_attributes(
text: '題名に「結束バンド」が含まれる?',
kind: 'title',
source: 'ai_generated',
status: 'pending',
priority_weight: 0.95,
gekanator_question_suggestion_id: suggestion.id,
created_by_id: user.id
)
expect(question.condition).to include(
'type' => 'title-contains',
'text' => '結束バンド'
)
expect(GekanatorAiRun.last).to have_attributes(
gekanator_question_suggestion_id: suggestion.id,
model: 'heuristic_converter_v1',
status: 'succeeded'
)
end
it 'converts concrete non-unknown suggestions to post-similarity questions' do
suggestion = create_suggestion!(
question_text: '喜多ちゃんが泣いてる?',
answer: 'partial'
)
question = described_class.call(suggestion: suggestion, user: user)
expect(question).to have_attributes(
text: '喜多ちゃんが泣いてる?',
kind: 'post_similarity',
source: 'ai_generated',
status: 'pending',
priority_weight: 1.0
)
expect(question.condition).to include(
'type' => 'post-similarity',
'postId' => correct_post.id,
'answer' => 'partial',
'threshold' => 0.65
)
end
it 'records a failed run when the suggestion cannot be converted' do
suggestion = create_suggestion!(
question_text: 'よく分からない質問?',
answer: 'unknown'
)
expect {
expect(described_class.call(suggestion: suggestion, user: user)).to be_nil
}.not_to change { GekanatorQuestion.count }
expect(GekanatorAiRun.last).to have_attributes(
gekanator_question_suggestion_id: suggestion.id,
status: 'failed'
)
end
it 'returns an existing generated question without creating a duplicate run' do
suggestion = create_suggestion!(question_text: 'タイトルは 10 文字以上?')
existing = GekanatorQuestion.create!(
text: 'タイトルは 10 文字以上?',
kind: 'title',
source: 'ai_generated',
status: 'pending',
priority_weight: 0.95,
condition: { type: 'title-length-at-least', length: 10 },
gekanator_question_suggestion: suggestion,
created_by: user
)
expect {
expect(described_class.call(suggestion: suggestion, user: user)).to eq(existing)
}.not_to change { GekanatorAiRun.count }
end
end