グカネータ作成 / ウィニング・ラン修正 (#41) #366
@@ -12,9 +12,35 @@ class GekanatorQuestionSuggestionsController < ApplicationController
|
||||
answer: params.require(:answer))
|
||||
|
||||
if suggestion.save
|
||||
render json: { id: suggestion.id }, status: :created
|
||||
render json: {
|
||||
id: suggestion.id,
|
||||
count: game.question_suggestions.count
|
||||
}, status: :created
|
||||
else
|
||||
render_validation_error suggestion
|
||||
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
|
||||
|
||||
@@ -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
|
||||
MAX_QUESTIONS_PER_GAME = 1
|
||||
MAX_QUESTIONS_PER_GAME = 3
|
||||
ANSWERS = ['yes', 'no', 'partial', 'probably_no', 'unknown'].freeze
|
||||
|
||||
belongs_to :gekanator_game
|
||||
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 :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
|
||||
resources :games, only: [:create], controller: '/gekanator_games'
|
||||
resources :posts, only: [:index], controller: '/gekanator_posts'
|
||||
resources :questions, only: [:index], controller: '/gekanator_questions'
|
||||
resources :question_suggestions,
|
||||
only: [:create],
|
||||
controller: '/gekanator_question_suggestions'
|
||||
controller: '/gekanator_question_suggestions' do
|
||||
member do
|
||||
post :ai_convert
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
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.
|
||||
|
||||
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|
|
||||
t.string "name", 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"
|
||||
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|
|
||||
t.bigint "user_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"
|
||||
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|
|
||||
t.binary "ip_address", limit: 16, null: false
|
||||
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"
|
||||
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|
|
||||
t.bigint "post_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 "updated_at", 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 ["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"
|
||||
end
|
||||
|
||||
@@ -366,6 +409,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_08_002000) do
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
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"], name: "index_theatre_watching_users_on_theatre_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_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: "guessed_post_id"
|
||||
add_foreign_key "gekanator_games", "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"
|
||||
add_foreign_key "gekanator_questions", "users", column: "created_by_id"
|
||||
add_foreign_key "material_versions", "materials"
|
||||
add_foreign_key "material_versions", "materials", column: "parent_id"
|
||||
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_similarities", "posts"
|
||||
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", "tags"
|
||||
add_foreign_key "post_tags", "users", column: "created_user_id"
|
||||
|
||||
@@ -22,6 +22,12 @@ export type GekanatorQuestionKind =
|
||||
| 'title'
|
||||
| 'original_date'
|
||||
|
||||
export type GekanatorQuestionSource =
|
||||
| 'default'
|
||||
| 'user_suggested'
|
||||
| 'ai_generated'
|
||||
| 'admin_curated'
|
||||
|
||||
export type GekanatorQuestionCondition =
|
||||
| { type: 'tag'; key: string }
|
||||
| { type: 'source'; host: string }
|
||||
@@ -35,13 +41,17 @@ export type StoredGekanatorQuestion = {
|
||||
id: string
|
||||
text: string
|
||||
kind: GekanatorQuestionKind
|
||||
condition: GekanatorQuestionCondition }
|
||||
condition: GekanatorQuestionCondition
|
||||
source?: GekanatorQuestionSource
|
||||
priorityWeight?: number }
|
||||
|
||||
export type GekanatorQuestion = {
|
||||
id: string
|
||||
text: string
|
||||
kind: GekanatorQuestionKind
|
||||
condition: GekanatorQuestionCondition
|
||||
source: GekanatorQuestionSource
|
||||
priorityWeight: number
|
||||
test: (post: Post) => boolean }
|
||||
|
||||
const countBy = <T extends string | number> (values: T[]): Map<T, number> => {
|
||||
@@ -188,6 +198,8 @@ export const restoreGekanatorQuestion = (
|
||||
question: StoredGekanatorQuestion,
|
||||
): GekanatorQuestion => ({
|
||||
...question,
|
||||
source: question.source ?? 'default',
|
||||
priorityWeight: question.priorityWeight ?? 1,
|
||||
test: (post: Post) => questionMatches (post, question.condition) })
|
||||
|
||||
|
||||
@@ -197,7 +209,9 @@ export const storeGekanatorQuestion = (
|
||||
id: question.id,
|
||||
text: question.text,
|
||||
kind: question.kind,
|
||||
condition: question.condition })
|
||||
condition: question.condition,
|
||||
source: question.source,
|
||||
priorityWeight: question.priorityWeight })
|
||||
|
||||
|
||||
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[] => {
|
||||
const tagCounts = countBy (posts.flatMap (post =>
|
||||
post.tags
|
||||
@@ -248,6 +268,8 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
|
||||
text: tagQuestionText (category, label),
|
||||
kind: 'tag' as const,
|
||||
condition: { type: 'tag' as const, key: String (key) },
|
||||
source: 'default' as const,
|
||||
priorityWeight: 1,
|
||||
test: (post: Post) => questionableTag (post, String (key)) }
|
||||
})
|
||||
|
||||
@@ -259,6 +281,8 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
|
||||
text: `${ host } の投稿を思ひ浮かべてゐる?`,
|
||||
kind: 'source' as const,
|
||||
condition: { type: 'source' as const, host },
|
||||
source: 'default' as const,
|
||||
priorityWeight: 1,
|
||||
test: (post: Post) => hostOf (post) === host }))
|
||||
|
||||
const originalYearQuestions = usefulEntries (originalYears)
|
||||
@@ -269,6 +293,8 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
|
||||
text: `オリジナルの投稿年は ${ year } 年?`,
|
||||
kind: 'original_date' as const,
|
||||
condition: { type: 'original-year' as const, year },
|
||||
source: 'default' as const,
|
||||
priorityWeight: 1,
|
||||
test: (post: Post) => originalYearOf (post) === year }))
|
||||
|
||||
const originalMonthQuestions = usefulEntries (originalMonths)
|
||||
@@ -279,6 +305,8 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
|
||||
text: `オリジナルの投稿月は ${ month } 月?`,
|
||||
kind: 'original_date' as const,
|
||||
condition: { type: 'original-month' as const, month },
|
||||
source: 'default' as const,
|
||||
priorityWeight: 1,
|
||||
test: (post: Post) => originalMonthOf (post) === month }))
|
||||
|
||||
const originalMonthDayQuestions = usefulEntries (originalMonthDays)
|
||||
@@ -292,6 +320,8 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
|
||||
text: `オリジナルの投稿日は ${ month } 月 ${ day } 日?`,
|
||||
kind: 'original_date' as const,
|
||||
condition: { type: 'original-month-day' as const, monthDay: String (monthDay) },
|
||||
source: 'default' as const,
|
||||
priorityWeight: 1,
|
||||
test: (post: Post) => originalMonthDayOf (post) === monthDay }
|
||||
})
|
||||
|
||||
@@ -303,12 +333,16 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
|
||||
condition: {
|
||||
type: 'title-length-greater-than' as const,
|
||||
length: titleLengthMedian },
|
||||
source: 'default' as const,
|
||||
priorityWeight: 1,
|
||||
test: (post: Post) => (post.title?.length ?? 0) > titleLengthMedian },
|
||||
{
|
||||
id: 'title:ascii',
|
||||
text: '題名に英数字が混じってゐる?',
|
||||
kind: 'title' 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 ?? '') }]
|
||||
.filter (question => {
|
||||
const yes = posts.filter (post => question.test (post)).length
|
||||
@@ -354,7 +388,7 @@ export const saveGekanatorQuestionSuggestion = async ({
|
||||
gekanatorGameId: number
|
||||
questionText: string
|
||||
answer: GekanatorAnswerValue
|
||||
}): Promise<{ id: number }> =>
|
||||
}): Promise<{ id: number; count: number }> =>
|
||||
await apiPost ('/gekanator/question_suggestions', {
|
||||
gekanator_game_id: gekanatorGameId,
|
||||
question_text: questionText,
|
||||
|
||||
@@ -10,7 +10,8 @@ export const postsKeys = {
|
||||
|
||||
export const gekanatorKeys = {
|
||||
root: ['gekanator'] as const,
|
||||
posts: () => ['gekanator', 'posts'] as const }
|
||||
posts: () => ['gekanator', 'posts'] as const,
|
||||
questions: () => ['gekanator', 'questions'] as const }
|
||||
|
||||
export const tagsKeys = {
|
||||
root: ['tags'] as const,
|
||||
|
||||
@@ -6,6 +6,7 @@ import PrefetchLink from '@/components/PrefetchLink'
|
||||
import MainArea from '@/components/layout/MainArea'
|
||||
import { SITE_TITLE } from '@/config'
|
||||
import { buildGekanatorQuestions,
|
||||
fetchGekanatorQuestions,
|
||||
fetchGekanatorPosts,
|
||||
restoreGekanatorQuestion,
|
||||
saveGekanatorGame,
|
||||
@@ -83,8 +84,10 @@ type StoredGekanatorGame = {
|
||||
reviewGuessedPostId: number | null
|
||||
reviewCorrectPostId: number | null
|
||||
savedGameId: number | null
|
||||
gameSeed?: string
|
||||
questionSuggestion: string
|
||||
questionSuggestionAnswer: GekanatorAnswerValue }
|
||||
questionSuggestionAnswer: GekanatorAnswerValue
|
||||
questionSuggestionCount?: number }
|
||||
|
||||
const answerOptions: AnswerOption[] = [
|
||||
{ label: 'はい', value: 'yes' },
|
||||
@@ -104,6 +107,84 @@ const hardMaxQuestions = 80
|
||||
const softenedAnswerWeight = .35
|
||||
const confidenceTemperature = 6
|
||||
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 => {
|
||||
@@ -326,7 +407,11 @@ const previewAnswer = ({
|
||||
|
||||
const mergeQuestions = (questions: GekanatorQuestion[]): 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 ()]
|
||||
}
|
||||
|
||||
@@ -367,11 +452,13 @@ const chooseQuestion = ({
|
||||
questions,
|
||||
scores,
|
||||
askedIds,
|
||||
gameSeed,
|
||||
}: {
|
||||
posts: Post[]
|
||||
questions: GekanatorQuestion[]
|
||||
scores: Map<number, number>
|
||||
askedIds: Set<string>
|
||||
gameSeed: string
|
||||
}): GekanatorQuestion | null => {
|
||||
const scoredPosts = posts
|
||||
.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 minSide = candidates.length < 10 ? 1 : Math.max (3, candidates.length * .08)
|
||||
const narrowPenalty = yes < minSide || no < minSide ? .15 : 0
|
||||
const sourceBonus = sourcePriorityOffset (question)
|
||||
const priorityBonus = priorityWeightOffset (question)
|
||||
|
||||
return { question,
|
||||
score: weightedSplitScore * 100
|
||||
+ unweightedSplitScore * 8
|
||||
+ tagPenalty
|
||||
+ narrowPenalty,
|
||||
+ narrowPenalty
|
||||
+ sourceBonus
|
||||
+ priorityBonus,
|
||||
narrow: narrowPenalty > 0 }
|
||||
})
|
||||
.filter ((item): item is {
|
||||
@@ -458,8 +549,35 @@ const chooseQuestion = ({
|
||||
const unansweredQuestions =
|
||||
questions.filter (question => !(askedIds.has (question.id)))
|
||||
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 storedGame = useMemo (loadStoredGame, [])
|
||||
const [gameSeed, setGameSeed] = useState (
|
||||
storedGame?.gameSeed ?? createGameSeed ())
|
||||
const [phase, setPhase] = useState<Phase> (storedGame?.phase ?? 'intro')
|
||||
const [scores, setScores] = useState<Map<number, number>> (
|
||||
() => new Map (storedGame?.scores ?? []))
|
||||
@@ -540,24 +660,37 @@ const GekanatorPage: FC = () => {
|
||||
storedGame?.questionSuggestion ?? '')
|
||||
const [questionSuggestionAnswer, setQuestionSuggestionAnswer] =
|
||||
useState<GekanatorAnswerValue> (storedGame?.questionSuggestionAnswer ?? 'yes')
|
||||
const [questionSuggestionCount, setQuestionSuggestionCount] = useState (
|
||||
storedGame?.questionSuggestionCount ?? 0)
|
||||
const [history, setHistory] = useState<GameSnapshot[]> ([])
|
||||
|
||||
const { data: posts = [], isLoading, error } = useQuery ({
|
||||
queryKey: gekanatorKeys.posts (),
|
||||
queryFn: fetchGekanatorPosts })
|
||||
const { data: acceptedQuestions = [], isFetched: acceptedQuestionsFetched } = useQuery ({
|
||||
queryKey: gekanatorKeys.questions (),
|
||||
queryFn: fetchGekanatorQuestions,
|
||||
select: questions => questions.map (restoreGekanatorQuestion) })
|
||||
|
||||
useEffect (() => {
|
||||
if (posts.length === 0 || storedAskedQuestionBankIds.length === 0)
|
||||
if (
|
||||
posts.length === 0
|
||||
|| storedAskedQuestionBankIds.length === 0
|
||||
|| !(acceptedQuestionsFetched)
|
||||
)
|
||||
return
|
||||
|
||||
const questionById = new Map (
|
||||
buildGekanatorQuestions (posts).map (question => [question.id, question]))
|
||||
mergeQuestions ([
|
||||
...buildGekanatorQuestions (posts),
|
||||
...acceptedQuestions])
|
||||
.map (question => [question.id, question]))
|
||||
setAskedQuestionBank (
|
||||
storedAskedQuestionBankIds
|
||||
.map (questionId => questionById.get (questionId))
|
||||
.filter ((question): question is GekanatorQuestion => question !== undefined))
|
||||
setStoredAskedQuestionBankIds ([])
|
||||
}, [posts, storedAskedQuestionBankIds])
|
||||
}, [posts, storedAskedQuestionBankIds, acceptedQuestions, acceptedQuestionsFetched])
|
||||
|
||||
useEffect (() => {
|
||||
if (!(isStoredPhase (phase)) && answers.length === 0)
|
||||
@@ -585,8 +718,10 @@ const GekanatorPage: FC = () => {
|
||||
reviewGuessedPostId,
|
||||
reviewCorrectPostId,
|
||||
savedGameId,
|
||||
gameSeed,
|
||||
questionSuggestion,
|
||||
questionSuggestionAnswer }
|
||||
questionSuggestionAnswer,
|
||||
questionSuggestionCount }
|
||||
|
||||
try
|
||||
{
|
||||
@@ -615,8 +750,10 @@ const GekanatorPage: FC = () => {
|
||||
reviewGuessedPostId,
|
||||
reviewCorrectPostId,
|
||||
savedGameId,
|
||||
gameSeed,
|
||||
questionSuggestion,
|
||||
questionSuggestionAnswer])
|
||||
questionSuggestionAnswer,
|
||||
questionSuggestionCount])
|
||||
|
||||
const eligiblePosts = useMemo (
|
||||
() => candidatePostsFor ({
|
||||
@@ -627,8 +764,10 @@ const GekanatorPage: FC = () => {
|
||||
rejectedPostIds }),
|
||||
[posts, askedQuestionBank, answers, softenedQuestionIds, rejectedPostIds])
|
||||
const questions = useMemo (
|
||||
() => buildGekanatorQuestions (eligiblePosts.length > 1 ? eligiblePosts : posts),
|
||||
[eligiblePosts, posts])
|
||||
() => mergeQuestions ([
|
||||
...buildGekanatorQuestions (eligiblePosts.length > 1 ? eligiblePosts : posts),
|
||||
...acceptedQuestions]),
|
||||
[acceptedQuestions, eligiblePosts, posts])
|
||||
const scoringQuestions = useMemo (() => {
|
||||
return mergeQuestions ([...questions, ...askedQuestionBank])
|
||||
}, [questions, askedQuestionBank])
|
||||
@@ -651,7 +790,11 @@ const GekanatorPage: FC = () => {
|
||||
.slice (0, 3),
|
||||
[eligiblePosts, scores])
|
||||
const currentQuestion = chooseQuestion ({
|
||||
posts: questionPosts, questions: scoringQuestions, scores, askedIds })
|
||||
posts: questionPosts,
|
||||
questions: scoringQuestions,
|
||||
scores,
|
||||
askedIds,
|
||||
gameSeed })
|
||||
const answerPreviews = useMemo (
|
||||
() => currentQuestion
|
||||
? answerOptions.map (option => previewAnswer ({
|
||||
@@ -681,10 +824,10 @@ const GekanatorPage: FC = () => {
|
||||
}})
|
||||
const questionSuggestionMutation = useMutation ({
|
||||
mutationFn: saveGekanatorQuestionSuggestion,
|
||||
onSuccess: () => {
|
||||
onSuccess: data => {
|
||||
setQuestionSuggestionCount (data.count)
|
||||
setQuestionSuggestion ('')
|
||||
setQuestionSuggestionAnswer ('yes')
|
||||
reset ()
|
||||
}})
|
||||
|
||||
const reset = () => {
|
||||
@@ -707,8 +850,10 @@ const GekanatorPage: FC = () => {
|
||||
setReviewGuessedPostId (null)
|
||||
setReviewCorrectPostId (null)
|
||||
setSavedGameId (null)
|
||||
setGameSeed (createGameSeed ())
|
||||
setQuestionSuggestion ('')
|
||||
setQuestionSuggestionAnswer ('yes')
|
||||
setQuestionSuggestionCount (0)
|
||||
setHistory ([])
|
||||
}
|
||||
|
||||
@@ -740,6 +885,7 @@ const GekanatorPage: FC = () => {
|
||||
let recoveredScoringQuestions = mergeQuestions ([
|
||||
...buildGekanatorQuestions (
|
||||
recoveredEligiblePosts.length > 1 ? recoveredEligiblePosts : posts),
|
||||
...acceptedQuestions,
|
||||
...nextAskedQuestionBank])
|
||||
|
||||
while (
|
||||
@@ -750,7 +896,8 @@ const GekanatorPage: FC = () => {
|
||||
posts: recoveredEligiblePosts,
|
||||
questions: recoveredScoringQuestions,
|
||||
scores: recoveredScores,
|
||||
askedIds: nextAskedIds })))
|
||||
askedIds: nextAskedIds,
|
||||
gameSeed })))
|
||||
)
|
||||
{
|
||||
if (nextAnswers.length >= hardMaxQuestions)
|
||||
@@ -778,6 +925,7 @@ const GekanatorPage: FC = () => {
|
||||
recoveredScoringQuestions = mergeQuestions ([
|
||||
...buildGekanatorQuestions (
|
||||
recoveredEligiblePosts.length > 1 ? recoveredEligiblePosts : posts),
|
||||
...acceptedQuestions,
|
||||
...nextAskedQuestionBank])
|
||||
}
|
||||
|
||||
@@ -934,9 +1082,23 @@ const GekanatorPage: FC = () => {
|
||||
saveReviewedResult (() => setPhase ('learned'))
|
||||
}
|
||||
|
||||
const restartFromQuestionSuggestion = () => {
|
||||
if (savedGameId !== null)
|
||||
{
|
||||
reset ()
|
||||
return
|
||||
}
|
||||
|
||||
saveReviewedResult (reset)
|
||||
}
|
||||
|
||||
const submitQuestionSuggestion = () => {
|
||||
const questionText = questionSuggestion.trim ()
|
||||
if (!(questionText) || questionSuggestionMutation.isPending)
|
||||
if (
|
||||
!(questionText)
|
||||
|| questionSuggestionMutation.isPending
|
||||
|| questionSuggestionCount >= maxQuestionSuggestionsPerGame
|
||||
)
|
||||
return
|
||||
|
||||
saveReviewedResult (gekanatorGameId => {
|
||||
@@ -1008,7 +1170,8 @@ const GekanatorPage: FC = () => {
|
||||
: nonRejectedPosts,
|
||||
questions: recovered.scoringQuestions,
|
||||
scores: recovered.scores,
|
||||
askedIds })
|
||||
askedIds,
|
||||
gameSeed })
|
||||
|
||||
if (nextQuestion)
|
||||
{
|
||||
@@ -1445,6 +1608,9 @@ const GekanatorPage: FC = () => {
|
||||
<div>
|
||||
<p className="text-sm text-neutral-500">質問追加</p>
|
||||
<p className="text-xl font-bold">どんな質問なら見分けられさう?</p>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-300">
|
||||
追加済み {questionSuggestionCount} / {maxQuestionSuggestionsPerGame}
|
||||
</p>
|
||||
</div>
|
||||
<label className="block space-y-2">
|
||||
<span className="font-bold">質問候補</span>
|
||||
@@ -1483,18 +1649,33 @@ const GekanatorPage: FC = () => {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded bg-pink-600 px-4 py-2 font-bold text-white
|
||||
hover:bg-pink-500 disabled:opacity-50"
|
||||
className="rounded border border-yellow-300 px-4 py-2
|
||||
hover:bg-yellow-100 dark:border-red-700
|
||||
dark:hover:bg-red-900 disabled:opacity-50"
|
||||
disabled={
|
||||
reviewCorrectPostId === null
|
||||
questionSuggestionCount >= maxQuestionSuggestionsPerGame
|
||||
|| reviewCorrectPostId === null
|
||||
|| questionSuggestion.trim () === ''
|
||||
|| saveMutation.isPending
|
||||
|| questionSuggestionMutation.isPending
|
||||
}
|
||||
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>
|
||||
</div>
|
||||
{questionSuggestionCount >= maxQuestionSuggestionsPerGame && (
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-300">
|
||||
このゲームでは質問候補をこれ以上追加できません。
|
||||
</p>)}
|
||||
{(saveMutation.isError || questionSuggestionMutation.isError) && (
|
||||
<p className="text-sm text-red-600">
|
||||
記録できませんでした。通信状態を確認してもう一度試して。
|
||||
|
||||
新しい課題から参照
ユーザをブロックする