From 96df2a4eaaa859427fbfc87d428862924128dab7 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Mon, 8 Jun 2026 00:30:20 +0900 Subject: [PATCH 01/13] #41 --- .../controllers/gekanator_games_controller.rb | 17 + backend/app/models/gekanator_game.rb | 18 + backend/app/models/post.rb | 10 + backend/config/routes.rb | 4 + .../20260607000000_create_gekanator_games.rb | 18 + backend/db/schema.rb | 39 +- backend/spec/requests/gekanator_games_spec.rb | 63 ++ frontend/src/App.tsx | 2 + frontend/src/components/TopNav.tsx | 1 + frontend/src/lib/gekanator.ts | 227 +++++ frontend/src/lib/queryKeys.ts | 4 + frontend/src/pages/GekanatorPage.tsx | 886 ++++++++++++++++++ 12 files changed, 1288 insertions(+), 1 deletion(-) create mode 100644 backend/app/controllers/gekanator_games_controller.rb create mode 100644 backend/app/models/gekanator_game.rb create mode 100644 backend/db/migrate/20260607000000_create_gekanator_games.rb create mode 100644 backend/spec/requests/gekanator_games_spec.rb create mode 100644 frontend/src/lib/gekanator.ts create mode 100644 frontend/src/pages/GekanatorPage.tsx diff --git a/backend/app/controllers/gekanator_games_controller.rb b/backend/app/controllers/gekanator_games_controller.rb new file mode 100644 index 0000000..5e2e1d6 --- /dev/null +++ b/backend/app/controllers/gekanator_games_controller.rb @@ -0,0 +1,17 @@ +class GekanatorGamesController < ApplicationController + def create + return head :unauthorized unless current_user + + answers = params.require(:answers).as_json + + game = GekanatorGame.create!( + user: current_user, + guessed_post_id: params.require(:guessed_post_id), + correct_post_id: params[:correct_post_id].presence, + won: bool?(:won), + question_count: params.require(:question_count), + answers:) + + render json: { id: game.id }, status: :created + end +end diff --git a/backend/app/models/gekanator_game.rb b/backend/app/models/gekanator_game.rb new file mode 100644 index 0000000..5102a33 --- /dev/null +++ b/backend/app/models/gekanator_game.rb @@ -0,0 +1,18 @@ +class GekanatorGame < ApplicationRecord + belongs_to :user, optional: true + belongs_to :guessed_post, class_name: 'Post' + belongs_to :correct_post, class_name: 'Post', optional: true + + validates :answers, presence: true + validates :question_count, numericality: { greater_than_or_equal_to: 0 } + validates :won, inclusion: { in: [true, false] } + validate :correct_post_required_when_lost + + private + + def correct_post_required_when_lost + return if won || correct_post_id.present? + + errors.add(:correct_post_id, '外れた時は正解の投稿を指定してくださぃ.') + end +end diff --git a/backend/app/models/post.rb b/backend/app/models/post.rb index 2da8b02..f682c52 100644 --- a/backend/app/models/post.rb +++ b/backend/app/models/post.rb @@ -11,6 +11,16 @@ 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: :nullify, + inverse_of: :correct_post has_many :parent_post_implications, class_name: 'PostImplication', diff --git a/backend/config/routes.rb b/backend/config/routes.rb index ab9fdc3..0c76bb1 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -63,6 +63,10 @@ Rails.application.routes.draw do end end + namespace :gekanator do + resources :games, only: [:create], controller: '/gekanator_games' + 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..520559f --- /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, foreign_key: true + t.references :guessed_post, null: false, foreign_key: { to_table: :posts } + t.references :correct_post, 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/schema.rb b/backend/db/schema.rb index 9fe2736..d08bd31 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_07_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,21 @@ 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_games", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.bigint "user_id" + t.bigint "guessed_post_id", null: false + t.bigint "correct_post_id" + 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 "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" @@ -137,6 +152,19 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_06_000000) do t.index ["target_post_id"], name: "index_post_similarities_on_target_post_id" end + create_table "post_tag_sections", primary_key: ["post_id", "tag_id", "begin_ms"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.bigint "post_id", null: false + t.bigint "tag_id", null: false + t.integer "begin_ms", null: false + t.integer "end_ms", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["post_id", "begin_ms"], name: "idx_post_tag_sections_post_id_begin_ms" + t.index ["tag_id"], name: "fk_rails_8be3847903" + t.check_constraint "`begin_ms` < `end_ms`", name: "chk_post_tag_sections_end_ms_after_begin_ms" + t.check_constraint "`begin_ms` >= 0", name: "chk_post_tag_sections_begin_ms_natural" + end + create_table "post_tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "post_id", null: false t.bigint "tag_id", null: false @@ -187,8 +215,11 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_06_000000) do t.datetime "original_created_before" t.datetime "updated_at", null: false t.integer "version_no", null: false + t.integer "video_ms" t.index ["uploaded_user_id"], name: "index_posts_on_uploaded_user_id" t.index ["url"], name: "index_posts_on_url", unique: true + t.index ["video_ms", "id"], name: "idx_posts_video_ms_id" + t.check_constraint "(`video_ms` is null) or (`video_ms` > 0)", name: "chk_posts_video_ms_positive" t.check_constraint "`version_no` > 0", name: "chk_posts_version_no_positive" end @@ -339,6 +370,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_06_000000) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["expires_at"], name: "index_theatre_watching_users_on_expires_at" + t.index ["theatre_id", "expires_at"], name: "idx_on_theatre_id_skip_expires_at_4c8de1dd42" t.index ["theatre_id", "expires_at"], name: "index_theatre_watching_users_on_theatre_id_and_expires_at" t.index ["theatre_id"], name: "index_theatre_watching_users_on_theatre_id" t.index ["user_id"], name: "index_theatre_watching_users_on_user_id" @@ -478,6 +510,9 @@ 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_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 "material_versions", "materials" add_foreign_key "material_versions", "materials", column: "parent_id" add_foreign_key "material_versions", "tags" @@ -495,6 +530,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_06_000000) do add_foreign_key "post_implications", "posts", column: "parent_post_id" add_foreign_key "post_similarities", "posts" add_foreign_key "post_similarities", "posts", column: "target_post_id" + add_foreign_key "post_tag_sections", "posts" + add_foreign_key "post_tag_sections", "tags" add_foreign_key "post_tags", "posts" add_foreign_key "post_tags", "tags" add_foreign_key "post_tags", "users", column: "created_user_id" diff --git a/backend/spec/requests/gekanator_games_spec.rb b/backend/spec/requests/gekanator_games_spec.rb new file mode 100644 index 0000000..117269d --- /dev/null +++ b/backend/spec/requests/gekanator_games_spec.rb @@ -0,0 +1,63 @@ +require 'rails_helper' + +RSpec.describe 'Gekanator games API', type: :request do + 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 user + + post '/gekanator/games', params: { + guessed_post_id: guessed_post.id, + won: true, + question_count: 3, + answers: [{ question_id: 'tag:1', answer: 'yes' }] } + + expect(response).to have_http_status(:created) + game = GekanatorGame.find(json['id']) + expect(game.user).to eq(user) + expect(game.guessed_post).to eq(guessed_post) + expect(game.correct_post).to be_nil + expect(game.won).to eq(true) + 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 user + + post '/gekanator/games', params: { + guessed_post_id: guessed_post.id, + correct_post_id: correct_post.id, + won: false, + question_count: 4, + answers: [{ question_id: 'tag:1', answer: 'no' }] } + + expect(response).to have_http_status(:created) + expect(GekanatorGame.find(json['id']).correct_post).to eq(correct_post) + end + + it 'rejects a lost game without the correct post' do + sign_in_as user + + post '/gekanator/games', params: { + guessed_post_id: guessed_post.id, + won: false, + question_count: 4, + answers: [{ question_id: 'tag:1', answer: 'no' }] } + + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'requires a user' do + post '/gekanator/games', params: { + guessed_post_id: guessed_post.id, + won: true, + question_count: 1, + answers: [] } + + expect(response).to have_http_status(:unauthorized) + end + end +end diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e0dd2e9..a42f5fe 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' @@ -80,6 +81,7 @@ const RouteTransitionWrapper = ({ user, setUser }: { }/> }/> }/> + }/> }/> }/> diff --git a/frontend/src/components/TopNav.tsx b/frontend/src/components/TopNav.tsx index 4a7e20b..1f49488 100644 --- a/frontend/src/components/TopNav.tsx +++ b/frontend/src/components/TopNav.tsx @@ -66,6 +66,7 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: { { name: '履歴', to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg }, { name: '編輯', to: `/wiki/${ wikiId || wikiTitle }/edit`, visible: wikiPageFlg }] }, { name: 'おたのしみ', visible: false, subMenu: [ + { name: 'グカネータ', to: '/gekanator' }, { name: '上映会 (β)', to: '/theatres/1' }] }, { name: 'ユーザ', to: '/users/settings', visible: false, subMenu: [ { name: '一覧', to: '/users', visible: false }, diff --git a/frontend/src/lib/gekanator.ts b/frontend/src/lib/gekanator.ts new file mode 100644 index 0000000..8ce3e67 --- /dev/null +++ b/frontend/src/lib/gekanator.ts @@ -0,0 +1,227 @@ +import { apiPost } from '@/lib/api' +import { fetchPosts } from '@/lib/posts' + +import type { Post } from '@/types' + +export type GekanatorAnswerValue = + | 'yes' + | 'no' + | 'partial' + | 'probably_no' + | 'unknown' + +export type GekanatorAnswerLog = { + questionId: string + questionText: string + answer: GekanatorAnswerValue } + +export type GekanatorQuestionKind = + | 'tag' + | 'title' + | 'date' + | 'media' + | 'source' + | 'structure' + +export type GekanatorQuestion = { + id: string + text: string + kind: GekanatorQuestionKind + test: (post: Post) => boolean } + +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 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 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操作')))) +} + + +export const fetchGekanatorPosts = async (): Promise => { + const limit = 200 + const first = await fetchPosts ({ + url: '', title: '', tags: '', match: 'all', + originalCreatedFrom: '', originalCreatedTo: '', + createdFrom: '', createdTo: '', updatedFrom: '', updatedTo: '', + page: 1, limit, order: 'original_created_at:desc' }) + const posts = [...first.posts] + const totalPages = Math.ceil (first.count / limit) + + for (let page = 2; page <= totalPages; page++) + { + const data = await fetchPosts ({ + url: '', title: '', tags: '', match: 'all', + originalCreatedFrom: '', originalCreatedTo: '', + createdFrom: '', createdTo: '', updatedFrom: '', updatedTo: '', + page, limit, order: 'original_created_at:desc' }) + posts.push (...data.posts) + } + + return posts +} + + +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 tagMedian = median (posts.map (post => post.tags.length)) + const titleLengthMedian = median (posts.map (post => post.title?.length ?? 0)) + const currentYear = new Date ().getFullYear () + + 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: category === 'nico' + ? `ニコニコに「${ label }」といふタグが付いてゐる?` + : `内容として「${ label }」に関係しさう?`, + kind: 'tag' as const, + 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, + test: (post: Post) => hostOf (post) === host })) + + return [ + ...sourceQuestions, + { + id: 'title:present', + text: '題名が付いてゐる投稿?', + kind: 'title', + test: post => Boolean (post.title) }, + { + id: 'title:long', + text: '題名が長めの投稿?', + kind: 'title', + test: post => (post.title?.length ?? 0) > titleLengthMedian }, + { + id: 'title:ascii', + text: '題名に英数字が混じってゐる?', + kind: 'title', + test: post => /[A-Za-z0-9]/.test (post.title ?? '') }, + { + id: 'media:thumbnail', + text: 'ぱっと見でサムネが付いてゐる投稿?', + kind: 'media', + test: post => Boolean (post.thumbnail || post.thumbnailBase) }, + { + id: 'media:video-source', + text: '動画として見られる投稿?', + kind: 'media', + test: post => /nicovideo|youtube|youtu\.be/.test (post.url) }, + { + id: 'structure:many-tags', + text: 'タグが多めに付いてゐる投稿?', + kind: 'structure', + test: post => post.tags.length > tagMedian }, + { + id: 'structure:no-title', + text: '題名がまだ付いてゐない投稿?', + kind: 'structure', + test: post => !(post.title) }, + { + id: 'date:recent', + text: '最近追加されたほうの投稿?', + kind: 'date', + test: post => new Date (post.createdAt).getFullYear () >= currentYear - 1 }, + { + id: 'date:old', + text: 'むかし追加されたほうの投稿?', + kind: 'date', + test: post => new Date (post.createdAt).getFullYear () <= currentYear - 3 }, + { + id: 'date:original-known', + text: 'オリジナルの投稿日時が分かってゐる投稿?', + kind: 'date', + test: post => Boolean (post.originalCreatedFrom || post.originalCreatedBefore) }, + ...tagQuestions] +} + + +export const saveGekanatorGame = async ({ + guessedPostId, + correctPostId, + won, + answers, +}: { + guessedPostId: number + correctPostId: number | null + won: boolean + answers: GekanatorAnswerLog[] +}): Promise<{ id: number }> => + await apiPost ('/gekanator/games', { + guessed_post_id: guessedPostId, + correct_post_id: correctPostId, + won, + question_count: answers.length, + answers: answers.map (answer => ({ + question_id: answer.questionId, + question_text: answer.questionText, + answer: answer.answer })) }) diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index b7dff2b..3c9ac5c 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -8,6 +8,10 @@ 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 } + 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..5fb059a --- /dev/null +++ b/frontend/src/pages/GekanatorPage.tsx @@ -0,0 +1,886 @@ +import { useMutation, useQuery } from '@tanstack/react-query' +import { 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, + fetchGekanatorPosts, + saveGekanatorGame } from '@/lib/gekanator' +import { gekanatorKeys } from '@/lib/queryKeys' +import { cn } from '@/lib/utils' + +import type { FC } from 'react' + +import type { GekanatorAnswerLog, + GekanatorAnswerValue, + GekanatorQuestion } from '@/lib/gekanator' +import type { Post } from '@/types' + +type Phase = 'intro' | 'question' | 'guess' | 'continue' | '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 + candidateIds: Set | null + softenedQuestionIds: Set + questionBank: GekanatorQuestion[] + search: string + rejectedPostIds: Set + lastGuessQuestionCount: number + lastRejectedGuessId: number | null + activeGuessId: number | null } + +const answerOptions: AnswerOption[] = [ + { label: 'はい', value: 'yes' }, + { label: 'いいえ', value: 'no' }, + { label: '部分的にそう', value: 'partial' }, + { label: 'たぶんいいえ', value: 'probably_no' }, + { label: 'わからない', value: 'unknown' }] + +const questionsBetweenGuesses = 25 +const hardMaxQuestions = 80 +const softenedAnswerWeight = .35 +const confidenceTemperature = 6 + + +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 answerWeightFor = ( + questionId: string, + softenedQuestionIds: Set, +): number => softenedQuestionIds.has (questionId) ? softenedAnswerWeight : 1 + + +const questionDifficulty = (question: GekanatorQuestion): number => { + if (question.id === 'structure:many-tags') + return 6 + if (question.id.startsWith ('date:')) + return 5 + if (question.id === 'title:long' || question.id === 'title:ascii') + return 4 + if (question.kind === 'source') + return 4 + if (question.kind === 'tag') + return 3 + if (question.kind === 'title' || question.kind === 'structure') + return 2 + + 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 => { + nextScores.set ( + post.id, + (nextScores.get (post.id) ?? 0) + + deltaFor (question.test (post), answer.answer) * weight) + }) + }) + + return nextScores +} + + +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 === 'yes' + ? posts.filter (post => question.test (post)) + : answer === 'no' + ? posts.filter (post => !(question.test (post))) + : posts + const nextPosts = + (answer === 'yes' || answer === 'no') && hardFilteredPosts.length > 0 + ? hardFilteredPosts + : posts + const nextScores = new Map (scores) + nextPosts.forEach (post => { + nextScores.set ( + post.id, + (nextScores.get (post.id) ?? 0) + deltaFor (question.test (post), 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 => 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]) +} + + +const chooseQuestion = ({ + posts, + questions, + scores, + askedIds, +}: { + posts: Post[] + questions: GekanatorQuestion[] + scores: Map + askedIds: Set +}): GekanatorQuestion | null => { + const scoredPosts = posts + .map (post => ({ post, score: scores.get (post.id) ?? 0 })) + .sort ((a, b) => b.score - a.score) + + 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 }[], + ) => { + const redundant = redundantSignatures (candidates) + + 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 splitScore = Math.abs (candidates.length / 2 - yes) + const answerPreviews = answerOptions.map (option => + previewAnswer ({ + posts: candidates.map (({ post }) => post), + scores, + question, + answer: option.value })) + const expectedEntropy = + answerPreviews.reduce ((sum, preview) => sum + preview.entropy, 0) + / answerPreviews.length + const expectedCandidateCount = + answerPreviews.reduce ((sum, preview) => sum + preview.candidateCount, 0) + / answerPreviews.length + const kindPenalty = askedIds.has (question.kind) ? 2 : 0 + const tagPenalty = question.kind === 'tag' ? 0 : 10 + const minSide = candidates.length < 10 ? 1 : Math.max (3, candidates.length * .08) + const narrowPenalty = yes < minSide || no < minSide ? candidates.length : 0 + + return { question, + score: splitScore + expectedEntropy + expectedCandidateCount / 8 + + kindPenalty + tagPenalty + narrowPenalty, + 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) + + return (ranked.find (item => !(item.narrow)) ?? ranked[0])?.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 GekanatorPage: FC = () => { + const [phase, setPhase] = useState ('intro') + const [scores, setScores] = useState> (new Map ()) + const [answers, setAnswers] = useState ([]) + const [askedIds, setAskedIds] = useState> (new Set ()) + const [candidateIds, setCandidateIds] = useState | null> (null) + const [softenedQuestionIds, setSoftenedQuestionIds] = useState> (new Set ()) + const [questionBank, setQuestionBank] = useState ([]) + const [search, setSearch] = useState ('') + const [saved, setSaved] = useState (false) + const [resultWon, setResultWon] = useState (null) + const [rejectedPostIds, setRejectedPostIds] = useState> (new Set ()) + const [lastGuessQuestionCount, setLastGuessQuestionCount] = useState (0) + const [lastRejectedGuessId, setLastRejectedGuessId] = useState (null) + const [activeGuessId, setActiveGuessId] = useState (null) + const [history, setHistory] = useState ([]) + + const { data: posts = [], isLoading, error } = useQuery ({ + queryKey: gekanatorKeys.posts (), + queryFn: fetchGekanatorPosts }) + + const candidatePosts = useMemo ( + () => posts.filter (post => candidateIds === null || candidateIds.has (post.id)), + [posts, candidateIds]) + const eligiblePosts = useMemo ( + () => candidatePosts.filter (post => !(rejectedPostIds.has (post.id))), + [candidatePosts, rejectedPostIds]) + const questions = useMemo ( + () => buildGekanatorQuestions (eligiblePosts.length > 1 ? eligiblePosts : posts), + [eligiblePosts, posts]) + const scoringQuestions = useMemo (() => { + return mergeQuestions ([...questions, ...questionBank]) + }, [questions, questionBank]) + 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: eligiblePosts, questions: scoringQuestions, scores, askedIds }) + const answerPreviews = useMemo ( + () => currentQuestion + ? answerOptions.map (option => previewAnswer ({ + posts: eligiblePosts, + scores, + question: currentQuestion, + answer: option.value })) + : [], + [currentQuestion, eligiblePosts, scores]) + const guess = bestPost (eligiblePosts, scores) + const displayedGuess = + posts.find (post => post.id === activeGuessId) ?? guess + const saveMutation = useMutation ({ mutationFn: saveGekanatorGame }) + + const reset = () => { + setPhase ('intro') + setScores (new Map ()) + setAnswers ([]) + setAskedIds (new Set ()) + setCandidateIds (null) + setSoftenedQuestionIds (new Set ()) + setQuestionBank ([]) + setSearch ('') + setSaved (false) + setResultWon (null) + setRejectedPostIds (new Set ()) + setLastGuessQuestionCount (0) + setLastRejectedGuessId (null) + setActiveGuessId (null) + setHistory ([]) + } + + 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), + candidateIds: candidateIds === null ? null : new Set (candidateIds), + softenedQuestionIds: new Set (softenedQuestionIds), + questionBank: [...questionBank], + search, + rejectedPostIds: new Set (rejectedPostIds), + lastGuessQuestionCount, + lastRejectedGuessId, + activeGuessId }]) + const nextAnswers = [...answers, { + questionId: currentQuestion.id, + questionText: currentQuestion.text, + answer: value }] + const nextAskedIds = new Set ([...askedIds, currentQuestion.id]) + const nextQuestionBank = [ + ...questionBank.filter (question => question.id !== currentQuestion.id), + currentQuestion] + const hardFilteredPosts = + value === 'yes' + ? eligiblePosts.filter (post => currentQuestion.test (post)) + : value === 'no' + ? eligiblePosts.filter (post => !(currentQuestion.test (post))) + : eligiblePosts + let nextCandidateIds = + (value === 'yes' || value === 'no') && hardFilteredPosts.length > 0 + ? new Set (hardFilteredPosts.map (post => post.id)) + : candidateIds + let nextSoftenedQuestionIds = new Set (softenedQuestionIds) + let nextScores = recalculateScores ({ + posts, + questions: nextQuestionBank, + answers: nextAnswers, + softenedQuestionIds: nextSoftenedQuestionIds }) + + let nextEligiblePosts = + posts.filter (post => + (nextCandidateIds === null || nextCandidateIds.has (post.id)) + && !(rejectedPostIds.has (post.id))) + let nextScoringQuestions = mergeQuestions ([ + ...buildGekanatorQuestions (nextEligiblePosts.length > 1 ? nextEligiblePosts : posts), + ...nextQuestionBank]) + while ( + nextAnswers.length < hardMaxQuestions + && nextEligiblePosts.length > 1 + && !(chooseQuestion ({ + posts: nextEligiblePosts, + questions: nextScoringQuestions, + scores: nextScores, + askedIds: nextAskedIds })) + ) + { + const softened = softenNextQuestionIds ({ + questions: nextQuestionBank, + answers: nextAnswers, + softenedQuestionIds: nextSoftenedQuestionIds }) + if (!(softened)) + break + + nextSoftenedQuestionIds = softened + nextCandidateIds = null + nextEligiblePosts = posts.filter (post => !(rejectedPostIds.has (post.id))) + nextScoringQuestions = mergeQuestions ([ + ...buildGekanatorQuestions (nextEligiblePosts), + ...nextQuestionBank]) + nextScores = recalculateScores ({ + posts, + questions: nextQuestionBank, + answers: nextAnswers, + softenedQuestionIds: nextSoftenedQuestionIds }) + } + + setScores (nextScores) + setAskedIds (nextAskedIds) + setCandidateIds (nextCandidateIds) + setSoftenedQuestionIds (nextSoftenedQuestionIds) + setQuestionBank (nextQuestionBank) + setAnswers (nextAnswers) + + const nextGuess = bestPost (nextEligiblePosts, nextScores) + const nextQuestionCount = answers.length + 1 + const definitelyKnown = nextEligiblePosts.length === 1 + const enoughQuestions = + nextQuestionCount - lastGuessQuestionCount >= questionsBetweenGuesses + const shouldGuess = + nextQuestionCount >= hardMaxQuestions + || definitelyKnown + || enoughQuestions + if (shouldGuess) + { + setActiveGuessId (nextGuess?.id ?? null) + setLastGuessQuestionCount (nextQuestionCount) + setPhase ('guess') + } + } + + const saveResult = (won: boolean, correctPostId: number | null) => { + const guessedPostId = + won ? displayedGuess?.id : lastRejectedGuessId ?? displayedGuess?.id + if (!(guessedPostId) || saved) + return + + setSaved (true) + setResultWon (won) + saveMutation.mutate ({ + guessedPostId, + correctPostId, + won, + answers }) + setPhase ('learned') + } + + const rejectGuess = () => { + if (!(displayedGuess)) + return + + setLastRejectedGuessId (displayedGuess.id) + if (answers.length >= hardMaxQuestions) + { + setSearch (' ') + return + } + + setRejectedPostIds (new Set ([...rejectedPostIds, displayedGuess.id])) + setActiveGuessId (null) + setSearch ('') + 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) + setCandidateIds (snapshot.candidateIds) + setSoftenedQuestionIds (snapshot.softenedQuestionIds) + setQuestionBank (snapshot.questionBank) + setSearch (snapshot.search) + setRejectedPostIds (snapshot.rejectedPostIds) + setLastGuessQuestionCount (snapshot.lastGuessQuestionCount) + setLastRejectedGuessId (snapshot.lastRejectedGuessId) + setActiveGuessId (snapshot.activeGuessId) + setHistory (history.slice (0, -1)) + } + + const softenAndContinue = () => { + const softened = softenNextQuestionIds ({ + questions: scoringQuestions, answers, softenedQuestionIds }) + if (!(softened)) + return + + setSoftenedQuestionIds (softened) + setCandidateIds (null) + setScores ( + recalculateScores ({ posts, + questions: scoringQuestions, + answers, + softenedQuestionIds: softened })) + } + + 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 dialogue = + phase === 'learned' && resultWon + ? <>グカカカカwwwww 洗澡鹿シーザオグカは何でもお見通し! + : <>私は洗澡鹿シーザオグカ.質問から投稿を何でも当ててみせるよ + + return ( + + + {`グカネータ | ${ SITE_TITLE }`} + + +
+
+

おたのしみ

+

+ グカネータ +

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

+ {dialogue} +

+ + {isLoading &&

投稿を読み込んでゐます...

} + {Boolean (error) &&

投稿を読み込めませんでした.

} + + {phase === 'intro' && !(isLoading) && posts.length > 0 && ( + )} + + {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 && ( + )} +
+
)} + + {phase === 'question' && !(currentQuestion) && ( +
+

+ さっきまでの答へを少し疑って考へ直すよ. +

+ {answers.length >= hardMaxQuestions || eligiblePosts.length <= 1 + ? ( + ) + : ( + )} +
)} + + {phase === 'guess' && displayedGuess && ( +
+

これを想像してゐたね?

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

続けますか?

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

覚えたよ.次はもっと見通す.

+ {saveMutation.isError && ( +

+ ただし学習ログの保存には失敗しました. +

)} + +
)} +
+
+
+ + {['guess', 'continue', 'question'].includes (phase) && search !== '' && ( +
+ +
+ {filteredPosts.map (post => ( + ))} + {search.trim () && filteredPosts.length === 0 && '見つかりません.'} +
+
)} +
+
) +} + + +export default GekanatorPage -- 2.34.1 From de21141f5a02bdec85a176775858fd33bfb47c03 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Mon, 8 Jun 2026 08:41:52 +0900 Subject: [PATCH 02/13] #41 --- .../controllers/gekanator_games_controller.rb | 2 +- backend/app/models/gekanator_game.rb | 10 +- backend/spec/requests/gekanator_games_spec.rb | 14 +- frontend/src/lib/gekanator.ts | 108 ++-- frontend/src/pages/GekanatorPage.tsx | 574 +++++++++++++----- 5 files changed, 468 insertions(+), 240 deletions(-) diff --git a/backend/app/controllers/gekanator_games_controller.rb b/backend/app/controllers/gekanator_games_controller.rb index 5e2e1d6..abc9c16 100644 --- a/backend/app/controllers/gekanator_games_controller.rb +++ b/backend/app/controllers/gekanator_games_controller.rb @@ -8,7 +8,7 @@ class GekanatorGamesController < ApplicationController user: current_user, guessed_post_id: params.require(:guessed_post_id), correct_post_id: params[:correct_post_id].presence, - won: bool?(:won), + won: params[:guessed_post_id].to_i == params[:correct_post_id].to_i, question_count: params.require(:question_count), answers:) diff --git a/backend/app/models/gekanator_game.rb b/backend/app/models/gekanator_game.rb index 5102a33..dad5819 100644 --- a/backend/app/models/gekanator_game.rb +++ b/backend/app/models/gekanator_game.rb @@ -4,15 +4,7 @@ class GekanatorGame < ApplicationRecord belongs_to :correct_post, class_name: 'Post', optional: true validates :answers, presence: true + validates :correct_post, presence: true validates :question_count, numericality: { greater_than_or_equal_to: 0 } validates :won, inclusion: { in: [true, false] } - validate :correct_post_required_when_lost - - private - - def correct_post_required_when_lost - return if won || correct_post_id.present? - - errors.add(:correct_post_id, '外れた時は正解の投稿を指定してくださぃ.') - end end diff --git a/backend/spec/requests/gekanator_games_spec.rb b/backend/spec/requests/gekanator_games_spec.rb index 117269d..4db3d69 100644 --- a/backend/spec/requests/gekanator_games_spec.rb +++ b/backend/spec/requests/gekanator_games_spec.rb @@ -11,7 +11,7 @@ RSpec.describe 'Gekanator games API', type: :request do post '/gekanator/games', params: { guessed_post_id: guessed_post.id, - won: true, + correct_post_id: guessed_post.id, question_count: 3, answers: [{ question_id: 'tag:1', answer: 'yes' }] } @@ -19,7 +19,7 @@ RSpec.describe 'Gekanator games API', type: :request do game = GekanatorGame.find(json['id']) expect(game.user).to eq(user) expect(game.guessed_post).to eq(guessed_post) - expect(game.correct_post).to be_nil + expect(game.correct_post).to eq(guessed_post) expect(game.won).to eq(true) expect(game.answers).to eq([{ 'question_id' => 'tag:1', 'answer' => 'yes' }]) end @@ -30,20 +30,20 @@ RSpec.describe 'Gekanator games API', type: :request do post '/gekanator/games', params: { guessed_post_id: guessed_post.id, correct_post_id: correct_post.id, - won: false, question_count: 4, answers: [{ question_id: 'tag:1', answer: 'no' }] } expect(response).to have_http_status(:created) - expect(GekanatorGame.find(json['id']).correct_post).to eq(correct_post) + game = GekanatorGame.find(json['id']) + expect(game.correct_post).to eq(correct_post) + expect(game.won).to eq(false) end - it 'rejects a lost game without the correct post' do + it 'rejects a game without the correct post' do sign_in_as user post '/gekanator/games', params: { guessed_post_id: guessed_post.id, - won: false, question_count: 4, answers: [{ question_id: 'tag:1', answer: 'no' }] } @@ -53,7 +53,7 @@ RSpec.describe 'Gekanator games API', type: :request do it 'requires a user' do post '/gekanator/games', params: { guessed_post_id: guessed_post.id, - won: true, + correct_post_id: guessed_post.id, question_count: 1, answers: [] } diff --git a/frontend/src/lib/gekanator.ts b/frontend/src/lib/gekanator.ts index 8ce3e67..8e64139 100644 --- a/frontend/src/lib/gekanator.ts +++ b/frontend/src/lib/gekanator.ts @@ -17,11 +17,9 @@ export type GekanatorAnswerLog = { export type GekanatorQuestionKind = | 'tag' - | 'title' - | 'date' - | 'media' | 'source' - | 'structure' + | 'title' + | 'original_date' export type GekanatorQuestion = { id: string @@ -54,6 +52,19 @@ const hostOf = (post: Post): string | 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 tagQuestionKey = ({ category, name }: { category: string; name: string }): string => `${ category }:${ name }` @@ -113,9 +124,11 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => { && !(tag.name.includes ('bot操作'))) .map (tag => tagQuestionKey (tag)))) const hosts = countBy (posts.map (hostOf).filter ((host): host is string => Boolean (host))) - const tagMedian = median (posts.map (post => post.tags.length)) + const originalYears = countBy ( + posts + .map (originalYearOf) + .filter ((year): year is number => year !== null)) const titleLengthMedian = median (posts.map (post => post.title?.length ?? 0)) - const currentYear = new Date ().getFullYear () const usefulEntries = (counts: Map) => [...counts.entries ()] @@ -144,63 +157,41 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => { .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, - test: (post: Post) => hostOf (post) === host })) + id: `source:${ host }`, + text: `${ host } の投稿を思ひ浮かべてゐる?`, + kind: 'source' as const, + test: (post: Post) => hostOf (post) === host })) - return [ - ...sourceQuestions, - { - id: 'title:present', - text: '題名が付いてゐる投稿?', - kind: 'title', - test: post => Boolean (post.title) }, + 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, + test: (post: Post) => originalYearOf (post) === year })) + + const titleQuestions = [ { id: 'title:long', text: '題名が長めの投稿?', - kind: 'title', - test: post => (post.title?.length ?? 0) > titleLengthMedian }, + kind: 'title' as const, + test: (post: Post) => (post.title?.length ?? 0) > titleLengthMedian }, { id: 'title:ascii', text: '題名に英数字が混じってゐる?', - kind: 'title', - test: post => /[A-Za-z0-9]/.test (post.title ?? '') }, - { - id: 'media:thumbnail', - text: 'ぱっと見でサムネが付いてゐる投稿?', - kind: 'media', - test: post => Boolean (post.thumbnail || post.thumbnailBase) }, - { - id: 'media:video-source', - text: '動画として見られる投稿?', - kind: 'media', - test: post => /nicovideo|youtube|youtu\.be/.test (post.url) }, - { - id: 'structure:many-tags', - text: 'タグが多めに付いてゐる投稿?', - kind: 'structure', - test: post => post.tags.length > tagMedian }, - { - id: 'structure:no-title', - text: '題名がまだ付いてゐない投稿?', - kind: 'structure', - test: post => !(post.title) }, - { - id: 'date:recent', - text: '最近追加されたほうの投稿?', - kind: 'date', - test: post => new Date (post.createdAt).getFullYear () >= currentYear - 1 }, - { - id: 'date:old', - text: 'むかし追加されたほうの投稿?', - kind: 'date', - test: post => new Date (post.createdAt).getFullYear () <= currentYear - 3 }, - { - id: 'date:original-known', - text: 'オリジナルの投稿日時が分かってゐる投稿?', - kind: 'date', - test: post => Boolean (post.originalCreatedFrom || post.originalCreatedBefore) }, + kind: 'title' as const, + 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, + ...titleQuestions, ...tagQuestions] } @@ -208,18 +199,15 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => { export const saveGekanatorGame = async ({ guessedPostId, correctPostId, - won, answers, }: { guessedPostId: number - correctPostId: number | null - won: boolean + correctPostId: number answers: GekanatorAnswerLog[] }): Promise<{ id: number }> => await apiPost ('/gekanator/games', { guessed_post_id: guessedPostId, correct_post_id: correctPostId, - won, question_count: answers.length, answers: answers.map (answer => ({ question_id: answer.questionId, diff --git a/frontend/src/pages/GekanatorPage.tsx b/frontend/src/pages/GekanatorPage.tsx index 5fb059a..b72ab99 100644 --- a/frontend/src/pages/GekanatorPage.tsx +++ b/frontend/src/pages/GekanatorPage.tsx @@ -18,7 +18,7 @@ import type { GekanatorAnswerLog, GekanatorQuestion } from '@/lib/gekanator' import type { Post } from '@/types' -type Phase = 'intro' | 'question' | 'guess' | 'continue' | 'learned' +type Phase = 'intro' | 'question' | 'guess' | 'continue' | 'review' | 'learned' type AnswerOption = { label: string @@ -41,14 +41,16 @@ type GameSnapshot = { scores: Map answers: GekanatorAnswerLog[] askedIds: Set - candidateIds: Set | null softenedQuestionIds: Set - questionBank: GekanatorQuestion[] + askedQuestionBank: GekanatorQuestion[] search: string + selectingCorrectPost: boolean rejectedPostIds: Set lastGuessQuestionCount: number lastRejectedGuessId: number | null - activeGuessId: number | null } + activeGuessId: number | null + reviewGuessedPostId: number | null + reviewCorrectPostId: number | null } const answerOptions: AnswerOption[] = [ { label: 'はい', value: 'yes' }, @@ -58,6 +60,9 @@ const answerOptions: AnswerOption[] = [ { label: 'わからない', value: 'unknown' }] const questionsBetweenGuesses = 25 +const minQuestionsBeforeCertainGuess = 5 +const certainGuessPercent = 99.5 +const runnerUpMaxPercent = .5 const hardMaxQuestions = 80 const softenedAnswerWeight = .35 const confidenceTemperature = 6 @@ -87,18 +92,14 @@ const answerWeightFor = ( const questionDifficulty = (question: GekanatorQuestion): number => { - if (question.id === 'structure:many-tags') - return 6 - if (question.id.startsWith ('date:')) - return 5 - if (question.id === 'title:long' || question.id === 'title:ascii') - return 4 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 - if (question.kind === 'title' || question.kind === 'structure') - return 2 return 1 } @@ -136,6 +137,47 @@ const recalculateScores = ({ } +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': + return question.test (post) + case 'no': + return !(question.test (post)) + default: + return true + } + }) + }) +} + + const confidencesFor = (posts: Post[], scores: Map): Confidence[] => { if (posts.length === 0) return [] @@ -306,26 +348,12 @@ const chooseQuestion = ({ return null const splitScore = Math.abs (candidates.length / 2 - yes) - const answerPreviews = answerOptions.map (option => - previewAnswer ({ - posts: candidates.map (({ post }) => post), - scores, - question, - answer: option.value })) - const expectedEntropy = - answerPreviews.reduce ((sum, preview) => sum + preview.entropy, 0) - / answerPreviews.length - const expectedCandidateCount = - answerPreviews.reduce ((sum, preview) => sum + preview.candidateCount, 0) - / answerPreviews.length - const kindPenalty = askedIds.has (question.kind) ? 2 : 0 - const tagPenalty = question.kind === 'tag' ? 0 : 10 + const tagPenalty = question.kind === 'tag' ? 0 : 20 const minSide = candidates.length < 10 ? 1 : Math.max (3, candidates.length * .08) const narrowPenalty = yes < minSide || no < minSide ? candidates.length : 0 return { question, - score: splitScore + expectedEntropy + expectedCandidateCount / 8 - + kindPenalty + tagPenalty + narrowPenalty, + score: splitScore + tagPenalty + narrowPenalty, narrow: narrowPenalty > 0 } }) .filter ((item): item is { @@ -373,34 +401,47 @@ const GekanatorPage: FC = () => { const [scores, setScores] = useState> (new Map ()) const [answers, setAnswers] = useState ([]) const [askedIds, setAskedIds] = useState> (new Set ()) - const [candidateIds, setCandidateIds] = useState | null> (null) const [softenedQuestionIds, setSoftenedQuestionIds] = useState> (new Set ()) - const [questionBank, setQuestionBank] = useState ([]) + const [askedQuestionBank, setAskedQuestionBank] = useState ([]) const [search, setSearch] = useState ('') + const [selectingCorrectPost, setSelectingCorrectPost] = useState (false) const [saved, setSaved] = useState (false) const [resultWon, setResultWon] = useState (null) const [rejectedPostIds, setRejectedPostIds] = useState> (new Set ()) const [lastGuessQuestionCount, setLastGuessQuestionCount] = useState (0) const [lastRejectedGuessId, setLastRejectedGuessId] = useState (null) const [activeGuessId, setActiveGuessId] = useState (null) + const [reviewGuessedPostId, setReviewGuessedPostId] = useState (null) + const [reviewCorrectPostId, setReviewCorrectPostId] = useState (null) const [history, setHistory] = useState ([]) const { data: posts = [], isLoading, error } = useQuery ({ queryKey: gekanatorKeys.posts (), queryFn: fetchGekanatorPosts }) - const candidatePosts = useMemo ( - () => posts.filter (post => candidateIds === null || candidateIds.has (post.id)), - [posts, candidateIds]) const eligiblePosts = useMemo ( - () => candidatePosts.filter (post => !(rejectedPostIds.has (post.id))), - [candidatePosts, rejectedPostIds]) + () => candidatePostsFor ({ + posts, + questions: askedQuestionBank, + answers, + softenedQuestionIds, + rejectedPostIds }), + [posts, askedQuestionBank, answers, softenedQuestionIds, rejectedPostIds]) const questions = useMemo ( () => buildGekanatorQuestions (eligiblePosts.length > 1 ? eligiblePosts : posts), [eligiblePosts, posts]) const scoringQuestions = useMemo (() => { - return mergeQuestions ([...questions, ...questionBank]) - }, [questions, questionBank]) + return mergeQuestions ([...questions, ...askedQuestionBank]) + }, [questions, askedQuestionBank]) + 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 })) @@ -408,7 +449,7 @@ const GekanatorPage: FC = () => { .slice (0, 3), [eligiblePosts, scores]) const currentQuestion = chooseQuestion ({ - posts: eligiblePosts, questions: scoringQuestions, scores, askedIds }) + posts: questionPosts, questions: scoringQuestions, scores, askedIds }) const answerPreviews = useMemo ( () => currentQuestion ? answerOptions.map (option => previewAnswer ({ @@ -418,29 +459,122 @@ const GekanatorPage: FC = () => { answer: option.value })) : [], [currentQuestion, eligiblePosts, scores]) - const guess = bestPost (eligiblePosts, scores) + const guessablePosts = + eligiblePosts.length > 0 + ? eligiblePosts + : nonRejectedPosts + const guess = bestPost (guessablePosts, scores) const displayedGuess = posts.find (post => post.id === activeGuessId) ?? guess - const saveMutation = useMutation ({ mutationFn: saveGekanatorGame }) + const reviewGuessedPost = + posts.find (post => post.id === reviewGuessedPostId) ?? null + const reviewCorrectPost = + posts.find (post => post.id === reviewCorrectPostId) ?? null + const saveMutation = useMutation ({ + mutationFn: saveGekanatorGame, + onSuccess: () => { + setSaved (true) + setResultWon (reviewGuessedPostId === reviewCorrectPostId) + setPhase ('learned') + }}) const reset = () => { + saveMutation.reset () setPhase ('intro') setScores (new Map ()) setAnswers ([]) setAskedIds (new Set ()) - setCandidateIds (null) setSoftenedQuestionIds (new Set ()) - setQuestionBank ([]) + setAskedQuestionBank ([]) setSearch ('') + setSelectingCorrectPost (false) setSaved (false) setResultWon (null) setRejectedPostIds (new Set ()) setLastGuessQuestionCount (0) setLastRejectedGuessId (null) setActiveGuessId (null) + setReviewGuessedPostId (null) + setReviewCorrectPostId (null) 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), + ...nextAskedQuestionBank]) + + while ( + recoveredEligiblePosts.length === 0 + || ( + recoveredEligiblePosts.length !== 1 + && !(chooseQuestion ({ + posts: recoveredEligiblePosts, + questions: recoveredScoringQuestions, + scores: recoveredScores, + askedIds: nextAskedIds }))) + ) + { + 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), + ...nextAskedQuestionBank]) + } + + return { + softenedQuestionIds: recoveredSoftenedQuestionIds, + scores: recoveredScores, + eligiblePosts: recoveredEligiblePosts, + scoringQuestions: recoveredScoringQuestions } + } + const answer = (value: GekanatorAnswerValue) => { if (!(currentQuestion)) { @@ -454,92 +588,66 @@ const GekanatorPage: FC = () => { scores: new Map (scores), answers: [...answers], askedIds: new Set (askedIds), - candidateIds: candidateIds === null ? null : new Set (candidateIds), softenedQuestionIds: new Set (softenedQuestionIds), - questionBank: [...questionBank], + askedQuestionBank: [...askedQuestionBank], search, + selectingCorrectPost, rejectedPostIds: new Set (rejectedPostIds), lastGuessQuestionCount, lastRejectedGuessId, - activeGuessId }]) + activeGuessId, + reviewGuessedPostId, + reviewCorrectPostId }]) const nextAnswers = [...answers, { questionId: currentQuestion.id, questionText: currentQuestion.text, answer: value }] const nextAskedIds = new Set ([...askedIds, currentQuestion.id]) - const nextQuestionBank = [ - ...questionBank.filter (question => question.id !== currentQuestion.id), + const nextAskedQuestionBank = [ + ...askedQuestionBank.filter (question => question.id !== currentQuestion.id), currentQuestion] - const hardFilteredPosts = - value === 'yes' - ? eligiblePosts.filter (post => currentQuestion.test (post)) - : value === 'no' - ? eligiblePosts.filter (post => !(currentQuestion.test (post))) - : eligiblePosts - let nextCandidateIds = - (value === 'yes' || value === 'no') && hardFilteredPosts.length > 0 - ? new Set (hardFilteredPosts.map (post => post.id)) - : candidateIds - let nextSoftenedQuestionIds = new Set (softenedQuestionIds) - let nextScores = recalculateScores ({ - posts, - questions: nextQuestionBank, - answers: nextAnswers, - softenedQuestionIds: nextSoftenedQuestionIds }) - - let nextEligiblePosts = - posts.filter (post => - (nextCandidateIds === null || nextCandidateIds.has (post.id)) - && !(rejectedPostIds.has (post.id))) - let nextScoringQuestions = mergeQuestions ([ - ...buildGekanatorQuestions (nextEligiblePosts.length > 1 ? nextEligiblePosts : posts), - ...nextQuestionBank]) - while ( - nextAnswers.length < hardMaxQuestions - && nextEligiblePosts.length > 1 - && !(chooseQuestion ({ - posts: nextEligiblePosts, - questions: nextScoringQuestions, - scores: nextScores, - askedIds: nextAskedIds })) - ) - { - const softened = softenNextQuestionIds ({ - questions: nextQuestionBank, - answers: nextAnswers, - softenedQuestionIds: nextSoftenedQuestionIds }) - if (!(softened)) - break - - nextSoftenedQuestionIds = softened - nextCandidateIds = null - nextEligiblePosts = posts.filter (post => !(rejectedPostIds.has (post.id))) - nextScoringQuestions = mergeQuestions ([ - ...buildGekanatorQuestions (nextEligiblePosts), - ...nextQuestionBank]) - nextScores = recalculateScores ({ - posts, - questions: nextQuestionBank, - answers: nextAnswers, - softenedQuestionIds: nextSoftenedQuestionIds }) - } + 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) - setCandidateIds (nextCandidateIds) setSoftenedQuestionIds (nextSoftenedQuestionIds) - setQuestionBank (nextQuestionBank) + setAskedQuestionBank (nextAskedQuestionBank) setAnswers (nextAnswers) - const nextGuess = bestPost (nextEligiblePosts, nextScores) + const nextGuessablePosts = + nextEligiblePosts.length > 0 + ? nextEligiblePosts + : nonRejectedPosts + const nextGuess = bestPost (nextGuessablePosts, nextScores) const nextQuestionCount = answers.length + 1 - const definitelyKnown = nextEligiblePosts.length === 1 - const enoughQuestions = - nextQuestionCount - lastGuessQuestionCount >= questionsBetweenGuesses + 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 - || definitelyKnown - || enoughQuestions + || canGuessByQuestionCount + || canGuessEarlyByConfidence if (shouldGuess) { setActiveGuessId (nextGuess?.id ?? null) @@ -548,20 +656,35 @@ const GekanatorPage: FC = () => { } } - const saveResult = (won: boolean, correctPostId: number | null) => { + const startReview = (correctPostId: number) => { const guessedPostId = - won ? displayedGuess?.id : lastRejectedGuessId ?? displayedGuess?.id - if (!(guessedPostId) || saved) + phase === 'continue' + ? lastRejectedGuessId ?? displayedGuess?.id + : displayedGuess?.id ?? lastRejectedGuessId + if (!(guessedPostId)) + return + + saveMutation.reset () + setReviewGuessedPostId (guessedPostId) + setReviewCorrectPostId (correctPostId) + setSearch ('') + setSelectingCorrectPost (false) + setPhase ('review') + } + + const saveReviewedResult = () => { + if ( + reviewGuessedPostId === null + || reviewCorrectPostId === null + || saveMutation.isPending + || saved + ) return - setSaved (true) - setResultWon (won) saveMutation.mutate ({ - guessedPostId, - correctPostId, - won, + guessedPostId: reviewGuessedPostId, + correctPostId: reviewCorrectPostId, answers }) - setPhase ('learned') } const rejectGuess = () => { @@ -571,13 +694,14 @@ const GekanatorPage: FC = () => { setLastRejectedGuessId (displayedGuess.id) if (answers.length >= hardMaxQuestions) { - setSearch (' ') + setSelectingCorrectPost (true) return } setRejectedPostIds (new Set ([...rejectedPostIds, displayedGuess.id])) setActiveGuessId (null) setSearch ('') + setSelectingCorrectPost (false) setLastGuessQuestionCount (answers.length) setPhase ('continue') } @@ -591,30 +715,66 @@ const GekanatorPage: FC = () => { setScores (snapshot.scores) setAnswers (snapshot.answers) setAskedIds (snapshot.askedIds) - setCandidateIds (snapshot.candidateIds) setSoftenedQuestionIds (snapshot.softenedQuestionIds) - setQuestionBank (snapshot.questionBank) + 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 softenAndContinue = () => { - const softened = softenNextQuestionIds ({ - questions: scoringQuestions, answers, softenedQuestionIds }) - if (!(softened)) - return + const continueGame = () => { + setSearch ('') + setSelectingCorrectPost (false) - setSoftenedQuestionIds (softened) - setCandidateIds (null) - setScores ( - recalculateScores ({ posts, - questions: scoringQuestions, - answers, - softenedQuestionIds: softened })) + 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, + askedIds }) + + if (nextQuestion) + { + setPhase ('question') + return + } + + setActiveGuessId (guess?.id ?? null) + setPhase ('guess') + } + + const correctAnswerAt = (index: number, value: GekanatorAnswerValue) => { + setAnswers (answers.map ((answer, i) => + i === index ? { ...answer, answer: value } : answer)) + } + + const selectCorrectPost = (post: Post) => { + if (phase === 'review') + { + setReviewCorrectPostId (post.id) + setSelectingCorrectPost (false) + setSearch ('') + return + } + + startReview (post.id) } const filteredPosts = posts @@ -646,6 +806,7 @@ const GekanatorPage: FC = () => { return ( + {`グカネータ | ${ SITE_TITLE }`} @@ -745,29 +906,19 @@ const GekanatorPage: FC = () => { {phase === 'question' && !(currentQuestion) && (

- さっきまでの答へを少し疑って考へ直すよ. + もう十分わかった。

- {answers.length >= hardMaxQuestions || eligiblePosts.length <= 1 - ? ( - ) - : ( - )} +
)} {phase === 'guess' && displayedGuess && ( @@ -779,7 +930,10 @@ const GekanatorPage: FC = () => { type="button" className="rounded bg-pink-600 px-4 py-2 font-bold text-white hover:bg-pink-500" - onClick={() => saveResult (true, null)}> + onClick={() => { + if (displayedGuess) + startReview (displayedGuess.id) + }}> 当たり )} + {saveMutation.isError && ( +

+ 学習ログの保存に失敗しました。もう一度試せます。 +

)} )} {phase === 'continue' && ( @@ -810,7 +968,7 @@ const GekanatorPage: FC = () => { type="button" className="rounded bg-pink-600 px-4 py-2 font-bold text-white hover:bg-pink-500" - onClick={() => setPhase ('question')}> + onClick={continueGame}> はい {history.length > 0 && ( @@ -833,13 +991,98 @@ const GekanatorPage: FC = () => { )} + {phase === 'review' && ( +
+
+

保存前確認

+

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

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

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

} + +
+ +
+
質問と回答
+
+ {answers.map ((answer, index) => ( +
+
+ 質問 {index + 1} +
+
{answer.questionText}
+ +
))} +
+
+ + {reviewGuessedPostId !== null && reviewCorrectPostId !== null && ( +

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

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

+ 学習ログの保存に失敗しました。もう一度試せます。 +

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

覚えたよ.次はもっと見通す.

- {saveMutation.isError && ( -

- ただし学習ログの保存には失敗しました. -

)} ))} {search.trim () && filteredPosts.length === 0 && '見つかりません.'} + {saveMutation.isError && ( +

+ 学習ログの保存に失敗しました。もう一度試せます。 +

)}
)} -- 2.34.1 From fb2b2a632cc5cfdd4d4dbd309981718fac867375 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Mon, 8 Jun 2026 08:45:52 +0900 Subject: [PATCH 03/13] #41 --- .../controllers/gekanator_games_controller.rb | 16 +++++++++++----- backend/app/models/gekanator_game.rb | 5 ++--- ...uire_gekanator_game_user_and_correct_post.rb | 17 +++++++++++++++++ backend/db/schema.rb | 6 +++--- 4 files changed, 33 insertions(+), 11 deletions(-) create mode 100644 backend/db/migrate/20260608000000_require_gekanator_game_user_and_correct_post.rb diff --git a/backend/app/controllers/gekanator_games_controller.rb b/backend/app/controllers/gekanator_games_controller.rb index abc9c16..9dba4a3 100644 --- a/backend/app/controllers/gekanator_games_controller.rb +++ b/backend/app/controllers/gekanator_games_controller.rb @@ -2,16 +2,22 @@ class GekanatorGamesController < ApplicationController def create return head :unauthorized unless current_user + guessed_post_id = params.require(:guessed_post_id) + correct_post_id = params[:correct_post_id].presence answers = params.require(:answers).as_json - game = GekanatorGame.create!( + game = GekanatorGame.new( user: current_user, - guessed_post_id: params.require(:guessed_post_id), - correct_post_id: params[:correct_post_id].presence, - won: params[:guessed_post_id].to_i == params[:correct_post_id].to_i, + guessed_post_id:, + correct_post_id:, + won: correct_post_id.present? && guessed_post_id.to_i == correct_post_id.to_i, question_count: params.require(:question_count), answers:) - render json: { id: game.id }, status: :created + if game.save + render json: { id: game.id }, status: :created + else + render json: { errors: game.errors.full_messages }, status: :unprocessable_entity + end end end diff --git a/backend/app/models/gekanator_game.rb b/backend/app/models/gekanator_game.rb index dad5819..2808be4 100644 --- a/backend/app/models/gekanator_game.rb +++ b/backend/app/models/gekanator_game.rb @@ -1,10 +1,9 @@ class GekanatorGame < ApplicationRecord - belongs_to :user, optional: true + belongs_to :user belongs_to :guessed_post, class_name: 'Post' - belongs_to :correct_post, class_name: 'Post', optional: true + belongs_to :correct_post, class_name: 'Post' validates :answers, presence: true - validates :correct_post, presence: true validates :question_count, numericality: { greater_than_or_equal_to: 0 } validates :won, inclusion: { in: [true, false] } end diff --git a/backend/db/migrate/20260608000000_require_gekanator_game_user_and_correct_post.rb b/backend/db/migrate/20260608000000_require_gekanator_game_user_and_correct_post.rb new file mode 100644 index 0000000..8071fab --- /dev/null +++ b/backend/db/migrate/20260608000000_require_gekanator_game_user_and_correct_post.rb @@ -0,0 +1,17 @@ +class RequireGekanatorGameUserAndCorrectPost < ActiveRecord::Migration[8.0] + def up + execute <<~SQL.squish + UPDATE gekanator_games + SET correct_post_id = guessed_post_id + WHERE correct_post_id IS NULL + SQL + + change_column_null :gekanator_games, :user_id, false + change_column_null :gekanator_games, :correct_post_id, false + end + + def down + change_column_null :gekanator_games, :correct_post_id, true + change_column_null :gekanator_games, :user_id, true + end +end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index d08bd31..3ef8127 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_07_000000) do +ActiveRecord::Schema[8.0].define(version: 2026_06_08_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 @@ -49,9 +49,9 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_07_000000) do end create_table "gekanator_games", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.bigint "user_id" + t.bigint "user_id", null: false t.bigint "guessed_post_id", null: false - t.bigint "correct_post_id" + t.bigint "correct_post_id", null: false t.boolean "won", null: false t.integer "question_count", null: false t.json "answers", null: false -- 2.34.1 From 543f051f8f9b6e02c21b86c21660a99ea432a4a0 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Mon, 8 Jun 2026 12:44:45 +0900 Subject: [PATCH 04/13] #41 --- AGENTS.md | 18 ++++++++---- backend/AGENTS.md | 15 +++++++--- .../controllers/gekanator_games_controller.rb | 4 +-- .../20260607000000_create_gekanator_games.rb | 4 +-- ...re_gekanator_game_user_and_correct_post.rb | 17 ----------- backend/db/schema.rb | 21 +------------- backend/spec/requests/gekanator_games_spec.rb | 28 +++++++++++++------ frontend/AGENTS.md | 4 +++ frontend/src/App.tsx | 17 +++++++++-- frontend/src/components/TopNav.tsx | 1 - frontend/src/lib/gekanator.ts | 1 - frontend/src/pages/GekanatorPage.tsx | 4 ++- 12 files changed, 70 insertions(+), 64 deletions(-) delete mode 100644 backend/db/migrate/20260608000000_require_gekanator_game_user_and_correct_post.rb 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 index 9dba4a3..affd42e 100644 --- a/backend/app/controllers/gekanator_games_controller.rb +++ b/backend/app/controllers/gekanator_games_controller.rb @@ -1,6 +1,6 @@ class GekanatorGamesController < ApplicationController def create - return head :unauthorized unless current_user + return head :not_found unless current_user&.admin? guessed_post_id = params.require(:guessed_post_id) correct_post_id = params[:correct_post_id].presence @@ -11,7 +11,7 @@ class GekanatorGamesController < ApplicationController guessed_post_id:, correct_post_id:, won: correct_post_id.present? && guessed_post_id.to_i == correct_post_id.to_i, - question_count: params.require(:question_count), + question_count: answers.length, answers:) if game.save diff --git a/backend/db/migrate/20260607000000_create_gekanator_games.rb b/backend/db/migrate/20260607000000_create_gekanator_games.rb index 520559f..a357bf9 100644 --- a/backend/db/migrate/20260607000000_create_gekanator_games.rb +++ b/backend/db/migrate/20260607000000_create_gekanator_games.rb @@ -1,9 +1,9 @@ class CreateGekanatorGames < ActiveRecord::Migration[8.0] def change create_table :gekanator_games do |t| - t.references :user, foreign_key: true + t.references :user, null: false, foreign_key: true t.references :guessed_post, null: false, foreign_key: { to_table: :posts } - t.references :correct_post, 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 diff --git a/backend/db/migrate/20260608000000_require_gekanator_game_user_and_correct_post.rb b/backend/db/migrate/20260608000000_require_gekanator_game_user_and_correct_post.rb deleted file mode 100644 index 8071fab..0000000 --- a/backend/db/migrate/20260608000000_require_gekanator_game_user_and_correct_post.rb +++ /dev/null @@ -1,17 +0,0 @@ -class RequireGekanatorGameUserAndCorrectPost < ActiveRecord::Migration[8.0] - def up - execute <<~SQL.squish - UPDATE gekanator_games - SET correct_post_id = guessed_post_id - WHERE correct_post_id IS NULL - SQL - - change_column_null :gekanator_games, :user_id, false - change_column_null :gekanator_games, :correct_post_id, false - end - - def down - change_column_null :gekanator_games, :correct_post_id, true - change_column_null :gekanator_games, :user_id, true - end -end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index 3ef8127..ee3d86a 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_06_08_000000) do +ActiveRecord::Schema[8.0].define(version: 2026_06_07_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 @@ -152,19 +152,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_08_000000) do t.index ["target_post_id"], name: "index_post_similarities_on_target_post_id" end - create_table "post_tag_sections", primary_key: ["post_id", "tag_id", "begin_ms"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.bigint "post_id", null: false - t.bigint "tag_id", null: false - t.integer "begin_ms", null: false - t.integer "end_ms", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["post_id", "begin_ms"], name: "idx_post_tag_sections_post_id_begin_ms" - t.index ["tag_id"], name: "fk_rails_8be3847903" - t.check_constraint "`begin_ms` < `end_ms`", name: "chk_post_tag_sections_end_ms_after_begin_ms" - t.check_constraint "`begin_ms` >= 0", name: "chk_post_tag_sections_begin_ms_natural" - end - create_table "post_tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "post_id", null: false t.bigint "tag_id", null: false @@ -215,11 +202,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_08_000000) do t.datetime "original_created_before" t.datetime "updated_at", null: false t.integer "version_no", null: false - t.integer "video_ms" t.index ["uploaded_user_id"], name: "index_posts_on_uploaded_user_id" t.index ["url"], name: "index_posts_on_url", unique: true - t.index ["video_ms", "id"], name: "idx_posts_video_ms_id" - t.check_constraint "(`video_ms` is null) or (`video_ms` > 0)", name: "chk_posts_video_ms_positive" t.check_constraint "`version_no` > 0", name: "chk_posts_version_no_positive" end @@ -370,7 +354,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_08_000000) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["expires_at"], name: "index_theatre_watching_users_on_expires_at" - t.index ["theatre_id", "expires_at"], name: "idx_on_theatre_id_skip_expires_at_4c8de1dd42" t.index ["theatre_id", "expires_at"], name: "index_theatre_watching_users_on_theatre_id_and_expires_at" t.index ["theatre_id"], name: "index_theatre_watching_users_on_theatre_id" t.index ["user_id"], name: "index_theatre_watching_users_on_user_id" @@ -530,8 +513,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_08_000000) do add_foreign_key "post_implications", "posts", column: "parent_post_id" add_foreign_key "post_similarities", "posts" add_foreign_key "post_similarities", "posts", column: "target_post_id" - add_foreign_key "post_tag_sections", "posts" - add_foreign_key "post_tag_sections", "tags" add_foreign_key "post_tags", "posts" add_foreign_key "post_tags", "tags" add_foreign_key "post_tags", "users", column: "created_user_id" diff --git a/backend/spec/requests/gekanator_games_spec.rb b/backend/spec/requests/gekanator_games_spec.rb index 4db3d69..79c0f5a 100644 --- a/backend/spec/requests/gekanator_games_spec.rb +++ b/backend/spec/requests/gekanator_games_spec.rb @@ -1,31 +1,32 @@ 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 user + sign_in_as admin post '/gekanator/games', params: { guessed_post_id: guessed_post.id, correct_post_id: guessed_post.id, - question_count: 3, answers: [{ question_id: 'tag:1', answer: 'yes' }] } expect(response).to have_http_status(:created) game = GekanatorGame.find(json['id']) - expect(game.user).to eq(user) + 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 user + sign_in_as admin post '/gekanator/games', params: { guessed_post_id: guessed_post.id, @@ -37,10 +38,11 @@ RSpec.describe 'Gekanator games API', type: :request do 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 user + sign_in_as admin post '/gekanator/games', params: { guessed_post_id: guessed_post.id, @@ -50,14 +52,24 @@ RSpec.describe 'Gekanator games API', type: :request do expect(response).to have_http_status(:unprocessable_entity) end - it 'requires a user' do + 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, - question_count: 1, answers: [] } - expect(response).to have_http_status(:unauthorized) + 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/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 a42f5fe..6809e3a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -40,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' @@ -81,7 +81,10 @@ const RouteTransitionWrapper = ({ user, setUser }: { }/> }/> }/> - }/> + + + }/> }/> }/> @@ -89,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/components/TopNav.tsx b/frontend/src/components/TopNav.tsx index 1f49488..4a7e20b 100644 --- a/frontend/src/components/TopNav.tsx +++ b/frontend/src/components/TopNav.tsx @@ -66,7 +66,6 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: { { name: '履歴', to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg }, { name: '編輯', to: `/wiki/${ wikiId || wikiTitle }/edit`, visible: wikiPageFlg }] }, { name: 'おたのしみ', visible: false, subMenu: [ - { name: 'グカネータ', to: '/gekanator' }, { name: '上映会 (β)', to: '/theatres/1' }] }, { name: 'ユーザ', to: '/users/settings', visible: false, subMenu: [ { name: '一覧', to: '/users', visible: false }, diff --git a/frontend/src/lib/gekanator.ts b/frontend/src/lib/gekanator.ts index 8e64139..eae4623 100644 --- a/frontend/src/lib/gekanator.ts +++ b/frontend/src/lib/gekanator.ts @@ -208,7 +208,6 @@ export const saveGekanatorGame = async ({ await apiPost ('/gekanator/games', { guessed_post_id: guessedPostId, correct_post_id: correctPostId, - question_count: answers.length, answers: answers.map (answer => ({ question_id: answer.questionId, question_text: answer.questionText, diff --git a/frontend/src/pages/GekanatorPage.tsx b/frontend/src/pages/GekanatorPage.tsx index b72ab99..7d2b68f 100644 --- a/frontend/src/pages/GekanatorPage.tsx +++ b/frontend/src/pages/GekanatorPage.tsx @@ -335,6 +335,8 @@ const chooseQuestion = ({ candidates: { post: Post; score: number }[], ) => { const redundant = redundantSignatures (candidates) + const nonTagCount = + questions.filter (question => askedIds.has (question.id) && question.kind !== 'tag').length return questionsToRank .map (question => { @@ -348,7 +350,7 @@ const chooseQuestion = ({ return null const splitScore = Math.abs (candidates.length / 2 - yes) - const tagPenalty = question.kind === 'tag' ? 0 : 20 + 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 ? candidates.length : 0 -- 2.34.1 From 77b5c8f2621fcab1ea0ce1d953329d0c05f768f5 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Mon, 8 Jun 2026 17:47:19 +0900 Subject: [PATCH 05/13] #41 --- frontend/src/pages/GekanatorPage.tsx | 41 ++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/frontend/src/pages/GekanatorPage.tsx b/frontend/src/pages/GekanatorPage.tsx index 7d2b68f..d6f2567 100644 --- a/frontend/src/pages/GekanatorPage.tsx +++ b/frontend/src/pages/GekanatorPage.tsx @@ -306,6 +306,10 @@ const chooseQuestion = ({ const scoredPosts = posts .map (post => ({ post, score: scores.get (post.id) ?? 0 })) .sort ((a, b) => b.score - a.score) + const topScore = scoredPosts[0]?.score ?? 0 + const nearTopCandidates = scoredPosts.filter (item => topScore - item.score <= 2) + const focusCandidates = + nearTopCandidates.length >= 2 && nearTopCandidates.length <= 8 ? nearTopCandidates : [] const signatureFor = ( question: GekanatorQuestion, @@ -333,6 +337,7 @@ const chooseQuestion = ({ const rank = ( questionsToRank: GekanatorQuestion[], candidates: { post: Post; score: number }[], + focusCandidates: { post: Post; score: number }[], ) => { const redundant = redundantSignatures (candidates) const nonTagCount = @@ -349,25 +354,49 @@ const chooseQuestion = ({ if (yes === 0 || no === 0) return null + const focusSignature = signatureFor (question, focusCandidates) + const focusYes = focusSignature.split ('').filter (value => value === '1').length + const focusNo = focusCandidates.length - focusYes + const separatesFocus = focusCandidates.length >= 2 && focusYes > 0 && focusNo > 0 + const splitScore = Math.abs (candidates.length / 2 - yes) + const focusSplitScore = focusCandidates.length >= 2 + ? Math.abs (focusCandidates.length / 2 - focusYes) + : 0 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 ? candidates.length : 0 + const focusPenalty = + focusCandidates.length >= 2 && !(separatesFocus) ? candidates.length * 10 : 0 return { question, - score: splitScore + tagPenalty + narrowPenalty, - narrow: narrowPenalty > 0 } + score: focusPenalty + + focusSplitScore * 20 + + splitScore + + tagPenalty + + narrowPenalty, + narrow: narrowPenalty > 0, + separatesFocus } }) .filter ((item): item is { - question: GekanatorQuestion - score: number - narrow: boolean } => item !== null && Number.isFinite (item.score)) + question: GekanatorQuestion + score: number + narrow: boolean + separatesFocus: 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) + const ranked = rank (unansweredQuestions, scoredPosts, focusCandidates) + + if (focusCandidates.length >= 2) + return ( + ranked.find (item => item.separatesFocus && !(item.narrow)) + ?? ranked.find (item => item.separatesFocus) + ?? ranked.find (item => !(item.narrow)) + ?? ranked[0] + )?.question ?? null return (ranked.find (item => !(item.narrow)) ?? ranked[0])?.question ?? null } -- 2.34.1 From 49d42d576af960a90fc8b605c83d34bee3ac2333 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Tue, 9 Jun 2026 00:35:25 +0900 Subject: [PATCH 06/13] #41 --- backend/app/models/post.rb | 2 +- frontend/src/lib/gekanator.ts | 88 +++++++++++++++++++++++++- frontend/src/pages/GekanatorPage.tsx | 94 +++++++++++++++++++++++++--- 3 files changed, 173 insertions(+), 11 deletions(-) diff --git a/backend/app/models/post.rb b/backend/app/models/post.rb index f682c52..15db1c5 100644 --- a/backend/app/models/post.rb +++ b/backend/app/models/post.rb @@ -19,7 +19,7 @@ class Post < ApplicationRecord has_many :gekanator_correct_games, class_name: 'GekanatorGame', foreign_key: :correct_post_id, - dependent: :nullify, + dependent: :delete_all, inverse_of: :correct_post has_many :parent_post_implications, diff --git a/frontend/src/lib/gekanator.ts b/frontend/src/lib/gekanator.ts index eae4623..f53b9a1 100644 --- a/frontend/src/lib/gekanator.ts +++ b/frontend/src/lib/gekanator.ts @@ -65,6 +65,37 @@ const originalYearOf = (post: Post): number | null => { } +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 }` @@ -78,6 +109,27 @@ const tagFromQuestionKey = (key: string): { category: string; name: string } => 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) @@ -128,6 +180,14 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => { 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) => @@ -146,9 +206,7 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => { return { id: `tag:${ key }`, - text: category === 'nico' - ? `ニコニコに「${ label }」といふタグが付いてゐる?` - : `内容として「${ label }」に関係しさう?`, + text: tagQuestionText (category, label), kind: 'tag' as const, test: (post: Post) => questionableTag (post, String (key)) } }) @@ -171,6 +229,28 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => { kind: 'original_date' as const, 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, + 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, + test: (post: Post) => originalMonthDayOf (post) === monthDay } + }) + const titleQuestions = [ { id: 'title:long', @@ -191,6 +271,8 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => { return [ ...sourceQuestions, ...originalYearQuestions, + ...originalMonthQuestions, + ...originalMonthDayQuestions, ...titleQuestions, ...tagQuestions] } diff --git a/frontend/src/pages/GekanatorPage.tsx b/frontend/src/pages/GekanatorPage.tsx index d6f2567..4d7f9d5 100644 --- a/frontend/src/pages/GekanatorPage.tsx +++ b/frontend/src/pages/GekanatorPage.tsx @@ -18,7 +18,14 @@ import type { GekanatorAnswerLog, GekanatorQuestion } from '@/lib/gekanator' import type { Post } from '@/types' -type Phase = 'intro' | 'question' | 'guess' | 'continue' | 'review' | 'learned' +type Phase = + | 'intro' + | 'question' + | 'guess' + | 'continue' + | 'end' + | 'review' + | 'learned' type AnswerOption = { label: string @@ -687,9 +694,11 @@ const GekanatorPage: FC = () => { } } - const startReview = (correctPostId: number) => { + const finishGame = (correctPostId: number) => { const guessedPostId = - phase === 'continue' + phase === 'end' || phase === 'review' + ? reviewGuessedPostId + : phase === 'continue' ? lastRejectedGuessId ?? displayedGuess?.id : displayedGuess?.id ?? lastRejectedGuessId if (!(guessedPostId)) @@ -700,6 +709,16 @@ const GekanatorPage: FC = () => { setReviewCorrectPostId (correctPostId) setSearch ('') setSelectingCorrectPost (false) + setPhase ('end') + } + + const startReview = () => { + if (reviewGuessedPostId === null || reviewCorrectPostId === null) + return + + saveMutation.reset () + setSelectingCorrectPost (false) + setSearch ('') setPhase ('review') } @@ -805,7 +824,7 @@ const GekanatorPage: FC = () => { return } - startReview (post.id) + finishGame (post.id) } const filteredPosts = posts @@ -963,7 +982,7 @@ const GekanatorPage: FC = () => { hover:bg-pink-500" onClick={() => { if (displayedGuess) - startReview (displayedGuess.id) + finishGame (displayedGuess.id) }}> 当たり @@ -1022,6 +1041,67 @@ const GekanatorPage: FC = () => { )} + {phase === 'end' && ( +
+
+

ゲーム終了

+

今回の結果を保存できます。

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

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

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

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

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

+ 学習ログの保存に失敗しました。もう一度試せます。 +

)} + +
+ + +
+
)} + {phase === 'review' && (
@@ -1105,7 +1185,7 @@ const GekanatorPage: FC = () => { className="rounded border border-neutral-300 px-4 py-2 hover:bg-neutral-100 dark:border-neutral-700 dark:hover:bg-red-900" - onClick={() => setPhase ('guess')}> + onClick={() => setPhase ('end')}> 戻る
@@ -1126,7 +1206,7 @@ const GekanatorPage: FC = () => {
- {['guess', 'continue', 'question', 'review'].includes (phase) + {['guess', 'continue', 'question', 'end', 'review'].includes (phase) && selectingCorrectPost && (
-- 2.34.1 From a1ea35a7ec13d751f72e58f9bc5cf6dfaad60494 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Tue, 9 Jun 2026 01:29:43 +0900 Subject: [PATCH 07/13] #41 --- ...kanator_question_suggestions_controller.rb | 19 ++ backend/app/models/gekanator_game.rb | 3 + .../models/gekanator_question_suggestion.rb | 21 ++ backend/config/routes.rb | 3 + ...0_create_gekanator_question_suggestions.rb | 14 + backend/db/schema.rb | 15 +- frontend/src/lib/gekanator.ts | 22 +- frontend/src/pages/GekanatorPage.tsx | 299 +++++++++++++----- 8 files changed, 306 insertions(+), 90 deletions(-) create mode 100644 backend/app/controllers/gekanator_question_suggestions_controller.rb create mode 100644 backend/app/models/gekanator_question_suggestion.rb create mode 100644 backend/db/migrate/20260608000000_create_gekanator_question_suggestions.rb 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..bcd1c6a --- /dev/null +++ b/backend/app/controllers/gekanator_question_suggestions_controller.rb @@ -0,0 +1,19 @@ +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)) + + if suggestion.save + render json: { id: suggestion.id }, status: :created + else + render_validation_error suggestion + end + end +end diff --git a/backend/app/models/gekanator_game.rb b/backend/app/models/gekanator_game.rb index 2808be4..ae10c83 100644 --- a/backend/app/models/gekanator_game.rb +++ b/backend/app/models/gekanator_game.rb @@ -2,6 +2,9 @@ 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 validates :answers, presence: true validates :question_count, numericality: { greater_than_or_equal_to: 0 } diff --git a/backend/app/models/gekanator_question_suggestion.rb b/backend/app/models/gekanator_question_suggestion.rb new file mode 100644 index 0000000..9034ee8 --- /dev/null +++ b/backend/app/models/gekanator_question_suggestion.rb @@ -0,0 +1,21 @@ +class GekanatorQuestionSuggestion < ApplicationRecord + MAX_QUESTIONS_PER_GAME = 1 + + belongs_to :gekanator_game + belongs_to :user + + validates :question_text, presence: true, length: { maximum: 1000 } + 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/config/routes.rb b/backend/config/routes.rb index 0c76bb1..a0ec2c8 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -65,6 +65,9 @@ Rails.application.routes.draw do namespace :gekanator do resources :games, only: [:create], controller: '/gekanator_games' + resources :question_suggestions, + only: [:create], + controller: '/gekanator_question_suggestions' end resources :users, only: [:create, :update] do 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/schema.rb b/backend/db/schema.rb index ee3d86a..986465c 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_07_000000) do +ActiveRecord::Schema[8.0].define(version: 2026_06_08_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 @@ -63,6 +63,17 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_07_000000) do t.check_constraint "`question_count` >= 0", name: "chk_gekanator_games_question_count_nonnegative" 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.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 "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" @@ -496,6 +507,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_07_000000) do add_foreign_key "gekanator_games", "posts", column: "correct_post_id" add_foreign_key "gekanator_games", "posts", column: "guessed_post_id" add_foreign_key "gekanator_games", "users" + add_foreign_key "gekanator_question_suggestions", "gekanator_games", on_delete: :cascade + add_foreign_key "gekanator_question_suggestions", "users" add_foreign_key "material_versions", "materials" add_foreign_key "material_versions", "materials", column: "parent_id" add_foreign_key "material_versions", "tags" diff --git a/frontend/src/lib/gekanator.ts b/frontend/src/lib/gekanator.ts index f53b9a1..16e7940 100644 --- a/frontend/src/lib/gekanator.ts +++ b/frontend/src/lib/gekanator.ts @@ -11,9 +11,10 @@ export type GekanatorAnswerValue = | 'unknown' export type GekanatorAnswerLog = { - questionId: string - questionText: string - answer: GekanatorAnswerValue } + questionId: string + questionText: string + answer: GekanatorAnswerValue + originalAnswer: GekanatorAnswerValue } export type GekanatorQuestionKind = | 'tag' @@ -293,4 +294,17 @@ export const saveGekanatorGame = async ({ answers: answers.map (answer => ({ question_id: answer.questionId, question_text: answer.questionText, - answer: answer.answer })) }) + answer: answer.answer, + original_answer: answer.originalAnswer })) }) + + +export const saveGekanatorQuestionSuggestion = async ({ + gekanatorGameId, + questionText, +}: { + gekanatorGameId: number + questionText: string +}): Promise<{ id: number }> => + await apiPost ('/gekanator/question_suggestions', { + gekanator_game_id: gekanatorGameId, + question_text: questionText }) diff --git a/frontend/src/pages/GekanatorPage.tsx b/frontend/src/pages/GekanatorPage.tsx index 4d7f9d5..c600993 100644 --- a/frontend/src/pages/GekanatorPage.tsx +++ b/frontend/src/pages/GekanatorPage.tsx @@ -7,7 +7,8 @@ import MainArea from '@/components/layout/MainArea' import { SITE_TITLE } from '@/config' import { buildGekanatorQuestions, fetchGekanatorPosts, - saveGekanatorGame } from '@/lib/gekanator' + saveGekanatorGame, + saveGekanatorQuestionSuggestion } from '@/lib/gekanator' import { gekanatorKeys } from '@/lib/queryKeys' import { cn } from '@/lib/utils' @@ -25,6 +26,7 @@ type Phase = | 'continue' | 'end' | 'review' + | 'question_suggestion' | 'learned' type AnswerOption = { @@ -66,6 +68,9 @@ const answerOptions: AnswerOption[] = [ { 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 @@ -313,10 +318,14 @@ const chooseQuestion = ({ const scoredPosts = posts .map (post => ({ post, score: scores.get (post.id) ?? 0 })) .sort ((a, b) => b.score - a.score) - const topScore = scoredPosts[0]?.score ?? 0 - const nearTopCandidates = scoredPosts.filter (item => topScore - item.score <= 2) - const focusCandidates = - nearTopCandidates.length >= 2 && nearTopCandidates.length <= 8 ? nearTopCandidates : [] + 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, @@ -342,9 +351,9 @@ const chooseQuestion = ({ } const rank = ( - questionsToRank: GekanatorQuestion[], - candidates: { post: Post; score: number }[], - focusCandidates: { post: Post; score: number }[], + questionsToRank: GekanatorQuestion[], + candidates: { post: Post; score: number }[], + weightedCandidates: { post: Post; score: number; weight: number }[], ) => { const redundant = redundantSignatures (candidates) const nonTagCount = @@ -361,49 +370,36 @@ const chooseQuestion = ({ if (yes === 0 || no === 0) return null - const focusSignature = signatureFor (question, focusCandidates) - const focusYes = focusSignature.split ('').filter (value => value === '1').length - const focusNo = focusCandidates.length - focusYes - const separatesFocus = focusCandidates.length >= 2 && focusYes > 0 && focusNo > 0 + 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 splitScore = Math.abs (candidates.length / 2 - yes) - const focusSplitScore = focusCandidates.length >= 2 - ? Math.abs (focusCandidates.length / 2 - focusYes) - : 0 - const tagPenalty = question.kind === 'tag' && nonTagCount < 4 ? 12 : 0 + 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 ? candidates.length : 0 - const focusPenalty = - focusCandidates.length >= 2 && !(separatesFocus) ? candidates.length * 10 : 0 + const narrowPenalty = yes < minSide || no < minSide ? .15 : 0 return { question, - score: focusPenalty - + focusSplitScore * 20 - + splitScore + score: weightedSplitScore * 100 + + unweightedSplitScore * 8 + tagPenalty + narrowPenalty, - narrow: narrowPenalty > 0, - separatesFocus } + narrow: narrowPenalty > 0 } }) .filter ((item): item is { - question: GekanatorQuestion - score: number - narrow: boolean - separatesFocus: boolean } => item !== null && Number.isFinite (item.score)) + 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, focusCandidates) - - if (focusCandidates.length >= 2) - return ( - ranked.find (item => item.separatesFocus && !(item.narrow)) - ?? ranked.find (item => item.separatesFocus) - ?? ranked.find (item => !(item.narrow)) - ?? ranked[0] - )?.question ?? null + const ranked = rank (unansweredQuestions, scoredPosts, normalisedWeightedPosts) return (ranked.find (item => !(item.narrow)) ?? ranked[0])?.question ?? null } @@ -434,6 +430,17 @@ const PostMiniCard: FC<{ post: Post }> = ({ post }) => ( ) +const expectedAnswerFor = ( + question: GekanatorQuestion | undefined, + correctPost: Post | null, +): GekanatorAnswerValue | null => { + if (!(question) || !(correctPost)) + return null + + return question.test (correctPost) ? 'yes' : 'no' +} + + const GekanatorPage: FC = () => { const [phase, setPhase] = useState ('intro') const [scores, setScores] = useState> (new Map ()) @@ -451,6 +458,8 @@ const GekanatorPage: FC = () => { const [activeGuessId, setActiveGuessId] = useState (null) const [reviewGuessedPostId, setReviewGuessedPostId] = useState (null) const [reviewCorrectPostId, setReviewCorrectPostId] = useState (null) + const [savedGameId, setSavedGameId] = useState (null) + const [questionSuggestion, setQuestionSuggestion] = useState ('') const [history, setHistory] = useState ([]) const { data: posts = [], isLoading, error } = useQuery ({ @@ -471,6 +480,9 @@ const GekanatorPage: FC = () => { 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))), @@ -510,10 +522,16 @@ const GekanatorPage: FC = () => { posts.find (post => post.id === reviewCorrectPostId) ?? null const saveMutation = useMutation ({ mutationFn: saveGekanatorGame, - onSuccess: () => { + onSuccess: (data, variables) => { setSaved (true) - setResultWon (reviewGuessedPostId === reviewCorrectPostId) - setPhase ('learned') + setSavedGameId (data.id) + setResultWon (variables.guessedPostId === variables.correctPostId) + }}) + const questionSuggestionMutation = useMutation ({ + mutationFn: saveGekanatorQuestionSuggestion, + onSuccess: () => { + setQuestionSuggestion ('') + reset () }}) const reset = () => { @@ -534,6 +552,8 @@ const GekanatorPage: FC = () => { setActiveGuessId (null) setReviewGuessedPostId (null) setReviewCorrectPostId (null) + setSavedGameId (null) + setQuestionSuggestion ('') setHistory ([]) } @@ -637,9 +657,10 @@ const GekanatorPage: FC = () => { reviewGuessedPostId, reviewCorrectPostId }]) const nextAnswers = [...answers, { - questionId: currentQuestion.id, - questionText: currentQuestion.text, - answer: value }] + questionId: currentQuestion.id, + questionText: currentQuestion.text, + answer: value, + originalAnswer: value }] const nextAskedIds = new Set ([...askedIds, currentQuestion.id]) const nextAskedQuestionBank = [ ...askedQuestionBank.filter (question => question.id !== currentQuestion.id), @@ -705,6 +726,9 @@ const GekanatorPage: FC = () => { return saveMutation.reset () + questionSuggestionMutation.reset () + setSaved (false) + setSavedGameId (null) setReviewGuessedPostId (guessedPostId) setReviewCorrectPostId (correctPostId) setSearch ('') @@ -717,24 +741,51 @@ const GekanatorPage: FC = () => { return saveMutation.reset () + questionSuggestionMutation.reset () + setSaved (false) + setSavedGameId (null) setSelectingCorrectPost (false) setSearch ('') setPhase ('review') } - const saveReviewedResult = () => { + const saveReviewedResult = (onSuccess: (gameId: number) => void) => { if ( reviewGuessedPostId === null || reviewCorrectPostId === null || saveMutation.isPending - || saved ) return + if (savedGameId !== null) + { + onSuccess (savedGameId) + return + } + saveMutation.mutate ({ guessedPostId: reviewGuessedPostId, correctPostId: reviewCorrectPostId, - answers }) + answers }, + { onSuccess: data => onSuccess (data.id) }) + } + + const saveAndReset = () => { + saveReviewedResult (reset) + } + + const saveAndLearn = () => { + saveReviewedResult (() => setPhase ('learned')) + } + + const submitQuestionSuggestion = () => { + const questionText = questionSuggestion.trim () + if (!(questionText) || questionSuggestionMutation.isPending) + return + + saveReviewedResult (gekanatorGameId => { + questionSuggestionMutation.mutate ({ gekanatorGameId, questionText }) + }) } const rejectGuess = () => { @@ -811,6 +862,8 @@ const GekanatorPage: FC = () => { } const correctAnswerAt = (index: number, value: GekanatorAnswerValue) => { + setSaved (false) + setSavedGameId (null) setAnswers (answers.map ((answer, i) => i === index ? { ...answer, answer: value } : answer)) } @@ -818,6 +871,8 @@ const GekanatorPage: FC = () => { const selectCorrectPost = (post: Post) => { if (phase === 'review') { + setSaved (false) + setSavedGameId (null) setReviewCorrectPostId (post.id) setSelectingCorrectPost (false) setSearch ('') @@ -1006,7 +1061,7 @@ const GekanatorPage: FC = () => { {saveMutation.isError && (

- 学習ログの保存に失敗しました。もう一度試せます。 + 記録できませんでした。通信状態を確認してもう一度試して。

)} )} @@ -1045,7 +1100,7 @@ const GekanatorPage: FC = () => {

ゲーム終了

-

今回の結果を保存できます。

+

グカカカカwwwww

{reviewGuessedPost && ( @@ -1076,7 +1131,7 @@ const GekanatorPage: FC = () => { {saveMutation.isError && (

- 学習ログの保存に失敗しました。もう一度試せます。 + 記録できませんでした。通信状態を確認してもう一度試して。

)}
@@ -1084,20 +1139,27 @@ const GekanatorPage: FC = () => { type="button" className="rounded bg-pink-600 px-4 py-2 font-bold text-white hover:bg-pink-500 disabled:opacity-50" - disabled={ - reviewCorrectPostId === null || saveMutation.isPending || saved - } - onClick={saveReviewedResult}> - 保存 + disabled={reviewCorrectPostId === null || saveMutation.isPending} + onClick={saveAndReset}> + もう一度 +
)} @@ -1133,29 +1195,49 @@ const GekanatorPage: FC = () => {
質問と回答
- {answers.map ((answer, index) => ( -
-
- 質問 {index + 1} -
-
{answer.questionText}
- -
))} + {answers.map ((answer, index) => { + const expectedAnswer = expectedAnswerFor ( + scoringQuestionById.get (answer.questionId), + reviewCorrectPost) + + return ( +
+
+ 質問 {index + 1} +
+
{answer.questionText}
+
+
+ グカネータ判定: + {expectedAnswer ? answerLabelFor (expectedAnswer) : '不明'} +
+
+ 実際の回答: + {answerLabelFor (answer.originalAnswer)} +
+ +
+
) + })}
@@ -1166,7 +1248,7 @@ const GekanatorPage: FC = () => { {saveMutation.isError && (

- 学習ログの保存に失敗しました。もう一度試せます。 + 記録できませんでした。通信状態を確認してもう一度試して。

)}
@@ -1175,10 +1257,12 @@ const GekanatorPage: FC = () => { className="rounded bg-pink-600 px-4 py-2 font-bold text-white hover:bg-pink-500 disabled:opacity-50" disabled={ - reviewCorrectPostId === null || saveMutation.isPending || saved + reviewCorrectPostId === null + || saveMutation.isPending + || questionSuggestionMutation.isPending } - onClick={saveReviewedResult}> - 保存 + onClick={saveAndLearn}> + 完了
)} + {phase === 'question_suggestion' && ( +
+
+

質問追加

+

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

+
+