From ae1deaac8c1e51c3033f45791f69f3b9649a9a05 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Tue, 9 Jun 2026 23:05:37 +0900 Subject: [PATCH] #41 --- ...kanator_question_suggestions_controller.rb | 28 ++- .../gekanator_questions_controller.rb | 57 +++++ backend/app/models/gekanator_ai_run.rb | 21 ++ backend/app/models/gekanator_question.rb | 19 ++ .../models/gekanator_question_suggestion.rb | 4 +- .../app/services/gekanator/ai_run_budget.rb | 22 ++ .../question_suggestion_ai_converter.rb | 18 ++ backend/config/routes.rb | 7 +- ...260609000000_create_gekanator_questions.rb | 19 ++ ...20260609001000_create_gekanator_ai_runs.rb | 13 + backend/db/schema.rb | 51 +++- frontend/src/lib/gekanator.ts | 56 ++++- frontend/src/lib/queryKeys.ts | 5 +- frontend/src/pages/GekanatorPage.tsx | 223 ++++++++++++++++-- 14 files changed, 505 insertions(+), 38 deletions(-) create mode 100644 backend/app/controllers/gekanator_questions_controller.rb create mode 100644 backend/app/models/gekanator_ai_run.rb create mode 100644 backend/app/models/gekanator_question.rb create mode 100644 backend/app/services/gekanator/ai_run_budget.rb create mode 100644 backend/app/services/gekanator/question_suggestion_ai_converter.rb create mode 100644 backend/db/migrate/20260609000000_create_gekanator_questions.rb create mode 100644 backend/db/migrate/20260609001000_create_gekanator_ai_runs.rb diff --git a/backend/app/controllers/gekanator_question_suggestions_controller.rb b/backend/app/controllers/gekanator_question_suggestions_controller.rb index bf361c6..a16e29b 100644 --- a/backend/app/controllers/gekanator_question_suggestions_controller.rb +++ b/backend/app/controllers/gekanator_question_suggestions_controller.rb @@ -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 diff --git a/backend/app/controllers/gekanator_questions_controller.rb b/backend/app/controllers/gekanator_questions_controller.rb new file mode 100644 index 0000000..1a61d46 --- /dev/null +++ b/backend/app/controllers/gekanator_questions_controller.rb @@ -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 diff --git a/backend/app/models/gekanator_ai_run.rb b/backend/app/models/gekanator_ai_run.rb new file mode 100644 index 0000000..3a35e29 --- /dev/null +++ b/backend/app/models/gekanator_ai_run.rb @@ -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 diff --git a/backend/app/models/gekanator_question.rb b/backend/app/models/gekanator_question.rb new file mode 100644 index 0000000..a4c838f --- /dev/null +++ b/backend/app/models/gekanator_question.rb @@ -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 diff --git a/backend/app/models/gekanator_question_suggestion.rb b/backend/app/models/gekanator_question_suggestion.rb index 2159f5e..80c2d43 100644 --- a/backend/app/models/gekanator_question_suggestion.rb +++ b/backend/app/models/gekanator_question_suggestion.rb @@ -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 } diff --git a/backend/app/services/gekanator/ai_run_budget.rb b/backend/app/services/gekanator/ai_run_budget.rb new file mode 100644 index 0000000..20115ef --- /dev/null +++ b/backend/app/services/gekanator/ai_run_budget.rb @@ -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 diff --git a/backend/app/services/gekanator/question_suggestion_ai_converter.rb b/backend/app/services/gekanator/question_suggestion_ai_converter.rb new file mode 100644 index 0000000..629732c --- /dev/null +++ b/backend/app/services/gekanator/question_suggestion_ai_converter.rb @@ -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 diff --git a/backend/config/routes.rb b/backend/config/routes.rb index 3d4b505..c6521e1 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -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 diff --git a/backend/db/migrate/20260609000000_create_gekanator_questions.rb b/backend/db/migrate/20260609000000_create_gekanator_questions.rb new file mode 100644 index 0000000..26d6fbd --- /dev/null +++ b/backend/db/migrate/20260609000000_create_gekanator_questions.rb @@ -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 diff --git a/backend/db/migrate/20260609001000_create_gekanator_ai_runs.rb b/backend/db/migrate/20260609001000_create_gekanator_ai_runs.rb new file mode 100644 index 0000000..695dd03 --- /dev/null +++ b/backend/db/migrate/20260609001000_create_gekanator_ai_runs.rb @@ -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 diff --git a/backend/db/schema.rb b/backend/db/schema.rb index f50a921..3cd1798 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_06_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" diff --git a/frontend/src/lib/gekanator.ts b/frontend/src/lib/gekanator.ts index 55dfd0c..dcd4dff 100644 --- a/frontend/src/lib/gekanator.ts +++ b/frontend/src/lib/gekanator.ts @@ -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 } @@ -32,17 +38,21 @@ export type GekanatorQuestionCondition = | { type: 'title-has-ascii' } export type StoredGekanatorQuestion = { - id: string - text: string - kind: GekanatorQuestionKind - condition: GekanatorQuestionCondition } + id: string + text: string + kind: GekanatorQuestionKind + condition: GekanatorQuestionCondition + source?: GekanatorQuestionSource + priorityWeight?: number } export type GekanatorQuestion = { - id: string - text: string - kind: GekanatorQuestionKind - condition: GekanatorQuestionCondition - test: (post: Post) => boolean } + id: string + text: string + kind: GekanatorQuestionKind + condition: GekanatorQuestionCondition + source: GekanatorQuestionSource + priorityWeight: number + test: (post: Post) => boolean } const countBy = (values: T[]): Map => { const counts = new Map () @@ -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 => { @@ -206,6 +220,12 @@ export const fetchGekanatorPosts = async (): Promise => { } +export const fetchGekanatorQuestions = async (): Promise => { + 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, diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index 3c9ac5c..864d0bb 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -9,8 +9,9 @@ export const postsKeys = { ['posts', 'changes', p] as const } export const gekanatorKeys = { - root: ['gekanator'] as const, - posts: () => ['gekanator', 'posts'] as const } + root: ['gekanator'] as const, + posts: () => ['gekanator', 'posts'] as const, + questions: () => ['gekanator', 'questions'] as const } export const tagsKeys = { root: ['tags'] as const, diff --git a/frontend/src/pages/GekanatorPage.tsx b/frontend/src/pages/GekanatorPage.tsx index 2b1443b..7a42884 100644 --- a/frontend/src/pages/GekanatorPage.tsx +++ b/frontend/src/pages/GekanatorPage.tsx @@ -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 () - 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 askedIds: Set + 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 (storedGame?.phase ?? 'intro') const [scores, setScores] = useState> ( () => new Map (storedGame?.scores ?? [])) @@ -540,24 +660,37 @@ const GekanatorPage: FC = () => { storedGame?.questionSuggestion ?? '') const [questionSuggestionAnswer, setQuestionSuggestionAnswer] = useState (storedGame?.questionSuggestionAnswer ?? 'yes') + const [questionSuggestionCount, setQuestionSuggestionCount] = useState ( + storedGame?.questionSuggestionCount ?? 0) const [history, setHistory] = useState ([]) 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 = () => {

質問追加

どんな質問なら見分けられさう?

+

+ 追加済み {questionSuggestionCount} / {maxQuestionSuggestionsPerGame} +