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

マージ済み
みてるぞ が 20 個のコミットを feature/041 から main へマージ 2026-06-12 01:35:32 +09:00
14個のファイルの変更505行の追加38行の削除
コミット ae1deaac8c の変更だけを表示してゐます - すべてのコミットを表示
+27 -1
ファイルの表示
@@ -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
+57
ファイルの表示
@@ -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
+21
ファイルの表示
@@ -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
+19
ファイルの表示
@@ -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
+3 -1
ファイルの表示
@@ -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 }
+22
ファイルの表示
@@ -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
+18
ファイルの表示
@@ -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
+6 -1
ファイルの表示
@@ -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
+19
ファイルの表示
@@ -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
+13
ファイルの表示
@@ -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"
+37 -3
ファイルの表示
@@ -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,
+2 -1
ファイルの表示
@@ -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,
+202 -21
ファイルの表示
@@ -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">