グカネータ / 質問パターン見直し (#41) #365
@@ -12,9 +12,35 @@ class GekanatorQuestionSuggestionsController < ApplicationController
|
|||||||
answer: params.require(:answer))
|
answer: params.require(:answer))
|
||||||
|
|
||||||
if suggestion.save
|
if suggestion.save
|
||||||
render json: { id: suggestion.id }, status: :created
|
render json: {
|
||||||
|
id: suggestion.id,
|
||||||
|
count: game.question_suggestions.count
|
||||||
|
}, status: :created
|
||||||
else
|
else
|
||||||
render_validation_error suggestion
|
render_validation_error suggestion
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def ai_convert
|
||||||
|
return head :not_found unless current_user&.admin?
|
||||||
|
|
||||||
|
suggestion = GekanatorQuestionSuggestion.find_by(id: params[:id])
|
||||||
|
return head :not_found unless suggestion
|
||||||
|
if Gekanator::AiRunBudget.exceeded_after_next_run?
|
||||||
|
suggestion.gekanator_ai_runs.create!(
|
||||||
|
model: 'budget_guard',
|
||||||
|
status: 'blocked_budget',
|
||||||
|
input_tokens: 0,
|
||||||
|
output_tokens: 0,
|
||||||
|
estimated_cost_jpy: 0)
|
||||||
|
return head :payment_required
|
||||||
|
end
|
||||||
|
|
||||||
|
Gekanator::QuestionSuggestionAiConverter.call(
|
||||||
|
suggestion: suggestion,
|
||||||
|
user: current_user)
|
||||||
|
head :no_content
|
||||||
|
rescue NotImplementedError
|
||||||
|
head :not_implemented
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
class GekanatorQuestionsController < ApplicationController
|
||||||
|
def index
|
||||||
|
return head :not_found unless current_user&.admin?
|
||||||
|
|
||||||
|
questions = GekanatorQuestion.accepted.order(priority_weight: :desc, id: :asc)
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
questions: questions.map { |question| question_json(question) }
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def question_json question
|
||||||
|
{
|
||||||
|
id: question_id_for(question),
|
||||||
|
text: question.text,
|
||||||
|
kind: question.kind,
|
||||||
|
condition: condition_json(question.condition),
|
||||||
|
source: question.source,
|
||||||
|
priority_weight: question.priority_weight
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def question_id_for question
|
||||||
|
condition = condition_json(question.condition).deep_symbolize_keys
|
||||||
|
|
||||||
|
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-greater-than'
|
||||||
|
"title:length-greater-than:#{ condition[:length] }"
|
||||||
|
when 'title-has-ascii'
|
||||||
|
'title:ascii'
|
||||||
|
else
|
||||||
|
"catalog:#{ question.id }"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def condition_json condition
|
||||||
|
json = condition.deep_dup.as_json
|
||||||
|
|
||||||
|
if json['type'] == 'original-month-day' && json['monthDay'].blank?
|
||||||
|
json['monthDay'] = json.delete('month_day')
|
||||||
|
end
|
||||||
|
|
||||||
|
json
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
class GekanatorAiRun < ApplicationRecord
|
||||||
|
STATUSES = ['pending', 'running', 'succeeded', 'failed', 'blocked_budget'].freeze
|
||||||
|
|
||||||
|
belongs_to :gekanator_question_suggestion
|
||||||
|
|
||||||
|
validates :model, presence: true, length: { maximum: 255 }
|
||||||
|
validates :status, presence: true, inclusion: { in: STATUSES }
|
||||||
|
validates :input_tokens,
|
||||||
|
presence: true,
|
||||||
|
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
|
||||||
|
validates :output_tokens,
|
||||||
|
presence: true,
|
||||||
|
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
|
||||||
|
validates :estimated_cost_jpy,
|
||||||
|
presence: true,
|
||||||
|
numericality: { greater_than_or_equal_to: 0 }
|
||||||
|
|
||||||
|
scope :this_month, lambda {
|
||||||
|
where(created_at: Time.current.beginning_of_month..Time.current.end_of_month)
|
||||||
|
}
|
||||||
|
end
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
class GekanatorQuestion < ApplicationRecord
|
||||||
|
KINDS = ['tag', 'source', 'title', 'original_date'].freeze
|
||||||
|
SOURCES = ['user_suggested', 'ai_generated', 'admin_curated'].freeze
|
||||||
|
STATUSES = ['pending', 'accepted', 'rejected'].freeze
|
||||||
|
|
||||||
|
belongs_to :gekanator_question_suggestion, optional: true
|
||||||
|
belongs_to :created_by, class_name: 'User', optional: true
|
||||||
|
|
||||||
|
validates :kind, presence: true, inclusion: { in: KINDS }
|
||||||
|
validates :source, presence: true, inclusion: { in: SOURCES }
|
||||||
|
validates :status, presence: true, inclusion: { in: STATUSES }
|
||||||
|
validates :text, presence: true, length: { maximum: 1000 }
|
||||||
|
validates :condition, presence: true
|
||||||
|
validates :priority_weight,
|
||||||
|
presence: true,
|
||||||
|
numericality: { greater_than: 0 }
|
||||||
|
|
||||||
|
scope :accepted, -> { where(status: 'accepted') }
|
||||||
|
end
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
class GekanatorQuestionSuggestion < ApplicationRecord
|
class GekanatorQuestionSuggestion < ApplicationRecord
|
||||||
MAX_QUESTIONS_PER_GAME = 1
|
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
|
||||||
belongs_to :user
|
belongs_to :user
|
||||||
|
has_many :gekanator_questions, dependent: :nullify
|
||||||
|
has_many :gekanator_ai_runs, dependent: :destroy
|
||||||
|
|
||||||
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 }
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
module Gekanator
|
||||||
|
class AiRunBudget
|
||||||
|
MONTHLY_LIMIT_JPY = BigDecimal('450').freeze
|
||||||
|
MAX_RUN_ESTIMATED_COST_JPY = BigDecimal('5').freeze
|
||||||
|
|
||||||
|
def self.remaining_monthly_budget_jpy
|
||||||
|
MONTHLY_LIMIT_JPY - monthly_cost_jpy
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.monthly_cost_jpy
|
||||||
|
GekanatorAiRun.this_month.sum(:estimated_cost_jpy)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.exceeded?
|
||||||
|
monthly_cost_jpy >= MONTHLY_LIMIT_JPY
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.exceeded_after_next_run?
|
||||||
|
monthly_cost_jpy + MAX_RUN_ESTIMATED_COST_JPY >= MONTHLY_LIMIT_JPY
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
module Gekanator
|
||||||
|
class QuestionSuggestionAiConverter
|
||||||
|
def self.call(...) = new(...).call
|
||||||
|
|
||||||
|
def initialize suggestion:, user:
|
||||||
|
@suggestion = suggestion
|
||||||
|
@user = user
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
raise NotImplementedError, 'AI question conversion is not implemented yet.'
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
attr_reader :suggestion, :user
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -66,9 +66,14 @@ Rails.application.routes.draw do
|
|||||||
namespace :gekanator do
|
namespace :gekanator do
|
||||||
resources :games, only: [:create], controller: '/gekanator_games'
|
resources :games, only: [:create], controller: '/gekanator_games'
|
||||||
resources :posts, only: [:index], controller: '/gekanator_posts'
|
resources :posts, only: [:index], controller: '/gekanator_posts'
|
||||||
|
resources :questions, only: [:index], controller: '/gekanator_questions'
|
||||||
resources :question_suggestions,
|
resources :question_suggestions,
|
||||||
only: [:create],
|
only: [:create],
|
||||||
controller: '/gekanator_question_suggestions'
|
controller: '/gekanator_question_suggestions' do
|
||||||
|
member do
|
||||||
|
post :ai_convert
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
resources :users, only: [:create, :update] do
|
resources :users, only: [:create, :update] do
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
class CreateGekanatorQuestions < ActiveRecord::Migration[8.0]
|
||||||
|
def change
|
||||||
|
create_table :gekanator_questions do |t|
|
||||||
|
t.string :text, null: false
|
||||||
|
t.string :kind, null: false
|
||||||
|
t.json :condition, null: false
|
||||||
|
t.string :source, null: false, default: 'ai_generated'
|
||||||
|
t.string :status, null: false, default: 'pending'
|
||||||
|
t.float :priority_weight, null: false, default: 1.0
|
||||||
|
t.references :gekanator_question_suggestion,
|
||||||
|
null: true,
|
||||||
|
foreign_key: true
|
||||||
|
t.references :created_by,
|
||||||
|
null: true,
|
||||||
|
foreign_key: { to_table: :users }
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
class CreateGekanatorAiRuns < ActiveRecord::Migration[8.0]
|
||||||
|
def change
|
||||||
|
create_table :gekanator_ai_runs do |t|
|
||||||
|
t.string :model, null: false
|
||||||
|
t.integer :input_tokens, null: false, default: 0
|
||||||
|
t.integer :output_tokens, null: false, default: 0
|
||||||
|
t.decimal :estimated_cost_jpy, precision: 8, scale: 3, null: false, default: 0
|
||||||
|
t.string :status, null: false, default: 'pending'
|
||||||
|
t.references :gekanator_question_suggestion, null: false, foreign_key: true
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
生成ファイル
+50
-1
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[8.0].define(version: 2026_06_08_002000) do
|
ActiveRecord::Schema[8.0].define(version: 2026_06_09_001000) do
|
||||||
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
t.string "name", null: false
|
t.string "name", null: false
|
||||||
t.string "record_type", null: false
|
t.string "record_type", null: false
|
||||||
@@ -48,6 +48,18 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_08_002000) do
|
|||||||
t.index ["tag_id"], name: "index_deerjikists_on_tag_id"
|
t.index ["tag_id"], name: "index_deerjikists_on_tag_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "gekanator_ai_runs", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
|
t.string "model", null: false
|
||||||
|
t.integer "input_tokens", default: 0, null: false
|
||||||
|
t.integer "output_tokens", default: 0, null: false
|
||||||
|
t.decimal "estimated_cost_jpy", precision: 8, scale: 3, default: "0.0", null: false
|
||||||
|
t.string "status", default: "pending", null: false
|
||||||
|
t.bigint "gekanator_question_suggestion_id", null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["gekanator_question_suggestion_id"], name: "index_gekanator_ai_runs_on_gekanator_question_suggestion_id"
|
||||||
|
end
|
||||||
|
|
||||||
create_table "gekanator_games", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
create_table "gekanator_games", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
t.bigint "user_id", null: false
|
t.bigint "user_id", null: false
|
||||||
t.bigint "guessed_post_id", null: false
|
t.bigint "guessed_post_id", null: false
|
||||||
@@ -75,6 +87,21 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_08_002000) do
|
|||||||
t.index ["user_id"], name: "index_gekanator_question_suggestions_on_user_id"
|
t.index ["user_id"], name: "index_gekanator_question_suggestions_on_user_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "gekanator_questions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
|
t.string "text", null: false
|
||||||
|
t.string "kind", null: false
|
||||||
|
t.json "condition", null: false
|
||||||
|
t.string "source", default: "ai_generated", null: false
|
||||||
|
t.string "status", default: "pending", null: false
|
||||||
|
t.float "priority_weight", default: 1.0, null: false
|
||||||
|
t.bigint "gekanator_question_suggestion_id"
|
||||||
|
t.bigint "created_by_id"
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["created_by_id"], name: "index_gekanator_questions_on_created_by_id"
|
||||||
|
t.index ["gekanator_question_suggestion_id"], name: "index_gekanator_questions_on_gekanator_question_suggestion_id"
|
||||||
|
end
|
||||||
|
|
||||||
create_table "ip_addresses", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
create_table "ip_addresses", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
t.binary "ip_address", limit: 16, null: false
|
t.binary "ip_address", limit: 16, null: false
|
||||||
t.datetime "banned_at"
|
t.datetime "banned_at"
|
||||||
@@ -164,6 +191,19 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_08_002000) do
|
|||||||
t.index ["target_post_id"], name: "index_post_similarities_on_target_post_id"
|
t.index ["target_post_id"], name: "index_post_similarities_on_target_post_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "post_tag_sections", primary_key: ["post_id", "tag_id", "begin_ms"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
|
t.bigint "post_id", null: false
|
||||||
|
t.bigint "tag_id", null: false
|
||||||
|
t.integer "begin_ms", null: false
|
||||||
|
t.integer "end_ms", null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["post_id", "begin_ms"], name: "idx_post_tag_sections_post_id_begin_ms"
|
||||||
|
t.index ["tag_id"], name: "fk_rails_8be3847903"
|
||||||
|
t.check_constraint "`begin_ms` < `end_ms`", name: "chk_post_tag_sections_end_ms_after_begin_ms"
|
||||||
|
t.check_constraint "`begin_ms` >= 0", name: "chk_post_tag_sections_begin_ms_natural"
|
||||||
|
end
|
||||||
|
|
||||||
create_table "post_tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
create_table "post_tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
t.bigint "post_id", null: false
|
t.bigint "post_id", null: false
|
||||||
t.bigint "tag_id", null: false
|
t.bigint "tag_id", null: false
|
||||||
@@ -214,8 +254,11 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_08_002000) do
|
|||||||
t.datetime "original_created_before"
|
t.datetime "original_created_before"
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.integer "version_no", null: false
|
t.integer "version_no", null: false
|
||||||
|
t.integer "video_ms"
|
||||||
t.index ["uploaded_user_id"], name: "index_posts_on_uploaded_user_id"
|
t.index ["uploaded_user_id"], name: "index_posts_on_uploaded_user_id"
|
||||||
t.index ["url"], name: "index_posts_on_url", unique: true
|
t.index ["url"], name: "index_posts_on_url", unique: true
|
||||||
|
t.index ["video_ms", "id"], name: "idx_posts_video_ms_id"
|
||||||
|
t.check_constraint "(`video_ms` is null) or (`video_ms` > 0)", name: "chk_posts_video_ms_positive"
|
||||||
t.check_constraint "`version_no` > 0", name: "chk_posts_version_no_positive"
|
t.check_constraint "`version_no` > 0", name: "chk_posts_version_no_positive"
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -366,6 +409,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_08_002000) do
|
|||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.index ["expires_at"], name: "index_theatre_watching_users_on_expires_at"
|
t.index ["expires_at"], name: "index_theatre_watching_users_on_expires_at"
|
||||||
|
t.index ["theatre_id", "expires_at"], name: "idx_on_theatre_id_skip_expires_at_4c8de1dd42"
|
||||||
t.index ["theatre_id", "expires_at"], name: "index_theatre_watching_users_on_theatre_id_and_expires_at"
|
t.index ["theatre_id", "expires_at"], name: "index_theatre_watching_users_on_theatre_id_and_expires_at"
|
||||||
t.index ["theatre_id"], name: "index_theatre_watching_users_on_theatre_id"
|
t.index ["theatre_id"], name: "index_theatre_watching_users_on_theatre_id"
|
||||||
t.index ["user_id"], name: "index_theatre_watching_users_on_user_id"
|
t.index ["user_id"], name: "index_theatre_watching_users_on_user_id"
|
||||||
@@ -505,11 +549,14 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_08_002000) do
|
|||||||
|
|
||||||
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
||||||
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
||||||
|
add_foreign_key "gekanator_ai_runs", "gekanator_question_suggestions"
|
||||||
add_foreign_key "gekanator_games", "posts", column: "correct_post_id"
|
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", "posts", column: "guessed_post_id"
|
||||||
add_foreign_key "gekanator_games", "users"
|
add_foreign_key "gekanator_games", "users"
|
||||||
add_foreign_key "gekanator_question_suggestions", "gekanator_games", on_delete: :cascade
|
add_foreign_key "gekanator_question_suggestions", "gekanator_games", on_delete: :cascade
|
||||||
add_foreign_key "gekanator_question_suggestions", "users"
|
add_foreign_key "gekanator_question_suggestions", "users"
|
||||||
|
add_foreign_key "gekanator_questions", "gekanator_question_suggestions"
|
||||||
|
add_foreign_key "gekanator_questions", "users", column: "created_by_id"
|
||||||
add_foreign_key "material_versions", "materials"
|
add_foreign_key "material_versions", "materials"
|
||||||
add_foreign_key "material_versions", "materials", column: "parent_id"
|
add_foreign_key "material_versions", "materials", column: "parent_id"
|
||||||
add_foreign_key "material_versions", "tags"
|
add_foreign_key "material_versions", "tags"
|
||||||
@@ -527,6 +574,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_08_002000) do
|
|||||||
add_foreign_key "post_implications", "posts", column: "parent_post_id"
|
add_foreign_key "post_implications", "posts", column: "parent_post_id"
|
||||||
add_foreign_key "post_similarities", "posts"
|
add_foreign_key "post_similarities", "posts"
|
||||||
add_foreign_key "post_similarities", "posts", column: "target_post_id"
|
add_foreign_key "post_similarities", "posts", column: "target_post_id"
|
||||||
|
add_foreign_key "post_tag_sections", "posts"
|
||||||
|
add_foreign_key "post_tag_sections", "tags"
|
||||||
add_foreign_key "post_tags", "posts"
|
add_foreign_key "post_tags", "posts"
|
||||||
add_foreign_key "post_tags", "tags"
|
add_foreign_key "post_tags", "tags"
|
||||||
add_foreign_key "post_tags", "users", column: "created_user_id"
|
add_foreign_key "post_tags", "users", column: "created_user_id"
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ export type GekanatorQuestionKind =
|
|||||||
| 'title'
|
| 'title'
|
||||||
| 'original_date'
|
| 'original_date'
|
||||||
|
|
||||||
|
export type GekanatorQuestionSource =
|
||||||
|
| 'default'
|
||||||
|
| 'user_suggested'
|
||||||
|
| 'ai_generated'
|
||||||
|
| 'admin_curated'
|
||||||
|
|
||||||
export type GekanatorQuestionCondition =
|
export type GekanatorQuestionCondition =
|
||||||
| { type: 'tag'; key: string }
|
| { type: 'tag'; key: string }
|
||||||
| { type: 'source'; host: string }
|
| { type: 'source'; host: string }
|
||||||
@@ -35,13 +41,17 @@ export type StoredGekanatorQuestion = {
|
|||||||
id: string
|
id: string
|
||||||
text: string
|
text: string
|
||||||
kind: GekanatorQuestionKind
|
kind: GekanatorQuestionKind
|
||||||
condition: GekanatorQuestionCondition }
|
condition: GekanatorQuestionCondition
|
||||||
|
source?: GekanatorQuestionSource
|
||||||
|
priorityWeight?: number }
|
||||||
|
|
||||||
export type GekanatorQuestion = {
|
export type GekanatorQuestion = {
|
||||||
id: string
|
id: string
|
||||||
text: string
|
text: string
|
||||||
kind: GekanatorQuestionKind
|
kind: GekanatorQuestionKind
|
||||||
condition: GekanatorQuestionCondition
|
condition: GekanatorQuestionCondition
|
||||||
|
source: GekanatorQuestionSource
|
||||||
|
priorityWeight: number
|
||||||
test: (post: Post) => boolean }
|
test: (post: Post) => boolean }
|
||||||
|
|
||||||
const countBy = <T extends string | number> (values: T[]): Map<T, number> => {
|
const countBy = <T extends string | number> (values: T[]): Map<T, number> => {
|
||||||
@@ -188,6 +198,8 @@ export const restoreGekanatorQuestion = (
|
|||||||
question: StoredGekanatorQuestion,
|
question: StoredGekanatorQuestion,
|
||||||
): GekanatorQuestion => ({
|
): GekanatorQuestion => ({
|
||||||
...question,
|
...question,
|
||||||
|
source: question.source ?? 'default',
|
||||||
|
priorityWeight: question.priorityWeight ?? 1,
|
||||||
test: (post: Post) => questionMatches (post, question.condition) })
|
test: (post: Post) => questionMatches (post, question.condition) })
|
||||||
|
|
||||||
|
|
||||||
@@ -197,7 +209,9 @@ export const storeGekanatorQuestion = (
|
|||||||
id: question.id,
|
id: question.id,
|
||||||
text: question.text,
|
text: question.text,
|
||||||
kind: question.kind,
|
kind: question.kind,
|
||||||
condition: question.condition })
|
condition: question.condition,
|
||||||
|
source: question.source,
|
||||||
|
priorityWeight: question.priorityWeight })
|
||||||
|
|
||||||
|
|
||||||
export const fetchGekanatorPosts = async (): Promise<Post[]> => {
|
export const fetchGekanatorPosts = async (): Promise<Post[]> => {
|
||||||
@@ -206,6 +220,12 @@ export const fetchGekanatorPosts = async (): Promise<Post[]> => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const fetchGekanatorQuestions = async (): Promise<StoredGekanatorQuestion[]> => {
|
||||||
|
const data = await apiGet<{ questions: StoredGekanatorQuestion[] }> ('/gekanator/questions')
|
||||||
|
return data.questions
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
|
export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
|
||||||
const tagCounts = countBy (posts.flatMap (post =>
|
const tagCounts = countBy (posts.flatMap (post =>
|
||||||
post.tags
|
post.tags
|
||||||
@@ -248,6 +268,8 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
|
|||||||
text: tagQuestionText (category, label),
|
text: tagQuestionText (category, label),
|
||||||
kind: 'tag' as const,
|
kind: 'tag' as const,
|
||||||
condition: { type: 'tag' as const, key: String (key) },
|
condition: { type: 'tag' as const, key: String (key) },
|
||||||
|
source: 'default' as const,
|
||||||
|
priorityWeight: 1,
|
||||||
test: (post: Post) => questionableTag (post, String (key)) }
|
test: (post: Post) => questionableTag (post, String (key)) }
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -259,6 +281,8 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
|
|||||||
text: `${ host } の投稿を思ひ浮かべてゐる?`,
|
text: `${ host } の投稿を思ひ浮かべてゐる?`,
|
||||||
kind: 'source' as const,
|
kind: 'source' as const,
|
||||||
condition: { type: 'source' as const, host },
|
condition: { type: 'source' as const, host },
|
||||||
|
source: 'default' as const,
|
||||||
|
priorityWeight: 1,
|
||||||
test: (post: Post) => hostOf (post) === host }))
|
test: (post: Post) => hostOf (post) === host }))
|
||||||
|
|
||||||
const originalYearQuestions = usefulEntries (originalYears)
|
const originalYearQuestions = usefulEntries (originalYears)
|
||||||
@@ -269,6 +293,8 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
|
|||||||
text: `オリジナルの投稿年は ${ year } 年?`,
|
text: `オリジナルの投稿年は ${ year } 年?`,
|
||||||
kind: 'original_date' as const,
|
kind: 'original_date' as const,
|
||||||
condition: { type: 'original-year' as const, year },
|
condition: { type: 'original-year' as const, year },
|
||||||
|
source: 'default' as const,
|
||||||
|
priorityWeight: 1,
|
||||||
test: (post: Post) => originalYearOf (post) === year }))
|
test: (post: Post) => originalYearOf (post) === year }))
|
||||||
|
|
||||||
const originalMonthQuestions = usefulEntries (originalMonths)
|
const originalMonthQuestions = usefulEntries (originalMonths)
|
||||||
@@ -279,6 +305,8 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
|
|||||||
text: `オリジナルの投稿月は ${ month } 月?`,
|
text: `オリジナルの投稿月は ${ month } 月?`,
|
||||||
kind: 'original_date' as const,
|
kind: 'original_date' as const,
|
||||||
condition: { type: 'original-month' as const, month },
|
condition: { type: 'original-month' as const, month },
|
||||||
|
source: 'default' as const,
|
||||||
|
priorityWeight: 1,
|
||||||
test: (post: Post) => originalMonthOf (post) === month }))
|
test: (post: Post) => originalMonthOf (post) === month }))
|
||||||
|
|
||||||
const originalMonthDayQuestions = usefulEntries (originalMonthDays)
|
const originalMonthDayQuestions = usefulEntries (originalMonthDays)
|
||||||
@@ -292,6 +320,8 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
|
|||||||
text: `オリジナルの投稿日は ${ month } 月 ${ day } 日?`,
|
text: `オリジナルの投稿日は ${ month } 月 ${ day } 日?`,
|
||||||
kind: 'original_date' as const,
|
kind: 'original_date' as const,
|
||||||
condition: { type: 'original-month-day' as const, monthDay: String (monthDay) },
|
condition: { type: 'original-month-day' as const, monthDay: String (monthDay) },
|
||||||
|
source: 'default' as const,
|
||||||
|
priorityWeight: 1,
|
||||||
test: (post: Post) => originalMonthDayOf (post) === monthDay }
|
test: (post: Post) => originalMonthDayOf (post) === monthDay }
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -303,12 +333,16 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
|
|||||||
condition: {
|
condition: {
|
||||||
type: 'title-length-greater-than' as const,
|
type: 'title-length-greater-than' as const,
|
||||||
length: titleLengthMedian },
|
length: titleLengthMedian },
|
||||||
|
source: 'default' as const,
|
||||||
|
priorityWeight: 1,
|
||||||
test: (post: Post) => (post.title?.length ?? 0) > titleLengthMedian },
|
test: (post: Post) => (post.title?.length ?? 0) > titleLengthMedian },
|
||||||
{
|
{
|
||||||
id: 'title:ascii',
|
id: 'title:ascii',
|
||||||
text: '題名に英数字が混じってゐる?',
|
text: '題名に英数字が混じってゐる?',
|
||||||
kind: 'title' as const,
|
kind: 'title' as const,
|
||||||
condition: { type: 'title-has-ascii' as const },
|
condition: { type: 'title-has-ascii' as const },
|
||||||
|
source: 'default' as const,
|
||||||
|
priorityWeight: 1,
|
||||||
test: (post: Post) => /[A-Za-z0-9]/.test (post.title ?? '') }]
|
test: (post: Post) => /[A-Za-z0-9]/.test (post.title ?? '') }]
|
||||||
.filter (question => {
|
.filter (question => {
|
||||||
const yes = posts.filter (post => question.test (post)).length
|
const yes = posts.filter (post => question.test (post)).length
|
||||||
@@ -354,7 +388,7 @@ export const saveGekanatorQuestionSuggestion = async ({
|
|||||||
gekanatorGameId: number
|
gekanatorGameId: number
|
||||||
questionText: string
|
questionText: string
|
||||||
answer: GekanatorAnswerValue
|
answer: GekanatorAnswerValue
|
||||||
}): Promise<{ id: number }> =>
|
}): Promise<{ id: number; count: number }> =>
|
||||||
await apiPost ('/gekanator/question_suggestions', {
|
await apiPost ('/gekanator/question_suggestions', {
|
||||||
gekanator_game_id: gekanatorGameId,
|
gekanator_game_id: gekanatorGameId,
|
||||||
question_text: questionText,
|
question_text: questionText,
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ export const postsKeys = {
|
|||||||
|
|
||||||
export const gekanatorKeys = {
|
export const gekanatorKeys = {
|
||||||
root: ['gekanator'] as const,
|
root: ['gekanator'] as const,
|
||||||
posts: () => ['gekanator', 'posts'] as const }
|
posts: () => ['gekanator', 'posts'] as const,
|
||||||
|
questions: () => ['gekanator', 'questions'] as const }
|
||||||
|
|
||||||
export const tagsKeys = {
|
export const tagsKeys = {
|
||||||
root: ['tags'] as const,
|
root: ['tags'] as const,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import PrefetchLink from '@/components/PrefetchLink'
|
|||||||
import MainArea from '@/components/layout/MainArea'
|
import MainArea from '@/components/layout/MainArea'
|
||||||
import { SITE_TITLE } from '@/config'
|
import { SITE_TITLE } from '@/config'
|
||||||
import { buildGekanatorQuestions,
|
import { buildGekanatorQuestions,
|
||||||
|
fetchGekanatorQuestions,
|
||||||
fetchGekanatorPosts,
|
fetchGekanatorPosts,
|
||||||
restoreGekanatorQuestion,
|
restoreGekanatorQuestion,
|
||||||
saveGekanatorGame,
|
saveGekanatorGame,
|
||||||
@@ -83,8 +84,10 @@ type StoredGekanatorGame = {
|
|||||||
reviewGuessedPostId: number | null
|
reviewGuessedPostId: number | null
|
||||||
reviewCorrectPostId: number | null
|
reviewCorrectPostId: number | null
|
||||||
savedGameId: number | null
|
savedGameId: number | null
|
||||||
|
gameSeed?: string
|
||||||
questionSuggestion: string
|
questionSuggestion: string
|
||||||
questionSuggestionAnswer: GekanatorAnswerValue }
|
questionSuggestionAnswer: GekanatorAnswerValue
|
||||||
|
questionSuggestionCount?: number }
|
||||||
|
|
||||||
const answerOptions: AnswerOption[] = [
|
const answerOptions: AnswerOption[] = [
|
||||||
{ label: 'はい', value: 'yes' },
|
{ label: 'はい', value: 'yes' },
|
||||||
@@ -104,6 +107,84 @@ const hardMaxQuestions = 80
|
|||||||
const softenedAnswerWeight = .35
|
const softenedAnswerWeight = .35
|
||||||
const confidenceTemperature = 6
|
const confidenceTemperature = 6
|
||||||
const gameStorageKey = 'gekanator:game:v1'
|
const gameStorageKey = 'gekanator:game:v1'
|
||||||
|
const maxQuestionSuggestionsPerGame = 3
|
||||||
|
|
||||||
|
const sourcePriorityOffset = (question: GekanatorQuestion): number => {
|
||||||
|
switch (question.source)
|
||||||
|
{
|
||||||
|
case 'user_suggested':
|
||||||
|
return -1.2
|
||||||
|
case 'admin_curated':
|
||||||
|
return -0.8
|
||||||
|
case 'ai_generated':
|
||||||
|
return -0.6
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const priorityWeightOffset = (question: GekanatorQuestion): number =>
|
||||||
|
(question.priorityWeight - 1) * -.8
|
||||||
|
|
||||||
|
|
||||||
|
const createGameSeed = (): string => {
|
||||||
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function')
|
||||||
|
return crypto.randomUUID ()
|
||||||
|
|
||||||
|
return `${ Date.now () }:${ Math.random ().toString (36).slice (2) }`
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const sourcePriorityForMerge = (question: GekanatorQuestion): number => {
|
||||||
|
switch (question.source)
|
||||||
|
{
|
||||||
|
case 'user_suggested':
|
||||||
|
return 3
|
||||||
|
case 'admin_curated':
|
||||||
|
return 3
|
||||||
|
case 'ai_generated':
|
||||||
|
return 3
|
||||||
|
default:
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const shouldReplaceMergedQuestion = (
|
||||||
|
current: GekanatorQuestion | undefined,
|
||||||
|
candidate: GekanatorQuestion,
|
||||||
|
): boolean => {
|
||||||
|
if (!(current))
|
||||||
|
return true
|
||||||
|
|
||||||
|
const currentSourcePriority = sourcePriorityForMerge (current)
|
||||||
|
const candidateSourcePriority = sourcePriorityForMerge (candidate)
|
||||||
|
if (candidateSourcePriority !== currentSourcePriority)
|
||||||
|
return candidateSourcePriority > currentSourcePriority
|
||||||
|
|
||||||
|
if (candidate.priorityWeight !== current.priorityWeight)
|
||||||
|
return candidate.priorityWeight > current.priorityWeight
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const hashString = (value: string): number => {
|
||||||
|
let hash = 2166136261
|
||||||
|
|
||||||
|
for (let i = 0; i < value.length; i += 1)
|
||||||
|
{
|
||||||
|
hash ^= value.charCodeAt (i)
|
||||||
|
hash = Math.imul (hash, 16777619)
|
||||||
|
}
|
||||||
|
|
||||||
|
return hash >>> 0
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const deterministicUnitFloat = (seed: string): number =>
|
||||||
|
hashString (seed) / 4294967295
|
||||||
|
|
||||||
|
|
||||||
const clearStoredGame = (): void => {
|
const clearStoredGame = (): void => {
|
||||||
@@ -326,7 +407,11 @@ const previewAnswer = ({
|
|||||||
|
|
||||||
const mergeQuestions = (questions: GekanatorQuestion[]): GekanatorQuestion[] => {
|
const mergeQuestions = (questions: GekanatorQuestion[]): GekanatorQuestion[] => {
|
||||||
const byId = new Map<string, GekanatorQuestion> ()
|
const byId = new Map<string, GekanatorQuestion> ()
|
||||||
questions.forEach (question => byId.set (question.id, question))
|
questions.forEach (question => {
|
||||||
|
const current = byId.get (question.id)
|
||||||
|
if (shouldReplaceMergedQuestion (current, question))
|
||||||
|
byId.set (question.id, question)
|
||||||
|
})
|
||||||
return [...byId.values ()]
|
return [...byId.values ()]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,11 +452,13 @@ const chooseQuestion = ({
|
|||||||
questions,
|
questions,
|
||||||
scores,
|
scores,
|
||||||
askedIds,
|
askedIds,
|
||||||
|
gameSeed,
|
||||||
}: {
|
}: {
|
||||||
posts: Post[]
|
posts: Post[]
|
||||||
questions: GekanatorQuestion[]
|
questions: GekanatorQuestion[]
|
||||||
scores: Map<number, number>
|
scores: Map<number, number>
|
||||||
askedIds: Set<string>
|
askedIds: Set<string>
|
||||||
|
gameSeed: string
|
||||||
}): GekanatorQuestion | null => {
|
}): GekanatorQuestion | null => {
|
||||||
const scoredPosts = posts
|
const scoredPosts = posts
|
||||||
.map (post => ({ post, score: scores.get (post.id) ?? 0 }))
|
.map (post => ({ post, score: scores.get (post.id) ?? 0 }))
|
||||||
@@ -440,12 +527,16 @@ const chooseQuestion = ({
|
|||||||
const tagPenalty = question.kind === 'tag' && nonTagCount < 4 ? .12 : 0
|
const tagPenalty = question.kind === 'tag' && nonTagCount < 4 ? .12 : 0
|
||||||
const minSide = candidates.length < 10 ? 1 : Math.max (3, candidates.length * .08)
|
const minSide = candidates.length < 10 ? 1 : Math.max (3, candidates.length * .08)
|
||||||
const narrowPenalty = yes < minSide || no < minSide ? .15 : 0
|
const narrowPenalty = yes < minSide || no < minSide ? .15 : 0
|
||||||
|
const sourceBonus = sourcePriorityOffset (question)
|
||||||
|
const priorityBonus = priorityWeightOffset (question)
|
||||||
|
|
||||||
return { question,
|
return { question,
|
||||||
score: weightedSplitScore * 100
|
score: weightedSplitScore * 100
|
||||||
+ unweightedSplitScore * 8
|
+ unweightedSplitScore * 8
|
||||||
+ tagPenalty
|
+ tagPenalty
|
||||||
+ narrowPenalty,
|
+ narrowPenalty
|
||||||
|
+ sourceBonus
|
||||||
|
+ priorityBonus,
|
||||||
narrow: narrowPenalty > 0 }
|
narrow: narrowPenalty > 0 }
|
||||||
})
|
})
|
||||||
.filter ((item): item is {
|
.filter ((item): item is {
|
||||||
@@ -458,8 +549,35 @@ const chooseQuestion = ({
|
|||||||
const unansweredQuestions =
|
const unansweredQuestions =
|
||||||
questions.filter (question => !(askedIds.has (question.id)))
|
questions.filter (question => !(askedIds.has (question.id)))
|
||||||
const ranked = rank (unansweredQuestions, scoredPosts, normalisedWeightedPosts)
|
const ranked = rank (unansweredQuestions, scoredPosts, normalisedWeightedPosts)
|
||||||
|
const pool = (
|
||||||
|
ranked.some (item => !(item.narrow))
|
||||||
|
? ranked.filter (item => !(item.narrow))
|
||||||
|
: ranked)
|
||||||
|
.slice (0, 12)
|
||||||
|
|
||||||
return (ranked.find (item => !(item.narrow)) ?? ranked[0])?.question ?? null
|
if (pool.length === 0)
|
||||||
|
return null
|
||||||
|
|
||||||
|
const bestScore = pool[0]?.score ?? 0
|
||||||
|
const weightedPool = pool.map (item => ({
|
||||||
|
...item,
|
||||||
|
weight: Math.exp ((bestScore - item.score) / 1.8) }))
|
||||||
|
const totalPoolWeight =
|
||||||
|
weightedPool.reduce ((sum, item) => sum + item.weight, 0) || 1
|
||||||
|
const seed = `${ gameSeed }:${ [...askedIds].sort ().join ('|') }:${
|
||||||
|
weightedPool.map (item => `${ item.question.id }:${ item.score.toFixed (4) }`).join ('|')
|
||||||
|
}`
|
||||||
|
const target = deterministicUnitFloat (seed) * totalPoolWeight
|
||||||
|
let cumulative = 0
|
||||||
|
|
||||||
|
for (const item of weightedPool)
|
||||||
|
{
|
||||||
|
cumulative += item.weight
|
||||||
|
if (target <= cumulative)
|
||||||
|
return item.question
|
||||||
|
}
|
||||||
|
|
||||||
|
return weightedPool[weightedPool.length - 1]?.question ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -501,6 +619,8 @@ const expectedAnswerFor = (
|
|||||||
|
|
||||||
const GekanatorPage: FC = () => {
|
const GekanatorPage: FC = () => {
|
||||||
const storedGame = useMemo (loadStoredGame, [])
|
const storedGame = useMemo (loadStoredGame, [])
|
||||||
|
const [gameSeed, setGameSeed] = useState (
|
||||||
|
storedGame?.gameSeed ?? createGameSeed ())
|
||||||
const [phase, setPhase] = useState<Phase> (storedGame?.phase ?? 'intro')
|
const [phase, setPhase] = useState<Phase> (storedGame?.phase ?? 'intro')
|
||||||
const [scores, setScores] = useState<Map<number, number>> (
|
const [scores, setScores] = useState<Map<number, number>> (
|
||||||
() => new Map (storedGame?.scores ?? []))
|
() => new Map (storedGame?.scores ?? []))
|
||||||
@@ -540,24 +660,37 @@ const GekanatorPage: FC = () => {
|
|||||||
storedGame?.questionSuggestion ?? '')
|
storedGame?.questionSuggestion ?? '')
|
||||||
const [questionSuggestionAnswer, setQuestionSuggestionAnswer] =
|
const [questionSuggestionAnswer, setQuestionSuggestionAnswer] =
|
||||||
useState<GekanatorAnswerValue> (storedGame?.questionSuggestionAnswer ?? 'yes')
|
useState<GekanatorAnswerValue> (storedGame?.questionSuggestionAnswer ?? 'yes')
|
||||||
|
const [questionSuggestionCount, setQuestionSuggestionCount] = useState (
|
||||||
|
storedGame?.questionSuggestionCount ?? 0)
|
||||||
const [history, setHistory] = useState<GameSnapshot[]> ([])
|
const [history, setHistory] = useState<GameSnapshot[]> ([])
|
||||||
|
|
||||||
const { data: posts = [], isLoading, error } = useQuery ({
|
const { data: posts = [], isLoading, error } = useQuery ({
|
||||||
queryKey: gekanatorKeys.posts (),
|
queryKey: gekanatorKeys.posts (),
|
||||||
queryFn: fetchGekanatorPosts })
|
queryFn: fetchGekanatorPosts })
|
||||||
|
const { data: acceptedQuestions = [], isFetched: acceptedQuestionsFetched } = useQuery ({
|
||||||
|
queryKey: gekanatorKeys.questions (),
|
||||||
|
queryFn: fetchGekanatorQuestions,
|
||||||
|
select: questions => questions.map (restoreGekanatorQuestion) })
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
if (posts.length === 0 || storedAskedQuestionBankIds.length === 0)
|
if (
|
||||||
|
posts.length === 0
|
||||||
|
|| storedAskedQuestionBankIds.length === 0
|
||||||
|
|| !(acceptedQuestionsFetched)
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
const questionById = new Map (
|
const questionById = new Map (
|
||||||
buildGekanatorQuestions (posts).map (question => [question.id, question]))
|
mergeQuestions ([
|
||||||
|
...buildGekanatorQuestions (posts),
|
||||||
|
...acceptedQuestions])
|
||||||
|
.map (question => [question.id, question]))
|
||||||
setAskedQuestionBank (
|
setAskedQuestionBank (
|
||||||
storedAskedQuestionBankIds
|
storedAskedQuestionBankIds
|
||||||
.map (questionId => questionById.get (questionId))
|
.map (questionId => questionById.get (questionId))
|
||||||
.filter ((question): question is GekanatorQuestion => question !== undefined))
|
.filter ((question): question is GekanatorQuestion => question !== undefined))
|
||||||
setStoredAskedQuestionBankIds ([])
|
setStoredAskedQuestionBankIds ([])
|
||||||
}, [posts, storedAskedQuestionBankIds])
|
}, [posts, storedAskedQuestionBankIds, acceptedQuestions, acceptedQuestionsFetched])
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
if (!(isStoredPhase (phase)) && answers.length === 0)
|
if (!(isStoredPhase (phase)) && answers.length === 0)
|
||||||
@@ -585,8 +718,10 @@ const GekanatorPage: FC = () => {
|
|||||||
reviewGuessedPostId,
|
reviewGuessedPostId,
|
||||||
reviewCorrectPostId,
|
reviewCorrectPostId,
|
||||||
savedGameId,
|
savedGameId,
|
||||||
|
gameSeed,
|
||||||
questionSuggestion,
|
questionSuggestion,
|
||||||
questionSuggestionAnswer }
|
questionSuggestionAnswer,
|
||||||
|
questionSuggestionCount }
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -615,8 +750,10 @@ const GekanatorPage: FC = () => {
|
|||||||
reviewGuessedPostId,
|
reviewGuessedPostId,
|
||||||
reviewCorrectPostId,
|
reviewCorrectPostId,
|
||||||
savedGameId,
|
savedGameId,
|
||||||
|
gameSeed,
|
||||||
questionSuggestion,
|
questionSuggestion,
|
||||||
questionSuggestionAnswer])
|
questionSuggestionAnswer,
|
||||||
|
questionSuggestionCount])
|
||||||
|
|
||||||
const eligiblePosts = useMemo (
|
const eligiblePosts = useMemo (
|
||||||
() => candidatePostsFor ({
|
() => candidatePostsFor ({
|
||||||
@@ -627,8 +764,10 @@ const GekanatorPage: FC = () => {
|
|||||||
rejectedPostIds }),
|
rejectedPostIds }),
|
||||||
[posts, askedQuestionBank, answers, softenedQuestionIds, rejectedPostIds])
|
[posts, askedQuestionBank, answers, softenedQuestionIds, rejectedPostIds])
|
||||||
const questions = useMemo (
|
const questions = useMemo (
|
||||||
() => buildGekanatorQuestions (eligiblePosts.length > 1 ? eligiblePosts : posts),
|
() => mergeQuestions ([
|
||||||
[eligiblePosts, posts])
|
...buildGekanatorQuestions (eligiblePosts.length > 1 ? eligiblePosts : posts),
|
||||||
|
...acceptedQuestions]),
|
||||||
|
[acceptedQuestions, eligiblePosts, posts])
|
||||||
const scoringQuestions = useMemo (() => {
|
const scoringQuestions = useMemo (() => {
|
||||||
return mergeQuestions ([...questions, ...askedQuestionBank])
|
return mergeQuestions ([...questions, ...askedQuestionBank])
|
||||||
}, [questions, askedQuestionBank])
|
}, [questions, askedQuestionBank])
|
||||||
@@ -651,7 +790,11 @@ const GekanatorPage: FC = () => {
|
|||||||
.slice (0, 3),
|
.slice (0, 3),
|
||||||
[eligiblePosts, scores])
|
[eligiblePosts, scores])
|
||||||
const currentQuestion = chooseQuestion ({
|
const currentQuestion = chooseQuestion ({
|
||||||
posts: questionPosts, questions: scoringQuestions, scores, askedIds })
|
posts: questionPosts,
|
||||||
|
questions: scoringQuestions,
|
||||||
|
scores,
|
||||||
|
askedIds,
|
||||||
|
gameSeed })
|
||||||
const answerPreviews = useMemo (
|
const answerPreviews = useMemo (
|
||||||
() => currentQuestion
|
() => currentQuestion
|
||||||
? answerOptions.map (option => previewAnswer ({
|
? answerOptions.map (option => previewAnswer ({
|
||||||
@@ -681,10 +824,10 @@ const GekanatorPage: FC = () => {
|
|||||||
}})
|
}})
|
||||||
const questionSuggestionMutation = useMutation ({
|
const questionSuggestionMutation = useMutation ({
|
||||||
mutationFn: saveGekanatorQuestionSuggestion,
|
mutationFn: saveGekanatorQuestionSuggestion,
|
||||||
onSuccess: () => {
|
onSuccess: data => {
|
||||||
|
setQuestionSuggestionCount (data.count)
|
||||||
setQuestionSuggestion ('')
|
setQuestionSuggestion ('')
|
||||||
setQuestionSuggestionAnswer ('yes')
|
setQuestionSuggestionAnswer ('yes')
|
||||||
reset ()
|
|
||||||
}})
|
}})
|
||||||
|
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
@@ -707,8 +850,10 @@ const GekanatorPage: FC = () => {
|
|||||||
setReviewGuessedPostId (null)
|
setReviewGuessedPostId (null)
|
||||||
setReviewCorrectPostId (null)
|
setReviewCorrectPostId (null)
|
||||||
setSavedGameId (null)
|
setSavedGameId (null)
|
||||||
|
setGameSeed (createGameSeed ())
|
||||||
setQuestionSuggestion ('')
|
setQuestionSuggestion ('')
|
||||||
setQuestionSuggestionAnswer ('yes')
|
setQuestionSuggestionAnswer ('yes')
|
||||||
|
setQuestionSuggestionCount (0)
|
||||||
setHistory ([])
|
setHistory ([])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -740,6 +885,7 @@ const GekanatorPage: FC = () => {
|
|||||||
let recoveredScoringQuestions = mergeQuestions ([
|
let recoveredScoringQuestions = mergeQuestions ([
|
||||||
...buildGekanatorQuestions (
|
...buildGekanatorQuestions (
|
||||||
recoveredEligiblePosts.length > 1 ? recoveredEligiblePosts : posts),
|
recoveredEligiblePosts.length > 1 ? recoveredEligiblePosts : posts),
|
||||||
|
...acceptedQuestions,
|
||||||
...nextAskedQuestionBank])
|
...nextAskedQuestionBank])
|
||||||
|
|
||||||
while (
|
while (
|
||||||
@@ -750,7 +896,8 @@ const GekanatorPage: FC = () => {
|
|||||||
posts: recoveredEligiblePosts,
|
posts: recoveredEligiblePosts,
|
||||||
questions: recoveredScoringQuestions,
|
questions: recoveredScoringQuestions,
|
||||||
scores: recoveredScores,
|
scores: recoveredScores,
|
||||||
askedIds: nextAskedIds })))
|
askedIds: nextAskedIds,
|
||||||
|
gameSeed })))
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
if (nextAnswers.length >= hardMaxQuestions)
|
if (nextAnswers.length >= hardMaxQuestions)
|
||||||
@@ -778,6 +925,7 @@ const GekanatorPage: FC = () => {
|
|||||||
recoveredScoringQuestions = mergeQuestions ([
|
recoveredScoringQuestions = mergeQuestions ([
|
||||||
...buildGekanatorQuestions (
|
...buildGekanatorQuestions (
|
||||||
recoveredEligiblePosts.length > 1 ? recoveredEligiblePosts : posts),
|
recoveredEligiblePosts.length > 1 ? recoveredEligiblePosts : posts),
|
||||||
|
...acceptedQuestions,
|
||||||
...nextAskedQuestionBank])
|
...nextAskedQuestionBank])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -934,9 +1082,23 @@ const GekanatorPage: FC = () => {
|
|||||||
saveReviewedResult (() => setPhase ('learned'))
|
saveReviewedResult (() => setPhase ('learned'))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const restartFromQuestionSuggestion = () => {
|
||||||
|
if (savedGameId !== null)
|
||||||
|
{
|
||||||
|
reset ()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
saveReviewedResult (reset)
|
||||||
|
}
|
||||||
|
|
||||||
const submitQuestionSuggestion = () => {
|
const submitQuestionSuggestion = () => {
|
||||||
const questionText = questionSuggestion.trim ()
|
const questionText = questionSuggestion.trim ()
|
||||||
if (!(questionText) || questionSuggestionMutation.isPending)
|
if (
|
||||||
|
!(questionText)
|
||||||
|
|| questionSuggestionMutation.isPending
|
||||||
|
|| questionSuggestionCount >= maxQuestionSuggestionsPerGame
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
saveReviewedResult (gekanatorGameId => {
|
saveReviewedResult (gekanatorGameId => {
|
||||||
@@ -1008,7 +1170,8 @@ const GekanatorPage: FC = () => {
|
|||||||
: nonRejectedPosts,
|
: nonRejectedPosts,
|
||||||
questions: recovered.scoringQuestions,
|
questions: recovered.scoringQuestions,
|
||||||
scores: recovered.scores,
|
scores: recovered.scores,
|
||||||
askedIds })
|
askedIds,
|
||||||
|
gameSeed })
|
||||||
|
|
||||||
if (nextQuestion)
|
if (nextQuestion)
|
||||||
{
|
{
|
||||||
@@ -1445,6 +1608,9 @@ const GekanatorPage: FC = () => {
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-neutral-500">質問追加</p>
|
<p className="text-sm text-neutral-500">質問追加</p>
|
||||||
<p className="text-xl font-bold">どんな質問なら見分けられさう?</p>
|
<p className="text-xl font-bold">どんな質問なら見分けられさう?</p>
|
||||||
|
<p className="text-sm text-neutral-600 dark:text-neutral-300">
|
||||||
|
追加済み {questionSuggestionCount} / {maxQuestionSuggestionsPerGame}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<label className="block space-y-2">
|
<label className="block space-y-2">
|
||||||
<span className="font-bold">質問候補</span>
|
<span className="font-bold">質問候補</span>
|
||||||
@@ -1483,18 +1649,33 @@ const GekanatorPage: FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="rounded bg-pink-600 px-4 py-2 font-bold text-white
|
className="rounded border border-yellow-300 px-4 py-2
|
||||||
hover:bg-pink-500 disabled:opacity-50"
|
hover:bg-yellow-100 dark:border-red-700
|
||||||
|
dark:hover:bg-red-900 disabled:opacity-50"
|
||||||
disabled={
|
disabled={
|
||||||
reviewCorrectPostId === null
|
questionSuggestionCount >= maxQuestionSuggestionsPerGame
|
||||||
|
|| reviewCorrectPostId === null
|
||||||
|| questionSuggestion.trim () === ''
|
|| questionSuggestion.trim () === ''
|
||||||
|| saveMutation.isPending
|
|| saveMutation.isPending
|
||||||
|| questionSuggestionMutation.isPending
|
|| questionSuggestionMutation.isPending
|
||||||
}
|
}
|
||||||
onClick={submitQuestionSuggestion}>
|
onClick={submitQuestionSuggestion}>
|
||||||
追加してもう一度
|
追加
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded bg-pink-600 px-4 py-2 font-bold text-white
|
||||||
|
hover:bg-pink-500 disabled:opacity-50"
|
||||||
|
disabled={saveMutation.isPending
|
||||||
|
|| questionSuggestionMutation.isPending}
|
||||||
|
onClick={restartFromQuestionSuggestion}>
|
||||||
|
もう一度
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{questionSuggestionCount >= maxQuestionSuggestionsPerGame && (
|
||||||
|
<p className="text-sm text-neutral-600 dark:text-neutral-300">
|
||||||
|
このゲームでは質問候補をこれ以上追加できません。
|
||||||
|
</p>)}
|
||||||
{(saveMutation.isError || questionSuggestionMutation.isError) && (
|
{(saveMutation.isError || questionSuggestionMutation.isError) && (
|
||||||
<p className="text-sm text-red-600">
|
<p className="text-sm text-red-600">
|
||||||
記録できませんでした。通信状態を確認してもう一度試して。
|
記録できませんでした。通信状態を確認してもう一度試して。
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする