diff --git a/AGENTS.md b/AGENTS.md index 16aa3c1..7690e5b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -126,12 +126,16 @@ npm run preview - In TypeScript and TSX only, replace every leading run of 8 spaces with a tab. - Tabs are only for leading indentation, never for spaces after non-space text. - Do not add production dependencies without explicit approval. +- Do not create, modify, or run tests unless the user explicitly asks for + test work. When the user asks for tests, keep working and rerun them until + they pass or the remaining failure is clearly blocked. ## Backend rules - Inspect existing routes, controllers, models, services, and specs before editing backend behavior. -- For API behavior changes, add or update request specs under `backend/spec/requests`. +- For API behavior changes, add or update request specs under + `backend/spec/requests` only when the user explicitly asks for tests. - Prefer RSpec for new backend tests; existing minitest files under `backend/test` do not make minitest the default for new coverage. - Do not weaken authentication, BAN user checks, or IP BAN checks. @@ -211,10 +215,11 @@ function PostFormTagsArea ({ tags, setTags }: Props) { `node_modules`, `dist`, `tmp`, `log`, and `storage` unless explicitly needed. - Before touching wiki, tag, versioning, BAN, IP BAN, or authentication behavior, inspect the related request specs and service objects. -- If frontend code changes, run the existing frontend verification commands - that apply: `npm run build`, `npm run lint`, and `npm run test:run`. -- If backend code changes, run the relevant RSpec command; for broad backend - changes, run `bundle exec rspec`. +- If frontend code changes, run only non-test verification commands that + apply, such as `npm run build` and `npm run lint`. Run `npm run test:run` + only when the user explicitly asks for tests. +- If backend code changes, do not run RSpec unless the user explicitly asks + for tests. - If a verification command cannot be run or fails, report the exact command and failure. ## Completion criteria @@ -222,7 +227,8 @@ function PostFormTagsArea ({ tags, setTags }: Props) { A task is complete only when: - implementation is complete, -- relevant verification commands pass, or failures are clearly explained, +- relevant non-test verification commands pass, or failures are clearly + explained, - unrelated files are not changed, - migrations and schema are consistent when schema changes are made, - user-facing behavior is documented when needed. diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 9553712..9085134 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -47,6 +47,10 @@ bundle exec rspec If a command cannot be run or fails, report the exact command and failure. +Do not create, modify, or run tests unless the user explicitly asks for test +work. When the user asks for tests, keep working and rerun them until they +pass or the remaining failure is clearly blocked. + ## Rails structure - `app/controllers`: API controllers. @@ -116,7 +120,8 @@ service, representation, and spec. - `User#banned?` and `IpAddress#banned?` check `banned_at.present?`. - Do not weaken BAN or IP BAN behavior. - If changing request authentication or controller before actions, add or - update request specs covering banned users and banned IP addresses. + update request specs covering banned users and banned IP addresses only when + the user explicitly asks for tests. ## RSpec @@ -130,8 +135,9 @@ service, representation, and spec. - `AuthHelper#sign_in_as(user)` stubs `ApplicationController#current_user`; use it when matching existing request spec style. -- Add or update request specs for API behavior changes, especially status - codes, permissions, response shape, and version conflict behavior. +- Add or update request specs for API behavior changes only when the user + explicitly asks for tests, especially status codes, permissions, response + shape, and version conflict behavior. ## Migrations @@ -164,7 +170,8 @@ service, representation, and spec. the record `version_no`. - Do not update versioned records without considering whether a version snapshot must be created. - For optimistic concurrency paths, preserve `base_version_no`, `force`, and - `merge` semantics and cover conflicts in request specs. + `merge` semantics. Cover conflicts in request specs only when the user + explicitly asks for tests. ## Domain cautions diff --git a/backend/app/controllers/gekanator_games_controller.rb b/backend/app/controllers/gekanator_games_controller.rb new file mode 100644 index 0000000..4f378d0 --- /dev/null +++ b/backend/app/controllers/gekanator_games_controller.rb @@ -0,0 +1,118 @@ +class GekanatorGamesController < ApplicationController + def create + return head :not_found unless current_user&.admin? + + guessed_post_id = params.require(:guessed_post_id) + correct_post_id = params[:correct_post_id].presence + answers = params.require(:answers).as_json + + game = GekanatorGame.new( + user: current_user, + guessed_post_id:, + correct_post_id:, + won: correct_post_id.present? && guessed_post_id.to_i == correct_post_id.to_i, + question_count: answers.length, + answers:) + + if game.save + render json: { id: game.id }, status: :created + else + render json: { errors: game.errors.full_messages }, status: :unprocessable_entity + end + end + + def extra_questions + return head :not_found unless current_user&.admin? + + game = GekanatorGame.find_by(id: params[:id]) + return head :not_found unless game + + asked_ids = Array(game.answers).filter_map { |answer| answer_question_id(answer) } + existing_example_ids = + GekanatorQuestionExample.where(post_id: game.correct_post_id) + .select(:gekanator_question_id) + # Direct examples only for now; post_similarity-based expansion is deferred. + questions = + GekanatorQuestion + .accepted + .where(kind: 'post_similarity', source: 'user_suggested') + .where.not(id: existing_example_ids) + .order(priority_weight: :desc, id: :asc) + + render json: { + questions: questions.filter_map { |question| + json = extra_question_json(question) + next if asked_ids.include?(json[:id].to_s) + next if asked_ids.include?("post-similarity:#{ json[:id] }") + + json + }.first(2) + } + end + + def extra_question_answers + return head :not_found unless current_user&.admin? + + game = GekanatorGame.find_by(id: params[:id]) + return head :not_found unless game + + answer_params = params.require(:answers) + if !answer_params.is_a?(Array) + return render_validation_error fields: { answers: ['配列で指定してください.'] } + end + + answers = answer_params.map { |answer| + { + question_id: answer.require(:question_id).to_i, + answer: answer.require(:answer) + } + } + questions = GekanatorQuestion.where(id: answers.map { _1[:question_id] }) + question_by_id = questions.index_by(&:id) + if questions.length != answers.length + return render_validation_error fields: { answers: ['質問が見つかりません.'] } + end + if questions.any? { |question| question.status != 'accepted' || question.kind != 'post_similarity' } + return render_validation_error fields: { answers: ['質問が不正です.'] } + end + + ActiveRecord::Base.transaction do + answers.each do |item| + question = question_by_id[item[:question_id]] + example = + GekanatorQuestionExample.find_or_initialize_by( + gekanator_question: question, + post: game.correct_post, + user: current_user) + example.assign_attributes( + gekanator_game: game, + answer: item[:answer], + source: 'post_game_extra', + weight: 1.0) + example.save! + end + end + + render json: { count: answers.length }, status: :created + end + + private + + def extra_question_json question + { + id: question.id, + text: question.text, + source: question.source, + priority_weight: question.priority_weight + } + end + + def answer_question_id answer + value = if answer.is_a?(Hash) + answer['question_id'].presence || answer[:question_id].presence || + answer['questionId'].presence || answer[:questionId].presence + end + + value&.to_s + end +end diff --git a/backend/app/controllers/gekanator_posts_controller.rb b/backend/app/controllers/gekanator_posts_controller.rb new file mode 100644 index 0000000..9654920 --- /dev/null +++ b/backend/app/controllers/gekanator_posts_controller.rb @@ -0,0 +1,46 @@ +class GekanatorPostsController < ApplicationController + def index + return head :not_found unless current_user&.admin? + + posts = + Post + .preload(tags: :tag_name) + .with_attached_thumbnail + .order(Arel.sql( + 'COALESCE(posts.original_created_before - INTERVAL 1 MINUTE, ' \ + 'posts.original_created_from, posts.created_at) DESC, posts.id DESC')) + + render json: { posts: posts.map { |post| post_json(post) } } + end + + private + + def post_json post + { + id: post.id, + url: post.url, + title: post.title, + thumbnail: thumbnail_url(post), + thumbnail_base: post.thumbnail_base, + original_created_from: post.original_created_from, + original_created_before: post.original_created_before, + tags: post.tags.map { |tag| tag_json(tag) } + } + end + + def tag_json tag + { + id: tag.id, + name: tag.name, + category: tag.category + } + end + + def thumbnail_url post + return nil unless post.thumbnail.attached? + + rails_storage_proxy_url(post.thumbnail, only_path: false) + rescue + nil + end +end diff --git a/backend/app/controllers/gekanator_question_suggestions_controller.rb b/backend/app/controllers/gekanator_question_suggestions_controller.rb new file mode 100644 index 0000000..d3f83aa --- /dev/null +++ b/backend/app/controllers/gekanator_question_suggestions_controller.rb @@ -0,0 +1,53 @@ +class GekanatorQuestionSuggestionsController < ApplicationController + def create + return head :not_found unless current_user&.admin? + + game = GekanatorGame.find_by(id: params.require(:gekanator_game_id)) + return head :not_found unless game + + suggestion = GekanatorQuestionSuggestion.new( + gekanator_game: game, + user: current_user, + question_text: params.require(:question_text), + answer: params.require(:answer)) + + if suggestion.valid? + ActiveRecord::Base.transaction do + suggestion.save! + Gekanator::QuestionSuggestionPromoter.call( + suggestion: suggestion, + user: current_user) + end + + render json: { + id: suggestion.id, + count: game.question_suggestions.count + }, 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..407b0f9 --- /dev/null +++ b/backend/app/controllers/gekanator_questions_controller.rb @@ -0,0 +1,83 @@ +class GekanatorQuestionsController < ApplicationController + def index + return head :not_found unless current_user&.admin? + + questions = + GekanatorQuestion + .accepted + .includes(:gekanator_question_examples) + .order(priority_weight: :desc, id: :asc) + + render json: { + questions: questions.map { |question| question_json(question) } + } + end + + private + + def question_json question + json = { + id: question_id_for(question), + text: question.text, + kind: question.kind, + condition: condition_json(question.condition), + source: question.source, + priority_weight: question.priority_weight + } + if question.kind == 'post_similarity' + json[:example_answers] = example_answers_json(question) + end + json + end + + def question_id_for question + 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' + when 'post-similarity' + "post-similarity:#{ question.id }" + 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 + + def example_answers_json question + question + .gekanator_question_examples + .group_by(&:post_id) + .transform_values { |examples| aggregate_answer(examples) } + end + + def aggregate_answer examples + examples + .group_by(&:answer) + .map { |answer, grouped| [answer, grouped.sum(&:weight), grouped.max_by(&:updated_at)&.updated_at] } + .sort_by { |(_answer, weight, updated_at)| [-weight, -(updated_at&.to_f || 0)] } + .first + &.first + end +end diff --git a/backend/app/models/gekanator_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_game.rb b/backend/app/models/gekanator_game.rb new file mode 100644 index 0000000..bf223d8 --- /dev/null +++ b/backend/app/models/gekanator_game.rb @@ -0,0 +1,15 @@ +class GekanatorGame < ApplicationRecord + belongs_to :user + belongs_to :guessed_post, class_name: 'Post' + belongs_to :correct_post, class_name: 'Post' + has_many :question_suggestions, + class_name: 'GekanatorQuestionSuggestion', + dependent: :delete_all + has_many :question_examples, + class_name: 'GekanatorQuestionExample', + dependent: :delete_all + + validates :answers, presence: true + validates :question_count, numericality: { greater_than_or_equal_to: 0 } + validates :won, inclusion: { in: [true, false] } +end diff --git a/backend/app/models/gekanator_question.rb b/backend/app/models/gekanator_question.rb new file mode 100644 index 0000000..719da6e --- /dev/null +++ b/backend/app/models/gekanator_question.rb @@ -0,0 +1,23 @@ +class GekanatorQuestion < ApplicationRecord + KINDS = ['tag', 'source', 'title', 'original_date', 'post_similarity'].freeze + SOURCES = ['user_suggested', 'ai_generated', 'admin_curated'].freeze + STATUSES = ['pending', 'accepted', 'rejected', 'disabled'].freeze + + belongs_to :gekanator_question_suggestion, optional: true + belongs_to :created_by, class_name: 'User', optional: true + has_many :gekanator_question_examples, dependent: :delete_all + + validates :kind, presence: true, inclusion: { in: KINDS } + validates :source, presence: true, inclusion: { in: SOURCES } + 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, + less_than_or_equal_to: 3 + } + + scope :accepted, -> { where(status: 'accepted') } +end diff --git a/backend/app/models/gekanator_question_example.rb b/backend/app/models/gekanator_question_example.rb new file mode 100644 index 0000000..5e55cbd --- /dev/null +++ b/backend/app/models/gekanator_question_example.rb @@ -0,0 +1,17 @@ +class GekanatorQuestionExample < ApplicationRecord + ANSWERS = GekanatorQuestionSuggestion::ANSWERS + SOURCES = ['initial_suggestion', 'post_game_extra'].freeze + + belongs_to :gekanator_question + belongs_to :post + belongs_to :user + belongs_to :gekanator_game, optional: true + + validates :answer, presence: true, inclusion: { in: ANSWERS } + validates :source, presence: true, inclusion: { in: SOURCES } + validates :weight, + presence: true, + numericality: { + greater_than: 0 + } +end diff --git a/backend/app/models/gekanator_question_suggestion.rb b/backend/app/models/gekanator_question_suggestion.rb new file mode 100644 index 0000000..80c2d43 --- /dev/null +++ b/backend/app/models/gekanator_question_suggestion.rb @@ -0,0 +1,25 @@ +class GekanatorQuestionSuggestion < ApplicationRecord + 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 } + validates :processed, inclusion: { in: [true, false] } + validate :question_suggestion_limit_per_game, on: :create + + private + + def question_suggestion_limit_per_game + return if gekanator_game_id.blank? + + count = GekanatorQuestionSuggestion.where(gekanator_game_id:).count + if count >= MAX_QUESTIONS_PER_GAME + errors.add(:base, '質問追加数を超えてゐます.') + end + end +end diff --git a/backend/app/models/post.rb b/backend/app/models/post.rb index 2da8b02..6a27183 100644 --- a/backend/app/models/post.rb +++ b/backend/app/models/post.rb @@ -11,6 +11,17 @@ class Post < ApplicationRecord has_many :user_post_views, dependent: :delete_all has_many :post_similarities, dependent: :delete_all has_many :post_versions + has_many :gekanator_guessed_games, + class_name: 'GekanatorGame', + foreign_key: :guessed_post_id, + dependent: :delete_all, + inverse_of: :guessed_post + has_many :gekanator_correct_games, + class_name: 'GekanatorGame', + foreign_key: :correct_post_id, + dependent: :delete_all, + inverse_of: :correct_post + has_many :gekanator_question_examples, dependent: :delete_all has_many :parent_post_implications, class_name: 'PostImplication', diff --git a/backend/app/services/gekanator/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/app/services/gekanator/question_suggestion_promoter.rb b/backend/app/services/gekanator/question_suggestion_promoter.rb new file mode 100644 index 0000000..d733a43 --- /dev/null +++ b/backend/app/services/gekanator/question_suggestion_promoter.rb @@ -0,0 +1,49 @@ +module Gekanator + class QuestionSuggestionPromoter + def self.call(...) = new(...).call + + def initialize suggestion:, user: + @suggestion = suggestion + @user = user + end + + def call + suggestion.with_lock do + return promoted_question if suggestion.processed? + return suggestion if suggestion.answer == 'unknown' + + question = GekanatorQuestion.create!( + text: suggestion.question_text, + kind: 'post_similarity', + source: 'user_suggested', + status: 'accepted', + priority_weight: 1.2, + condition: { + type: 'post-similarity', + postId: suggestion.gekanator_game.correct_post_id, + answer: suggestion.answer, + threshold: 0.65 + }, + gekanator_question_suggestion: suggestion, + created_by: user) + GekanatorQuestionExample.create!( + gekanator_question: question, + post: suggestion.gekanator_game.correct_post, + user: user, + gekanator_game: suggestion.gekanator_game, + answer: suggestion.answer, + source: 'initial_suggestion') + suggestion.update!(processed: true) + question + end + end + + private + + attr_reader :suggestion, :user + + def promoted_question + suggestion.gekanator_questions.order(id: :desc).first + end + end +end diff --git a/backend/config/routes.rb b/backend/config/routes.rb index ab9fdc3..f30959b 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -63,6 +63,24 @@ Rails.application.routes.draw do end end + namespace :gekanator do + resources :games, only: [:create], controller: '/gekanator_games' do + member do + get :extra_questions + post :extra_question_answers + end + end + resources :posts, only: [:index], controller: '/gekanator_posts' + resources :questions, only: [:index], controller: '/gekanator_questions' + resources :question_suggestions, + only: [:create], + controller: '/gekanator_question_suggestions' do + member do + post :ai_convert + end + end + end + resources :users, only: [:create, :update] do collection do post :verify diff --git a/backend/db/migrate/20260607000000_create_gekanator_games.rb b/backend/db/migrate/20260607000000_create_gekanator_games.rb new file mode 100644 index 0000000..a357bf9 --- /dev/null +++ b/backend/db/migrate/20260607000000_create_gekanator_games.rb @@ -0,0 +1,18 @@ +class CreateGekanatorGames < ActiveRecord::Migration[8.0] + def change + create_table :gekanator_games do |t| + t.references :user, null: false, foreign_key: true + t.references :guessed_post, null: false, foreign_key: { to_table: :posts } + t.references :correct_post, null: false, foreign_key: { to_table: :posts } + t.boolean :won, null: false + t.integer :question_count, null: false + t.json :answers, null: false + + t.timestamps + end + + add_check_constraint :gekanator_games, + 'question_count >= 0', + name: 'chk_gekanator_games_question_count_nonnegative' + end +end diff --git a/backend/db/migrate/20260608000000_create_gekanator_question_suggestions.rb b/backend/db/migrate/20260608000000_create_gekanator_question_suggestions.rb new file mode 100644 index 0000000..57b38bb --- /dev/null +++ b/backend/db/migrate/20260608000000_create_gekanator_question_suggestions.rb @@ -0,0 +1,14 @@ +class CreateGekanatorQuestionSuggestions < ActiveRecord::Migration[8.0] + def change + create_table :gekanator_question_suggestions do |t| + t.references :gekanator_game, + null: false, + foreign_key: { on_delete: :cascade } + t.references :user, null: false, foreign_key: true + t.text :question_text, null: false + t.boolean :processed, null: false, default: false + + t.timestamps + end + end +end diff --git a/backend/db/migrate/20260608002000_add_answer_to_gekanator_question_suggestions.rb b/backend/db/migrate/20260608002000_add_answer_to_gekanator_question_suggestions.rb new file mode 100644 index 0000000..104b156 --- /dev/null +++ b/backend/db/migrate/20260608002000_add_answer_to_gekanator_question_suggestions.rb @@ -0,0 +1,5 @@ +class AddAnswerToGekanatorQuestionSuggestions < ActiveRecord::Migration[8.0] + def change + add_column :gekanator_question_suggestions, :answer, :string, null: false + end +end 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/migrate/20260610000000_create_gekanator_question_examples.rb b/backend/db/migrate/20260610000000_create_gekanator_question_examples.rb new file mode 100644 index 0000000..f115067 --- /dev/null +++ b/backend/db/migrate/20260610000000_create_gekanator_question_examples.rb @@ -0,0 +1,19 @@ +class CreateGekanatorQuestionExamples < ActiveRecord::Migration[8.0] + def change + create_table :gekanator_question_examples do |t| + t.references :gekanator_question, null: false, foreign_key: true + t.references :post, null: false, foreign_key: true + t.references :user, null: false, foreign_key: true + t.references :gekanator_game, null: true, foreign_key: true + t.string :answer, null: false + t.string :source, null: false, default: 'post_game_extra' + t.float :weight, null: false, default: 1.0 + t.timestamps + end + + add_index :gekanator_question_examples, + [:gekanator_question_id, :post_id, :user_id], + unique: true, + name: 'idx_gekanator_question_examples_on_question_post_user' + end +end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index 9fe2736..a873873 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_06_000000) do +ActiveRecord::Schema[8.0].define(version: 2026_06_10_000000) do create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false @@ -48,6 +48,77 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_06_000000) 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 + t.bigint "correct_post_id", null: false + t.boolean "won", null: false + t.integer "question_count", null: false + t.json "answers", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["correct_post_id"], name: "index_gekanator_games_on_correct_post_id" + t.index ["guessed_post_id"], name: "index_gekanator_games_on_guessed_post_id" + t.index ["user_id"], name: "index_gekanator_games_on_user_id" + t.check_constraint "`question_count` >= 0", name: "chk_gekanator_games_question_count_nonnegative" + end + + create_table "gekanator_question_examples", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.bigint "gekanator_question_id", null: false + t.bigint "post_id", null: false + t.bigint "user_id", null: false + t.bigint "gekanator_game_id" + t.string "answer", null: false + t.string "source", default: "post_game_extra", null: false + t.float "weight", default: 1.0, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["gekanator_game_id"], name: "index_gekanator_question_examples_on_gekanator_game_id" + t.index ["gekanator_question_id", "post_id", "user_id"], name: "idx_gekanator_question_examples_on_question_post_user", unique: true + t.index ["gekanator_question_id"], name: "index_gekanator_question_examples_on_gekanator_question_id" + t.index ["post_id"], name: "index_gekanator_question_examples_on_post_id" + t.index ["user_id"], name: "index_gekanator_question_examples_on_user_id" + end + + create_table "gekanator_question_suggestions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.bigint "gekanator_game_id", null: false + t.bigint "user_id", null: false + t.text "question_text", null: false + t.boolean "processed", default: false, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.string "answer", null: false + t.index ["gekanator_game_id"], name: "index_gekanator_question_suggestions_on_gekanator_game_id" + 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" @@ -478,6 +549,18 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_06_000000) 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_examples", "gekanator_games" + add_foreign_key "gekanator_question_examples", "gekanator_questions" + add_foreign_key "gekanator_question_examples", "posts" + add_foreign_key "gekanator_question_examples", "users" + add_foreign_key "gekanator_question_suggestions", "gekanator_games", on_delete: :cascade + add_foreign_key "gekanator_question_suggestions", "users" + add_foreign_key "gekanator_questions", "gekanator_question_suggestions" + 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" diff --git a/backend/spec/requests/gekanator_games_spec.rb b/backend/spec/requests/gekanator_games_spec.rb new file mode 100644 index 0000000..79c0f5a --- /dev/null +++ b/backend/spec/requests/gekanator_games_spec.rb @@ -0,0 +1,75 @@ +require 'rails_helper' + +RSpec.describe 'Gekanator games API', type: :request do + let!(:admin) { create_admin_user! } + let!(:user) { create_member_user! } + let!(:guessed_post) { Post.create!(title: 'guess', url: 'https://example.com/guess') } + let!(:correct_post) { Post.create!(title: 'correct', url: 'https://example.com/correct') } + + describe 'POST /gekanator/games' do + it 'stores a won game' do + sign_in_as admin + + post '/gekanator/games', params: { + guessed_post_id: guessed_post.id, + correct_post_id: guessed_post.id, + answers: [{ question_id: 'tag:1', answer: 'yes' }] } + + expect(response).to have_http_status(:created) + game = GekanatorGame.find(json['id']) + expect(game.user).to eq(admin) + expect(game.guessed_post).to eq(guessed_post) + expect(game.correct_post).to eq(guessed_post) + expect(game.won).to eq(true) + expect(game.question_count).to eq(1) + expect(game.answers).to eq([{ 'question_id' => 'tag:1', 'answer' => 'yes' }]) + end + + it 'stores a lost game with the correct post' do + sign_in_as admin + + post '/gekanator/games', params: { + guessed_post_id: guessed_post.id, + correct_post_id: correct_post.id, + question_count: 4, + answers: [{ question_id: 'tag:1', answer: 'no' }] } + + expect(response).to have_http_status(:created) + game = GekanatorGame.find(json['id']) + expect(game.correct_post).to eq(correct_post) + expect(game.won).to eq(false) + expect(game.question_count).to eq(1) + end + + it 'rejects a game without the correct post' do + sign_in_as admin + + post '/gekanator/games', params: { + guessed_post_id: guessed_post.id, + question_count: 4, + answers: [{ question_id: 'tag:1', answer: 'no' }] } + + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'returns not found without an admin user' do + post '/gekanator/games', params: { + guessed_post_id: guessed_post.id, + correct_post_id: guessed_post.id, + answers: [] } + + expect(response).to have_http_status(:not_found) + end + + it 'returns not found for a non-admin user' do + sign_in_as user + + post '/gekanator/games', params: { + guessed_post_id: guessed_post.id, + correct_post_id: guessed_post.id, + answers: [{ question_id: 'tag:1', answer: 'yes' }] } + + expect(response).to have_http_status(:not_found) + end + end +end diff --git a/backend/spec/requests/gekanator_learning_spec.rb b/backend/spec/requests/gekanator_learning_spec.rb new file mode 100644 index 0000000..4d11522 --- /dev/null +++ b/backend/spec/requests/gekanator_learning_spec.rb @@ -0,0 +1,588 @@ +require 'rails_helper' + +RSpec.describe 'Gekanator learning API', type: :request do + let(:admin) { create(:user, :admin) } + let(:member) { create(:user, :member) } + let(:other_user) { create(:user, :member) } + + let!(:guessed_post) do + Post.create!( + title: 'guessed', + url: 'https://example.com/guessed' + ) + end + + let!(:correct_post) do + Post.create!( + title: 'correct', + url: 'https://example.com/correct' + ) + end + + let!(:other_post) do + Post.create!( + title: 'other', + url: 'https://example.com/other' + ) + end + + let!(:game) do + GekanatorGame.create!( + user: admin, + guessed_post: guessed_post, + correct_post: correct_post, + won: false, + question_count: 1, + answers: [ + { + 'question_id' => 'tag:character:喜多郁代', + 'question_text' => '喜多ちゃんが関係してる?', + 'answer' => 'yes', + 'original_answer' => 'yes' + } + ] + ) + end + + def create_post_similarity_question!( + text: '喜多ちゃんが泣いてる?', + post: correct_post, + answer: 'yes', + status: 'accepted', + source: 'user_suggested', + priority_weight: 1.2 + ) + GekanatorQuestion.create!( + text: text, + kind: 'post_similarity', + source: source, + status: status, + priority_weight: priority_weight, + condition: { + type: 'post-similarity', + postId: post.id, + answer: answer, + threshold: 0.65 + }, + created_by: admin + ) + end + + describe 'POST /gekanator/games' do + it 'stores a game result for an admin user' do + sign_in_as admin + + post '/gekanator/games', params: { + guessed_post_id: guessed_post.id, + correct_post_id: correct_post.id, + answers: [ + { + question_id: 'tag:character:喜多郁代', + question_text: '喜多ちゃんが関係してる?', + answer: 'yes', + original_answer: 'yes' + } + ] + } + + expect(response).to have_http_status(:created) + + created = GekanatorGame.find(json['id']) + expect(created.user).to eq(admin) + expect(created.guessed_post).to eq(guessed_post) + expect(created.correct_post).to eq(correct_post) + expect(created.won).to eq(false) + expect(created.question_count).to eq(1) + expect(created.answers).to eq([ + { + 'question_id' => 'tag:character:喜多郁代', + 'question_text' => '喜多ちゃんが関係してる?', + 'answer' => 'yes', + 'original_answer' => 'yes' + } + ]) + end + + it 'stores a won game when guessed_post_id equals correct_post_id' do + sign_in_as admin + + post '/gekanator/games', params: { + guessed_post_id: correct_post.id, + correct_post_id: correct_post.id, + answers: [] + } + + expect(response).to have_http_status(:created) + expect(GekanatorGame.find(json['id']).won).to eq(true) + end + + it 'rejects a game without correct_post_id' do + sign_in_as admin + + expect { + post '/gekanator/games', params: { + guessed_post_id: guessed_post.id, + answers: [] + } + }.not_to change { GekanatorGame.count } + + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'returns not found for a non-admin user' do + sign_in_as member + + post '/gekanator/games', params: { + guessed_post_id: guessed_post.id, + correct_post_id: correct_post.id, + answers: [] + } + + expect(response).to have_http_status(:not_found) + end + end + + describe 'POST /gekanator/question_suggestions' do + it 'creates a suggestion and promotes yes answer to an accepted post_similarity question' do + sign_in_as admin + + expect { + post '/gekanator/question_suggestions', params: { + gekanator_game_id: game.id, + question_text: '喜多ちゃんが泣いてる?', + answer: 'yes' + } + }.to change { GekanatorQuestionSuggestion.count }.by(1) + .and change { GekanatorQuestion.count }.by(1) + .and change { GekanatorQuestionExample.count }.by(1) + + expect(response).to have_http_status(:created) + + suggestion = GekanatorQuestionSuggestion.last + question = GekanatorQuestion.last + example = GekanatorQuestionExample.last + + expect(json).to include( + 'id' => suggestion.id, + 'count' => 1 + ) + + expect(suggestion).to have_attributes( + gekanator_game_id: game.id, + user_id: admin.id, + question_text: '喜多ちゃんが泣いてる?', + answer: 'yes', + processed: true + ) + + expect(question).to have_attributes( + text: '喜多ちゃんが泣いてる?', + kind: 'post_similarity', + source: 'user_suggested', + status: 'accepted', + priority_weight: 1.2, + gekanator_question_suggestion_id: suggestion.id, + created_by_id: admin.id + ) + expect(question.condition).to include( + 'type' => 'post-similarity', + 'postId' => correct_post.id, + 'answer' => 'yes', + 'threshold' => 0.65 + ) + + expect(example).to have_attributes( + gekanator_question_id: question.id, + post_id: correct_post.id, + user_id: admin.id, + gekanator_game_id: game.id, + answer: 'yes', + source: 'initial_suggestion', + weight: 1.0 + ) + end + + it 'promotes no, partial, and probably_no answers' do + sign_in_as admin + + ['no', 'partial', 'probably_no'].each do |answer| + expect { + post '/gekanator/question_suggestions', params: { + gekanator_game_id: game.id, + question_text: "answer #{answer} question?", + answer: answer + } + }.to change { GekanatorQuestion.count }.by(1) + .and change { GekanatorQuestionExample.count }.by(1) + + expect(response).to have_http_status(:created) + expect(GekanatorQuestion.last.condition['answer']).to eq(answer) + expect(GekanatorQuestionExample.last.answer).to eq(answer) + end + end + + it 'does not promote unknown answers' do + sign_in_as admin + + expect { + post '/gekanator/question_suggestions', params: { + gekanator_game_id: game.id, + question_text: 'よく分からない質問?', + answer: 'unknown' + } + }.to change { GekanatorQuestionSuggestion.count }.by(1) + .and change { GekanatorQuestion.count }.by(0) + .and change { GekanatorQuestionExample.count }.by(0) + + expect(response).to have_http_status(:created) + expect(GekanatorQuestionSuggestion.last.processed).to eq(false) + end + + it 'limits suggestions to three per game' do + sign_in_as admin + + 3.times do |i| + GekanatorQuestionSuggestion.create!( + gekanator_game: game, + user: admin, + question_text: "existing question #{i}", + answer: 'unknown' + ) + end + + expect { + post '/gekanator/question_suggestions', params: { + gekanator_game_id: game.id, + question_text: 'fourth question?', + answer: 'yes' + } + }.not_to change { GekanatorQuestionSuggestion.count } + + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'returns not found for a non-admin user' do + sign_in_as member + + post '/gekanator/question_suggestions', params: { + gekanator_game_id: game.id, + question_text: 'member question?', + answer: 'yes' + } + + expect(response).to have_http_status(:not_found) + end + end + + describe 'GET /gekanator/games/:id/extra_questions' do + it 'returns at most two accepted user_suggested post_similarity questions' do + sign_in_as admin + + low = create_post_similarity_question!( + text: 'low?', + priority_weight: 1.0 + ) + high = create_post_similarity_question!( + text: 'high?', + priority_weight: 3.0 + ) + middle = create_post_similarity_question!( + text: 'middle?', + priority_weight: 2.0 + ) + + get "/gekanator/games/#{game.id}/extra_questions" + + expect(response).to have_http_status(:ok) + expect(json['questions'].length).to eq(2) + expect(json['questions'].map { _1['id'] }).to eq([high.id, middle.id]) + expect(json['questions'].map { _1['id'] }).not_to include(low.id) + end + + it 'does not return questions that already have an example for the correct post' do + sign_in_as admin + + existing = create_post_similarity_question!(text: 'already learned?') + fresh = create_post_similarity_question!(text: 'fresh?') + + GekanatorQuestionExample.create!( + gekanator_question: existing, + post: correct_post, + user: admin, + gekanator_game: game, + answer: 'yes', + source: 'post_game_extra' + ) + + get "/gekanator/games/#{game.id}/extra_questions" + + expect(response).to have_http_status(:ok) + expect(json['questions'].map { _1['id'] }).to include(fresh.id) + expect(json['questions'].map { _1['id'] }).not_to include(existing.id) + end + + it 'does not return questions already asked in the game using snake_case question_id' do + sign_in_as admin + + asked = create_post_similarity_question!(text: 'already asked?') + fresh = create_post_similarity_question!(text: 'fresh?') + game.update!( + answers: [ + { + 'question_id' => "post-similarity:#{asked.id}", + 'answer' => 'yes' + } + ] + ) + + get "/gekanator/games/#{game.id}/extra_questions" + + expect(response).to have_http_status(:ok) + expect(json['questions'].map { _1['id'] }).to include(fresh.id) + expect(json['questions'].map { _1['id'] }).not_to include(asked.id) + end + + it 'does not return questions already asked in the game using camelCase questionId' do + sign_in_as admin + + asked = create_post_similarity_question!(text: 'already asked?') + fresh = create_post_similarity_question!(text: 'fresh?') + game.update!( + answers: [ + { + 'questionId' => "post-similarity:#{asked.id}", + 'answer' => 'yes' + } + ] + ) + + get "/gekanator/games/#{game.id}/extra_questions" + + expect(response).to have_http_status(:ok) + expect(json['questions'].map { _1['id'] }).to include(fresh.id) + expect(json['questions'].map { _1['id'] }).not_to include(asked.id) + end + + it 'does not return non-accepted, non-user_suggested, or non-post_similarity questions' do + sign_in_as admin + + accepted = create_post_similarity_question!(text: 'accepted?') + create_post_similarity_question!(text: 'disabled?', status: 'disabled') + create_post_similarity_question!(text: 'ai?', source: 'ai_generated') + GekanatorQuestion.create!( + text: 'tag?', + kind: 'tag', + source: 'user_suggested', + status: 'accepted', + priority_weight: 1.0, + condition: { type: 'tag', key: 'character:喜多郁代' } + ) + + get "/gekanator/games/#{game.id}/extra_questions" + + expect(response).to have_http_status(:ok) + expect(json['questions'].map { _1['id'] }).to contain_exactly(accepted.id) + end + end + + describe 'POST /gekanator/games/:id/extra_question_answers' do + it 'creates examples for extra question answers' do + sign_in_as admin + + question = create_post_similarity_question!(text: 'extra?') + + expect { + post "/gekanator/games/#{game.id}/extra_question_answers", params: { + answers: [ + { + question_id: question.id.to_s, + answer: 'partial' + } + ] + } + }.to change { GekanatorQuestionExample.count }.by(1) + + expect(response).to have_http_status(:created) + expect(json).to include('count' => 1) + + example = GekanatorQuestionExample.last + expect(example).to have_attributes( + gekanator_question_id: question.id, + post_id: correct_post.id, + user_id: admin.id, + gekanator_game_id: game.id, + answer: 'partial', + source: 'post_game_extra', + weight: 1.0 + ) + end + + it 'updates an existing example for the same question, post, and user' do + sign_in_as admin + + question = create_post_similarity_question!(text: 'extra?') + existing = GekanatorQuestionExample.create!( + gekanator_question: question, + post: correct_post, + user: admin, + answer: 'no', + source: 'post_game_extra', + weight: 1.0 + ) + + expect { + post "/gekanator/games/#{game.id}/extra_question_answers", params: { + answers: [ + { + question_id: question.id, + answer: 'yes' + } + ] + } + }.not_to change { GekanatorQuestionExample.count } + + expect(response).to have_http_status(:created) + expect(existing.reload).to have_attributes( + answer: 'yes', + source: 'post_game_extra', + gekanator_game_id: game.id + ) + end + + it 'rejects missing questions' do + sign_in_as admin + + expect { + post "/gekanator/games/#{game.id}/extra_question_answers", params: { + answers: [ + { + question_id: 999_999_999, + answer: 'yes' + } + ] + } + }.not_to change { GekanatorQuestionExample.count } + + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'rejects non-accepted questions' do + sign_in_as admin + + question = create_post_similarity_question!( + text: 'disabled?', + status: 'disabled' + ) + + post "/gekanator/games/#{game.id}/extra_question_answers", params: { + answers: [ + { + question_id: question.id, + answer: 'yes' + } + ] + } + + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'rejects non-post_similarity questions' do + sign_in_as admin + + question = GekanatorQuestion.create!( + text: 'tag?', + kind: 'tag', + source: 'user_suggested', + status: 'accepted', + priority_weight: 1.0, + condition: { type: 'tag', key: 'character:喜多郁代' } + ) + + post "/gekanator/games/#{game.id}/extra_question_answers", params: { + answers: [ + { + question_id: question.id, + answer: 'yes' + } + ] + } + + expect(response).to have_http_status(:unprocessable_entity) + end + end + + describe 'GET /gekanator/questions' do + it 'returns accepted questions only and includes example_answers for post_similarity questions' do + sign_in_as admin + + accepted = create_post_similarity_question!(text: 'accepted?') + create_post_similarity_question!( + text: 'disabled?', + status: 'disabled' + ) + + GekanatorQuestionExample.create!( + gekanator_question: accepted, + post: correct_post, + user: admin, + answer: 'yes', + source: 'initial_suggestion', + weight: 1.0 + ) + + get '/gekanator/questions' + + expect(response).to have_http_status(:ok) + expect(json['questions'].length).to eq(1) + + question_json = json['questions'].first + expect(question_json).to include( + 'id' => "post-similarity:#{accepted.id}", + 'text' => 'accepted?', + 'kind' => 'post_similarity', + 'source' => 'user_suggested', + 'priority_weight' => 1.2 + ) + expect(question_json['condition']).to include( + 'type' => 'post-similarity', + 'postId' => correct_post.id, + 'answer' => 'yes', + 'threshold' => 0.65 + ) + expect(question_json['example_answers']).to include( + correct_post.id.to_s => 'yes' + ) + end + + it 'aggregates example_answers by weight' do + sign_in_as admin + + question = create_post_similarity_question!(text: 'weighted?') + + GekanatorQuestionExample.create!( + gekanator_question: question, + post: other_post, + user: admin, + answer: 'yes', + source: 'post_game_extra', + weight: 1.0 + ) + GekanatorQuestionExample.create!( + gekanator_question: question, + post: other_post, + user: other_user, + answer: 'no', + source: 'post_game_extra', + weight: 2.0 + ) + + get '/gekanator/questions' + + expect(response).to have_http_status(:ok) + question_json = json['questions'].find { _1['id'] == "post-similarity:#{question.id}" } + expect(question_json['example_answers']).to include( + other_post.id.to_s => 'no' + ) + end + end +end diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 124c9b7..67f7b09 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -33,6 +33,10 @@ npm run lint If either command cannot be run or fails, report the exact command and failure. +Do not create, modify, or run tests unless the user explicitly asks for test +work. When the user asks for tests, keep working and rerun them until they +pass or the remaining failure is clearly blocked. + ## TypeScript - TypeScript is strict. `tsconfig.app.json` enables `strict`, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e0dd2e9..6809e3a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -18,6 +18,7 @@ import MaterialListPage from '@/pages/materials/MaterialListPage' import MaterialNewPage from '@/pages/materials/MaterialNewPage' // import MaterialSearchPage from '@/pages/materials/MaterialSearchPage' import MorePage from '@/pages/MorePage' +import GekanatorPage from '@/pages/GekanatorPage' import NicoTagListPage from '@/pages/tags/NicoTagListPage' import NotFound from '@/pages/NotFound' import TOSPage from '@/pages/TOSPage.mdx' @@ -39,7 +40,7 @@ import WikiHistoryPage from '@/pages/wiki/WikiHistoryPage' import WikiNewPage from '@/pages/wiki/WikiNewPage' import WikiSearchPage from '@/pages/wiki/WikiSearchPage' -import type { Dispatch, FC, SetStateAction } from 'react' +import type { Dispatch, FC, ReactNode, SetStateAction } from 'react' import type { User } from '@/types' @@ -80,6 +81,10 @@ const RouteTransitionWrapper = ({ user, setUser }: { }/> }/> }/> + + + }/> }/> }/> @@ -87,6 +92,16 @@ const RouteTransitionWrapper = ({ user, setUser }: { } +const AdminOnly = ({ user, children }: { + user: User | null + children: ReactNode }) => { + if (user?.role !== 'admin') + return + + return <>{children} +} + + const PostDetailRoute = ({ user }: { user: User | null }) => { const location = useLocation () const key = location.pathname diff --git a/frontend/src/lib/gekanator.test.ts b/frontend/src/lib/gekanator.test.ts new file mode 100644 index 0000000..5afb8f7 --- /dev/null +++ b/frontend/src/lib/gekanator.test.ts @@ -0,0 +1,296 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { apiPost } from '@/lib/api' +import { + expectedAnswerForQuestion, + restoreGekanatorQuestion, + saveGekanatorExtraQuestionAnswers, + saveGekanatorGame, + saveGekanatorQuestionSuggestion, +} from '@/lib/gekanator' + +import type { + GekanatorAnswerLog, + StoredGekanatorQuestion, +} from '@/lib/gekanator' +import type { Post } from '@/types' + +vi.mock('@/lib/api', () => ({ + apiGet: vi.fn(), + apiPost: vi.fn(), +})) + +const mockedApiPost = vi.mocked(apiPost) + +const post = (overrides: Partial = {}): Post => ({ + id: 1, + url: 'https://example.com/posts/1', + title: 'post title', + thumbnail: null, + thumbnailBase: null, + tags: [], + viewed: false, + related: [], + originalCreatedFrom: null, + originalCreatedBefore: null, + createdAt: '2026-06-10T00:00:00.000Z', + updatedAt: '2026-06-10T00:00:00.000Z', + uploadedUser: null, + ...overrides, +}) + +describe('expectedAnswerForQuestion', () => { + it('returns a direct example answer when present', () => { + const question: StoredGekanatorQuestion = { + id: 'post-similarity:10', + text: '喜多ちゃんが泣いてる?', + kind: 'post_similarity', + source: 'user_suggested', + priorityWeight: 1.2, + condition: { + type: 'post-similarity', + postId: 999, + answer: 'yes', + threshold: 0.65, + }, + exampleAnswers: { + 1: 'partial', + }, + } + + expect(expectedAnswerForQuestion(question, post({ id: 1 }))).toBe('partial') + }) + + it('returns the condition answer for the original post_similarity post', () => { + const question: StoredGekanatorQuestion = { + id: 'post-similarity:10', + text: '喜多ちゃんが泣いてる?', + kind: 'post_similarity', + source: 'user_suggested', + priorityWeight: 1.2, + condition: { + type: 'post-similarity', + postId: 123, + answer: 'probably_no', + threshold: 0.65, + }, + } + + expect(expectedAnswerForQuestion(question, post({ id: 123 }))).toBe('probably_no') + }) + + it('returns null for an unrelated post_similarity post without examples', () => { + const question: StoredGekanatorQuestion = { + id: 'post-similarity:10', + text: '喜多ちゃんが泣いてる?', + kind: 'post_similarity', + source: 'user_suggested', + priorityWeight: 1.2, + condition: { + type: 'post-similarity', + postId: 123, + answer: 'yes', + threshold: 0.65, + }, + } + + expect(expectedAnswerForQuestion(question, post({ id: 456 }))).toBeNull() + }) + + it('returns yes for a matching tag question', () => { + const question: StoredGekanatorQuestion = { + id: 'tag:character:喜多郁代', + text: '喜多ちゃんが関係してる?', + kind: 'tag', + condition: { + type: 'tag', + key: 'character:喜多郁代', + }, + } + + expect( + expectedAnswerForQuestion( + question, + post({ + tags: [ + { + id: 1, + name: '喜多郁代', + category: 'character', + aliases: [], + parents: [], + postCount: 1, + createdAt: '2026-06-10T00:00:00.000Z', + updatedAt: '2026-06-10T00:00:00.000Z', + hasWiki: false, + materialId: null, + }, + ], + }), + ), + ).toBe('yes') + }) + + it('returns no for a non-matching tag question', () => { + const question: StoredGekanatorQuestion = { + id: 'tag:character:喜多郁代', + text: '喜多ちゃんが関係してる?', + kind: 'tag', + condition: { + type: 'tag', + key: 'character:喜多郁代', + }, + } + + expect(expectedAnswerForQuestion(question, post({ tags: [] }))).toBe('no') + }) +}) + +describe('restoreGekanatorQuestion', () => { + it('uses default source and priority weight when omitted', () => { + const question = restoreGekanatorQuestion({ + id: 'tag:character:喜多郁代', + text: '喜多ちゃんが関係してる?', + kind: 'tag', + condition: { + type: 'tag', + key: 'character:喜多郁代', + }, + }) + + expect(question.source).toBe('default') + expect(question.priorityWeight).toBe(1) + }) + + it('tests a post_similarity question using direct examples', () => { + const question = restoreGekanatorQuestion({ + id: 'post-similarity:10', + text: '喜多ちゃんが泣いてる?', + kind: 'post_similarity', + source: 'user_suggested', + priorityWeight: 1.2, + condition: { + type: 'post-similarity', + postId: 999, + answer: 'yes', + threshold: 0.65, + }, + exampleAnswers: { + 1: 'yes', + 2: 'no', + }, + }) + + expect(question.test(post({ id: 1 }))).toBe(true) + expect(question.test(post({ id: 2 }))).toBe(false) + expect(question.test(post({ id: 3 }))).toBe(false) + }) +}) + +describe('Gekanator API writers', () => { + beforeEach(() => { + mockedApiPost.mockReset() + }) + + it('sends game results using snake_case request keys', async () => { + mockedApiPost.mockResolvedValue({ id: 100 }) + + const answers: GekanatorAnswerLog[] = [ + { + questionId: 'tag:character:喜多郁代', + questionText: '喜多ちゃんが関係してる?', + questionCondition: { + type: 'tag', + key: 'character:喜多郁代', + }, + answer: 'yes', + originalAnswer: 'partial', + }, + ] + + await expect( + saveGekanatorGame({ + guessedPostId: 1, + correctPostId: 2, + answers, + }), + ).resolves.toEqual({ id: 100 }) + + expect(mockedApiPost).toHaveBeenCalledWith('/gekanator/games', { + guessed_post_id: 1, + correct_post_id: 2, + answers: [ + { + question_id: 'tag:character:喜多郁代', + question_text: '喜多ちゃんが関係してる?', + question_condition: { + type: 'tag', + key: 'character:喜多郁代', + }, + answer: 'yes', + original_answer: 'partial', + }, + ], + }) + }) + + it('sends question suggestions using snake_case request keys', async () => { + mockedApiPost.mockResolvedValue({ + id: 10, + count: 1, + }) + + await expect( + saveGekanatorQuestionSuggestion({ + gekanatorGameId: 100, + questionText: '喜多ちゃんが泣いてる?', + answer: 'yes', + }), + ).resolves.toEqual({ + id: 10, + count: 1, + }) + + expect(mockedApiPost).toHaveBeenCalledWith('/gekanator/question_suggestions', { + gekanator_game_id: 100, + question_text: '喜多ちゃんが泣いてる?', + answer: 'yes', + }) + }) + + it('sends extra question answers using snake_case request keys', async () => { + mockedApiPost.mockResolvedValue({ + count: 2, + }) + + await saveGekanatorExtraQuestionAnswers({ + gameId: 100, + answers: [ + { + questionId: 10, + answer: 'yes', + }, + { + questionId: 11, + answer: 'probably_no', + }, + ], + }) + + expect(mockedApiPost).toHaveBeenCalledWith( + '/gekanator/games/100/extra_question_answers', + { + answers: [ + { + question_id: 10, + answer: 'yes', + }, + { + question_id: 11, + answer: 'probably_no', + }, + ], + }, + ) + }) +}) diff --git a/frontend/src/lib/gekanator.ts b/frontend/src/lib/gekanator.ts new file mode 100644 index 0000000..252e79d --- /dev/null +++ b/frontend/src/lib/gekanator.ts @@ -0,0 +1,483 @@ +import { apiGet, apiPost } from '@/lib/api' + +import type { Post } from '@/types' + +export type GekanatorAnswerValue = + | 'yes' + | 'no' + | 'partial' + | 'probably_no' + | 'unknown' + +export type GekanatorAnswerLog = { + questionId: string + questionText: string + questionCondition?: GekanatorQuestionCondition + answer: GekanatorAnswerValue + originalAnswer: GekanatorAnswerValue } + +export type GekanatorQuestionKind = + | 'tag' + | 'source' + | 'title' + | 'original_date' + | 'post_similarity' + +export type GekanatorQuestionSource = + | 'default' + | 'user_suggested' + | 'ai_generated' + | 'admin_curated' + +export type GekanatorQuestionCondition = + | { type: 'tag'; key: string } + | { type: 'source'; host: string } + | { type: 'original-year'; year: number } + | { type: 'original-month'; month: number } + | { type: 'original-month-day'; monthDay: string } + | { type: 'title-length-greater-than'; length: number } + | { type: 'title-has-ascii' } + | { + type: 'post-similarity' + postId: number + answer: GekanatorAnswerValue + threshold: number + } + +export type GekanatorExtraQuestion = { + id: number + text: string + source: GekanatorQuestionSource + priorityWeight: number } + +export type StoredGekanatorQuestion = { + id: string + text: string + kind: GekanatorQuestionKind + condition: GekanatorQuestionCondition + source?: GekanatorQuestionSource + priorityWeight?: number + exampleAnswers?: Record<`${ number }`, GekanatorAnswerValue> } + +export type GekanatorQuestion = { + id: string + text: string + kind: GekanatorQuestionKind + condition: GekanatorQuestionCondition + source: GekanatorQuestionSource + priorityWeight: number + exampleAnswers?: Record<`${ number }`, GekanatorAnswerValue> + test: (post: Post) => boolean } + + +const directExampleAnswerFor = ( + question: StoredGekanatorQuestion, + post: Post, +): GekanatorAnswerValue | null => { + const direct = question.exampleAnswers?.[String (post.id) as `${ number }`] + if (direct) + return direct + + if (question.condition.type === 'post-similarity' && question.condition.postId === post.id) + return question.condition.answer + + return null +} + +const countBy = (values: T[]): Map => { + const counts = new Map () + values.forEach (value => counts.set (value, (counts.get (value) ?? 0) + 1)) + return counts +} + + +const median = (values: number[]): number => { + const sorted = [...values].sort ((a, b) => a - b) + return sorted[Math.floor (sorted.length / 2)] ?? 0 +} + + +const hostOf = (post: Post): string | null => { + try + { + return new URL (post.url).hostname.replace (/^www\./, '') + } + catch + { + return null + } +} + + +const originalYearOf = (post: Post): number | null => { + const value = post.originalCreatedFrom || post.originalCreatedBefore + if (!(value)) + return null + + const date = new Date (value) + if (Number.isNaN (date.getTime ())) + return null + + return date.getFullYear () +} + + +const originalDateOf = (post: Post): Date | null => { + const value = post.originalCreatedFrom || post.originalCreatedBefore + if (!(value)) + return null + + const date = new Date (value) + if (Number.isNaN (date.getTime ())) + return null + + return date +} + + +const originalMonthOf = (post: Post): number | null => { + const date = originalDateOf (post) + if (!(date)) + return null + + return date.getMonth () + 1 +} + + +const originalMonthDayOf = (post: Post): string | null => { + const date = originalDateOf (post) + if (!(date)) + return null + + return `${ date.getMonth () + 1 }-${ date.getDate () }` +} + + +const tagQuestionKey = ({ category, name }: { category: string; name: string }): string => + `${ category }:${ name }` + + +const tagFromQuestionKey = (key: string): { category: string; name: string } => { + const [category, ...rest] = key.split (':') + return { category: category ?? '', name: rest.join (':') } +} + + +const nicoTagLabel = (name: string): string => name.replace (/^nico:/, '') + + +const tagQuestionText = (category: string, label: string): string => { + switch (category) + { + case 'deerjikist': + return `作者・ニジラーとして「${ label }」に関係してゐる?` + case 'meme': + return `元ネタ・ミームとして「${ label }」に関係しさう?` + case 'character': + return `「${ label }」といふキャラクターが関係してゐる?` + case 'material': + return `素材として「${ label }」に関係してゐる?` + case 'nico': + return `ニコニコに「${ label }」といふタグが付いてゐる?` + case 'general': + case 'meta': + default: + return `内容として「${ label }」に関係しさう?` + } +} + + +const questionableTag = (post: Post, key: string): boolean => { + const { category, name } = tagFromQuestionKey (key) + + return ( + post.tags.some (tag => + tag.name === name + && tag.category === category + && !(tag.category === 'meta') + && !(tag.name.includes ('タグ希望')) + && !(tag.name.includes ('bot操作')))) +} + + +const questionMatches = ( + post: Post, + question: StoredGekanatorQuestion, +): boolean => { + const directAnswer = directExampleAnswerFor (question, post) + if (directAnswer) + return question.condition.type === 'post-similarity' + ? directAnswer === question.condition.answer + : directAnswer === 'yes' + + switch (question.condition.type) + { + case 'tag': + return questionableTag (post, question.condition.key) + case 'source': + return hostOf (post) === question.condition.host + case 'original-year': + return originalYearOf (post) === question.condition.year + case 'original-month': + return originalMonthOf (post) === question.condition.month + case 'original-month-day': + return originalMonthDayOf (post) === question.condition.monthDay + case 'title-length-greater-than': + return (post.title?.length ?? 0) > question.condition.length + case 'title-has-ascii': + return /[A-Za-z0-9]/.test (post.title ?? '') + case 'post-similarity': + return false + } +} + + +export const expectedAnswerForQuestion = ( + question: StoredGekanatorQuestion | GekanatorQuestion | undefined, + post: Post | null, +): GekanatorAnswerValue | null => { + if (!(question) || !(post)) + return null + + const directAnswer = directExampleAnswerFor (question, post) + if (directAnswer) + return directAnswer + + switch (question.condition.type) + { + case 'tag': + case 'source': + case 'original-year': + case 'original-month': + case 'original-month-day': + case 'title-length-greater-than': + case 'title-has-ascii': + return questionMatches (post, question) ? 'yes' : 'no' + case 'post-similarity': + return null + } +} + + +export const restoreGekanatorQuestion = ( + question: StoredGekanatorQuestion, +): GekanatorQuestion => ({ + ...question, + source: question.source ?? 'default', + priorityWeight: question.priorityWeight ?? 1, + test: (post: Post) => questionMatches (post, question) }) + + +export const storeGekanatorQuestion = ( + question: GekanatorQuestion, +): StoredGekanatorQuestion => ({ + id: question.id, + text: question.text, + kind: question.kind, + condition: question.condition, + source: question.source, + priorityWeight: question.priorityWeight, + exampleAnswers: question.exampleAnswers }) + + +export const fetchGekanatorPosts = async (): Promise => { + const data = await apiGet<{ posts: Post[] }> ('/gekanator/posts') + return data.posts +} + + +export const fetchGekanatorQuestions = async (): Promise => { + const data = await apiGet<{ questions: StoredGekanatorQuestion[] }> ('/gekanator/questions') + return data.questions +} + + +export const fetchGekanatorExtraQuestions = async ( + gameId: number, +): Promise => { + const data = await apiGet<{ questions: GekanatorExtraQuestion[] }> ( + `/gekanator/games/${ gameId }/extra_questions`) + return data.questions +} + + +export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => { + const tagCounts = countBy (posts.flatMap (post => + post.tags + .filter (tag => + !(tag.category === 'meta') + && !(tag.name.includes ('タグ希望')) + && !(tag.name.includes ('bot操作'))) + .map (tag => tagQuestionKey (tag)))) + const hosts = countBy (posts.map (hostOf).filter ((host): host is string => Boolean (host))) + const originalYears = countBy ( + posts + .map (originalYearOf) + .filter ((year): year is number => year !== null)) + const originalMonths = countBy ( + posts + .map (originalMonthOf) + .filter ((month): month is number => month !== null)) + const originalMonthDays = countBy ( + posts + .map (originalMonthDayOf) + .filter ((monthDay): monthDay is string => monthDay !== null)) + const titleLengthMedian = median (posts.map (post => post.title?.length ?? 0)) + + const usefulEntries = (counts: Map) => + [...counts.entries ()] + .filter (([, count]) => count > 0 && count < posts.length) + .sort ((a, b) => Math.abs (posts.length / 2 - a[1]) + - Math.abs (posts.length / 2 - b[1])) + .slice (0, 80) + + const tagQuestions = usefulEntries (tagCounts) + .filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7)) + .slice (0, 80) + .map (([key]) => { + const { category, name } = tagFromQuestionKey (String (key)) + const label = category === 'nico' ? nicoTagLabel (name) : name + + return { + id: `tag:${ key }`, + 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)) } + }) + + const sourceQuestions = usefulEntries (hosts) + .filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7)) + .slice (0, 20) + .map (([host]) => ({ + id: `source:${ host }`, + 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) + .filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7)) + .slice (0, 20) + .map (([year]) => ({ + id: `original-year:${ year }`, + 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) + .filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7)) + .slice (0, 20) + .map (([month]) => ({ + id: `original-month:${ month }`, + 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) + .filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7)) + .slice (0, 20) + .map (([monthDay]) => { + const [month, day] = String (monthDay).split ('-') + + return { + id: `original-month-day:${ monthDay }`, + 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 } + }) + + const titleQuestions = [ + { + id: 'title:long', + text: '題名が長めの投稿?', + kind: 'title' as const, + 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 + const no = posts.length - yes + return yes >= 2 && no >= 2 && yes <= posts.length * .7 && no <= posts.length * .7 + }) + + return [ + ...sourceQuestions, + ...originalYearQuestions, + ...originalMonthQuestions, + ...originalMonthDayQuestions, + ...titleQuestions, + ...tagQuestions] +} + + +export const saveGekanatorGame = async ({ + guessedPostId, + correctPostId, + answers, +}: { + guessedPostId: number + correctPostId: number + answers: GekanatorAnswerLog[] +}): Promise<{ id: number }> => + await apiPost ('/gekanator/games', { + guessed_post_id: guessedPostId, + correct_post_id: correctPostId, + answers: answers.map (answer => ({ + question_id: answer.questionId, + question_text: answer.questionText, + question_condition: answer.questionCondition ?? null, + answer: answer.answer, + original_answer: answer.originalAnswer })) }) + + +export const saveGekanatorQuestionSuggestion = async ({ + gekanatorGameId, + questionText, + answer, +}: { + gekanatorGameId: number + questionText: string + answer: GekanatorAnswerValue +}): Promise<{ id: number; count: number }> => + await apiPost ('/gekanator/question_suggestions', { + gekanator_game_id: gekanatorGameId, + question_text: questionText, + answer }) + + +export const saveGekanatorExtraQuestionAnswers = async ({ + gameId, + answers, +}: { + gameId: number + answers: { questionId: number; answer: GekanatorAnswerValue }[] +}) => + await apiPost (`/gekanator/games/${ gameId }/extra_question_answers`, { + answers: answers.map (item => ({ + question_id: item.questionId, + answer: item.answer })) }) diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index b7dff2b..5e8b837 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -8,6 +8,13 @@ export const postsKeys = { changes: (p: { post?: string; tag?: string; page: number; limit: number }) => ['posts', 'changes', p] as const } +export const gekanatorKeys = { + root: ['gekanator'] as const, + posts: () => ['gekanator', 'posts'] as const, + questions: () => ['gekanator', 'questions'] as const, + extraQuestions: (gameId: number) => + ['gekanator', 'games', gameId, 'extra-questions'] as const } + export const tagsKeys = { root: ['tags'] as const, index: (p: FetchTagsParams) => ['tags', 'index', p] as const, diff --git a/frontend/src/pages/GekanatorPage.tsx b/frontend/src/pages/GekanatorPage.tsx new file mode 100644 index 0000000..3dcf84f --- /dev/null +++ b/frontend/src/pages/GekanatorPage.tsx @@ -0,0 +1,2147 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useEffect, useMemo, useState } from 'react' +import { Helmet } from 'react-helmet-async' + +import PrefetchLink from '@/components/PrefetchLink' +import MainArea from '@/components/layout/MainArea' +import { SITE_TITLE } from '@/config' +import { buildGekanatorQuestions, + expectedAnswerForQuestion, + fetchGekanatorExtraQuestions, + fetchGekanatorQuestions, + fetchGekanatorPosts, + restoreGekanatorQuestion, + saveGekanatorExtraQuestionAnswers, + saveGekanatorGame, + saveGekanatorQuestionSuggestion, + storeGekanatorQuestion } from '@/lib/gekanator' +import { gekanatorKeys } from '@/lib/queryKeys' +import { cn } from '@/lib/utils' + +import type { FC } from 'react' + +import type { GekanatorAnswerLog, + GekanatorAnswerValue, + GekanatorExtraQuestion, + GekanatorQuestion, + StoredGekanatorQuestion } from '@/lib/gekanator' +import type { Post } from '@/types' + +type Phase = + | 'intro' + | 'question' + | 'guess' + | 'continue' + | 'end' + | 'review' + | 'question_suggestion' + | 'extra_questions' + | 'learned' + +type AnswerOption = { + label: string + value: GekanatorAnswerValue } + +type Confidence = { + post: Post + score: number + percent: number } + +type AnswerPreview = { + answer: GekanatorAnswerValue + top: Confidence | null + candidateCount: number + effectiveCandidates: number + entropy: number } + +type GameSnapshot = { + phase: Phase + scores: Map + answers: GekanatorAnswerLog[] + askedIds: Set + softenedQuestionIds: Set + askedQuestionBank: GekanatorQuestion[] + search: string + selectingCorrectPost: boolean + rejectedPostIds: Set + lastGuessQuestionCount: number + lastRejectedGuessId: number | null + activeGuessId: number | null + reviewGuessedPostId: number | null + reviewCorrectPostId: number | null } + +type StoredGekanatorGame = { + phase: Phase + scores: [number, number][] + answers: GekanatorAnswerLog[] + askedIds: string[] + softenedQuestionIds: string[] + askedQuestionBank?: StoredGekanatorQuestion[] + askedQuestionBankIds?: string[] + search: string + selectingCorrectPost: boolean + saved: boolean + resultWon: boolean | null + rejectedPostIds: number[] + lastGuessQuestionCount: number + lastRejectedGuessId: number | null + activeGuessId: number | null + reviewGuessedPostId: number | null + reviewCorrectPostId: number | null + savedGameId: number | null + gameSeed?: string + questionSuggestion: string + questionSuggestionAnswer: GekanatorAnswerValue + questionSuggestionCount?: number + extraQuestions?: GekanatorExtraQuestion[] + extraQuestionAnswers?: Record + extraQuestionState?: 'idle' | 'loading' | 'ready' | 'empty' | 'saved' } + +const answerOptions: AnswerOption[] = [ + { label: 'はい', value: 'yes' }, + { label: 'いいえ', value: 'no' }, + { label: '部分的にそう', value: 'partial' }, + { label: 'たぶんいいえ', value: 'probably_no' }, + { label: 'わからない', value: 'unknown' }] + +const answerLabelFor = (value: GekanatorAnswerValue): string => + answerOptions.find (option => option.value === value)?.label ?? value + +const questionsBetweenGuesses = 25 +const minQuestionsBeforeCertainGuess = 5 +const certainGuessPercent = 99.5 +const runnerUpMaxPercent = .5 +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 => + (Math.min (3, Math.max (.2, 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 => { + try + { + sessionStorage.removeItem (gameStorageKey) + } + catch + { + return + } +} + + +const loadStoredGame = (): StoredGekanatorGame | null => { + try + { + const raw = sessionStorage.getItem (gameStorageKey) + if (!(raw)) + return null + + return JSON.parse (raw) as StoredGekanatorGame + } + catch + { + clearStoredGame () + return null + } +} + + +const isStoredPhase = (phase: Phase): boolean => phase !== 'intro' + + +const resettableExtraQuestionState = (): { + extraQuestions: GekanatorExtraQuestion[] + extraQuestionAnswers: Record + extraQuestionState: 'idle' +} => ({ + extraQuestions: [], + extraQuestionAnswers: { }, + extraQuestionState: 'idle' }) + + +const deltaFor = (matched: boolean, answer: GekanatorAnswerValue): number => { + switch (answer) + { + case 'yes': + return matched ? 4 : -4 + case 'no': + return matched ? -4 : 4 + case 'partial': + return matched ? 2 : -1 + case 'probably_no': + return matched ? -2 : 2 + case 'unknown': + return 0 + } +} + + +const answerScalarFor = ( + answer: GekanatorAnswerValue | null, +): number | null => { + switch (answer) + { + case 'yes': + return 1 + case 'partial': + return .5 + case 'probably_no': + return -.5 + case 'no': + return -1 + case 'unknown': + case null: + return null + } +} + + +const deltaForExpectedAnswer = ( + expected: GekanatorAnswerValue | null, + answer: GekanatorAnswerValue, +): number => { + if (answer === 'unknown' || expected === null || expected === 'unknown') + return 0 + + if (expected === 'yes' || expected === 'no') + return deltaFor (expected === 'yes', answer) + + const expectedScalar = answerScalarFor (expected) + const answerScalar = answerScalarFor (answer) + if (expectedScalar === null || answerScalar === null) + return 0 + + const distance = Math.abs (expectedScalar - answerScalar) + if (distance >= 2) + return -4 + if (distance >= 1.5) + return -2 + if (distance >= 1) + return 0 + if (distance >= .5) + return 2 + + return 4 +} + + +const answerWeightFor = ( + questionId: string, + softenedQuestionIds: Set, +): number => softenedQuestionIds.has (questionId) ? softenedAnswerWeight : 1 + + +const questionDifficulty = (question: GekanatorQuestion): number => { + if (question.kind === 'source') + return 4 + if (question.kind === 'original_date') + return 4 + if (question.kind === 'title') + return 4 + if (question.kind === 'tag') + return 3 + + return 1 +} + + +const recalculateScores = ({ + posts, + questions, + answers, + softenedQuestionIds, +}: { + posts: Post[] + questions: GekanatorQuestion[] + answers: GekanatorAnswerLog[] + softenedQuestionIds: Set +}): Map => { + const questionById = new Map (questions.map (question => [question.id, question])) + const nextScores = new Map () + + answers.forEach (answer => { + const question = questionById.get (answer.questionId) + if (!(question)) + return + + const weight = answerWeightFor (answer.questionId, softenedQuestionIds) + posts.forEach (post => { + const expected = expectedAnswerForQuestion (question, post) + nextScores.set ( + post.id, + (nextScores.get (post.id) ?? 0) + + deltaForExpectedAnswer (expected, answer.answer) * weight) + }) + }) + + return nextScores +} + + +const candidatePostsFor = ({ + posts, + questions, + answers, + softenedQuestionIds, + rejectedPostIds, +}: { + posts: Post[] + questions: GekanatorQuestion[] + answers: GekanatorAnswerLog[] + softenedQuestionIds: Set + rejectedPostIds: Set +}): Post[] => { + const questionById = new Map (questions.map (question => [question.id, question])) + + return posts.filter (post => { + if (rejectedPostIds.has (post.id)) + return false + + return answers.every (answer => { + if (softenedQuestionIds.has (answer.questionId)) + return true + + const question = questionById.get (answer.questionId) + if (!(question)) + return true + + switch (answer.answer) + { + case 'yes': + case 'no': { + const expected = expectedAnswerForQuestion (question, post) + return expected === null || expected === 'unknown' || expected === answer.answer + } + default: + return true + } + }) + }) +} + + +const confidencesFor = (posts: Post[], scores: Map): Confidence[] => { + if (posts.length === 0) + return [] + + const raw = posts.map (post => ({ post, score: scores.get (post.id) ?? 0 })) + const maxScore = Math.max (...raw.map (({ score }) => score)) + const weighted = raw.map (item => ({ + ...item, + weight: Math.exp ((item.score - maxScore) / confidenceTemperature) })) + const total = weighted.reduce ((sum, item) => sum + item.weight, 0) || 1 + + return weighted + .map (({ post, score, weight }) => ({ + post, + score, + percent: weight / total * 100 })) + .sort ((a, b) => b.percent - a.percent) +} + + +const entropyFor = (confidences: Confidence[]): number => + confidences.reduce ((sum, item) => { + const p = item.percent / 100 + return p > 0 ? sum - p * Math.log2 (p) : sum + }, 0) + + +const effectiveCandidatesFor = (confidences: Confidence[]): number => { + const concentration = confidences.reduce ((sum, item) => { + const p = item.percent / 100 + return sum + p * p + }, 0) + + return concentration > 0 ? 1 / concentration : 0 +} + + +const previewAnswer = ({ + posts, + scores, + question, + answer, +}: { + posts: Post[] + scores: Map + question: GekanatorQuestion + answer: GekanatorAnswerValue +}): AnswerPreview => { + const hardFilteredPosts = + answer === 'unknown' + ? posts + : posts.filter (post => { + const expected = expectedAnswerForQuestion (question, post) + return expected === null || expected === 'unknown' || expected === answer + }) + const nextPosts = + hardFilteredPosts.length > 0 + ? hardFilteredPosts + : posts + const nextScores = new Map (scores) + nextPosts.forEach (post => { + const expected = expectedAnswerForQuestion (question, post) + nextScores.set ( + post.id, + (nextScores.get (post.id) ?? 0) + deltaForExpectedAnswer (expected, answer)) + }) + + const confidences = confidencesFor (nextPosts, nextScores) + + return { + answer, + top: confidences[0] ?? null, + candidateCount: nextPosts.length, + effectiveCandidates: effectiveCandidatesFor (confidences), + entropy: entropyFor (confidences) } +} + + +const mergeQuestions = (questions: GekanatorQuestion[]): GekanatorQuestion[] => { + const byId = new Map () + questions.forEach (question => { + const current = byId.get (question.id) + if (shouldReplaceMergedQuestion (current, question)) + byId.set (question.id, question) + }) + return [...byId.values ()] +} + + +const softenNextQuestionIds = ({ + questions, + answers, + softenedQuestionIds, +}: { + questions: GekanatorQuestion[] + answers: GekanatorAnswerLog[] + softenedQuestionIds: Set +}): Set | null => { + const questionById = new Map (questions.map (question => [question.id, question])) + const candidate = [...answers] + .reverse () + .map (answer => { + const question = questionById.get (answer.questionId) + return { answer, question } + }) + .filter ((item): item is { + answer: GekanatorAnswerLog + question: GekanatorQuestion } => + item.question !== undefined + && item.answer.answer !== 'unknown' + && !(softenedQuestionIds.has (item.answer.questionId))) + .sort ((a, b) => questionDifficulty (b.question) - questionDifficulty (a.question))[0] + + if (!(candidate)) + return null + + return new Set ([...softenedQuestionIds, candidate.answer.questionId]) +} + + +type ExclusiveConditionGroup = + | 'original-month' + | 'original-year' + | 'original-month-day' + | 'source' + + +const exclusiveConditionGroupFor = ( + condition: GekanatorQuestion['condition'], +): ExclusiveConditionGroup | null => { + switch (condition.type) + { + case 'original-month': + return 'original-month' + case 'original-year': + return 'original-year' + case 'original-month-day': + return 'original-month-day' + case 'source': + return 'source' + default: + return null + } +} + + +const sameConditionValue = ( + left: GekanatorQuestion['condition'], + right: GekanatorQuestion['condition'], +): boolean => { + if (left.type !== right.type) + return false + + const valueKeyFor = (condition: GekanatorQuestion['condition']): string => { + switch (condition.type) + { + case 'tag': + return condition.key + case 'source': + return condition.host + case 'original-year': + return String (condition.year) + case 'original-month': + return String (condition.month) + case 'original-month-day': + return condition.monthDay + case 'title-length-greater-than': + return String (condition.length) + case 'title-has-ascii': + return '' + case 'post-similarity': + return `${ condition.postId }:${ condition.answer }:${ condition.threshold }` + } + } + + return valueKeyFor (left) === valueKeyFor (right) +} + + +const monthForCondition = ( + condition: GekanatorQuestion['condition'], +): number | null => { + if (condition.type === 'original-month') + return condition.month + + if (condition.type !== 'original-month-day') + return null + + const month = Number (condition.monthDay.split ('-')[0]) + return Number.isInteger (month) ? month : null +} + + +const isMonthCrossMatch = ( + candidate: GekanatorQuestion['condition'], + previous: GekanatorQuestion['condition'], +): boolean => { + const candidateMonth = monthForCondition (candidate) + const previousMonth = monthForCondition (previous) + if (candidateMonth === null || previousMonth === null) + return false + + const sameType = candidate.type === previous.type + if (sameType) + return false + + return candidateMonth === previousMonth +} + + +const isExclusiveContradiction = ( + candidate: GekanatorQuestion['condition'], + previous: GekanatorQuestion['condition'], +): boolean => { + const candidateGroup = exclusiveConditionGroupFor (candidate) + const previousGroup = exclusiveConditionGroupFor (previous) + + if (candidateGroup !== null && candidateGroup === previousGroup) + return !(sameConditionValue (candidate, previous)) + + const candidateMonth = monthForCondition (candidate) + const previousMonth = monthForCondition (previous) + if (candidateMonth !== null && previousMonth !== null) + return candidateMonth !== previousMonth + + return false +} + + +const contradictionPenaltyFor = ({ + question, + answers, +}: { + question: GekanatorQuestion + answers: GekanatorAnswerLog[] +}): number => { + return answers.reduce ((sum, answer) => { + const previous = answer.questionCondition + if (!(previous)) + return sum + + switch (answer.answer) + { + case 'yes': + return sum + (isExclusiveContradiction (question.condition, previous) ? 100 : 0) + case 'partial': + return sum + (isExclusiveContradiction (question.condition, previous) ? 25 : 0) + case 'no': + return sum + ( + sameConditionValue (question.condition, previous) + || isMonthCrossMatch (question.condition, previous) + ? 40 + : 0) + case 'probably_no': + return sum + ( + sameConditionValue (question.condition, previous) + || isMonthCrossMatch (question.condition, previous) + ? 20 + : 0) + default: + return sum + } + }, 0) +} + + +const chooseQuestion = ({ + posts, + questions, + scores, + answers, + askedIds, + gameSeed, +}: { + posts: Post[] + questions: GekanatorQuestion[] + scores: Map + answers: GekanatorAnswerLog[] + askedIds: Set + gameSeed: string +}): GekanatorQuestion | null => { + const scoredPosts = posts + .map (post => ({ post, score: scores.get (post.id) ?? 0 })) + .sort ((a, b) => b.score - a.score) + const maxScore = scoredPosts[0]?.score ?? 0 + const weightedPosts = scoredPosts.map (item => ({ + ...item, + weight: Math.exp ((item.score - maxScore) / confidenceTemperature) })) + const totalWeight = + weightedPosts.reduce ((sum, item) => sum + item.weight, 0) || 1 + const normalisedWeightedPosts = + weightedPosts.map (item => ({ ...item, weight: item.weight / totalWeight })) + + const signatureFor = ( + question: GekanatorQuestion, + candidates: { post: Post; score: number }[], + ): string => candidates.map (({ post }) => question.test (post) ? '1' : '0').join ('') + + const invertedSignature = (signature: string): string => + signature.replace (/[01]/g, value => value === '1' ? '0' : '1') + + const redundantSignatures = ( + candidates: { post: Post; score: number }[], + ): Set => { + const signatures = new Set () + questions + .filter (question => askedIds.has (question.id)) + .forEach (question => { + const signature = signatureFor (question, candidates) + signatures.add (signature) + signatures.add (invertedSignature (signature)) + }) + + return signatures + } + + const rank = ( + questionsToRank: GekanatorQuestion[], + candidates: { post: Post; score: number }[], + weightedCandidates: { post: Post; score: number; weight: number }[], + ) => { + const redundant = redundantSignatures (candidates) + const nonTagCount = + questions.filter (question => askedIds.has (question.id) && question.kind !== 'tag').length + + return questionsToRank + .map (question => { + const signature = signatureFor (question, candidates) + if (redundant.has (signature)) + return null + + const yes = signature.split ('').filter (value => value === '1').length + const no = candidates.length - yes + if (yes === 0 || no === 0) + return null + + const yesWeight = weightedCandidates.reduce ( + (sum, item) => sum + (question.test (item.post) ? item.weight : 0), + 0) + const noWeight = 1 - yesWeight + if (yesWeight <= 0 || noWeight <= 0) + return null + + const weightedSplitScore = Math.abs (.5 - yesWeight) + const unweightedSplitScore = Math.abs (candidates.length / 2 - yes) / candidates.length + 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 contradictionPenalty = contradictionPenaltyFor ({ question, answers }) + const sourceBonus = sourcePriorityOffset (question) + const priorityBonus = priorityWeightOffset (question) + + return { question, + score: weightedSplitScore * 100 + + unweightedSplitScore * 8 + + tagPenalty + + narrowPenalty + + contradictionPenalty + + sourceBonus + + priorityBonus, + narrow: narrowPenalty > 0 } + }) + .filter ((item): item is { + question: GekanatorQuestion + score: number + narrow: boolean } => item !== null && Number.isFinite (item.score)) + .sort ((a, b) => a.score - b.score) + } + + 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) + + 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 +} + + +const bestPost = (posts: Post[], scores: Map): Post | null => + posts + .map (post => ({ post, score: scores.get (post.id) ?? 0 })) + .sort ((a, b) => b.score - a.score)[0]?.post ?? null + + +const PostMiniCard: FC<{ post: Post }> = ({ post }) => ( +
+ {post.title +
+ + #{post.id} {post.title || post.url} + +
+ {post.tags.slice (0, 6).map (tag => tag.name).join (' / ')} +
+
+
) + + +const expectedAnswerFor = ( + question: GekanatorQuestion | undefined, + correctPost: Post | null, +): GekanatorAnswerValue | null => + expectedAnswerForQuestion (question, correctPost) + + +const GekanatorPage: FC = () => { + const storedGame = useMemo (loadStoredGame, []) + const queryClient = useQueryClient () + const [gameSeed, setGameSeed] = useState ( + storedGame?.gameSeed ?? createGameSeed ()) + const [phase, setPhase] = useState (storedGame?.phase ?? 'intro') + const [scores, setScores] = useState> ( + () => new Map (storedGame?.scores ?? [])) + const [answers, setAnswers] = useState ( + storedGame?.answers ?? []) + const [askedIds, setAskedIds] = useState> ( + () => new Set (storedGame?.askedIds ?? [])) + const [softenedQuestionIds, setSoftenedQuestionIds] = useState> ( + () => new Set (storedGame?.softenedQuestionIds ?? [])) + const [askedQuestionBank, setAskedQuestionBank] = useState ( + () => (storedGame?.askedQuestionBank ?? []).map (restoreGekanatorQuestion)) + const [storedAskedQuestionBankIds, setStoredAskedQuestionBankIds] = useState ( + (storedGame?.askedQuestionBank?.length ?? 0) > 0 + ? [] + : storedGame?.askedQuestionBankIds ?? []) + const [search, setSearch] = useState (storedGame?.search ?? '') + const [selectingCorrectPost, setSelectingCorrectPost] = useState ( + storedGame?.selectingCorrectPost ?? false) + const [saved, setSaved] = useState (storedGame?.saved ?? false) + const [resultWon, setResultWon] = useState ( + storedGame?.resultWon ?? null) + const [rejectedPostIds, setRejectedPostIds] = useState> ( + () => new Set (storedGame?.rejectedPostIds ?? [])) + const [lastGuessQuestionCount, setLastGuessQuestionCount] = useState ( + storedGame?.lastGuessQuestionCount ?? 0) + const [lastRejectedGuessId, setLastRejectedGuessId] = useState ( + storedGame?.lastRejectedGuessId ?? null) + const [activeGuessId, setActiveGuessId] = useState ( + storedGame?.activeGuessId ?? null) + const [reviewGuessedPostId, setReviewGuessedPostId] = useState ( + storedGame?.reviewGuessedPostId ?? null) + const [reviewCorrectPostId, setReviewCorrectPostId] = useState ( + storedGame?.reviewCorrectPostId ?? null) + const [savedGameId, setSavedGameId] = useState ( + storedGame?.savedGameId ?? null) + const [questionSuggestion, setQuestionSuggestion] = useState ( + storedGame?.questionSuggestion ?? '') + const [questionSuggestionAnswer, setQuestionSuggestionAnswer] = + useState (storedGame?.questionSuggestionAnswer ?? 'yes') + const [questionSuggestionCount, setQuestionSuggestionCount] = useState ( + storedGame?.questionSuggestionCount ?? 0) + const [extraQuestions, setExtraQuestions] = useState ( + storedGame?.extraQuestions ?? []) + const [extraQuestionAnswers, setExtraQuestionAnswers] = + useState> ( + storedGame?.extraQuestionAnswers ?? { }) + const [extraQuestionState, setExtraQuestionState] = useState< + 'idle' | 'loading' | 'ready' | 'empty' | 'saved' + > (storedGame?.extraQuestionState ?? 'idle') + const [history, setHistory] = useState ([]) + + const { data: posts = [], isLoading, error } = useQuery ({ + queryKey: gekanatorKeys.posts (), + queryFn: fetchGekanatorPosts, + refetchOnWindowFocus: false }) + const { + data: acceptedQuestions = [], + isFetched: acceptedQuestionsFetched, + isLoading: acceptedQuestionsLoading, + error: acceptedQuestionsError + } = useQuery ({ + queryKey: gekanatorKeys.questions (), + queryFn: fetchGekanatorQuestions, + select: questions => questions.map (restoreGekanatorQuestion), + refetchOnWindowFocus: false }) + + useEffect (() => { + if ( + posts.length === 0 + || storedAskedQuestionBankIds.length === 0 + || !(acceptedQuestionsFetched) + ) + return + + const questionById = new Map ( + 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, acceptedQuestions, acceptedQuestionsFetched]) + + useEffect (() => { + if (!(isStoredPhase (phase)) && answers.length === 0) + { + clearStoredGame () + return + } + + const stored: StoredGekanatorGame = { + phase, + scores: [...scores.entries ()], + answers, + askedIds: [...askedIds], + softenedQuestionIds: [...softenedQuestionIds], + askedQuestionBank: askedQuestionBank.map (storeGekanatorQuestion), + askedQuestionBankIds: storedAskedQuestionBankIds, + search, + selectingCorrectPost, + saved, + resultWon, + rejectedPostIds: [...rejectedPostIds], + lastGuessQuestionCount, + lastRejectedGuessId, + activeGuessId, + reviewGuessedPostId, + reviewCorrectPostId, + savedGameId, + gameSeed, + questionSuggestion, + questionSuggestionAnswer, + questionSuggestionCount, + extraQuestions, + extraQuestionAnswers, + extraQuestionState } + + try + { + sessionStorage.setItem (gameStorageKey, JSON.stringify (stored)) + } + catch + { + return + } + }, [ + phase, + scores, + answers, + askedIds, + softenedQuestionIds, + askedQuestionBank, + storedAskedQuestionBankIds, + search, + selectingCorrectPost, + saved, + resultWon, + rejectedPostIds, + lastGuessQuestionCount, + lastRejectedGuessId, + activeGuessId, + reviewGuessedPostId, + reviewCorrectPostId, + savedGameId, + gameSeed, + questionSuggestion, + questionSuggestionAnswer, + questionSuggestionCount, + extraQuestions, + extraQuestionAnswers, + extraQuestionState]) + + const eligiblePosts = useMemo ( + () => candidatePostsFor ({ + posts, + questions: askedQuestionBank, + answers, + softenedQuestionIds, + rejectedPostIds }), + [posts, askedQuestionBank, answers, softenedQuestionIds, rejectedPostIds]) + const questions = useMemo ( + () => mergeQuestions ([ + ...buildGekanatorQuestions (eligiblePosts.length > 1 ? eligiblePosts : posts), + ...acceptedQuestions]), + [acceptedQuestions, eligiblePosts, posts]) + const scoringQuestions = useMemo (() => { + return mergeQuestions ([...questions, ...askedQuestionBank]) + }, [questions, askedQuestionBank]) + const scoringQuestionById = useMemo ( + () => new Map (scoringQuestions.map (question => [question.id, question])), + [scoringQuestions]) + const questionsSinceLastGuess = answers.length - lastGuessQuestionCount + const nonRejectedPosts = useMemo ( + () => posts.filter (post => !(rejectedPostIds.has (post.id))), + [posts, rejectedPostIds]) + const questionPosts = + eligiblePosts.length > 1 + || questionsSinceLastGuess >= minQuestionsBeforeCertainGuess + ? eligiblePosts + : nonRejectedPosts + const topScoredPosts = useMemo ( + () => eligiblePosts + .map (post => ({ post, score: scores.get (post.id) ?? 0 })) + .sort ((a, b) => b.score - a.score) + .slice (0, 3), + [eligiblePosts, scores]) + const currentQuestion = chooseQuestion ({ + posts: questionPosts, + questions: scoringQuestions, + scores, + answers, + askedIds, + gameSeed }) + const answerPreviews = useMemo ( + () => currentQuestion + ? answerOptions.map (option => previewAnswer ({ + posts: eligiblePosts, + scores, + question: currentQuestion, + answer: option.value })) + : [], + [currentQuestion, eligiblePosts, scores]) + const guessablePosts = + eligiblePosts.length > 0 + ? eligiblePosts + : nonRejectedPosts + const guess = bestPost (guessablePosts, scores) + const displayedGuess = + posts.find (post => post.id === activeGuessId) ?? guess + const reviewGuessedPost = + posts.find (post => post.id === reviewGuessedPostId) ?? null + const reviewCorrectPost = + posts.find (post => post.id === reviewCorrectPostId) ?? null + const saveMutation = useMutation ({ + mutationFn: saveGekanatorGame, + onSuccess: (data, variables) => { + setSaved (true) + setSavedGameId (data.id) + setResultWon (variables.guessedPostId === variables.correctPostId) + }}) + const questionSuggestionMutation = useMutation ({ + mutationFn: saveGekanatorQuestionSuggestion, + onSuccess: async data => { + await queryClient.refetchQueries ({ queryKey: gekanatorKeys.questions () }) + setQuestionSuggestionCount (data.count) + setQuestionSuggestion ('') + setQuestionSuggestionAnswer ('yes') + }}) + const extraQuestionAnswersMutation = useMutation ({ + mutationFn: saveGekanatorExtraQuestionAnswers, + onSuccess: async () => { + await queryClient.refetchQueries ({ queryKey: gekanatorKeys.questions () }) + setExtraQuestionState ('saved') + setPhase ('learned') + }}) + + const resetExtraQuestionState = () => { + const next = resettableExtraQuestionState () + setExtraQuestions (next.extraQuestions) + setExtraQuestionAnswers (next.extraQuestionAnswers) + setExtraQuestionState (next.extraQuestionState) + extraQuestionAnswersMutation.reset () + } + + const reset = () => { + clearStoredGame () + saveMutation.reset () + questionSuggestionMutation.reset () + setPhase ('intro') + setScores (new Map ()) + setAnswers ([]) + setAskedIds (new Set ()) + setSoftenedQuestionIds (new Set ()) + setAskedQuestionBank ([]) + setSearch ('') + setSelectingCorrectPost (false) + setSaved (false) + setResultWon (null) + setRejectedPostIds (new Set ()) + setLastGuessQuestionCount (0) + setLastRejectedGuessId (null) + setActiveGuessId (null) + setReviewGuessedPostId (null) + setReviewCorrectPostId (null) + setSavedGameId (null) + setGameSeed (createGameSeed ()) + setQuestionSuggestion ('') + setQuestionSuggestionAnswer ('yes') + setQuestionSuggestionCount (0) + resetExtraQuestionState () + setHistory ([]) + } + + const recoverQuestionState = ({ + nextAnswers, + nextAskedIds, + nextAskedQuestionBank, + nextSoftenedQuestionIds, + nextRejectedPostIds, + }: { + nextAnswers: GekanatorAnswerLog[] + nextAskedIds: Set + nextAskedQuestionBank: GekanatorQuestion[] + nextSoftenedQuestionIds: Set + nextRejectedPostIds: Set + }) => { + let recoveredSoftenedQuestionIds = new Set (nextSoftenedQuestionIds) + let recoveredScores = recalculateScores ({ + posts, + questions: nextAskedQuestionBank, + answers: nextAnswers, + softenedQuestionIds: recoveredSoftenedQuestionIds }) + let recoveredEligiblePosts = candidatePostsFor ({ + posts, + questions: nextAskedQuestionBank, + answers: nextAnswers, + softenedQuestionIds: recoveredSoftenedQuestionIds, + rejectedPostIds: nextRejectedPostIds }) + let recoveredScoringQuestions = mergeQuestions ([ + ...buildGekanatorQuestions ( + recoveredEligiblePosts.length > 1 ? recoveredEligiblePosts : posts), + ...acceptedQuestions, + ...nextAskedQuestionBank]) + + while ( + recoveredEligiblePosts.length === 0 + || ( + recoveredEligiblePosts.length !== 1 + && !(chooseQuestion ({ + posts: recoveredEligiblePosts, + questions: recoveredScoringQuestions, + scores: recoveredScores, + answers: nextAnswers, + askedIds: nextAskedIds, + gameSeed }))) + ) + { + if (nextAnswers.length >= hardMaxQuestions) + break + + const softened = softenNextQuestionIds ({ + questions: nextAskedQuestionBank, + answers: nextAnswers, + softenedQuestionIds: recoveredSoftenedQuestionIds }) + if (!(softened)) + break + + recoveredSoftenedQuestionIds = softened + recoveredScores = recalculateScores ({ + posts, + questions: nextAskedQuestionBank, + answers: nextAnswers, + softenedQuestionIds: recoveredSoftenedQuestionIds }) + recoveredEligiblePosts = candidatePostsFor ({ + posts, + questions: nextAskedQuestionBank, + answers: nextAnswers, + softenedQuestionIds: recoveredSoftenedQuestionIds, + rejectedPostIds: nextRejectedPostIds }) + recoveredScoringQuestions = mergeQuestions ([ + ...buildGekanatorQuestions ( + recoveredEligiblePosts.length > 1 ? recoveredEligiblePosts : posts), + ...acceptedQuestions, + ...nextAskedQuestionBank]) + } + + return { + softenedQuestionIds: recoveredSoftenedQuestionIds, + scores: recoveredScores, + eligiblePosts: recoveredEligiblePosts, + scoringQuestions: recoveredScoringQuestions } + } + + const answer = (value: GekanatorAnswerValue) => { + if (!(currentQuestion)) + { + setActiveGuessId (guess?.id ?? null) + setPhase ('guess') + return + } + + setHistory ([...history, { + phase, + scores: new Map (scores), + answers: [...answers], + askedIds: new Set (askedIds), + softenedQuestionIds: new Set (softenedQuestionIds), + askedQuestionBank: [...askedQuestionBank], + search, + selectingCorrectPost, + rejectedPostIds: new Set (rejectedPostIds), + lastGuessQuestionCount, + lastRejectedGuessId, + activeGuessId, + reviewGuessedPostId, + reviewCorrectPostId }]) + const nextAnswers = [...answers, { + questionId: currentQuestion.id, + questionText: currentQuestion.text, + questionCondition: currentQuestion.condition, + answer: value, + originalAnswer: value }] + const nextAskedIds = new Set ([...askedIds, currentQuestion.id]) + const nextAskedQuestionBank = [ + ...askedQuestionBank.filter (question => question.id !== currentQuestion.id), + currentQuestion] + const recovered = recoverQuestionState ({ + nextAnswers, + nextAskedIds, + nextAskedQuestionBank, + nextSoftenedQuestionIds: softenedQuestionIds, + nextRejectedPostIds: rejectedPostIds }) + const nextSoftenedQuestionIds = recovered.softenedQuestionIds + const nextScores = recovered.scores + const nextEligiblePosts = recovered.eligiblePosts + + setScores (nextScores) + setAskedIds (nextAskedIds) + setSoftenedQuestionIds (nextSoftenedQuestionIds) + setAskedQuestionBank (nextAskedQuestionBank) + setAnswers (nextAnswers) + + const nextGuessablePosts = + nextEligiblePosts.length > 0 + ? nextEligiblePosts + : nonRejectedPosts + const nextGuess = bestPost (nextGuessablePosts, nextScores) + const nextQuestionCount = answers.length + 1 + const nextQuestionsSinceLastGuess = + nextQuestionCount - lastGuessQuestionCount + const nextConfidences = confidencesFor (nextGuessablePosts, nextScores) + const topConfidence = nextConfidences[0] ?? null + const runnerUpConfidence = nextConfidences[1] ?? null + const structurallyCertain = nextEligiblePosts.length === 1 + const statisticallyCertain = + topConfidence !== null + && topConfidence.percent >= certainGuessPercent + && (runnerUpConfidence === null + || runnerUpConfidence.percent <= runnerUpMaxPercent) + const canGuessByQuestionCount = + nextQuestionsSinceLastGuess >= questionsBetweenGuesses + const canGuessEarlyByConfidence = + nextQuestionsSinceLastGuess >= minQuestionsBeforeCertainGuess + && (structurallyCertain || statisticallyCertain) + const shouldGuess = + nextQuestionCount >= hardMaxQuestions + || canGuessByQuestionCount + || canGuessEarlyByConfidence + if (shouldGuess) + { + setActiveGuessId (nextGuess?.id ?? null) + setLastGuessQuestionCount (nextQuestionCount) + setPhase ('guess') + } + } + + const finishGame = (correctPostId: number) => { + const guessedPostId = + phase === 'end' || phase === 'review' + ? reviewGuessedPostId + : phase === 'continue' + ? lastRejectedGuessId ?? displayedGuess?.id + : displayedGuess?.id ?? lastRejectedGuessId + if (!(guessedPostId)) + return + + saveMutation.reset () + questionSuggestionMutation.reset () + resetExtraQuestionState () + setSaved (false) + setSavedGameId (null) + setReviewGuessedPostId (guessedPostId) + setReviewCorrectPostId (correctPostId) + setSearch ('') + setSelectingCorrectPost (false) + setPhase ('end') + } + + const startReview = () => { + if (reviewGuessedPostId === null || reviewCorrectPostId === null) + return + + saveMutation.reset () + questionSuggestionMutation.reset () + resetExtraQuestionState () + setSaved (false) + setSavedGameId (null) + setSelectingCorrectPost (false) + setSearch ('') + setPhase ('review') + } + + const saveReviewedResult = (onSuccess: (gameId: number) => void) => { + if ( + reviewGuessedPostId === null + || reviewCorrectPostId === null + || saveMutation.isPending + ) + return + + if (savedGameId !== null) + { + onSuccess (savedGameId) + return + } + + saveMutation.mutate ({ + guessedPostId: reviewGuessedPostId, + correctPostId: reviewCorrectPostId, + answers }, + { onSuccess: data => onSuccess (data.id) }) + } + + const saveAndReset = () => { + saveReviewedResult (reset) + } + + const saveAndLearn = () => { + resetExtraQuestionState () + saveReviewedResult (() => setPhase ('learned')) + } + + const restartFromQuestionSuggestion = () => { + if (savedGameId !== null) + { + reset () + return + } + + saveReviewedResult (reset) + } + + const submitQuestionSuggestion = () => { + const questionText = questionSuggestion.trim () + if ( + !(questionText) + || questionSuggestionMutation.isPending + || questionSuggestionCount >= maxQuestionSuggestionsPerGame + ) + return + + saveReviewedResult (gekanatorGameId => { + questionSuggestionMutation.mutate ({ + gekanatorGameId, + questionText, + answer: questionSuggestionAnswer }) + }) + } + + const rejectGuess = () => { + if (!(displayedGuess)) + return + + setLastRejectedGuessId (displayedGuess.id) + if (answers.length >= hardMaxQuestions) + { + setSelectingCorrectPost (true) + return + } + + setRejectedPostIds (new Set ([...rejectedPostIds, displayedGuess.id])) + setActiveGuessId (null) + setSearch ('') + setSelectingCorrectPost (false) + setLastGuessQuestionCount (answers.length) + setPhase ('continue') + } + + const undoAnswer = () => { + const snapshot = history[history.length - 1] + if (!(snapshot) || saved) + return + + setPhase (snapshot.phase) + setScores (snapshot.scores) + setAnswers (snapshot.answers) + setAskedIds (snapshot.askedIds) + setSoftenedQuestionIds (snapshot.softenedQuestionIds) + setAskedQuestionBank (snapshot.askedQuestionBank) + setSearch (snapshot.search) + setSelectingCorrectPost (snapshot.selectingCorrectPost) + setRejectedPostIds (snapshot.rejectedPostIds) + setLastGuessQuestionCount (snapshot.lastGuessQuestionCount) + setLastRejectedGuessId (snapshot.lastRejectedGuessId) + setActiveGuessId (snapshot.activeGuessId) + setReviewGuessedPostId (snapshot.reviewGuessedPostId) + setReviewCorrectPostId (snapshot.reviewCorrectPostId) + setHistory (history.slice (0, -1)) + } + + const continueGame = () => { + setSearch ('') + setSelectingCorrectPost (false) + + const recovered = recoverQuestionState ({ + nextAnswers: answers, + nextAskedIds: askedIds, + nextAskedQuestionBank: askedQuestionBank, + nextSoftenedQuestionIds: softenedQuestionIds, + nextRejectedPostIds: rejectedPostIds }) + + setSoftenedQuestionIds (recovered.softenedQuestionIds) + setScores (recovered.scores) + + const nextQuestion = chooseQuestion ({ + posts: recovered.eligiblePosts.length > 1 + ? recovered.eligiblePosts + : nonRejectedPosts, + questions: recovered.scoringQuestions, + scores: recovered.scores, + answers, + askedIds, + gameSeed }) + + if (nextQuestion) + { + setPhase ('question') + return + } + + setActiveGuessId (guess?.id ?? null) + setPhase ('guess') + } + + const correctAnswerAt = (index: number, value: GekanatorAnswerValue) => { + setSaved (false) + setSavedGameId (null) + resetExtraQuestionState () + setAnswers (answers.map ((answer, i) => + i === index ? { ...answer, answer: value } : answer)) + } + + const selectCorrectPost = (post: Post) => { + if (phase === 'review') + { + setSaved (false) + setSavedGameId (null) + resetExtraQuestionState () + setReviewCorrectPostId (post.id) + setSelectingCorrectPost (false) + setSearch ('') + return + } + + finishGame (post.id) + } + + const filteredPosts = posts + .filter (post => { + const needle = search.trim ().toLowerCase () + if (!(needle)) + return false + if (/^\d+$/.test (needle) && post.id === Number (needle)) + return true + + return [post.title, post.url, ...post.tags.map (tag => tag.name)] + .filter ((value): value is string => Boolean (value)) + .some (value => value.toLowerCase ().includes (needle)) + }) + .sort ((a, b) => { + const id = Number (search.trim ()) + if (Number.isFinite (id)) + return Number (b.id === id) - Number (a.id === id) + + return 0 + }) + .slice (0, 20) + + const loadExtraQuestions = async (gameId: number) => { + extraQuestionAnswersMutation.reset () + setExtraQuestionState ('loading') + setExtraQuestions ([]) + setExtraQuestionAnswers ({ }) + setPhase ('extra_questions') + + try + { + const questions = await queryClient.fetchQuery ({ + queryKey: gekanatorKeys.extraQuestions (gameId), + queryFn: () => fetchGekanatorExtraQuestions (gameId) }) + setExtraQuestions (questions) + setExtraQuestionState (questions.length > 0 ? 'ready' : 'empty') + } + catch + { + setExtraQuestionState ('empty') + } + } + + const startExtraQuestions = () => { + if (reviewCorrectPostId === null || saveMutation.isPending) + return + + saveReviewedResult (gameId => { + void loadExtraQuestions (gameId) + }) + } + + const answerExtraQuestion = ( + questionId: number, + value: GekanatorAnswerValue, + ) => { + setExtraQuestionAnswers ({ + ...extraQuestionAnswers, + [String (questionId)]: value }) + } + + const saveExtraQuestions = () => { + if ( + savedGameId === null + || extraQuestionAnswersMutation.isPending + || extraQuestions.some (question => !(extraQuestionAnswers[String (question.id)])) + ) + return + + extraQuestionAnswersMutation.mutate ({ + gameId: savedGameId, + answers: extraQuestions.map (question => ({ + questionId: question.id, + answer: extraQuestionAnswers[String (question.id)] })) }) + } + + const dialogue = + phase === 'learned' && resultWon + ? <>グカカカカwwwww 洗澡鹿シーザオグカは何でもお見通し! + : <>私は洗澡鹿シーザオグカ.質問から投稿を何でも当ててみせるよ + const introLoading = isLoading || acceptedQuestionsLoading + const readyToStart = + !(introLoading) + && acceptedQuestionsFetched + && posts.length > 0 + && !(error) + && !(acceptedQuestionsError) + + return ( + + + + {`グカネータ | ${ SITE_TITLE }`} + + +
+
+

おたのしみ

+

+ グカネータ +

+
+ +
+
+
+ 洗澡鹿 +
+
+

+ {dialogue} +

+ + {introLoading && ( +

+ {phase === 'intro' + ? '投稿を読み込んでゐます...' + : '前回のグカネータ状態を復元してゐます...'} +

)} + {(Boolean (error) || Boolean (acceptedQuestionsError)) + &&

グカネータの質問データを読み込めませんでした.

} + + {phase === 'intro' && readyToStart && ( + )} + + {phase === 'question' && currentQuestion && ( +
+
+

+ 質問 {answers.length + 1} +

+

{currentQuestion.text}

+
+
+
現在候補: {eligiblePosts.length} 件
+ {topScoredPosts.length > 0 && ( +
+ {topScoredPosts.map (item => ( + + #{item.post.id}: score {item.score.toFixed (1)} + ))} +
)} +
+ {answerPreviews.length > 0 && ( +
+ {answerOptions.map (option => { + const preview = + answerPreviews.find (item => item.answer === option.value) + return ( +
+ {option.label} + {' '} + + なら候補 {preview ? preview.candidateCount : 0} 件 + +
) + })} +
)} +
+ {answerOptions.map (option => ( + ))} + {history.length > 0 && ( + )} +
+
)} + + {!(isLoading) && phase === 'question' && !(currentQuestion) && ( +
+

+ もう十分わかった。 +

+ +
)} + + {phase === 'guess' && displayedGuess && ( +
+

これを想像してゐたね?

+ +
+ + + {history.length > 0 && ( + )} +
+ {saveMutation.isError && ( +

+ 記録できませんでした。通信状態を確認してもう一度試して。 +

)} +
)} + + {phase === 'continue' && ( +
+

続けますか?

+
+ + + {history.length > 0 && ( + )} +
+
)} + + {phase === 'end' && ( +
+
+

ゲーム終了

+

グカカカカwwwww

+
+ + {reviewGuessedPost && ( +
+
推測した投稿
+ +
)} + +
+
正解の投稿
+ {reviewCorrectPost + ? + :

正解投稿を選んでください。

} + +
+ + {reviewGuessedPostId !== null && reviewCorrectPostId !== null && ( +

+ 判定: {reviewGuessedPostId === reviewCorrectPostId ? '当たり' : '違ひ'} +

)} + + {saveMutation.isError && ( +

+ 記録できませんでした。通信状態を確認してもう一度試して。 +

)} + +
+ + + + +
+
)} + + {phase === 'review' && ( +
+
+

保存前確認

+

今回の結果を確認してね。

+
+ + {reviewGuessedPost && ( +
+
推測した投稿
+ +
)} + +
+
正解の投稿
+ {reviewCorrectPost + ? + :

正解投稿を選んでください。

} + +
+ +
+
質問と回答
+
+ {answers.map ((answer, index) => { + const expectedAnswer = expectedAnswerFor ( + scoringQuestionById.get (answer.questionId), + reviewCorrectPost) + + return ( +
+
+ 質問 {index + 1} +
+
{answer.questionText}
+
+
+ グカネータ判定: + {expectedAnswer ? answerLabelFor (expectedAnswer) : '不明'} +
+
+ 実際の回答: + {answerLabelFor (answer.originalAnswer)} +
+ +
+
) + })} +
+
+ + {reviewGuessedPostId !== null && reviewCorrectPostId !== null && ( +

+ 判定: {reviewGuessedPostId === reviewCorrectPostId ? '当たり' : '違ひ'} +

)} + + {saveMutation.isError && ( +

+ 記録できませんでした。通信状態を確認してもう一度試して。 +

)} + +
+ + +
+
)} + + {phase === 'question_suggestion' && ( +
+
+

質問追加

+

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

+

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

+
+