diff --git a/backend/app/controllers/gekanator_games_controller.rb b/backend/app/controllers/gekanator_games_controller.rb
index affd42e..c1b297e 100644
--- a/backend/app/controllers/gekanator_games_controller.rb
+++ b/backend/app/controllers/gekanator_games_controller.rb
@@ -20,4 +20,91 @@ class GekanatorGamesController < ApplicationController
render json: { errors: game.errors.full_messages }, status: :unprocessable_entity
end
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
+
+ asked_ids = Array(game.answers).filter_map { |answer|
+ answer['questionId'] || answer[:questionId]
+ }
+ existing_example_ids =
+ GekanatorQuestionExample.where(post_id: game.correct_post_id)
+ .select(:gekanator_question_id)
+ questions =
+ GekanatorQuestion
+ .accepted
+ .where(kind: 'post_similarity', source: 'user_suggested')
+ .where.not(id: existing_example_ids)
+ .order(priority_weight: :desc, id: :asc)
+
+ 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)
+ }
+ 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
+
+ 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),
+ 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.assign_attributes(
+ gekanator_game: game,
+ answer: item[:answer],
+ source: 'post_game_extra',
+ weight: 1.0)
+ 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
end
diff --git a/backend/app/controllers/gekanator_question_suggestions_controller.rb b/backend/app/controllers/gekanator_question_suggestions_controller.rb
index a16e29b..d3f83aa 100644
--- a/backend/app/controllers/gekanator_question_suggestions_controller.rb
+++ b/backend/app/controllers/gekanator_question_suggestions_controller.rb
@@ -11,7 +11,14 @@ class GekanatorQuestionSuggestionsController < ApplicationController
question_text: params.require(:question_text),
answer: params.require(:answer))
- if suggestion.save
+ if suggestion.valid?
+ ActiveRecord::Base.transaction do
+ suggestion.save!
+ Gekanator::QuestionSuggestionPromoter.call(
+ suggestion: suggestion,
+ user: current_user)
+ end
+
render json: {
id: suggestion.id,
count: game.question_suggestions.count
diff --git a/backend/app/controllers/gekanator_questions_controller.rb b/backend/app/controllers/gekanator_questions_controller.rb
index 1a61d46..407b0f9 100644
--- a/backend/app/controllers/gekanator_questions_controller.rb
+++ b/backend/app/controllers/gekanator_questions_controller.rb
@@ -2,7 +2,11 @@ class GekanatorQuestionsController < ApplicationController
def index
return head :not_found unless current_user&.admin?
- questions = GekanatorQuestion.accepted.order(priority_weight: :desc, id: :asc)
+ questions =
+ GekanatorQuestion
+ .accepted
+ .includes(:gekanator_question_examples)
+ .order(priority_weight: :desc, id: :asc)
render json: {
questions: questions.map { |question| question_json(question) }
@@ -12,7 +16,7 @@ class GekanatorQuestionsController < ApplicationController
private
def question_json question
- {
+ json = {
id: question_id_for(question),
text: question.text,
kind: question.kind,
@@ -20,6 +24,10 @@ class GekanatorQuestionsController < ApplicationController
source: question.source,
priority_weight: question.priority_weight
}
+ if question.kind == 'post_similarity'
+ json[:example_answers] = example_answers_json(question)
+ end
+ json
end
def question_id_for question
@@ -40,6 +48,8 @@ class GekanatorQuestionsController < ApplicationController
"title:length-greater-than:#{ condition[:length] }"
when 'title-has-ascii'
'title:ascii'
+ when 'post-similarity'
+ "post-similarity:#{ question.id }"
else
"catalog:#{ question.id }"
end
@@ -54,4 +64,20 @@ class GekanatorQuestionsController < ApplicationController
json
end
+
+ def example_answers_json question
+ question
+ .gekanator_question_examples
+ .group_by(&:post_id)
+ .transform_values { |examples| aggregate_answer(examples) }
+ end
+
+ def aggregate_answer examples
+ examples
+ .group_by(&:answer)
+ .map { |answer, grouped| [answer, grouped.sum(&:weight), grouped.max_by(&:updated_at)&.updated_at] }
+ .sort_by { |(_answer, weight, updated_at)| [-weight, -(updated_at&.to_f || 0)] }
+ .first
+ &.first
+ end
end
diff --git a/backend/app/models/gekanator_game.rb b/backend/app/models/gekanator_game.rb
index ae10c83..bf223d8 100644
--- a/backend/app/models/gekanator_game.rb
+++ b/backend/app/models/gekanator_game.rb
@@ -5,6 +5,9 @@ class GekanatorGame < ApplicationRecord
has_many :question_suggestions,
class_name: 'GekanatorQuestionSuggestion',
dependent: :delete_all
+ has_many :question_examples,
+ class_name: 'GekanatorQuestionExample',
+ dependent: :delete_all
validates :answers, presence: true
validates :question_count, numericality: { greater_than_or_equal_to: 0 }
diff --git a/backend/app/models/gekanator_question.rb b/backend/app/models/gekanator_question.rb
index 8f9b925..719da6e 100644
--- a/backend/app/models/gekanator_question.rb
+++ b/backend/app/models/gekanator_question.rb
@@ -1,10 +1,11 @@
class GekanatorQuestion < ApplicationRecord
- KINDS = ['tag', 'source', 'title', 'original_date'].freeze
+ KINDS = ['tag', 'source', 'title', 'original_date', 'post_similarity'].freeze
SOURCES = ['user_suggested', 'ai_generated', 'admin_curated'].freeze
- STATUSES = ['pending', 'accepted', 'rejected'].freeze
+ STATUSES = ['pending', 'accepted', 'rejected', 'disabled'].freeze
belongs_to :gekanator_question_suggestion, optional: true
belongs_to :created_by, class_name: 'User', optional: true
+ has_many :gekanator_question_examples, dependent: :delete_all
validates :kind, presence: true, inclusion: { in: KINDS }
validates :source, presence: true, inclusion: { in: SOURCES }
diff --git a/backend/app/models/gekanator_question_example.rb b/backend/app/models/gekanator_question_example.rb
new file mode 100644
index 0000000..5e55cbd
--- /dev/null
+++ b/backend/app/models/gekanator_question_example.rb
@@ -0,0 +1,17 @@
+class GekanatorQuestionExample < ApplicationRecord
+ ANSWERS = GekanatorQuestionSuggestion::ANSWERS
+ SOURCES = ['initial_suggestion', 'post_game_extra'].freeze
+
+ belongs_to :gekanator_question
+ belongs_to :post
+ belongs_to :user
+ belongs_to :gekanator_game, optional: true
+
+ validates :answer, presence: true, inclusion: { in: ANSWERS }
+ validates :source, presence: true, inclusion: { in: SOURCES }
+ validates :weight,
+ presence: true,
+ numericality: {
+ greater_than: 0
+ }
+end
diff --git a/backend/app/models/post.rb b/backend/app/models/post.rb
index 15db1c5..6a27183 100644
--- a/backend/app/models/post.rb
+++ b/backend/app/models/post.rb
@@ -21,6 +21,7 @@ class Post < ApplicationRecord
foreign_key: :correct_post_id,
dependent: :delete_all,
inverse_of: :correct_post
+ has_many :gekanator_question_examples, dependent: :delete_all
has_many :parent_post_implications,
class_name: 'PostImplication',
diff --git a/backend/app/services/gekanator/question_suggestion_promoter.rb b/backend/app/services/gekanator/question_suggestion_promoter.rb
new file mode 100644
index 0000000..d99b1bd
--- /dev/null
+++ b/backend/app/services/gekanator/question_suggestion_promoter.rb
@@ -0,0 +1,48 @@
+module Gekanator
+ class QuestionSuggestionPromoter
+ def self.call(...) = new(...).call
+
+ def initialize suggestion:, user:
+ @suggestion = suggestion
+ @user = user
+ end
+
+ def call
+ suggestion.with_lock do
+ return promoted_question if suggestion.processed?
+
+ question = GekanatorQuestion.create!(
+ text: suggestion.question_text,
+ kind: 'post_similarity',
+ source: 'user_suggested',
+ status: 'accepted',
+ priority_weight: 1.2,
+ condition: {
+ type: 'post-similarity',
+ postId: suggestion.gekanator_game.correct_post_id,
+ answer: suggestion.answer,
+ threshold: 0.65
+ },
+ 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,
+ answer: suggestion.answer,
+ source: 'initial_suggestion')
+ suggestion.update!(processed: true)
+ question
+ end
+ end
+
+ private
+
+ attr_reader :suggestion, :user
+
+ def promoted_question
+ suggestion.gekanator_questions.order(id: :desc).first
+ end
+ end
+end
diff --git a/backend/config/routes.rb b/backend/config/routes.rb
index c6521e1..f30959b 100644
--- a/backend/config/routes.rb
+++ b/backend/config/routes.rb
@@ -64,7 +64,12 @@ Rails.application.routes.draw do
end
namespace :gekanator do
- resources :games, only: [:create], controller: '/gekanator_games'
+ resources :games, only: [:create], controller: '/gekanator_games' do
+ member do
+ get :extra_questions
+ post :extra_question_answers
+ end
+ end
resources :posts, only: [:index], controller: '/gekanator_posts'
resources :questions, only: [:index], controller: '/gekanator_questions'
resources :question_suggestions,
diff --git a/backend/db/migrate/20260610000000_create_gekanator_question_examples.rb b/backend/db/migrate/20260610000000_create_gekanator_question_examples.rb
new file mode 100644
index 0000000..f115067
--- /dev/null
+++ b/backend/db/migrate/20260610000000_create_gekanator_question_examples.rb
@@ -0,0 +1,19 @@
+class CreateGekanatorQuestionExamples < ActiveRecord::Migration[8.0]
+ def change
+ create_table :gekanator_question_examples do |t|
+ t.references :gekanator_question, null: false, foreign_key: true
+ t.references :post, null: false, foreign_key: true
+ t.references :user, null: false, foreign_key: true
+ t.references :gekanator_game, null: true, foreign_key: true
+ t.string :answer, null: false
+ t.string :source, null: false, default: 'post_game_extra'
+ t.float :weight, null: false, default: 1.0
+ t.timestamps
+ end
+
+ add_index :gekanator_question_examples,
+ [:gekanator_question_id, :post_id, :user_id],
+ unique: true,
+ name: 'idx_gekanator_question_examples_on_question_post_user'
+ end
+end
diff --git a/backend/db/schema.rb b/backend/db/schema.rb
index 3cd1798..a7d7dac 100644
--- a/backend/db/schema.rb
+++ b/backend/db/schema.rb
@@ -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_09_001000) do
+ActiveRecord::Schema[8.0].define(version: 2026_06_10_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
@@ -75,6 +75,23 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_09_001000) do
t.check_constraint "`question_count` >= 0", name: "chk_gekanator_games_question_count_nonnegative"
end
+ create_table "gekanator_question_examples", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
+ t.bigint "gekanator_question_id", null: false
+ t.bigint "post_id", null: false
+ t.bigint "user_id", null: false
+ t.bigint "gekanator_game_id"
+ t.string "answer", null: false
+ t.string "source", default: "post_game_extra", null: false
+ t.float "weight", default: 1.0, null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", 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"
+ t.index ["post_id"], name: "index_gekanator_question_examples_on_post_id"
+ t.index ["user_id"], name: "index_gekanator_question_examples_on_user_id"
+ end
+
create_table "gekanator_question_suggestions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "gekanator_game_id", null: false
t.bigint "user_id", null: false
@@ -553,6 +570,10 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_09_001000) do
add_foreign_key "gekanator_games", "posts", column: "correct_post_id"
add_foreign_key "gekanator_games", "posts", column: "guessed_post_id"
add_foreign_key "gekanator_games", "users"
+ add_foreign_key "gekanator_question_examples", "gekanator_games"
+ add_foreign_key "gekanator_question_examples", "gekanator_questions"
+ add_foreign_key "gekanator_question_examples", "posts"
+ add_foreign_key "gekanator_question_examples", "users"
add_foreign_key "gekanator_question_suggestions", "gekanator_games", on_delete: :cascade
add_foreign_key "gekanator_question_suggestions", "users"
add_foreign_key "gekanator_questions", "gekanator_question_suggestions"
diff --git a/frontend/src/lib/gekanator.ts b/frontend/src/lib/gekanator.ts
index dcd4dff..252e79d 100644
--- a/frontend/src/lib/gekanator.ts
+++ b/frontend/src/lib/gekanator.ts
@@ -21,6 +21,7 @@ export type GekanatorQuestionKind =
| 'source'
| 'title'
| 'original_date'
+ | 'post_similarity'
export type GekanatorQuestionSource =
| 'default'
@@ -36,6 +37,18 @@ export type GekanatorQuestionCondition =
| { type: 'original-month-day'; monthDay: string }
| { type: 'title-length-greater-than'; length: number }
| { type: 'title-has-ascii' }
+ | {
+ type: 'post-similarity'
+ postId: number
+ answer: GekanatorAnswerValue
+ threshold: number
+ }
+
+export type GekanatorExtraQuestion = {
+ id: number
+ text: string
+ source: GekanatorQuestionSource
+ priorityWeight: number }
export type StoredGekanatorQuestion = {
id: string
@@ -43,7 +56,8 @@ export type StoredGekanatorQuestion = {
kind: GekanatorQuestionKind
condition: GekanatorQuestionCondition
source?: GekanatorQuestionSource
- priorityWeight?: number }
+ priorityWeight?: number
+ exampleAnswers?: Record<`${ number }`, GekanatorAnswerValue> }
export type GekanatorQuestion = {
id: string
@@ -52,8 +66,24 @@ export type GekanatorQuestion = {
condition: GekanatorQuestionCondition
source: GekanatorQuestionSource
priorityWeight: number
+ exampleAnswers?: Record<`${ number }`, GekanatorAnswerValue>
test: (post: Post) => boolean }
+
+const directExampleAnswerFor = (
+ question: StoredGekanatorQuestion,
+ post: Post,
+): GekanatorAnswerValue | null => {
+ const direct = question.exampleAnswers?.[String (post.id) as `${ number }`]
+ if (direct)
+ return direct
+
+ if (question.condition.type === 'post-similarity' && question.condition.postId === post.id)
+ return question.condition.answer
+
+ return null
+}
+
const countBy =
追加学習
+追加で 2 問まで答へてください。
+追加質問を読み込んでゐます...
)} + + {extraQuestionState === 'empty' && ( +追加で学習できる質問はありませんでした。
)} + + {extraQuestionState === 'ready' && ( ++ 学習内容を保存できませんでした。通信状態を確認してもう一度試して。 +
)} + +覚えたよ.次はもっと見通す.
+{extraQuestionState === 'saved' ? '学習しました。' : '覚えたよ.次はもっと見通す.'}