From 96df2a4eaaa859427fbfc87d428862924128dab7 Mon Sep 17 00:00:00 2001
From: miteruzo
Date: Mon, 8 Jun 2026 00:30:20 +0900
Subject: [PATCH 01/15] #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.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
- {isLoading && 投稿を読み込んでゐます...
}
+ {isLoading && (
+
+ {phase === 'intro'
+ ? '投稿を読み込んでゐます...'
+ : '前回のグカネータ状態を復元してゐます...'}
+
)}
{Boolean (error) && 投稿を読み込めませんでした.
}
{phase === 'intro' && !(isLoading) && posts.length > 0 && (
@@ -1008,7 +1172,7 @@ const GekanatorPage: FC = () => {
)}
- {phase === 'question' && !(currentQuestion) && (
+ {!(isLoading) && phase === 'question' && !(currentQuestion) && (
もう十分わかった。
@@ -1157,7 +1321,8 @@ const GekanatorPage: FC = () => {
className="rounded border border-yellow-300 px-4 py-2
hover:bg-yellow-100 dark:border-red-700
dark:hover:bg-red-900"
- disabled={saveMutation.isPending || questionSuggestionMutation.isPending}
+ disabled={saveMutation.isPending
+ || questionSuggestionMutation.isPending}
onClick={() => setPhase ('question_suggestion')}>
質問を追加
@@ -1290,13 +1455,29 @@ const GekanatorPage: FC = () => {
bg-white px-3 py-2 dark:border-red-700
dark:bg-red-950"/>
+
--
2.34.1
From ae1deaac8c1e51c3033f45791f69f3b9649a9a05 Mon Sep 17 00:00:00 2001
From: miteruzo
Date: Tue, 9 Jun 2026 23:05:37 +0900
Subject: [PATCH 09/15] #41
---
...kanator_question_suggestions_controller.rb | 28 ++-
.../gekanator_questions_controller.rb | 57 +++++
backend/app/models/gekanator_ai_run.rb | 21 ++
backend/app/models/gekanator_question.rb | 19 ++
.../models/gekanator_question_suggestion.rb | 4 +-
.../app/services/gekanator/ai_run_budget.rb | 22 ++
.../question_suggestion_ai_converter.rb | 18 ++
backend/config/routes.rb | 7 +-
...260609000000_create_gekanator_questions.rb | 19 ++
...20260609001000_create_gekanator_ai_runs.rb | 13 +
backend/db/schema.rb | 51 +++-
frontend/src/lib/gekanator.ts | 56 ++++-
frontend/src/lib/queryKeys.ts | 5 +-
frontend/src/pages/GekanatorPage.tsx | 223 ++++++++++++++++--
14 files changed, 505 insertions(+), 38 deletions(-)
create mode 100644 backend/app/controllers/gekanator_questions_controller.rb
create mode 100644 backend/app/models/gekanator_ai_run.rb
create mode 100644 backend/app/models/gekanator_question.rb
create mode 100644 backend/app/services/gekanator/ai_run_budget.rb
create mode 100644 backend/app/services/gekanator/question_suggestion_ai_converter.rb
create mode 100644 backend/db/migrate/20260609000000_create_gekanator_questions.rb
create mode 100644 backend/db/migrate/20260609001000_create_gekanator_ai_runs.rb
diff --git a/backend/app/controllers/gekanator_question_suggestions_controller.rb b/backend/app/controllers/gekanator_question_suggestions_controller.rb
index bf361c6..a16e29b 100644
--- a/backend/app/controllers/gekanator_question_suggestions_controller.rb
+++ b/backend/app/controllers/gekanator_question_suggestions_controller.rb
@@ -12,9 +12,35 @@ class GekanatorQuestionSuggestionsController < ApplicationController
answer: params.require(:answer))
if suggestion.save
- render json: { id: suggestion.id }, status: :created
+ render json: {
+ id: suggestion.id,
+ count: game.question_suggestions.count
+ }, status: :created
else
render_validation_error suggestion
end
end
+
+ def ai_convert
+ return head :not_found unless current_user&.admin?
+
+ suggestion = GekanatorQuestionSuggestion.find_by(id: params[:id])
+ return head :not_found unless suggestion
+ if Gekanator::AiRunBudget.exceeded_after_next_run?
+ suggestion.gekanator_ai_runs.create!(
+ model: 'budget_guard',
+ status: 'blocked_budget',
+ input_tokens: 0,
+ output_tokens: 0,
+ estimated_cost_jpy: 0)
+ return head :payment_required
+ end
+
+ Gekanator::QuestionSuggestionAiConverter.call(
+ suggestion: suggestion,
+ user: current_user)
+ head :no_content
+ rescue NotImplementedError
+ head :not_implemented
+ end
end
diff --git a/backend/app/controllers/gekanator_questions_controller.rb b/backend/app/controllers/gekanator_questions_controller.rb
new file mode 100644
index 0000000..1a61d46
--- /dev/null
+++ b/backend/app/controllers/gekanator_questions_controller.rb
@@ -0,0 +1,57 @@
+class GekanatorQuestionsController < ApplicationController
+ def index
+ return head :not_found unless current_user&.admin?
+
+ questions = GekanatorQuestion.accepted.order(priority_weight: :desc, id: :asc)
+
+ render json: {
+ questions: questions.map { |question| question_json(question) }
+ }
+ end
+
+ private
+
+ def question_json question
+ {
+ id: question_id_for(question),
+ text: question.text,
+ kind: question.kind,
+ condition: condition_json(question.condition),
+ source: question.source,
+ priority_weight: question.priority_weight
+ }
+ end
+
+ def question_id_for question
+ condition = condition_json(question.condition).deep_symbolize_keys
+
+ case condition[:type]
+ when 'tag'
+ "tag:#{ condition[:key] }"
+ when 'source'
+ "source:#{ condition[:host] }"
+ when 'original-year'
+ "original-year:#{ condition[:year] }"
+ when 'original-month'
+ "original-month:#{ condition[:month] }"
+ when 'original-month-day'
+ "original-month-day:#{ condition[:monthDay] || condition[:month_day] }"
+ when 'title-length-greater-than'
+ "title:length-greater-than:#{ condition[:length] }"
+ when 'title-has-ascii'
+ 'title:ascii'
+ else
+ "catalog:#{ question.id }"
+ end
+ end
+
+ def condition_json condition
+ json = condition.deep_dup.as_json
+
+ if json['type'] == 'original-month-day' && json['monthDay'].blank?
+ json['monthDay'] = json.delete('month_day')
+ end
+
+ json
+ end
+end
diff --git a/backend/app/models/gekanator_ai_run.rb b/backend/app/models/gekanator_ai_run.rb
new file mode 100644
index 0000000..3a35e29
--- /dev/null
+++ b/backend/app/models/gekanator_ai_run.rb
@@ -0,0 +1,21 @@
+class GekanatorAiRun < ApplicationRecord
+ STATUSES = ['pending', 'running', 'succeeded', 'failed', 'blocked_budget'].freeze
+
+ belongs_to :gekanator_question_suggestion
+
+ validates :model, presence: true, length: { maximum: 255 }
+ validates :status, presence: true, inclusion: { in: STATUSES }
+ validates :input_tokens,
+ presence: true,
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+ validates :output_tokens,
+ presence: true,
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+ validates :estimated_cost_jpy,
+ presence: true,
+ numericality: { greater_than_or_equal_to: 0 }
+
+ scope :this_month, lambda {
+ where(created_at: Time.current.beginning_of_month..Time.current.end_of_month)
+ }
+end
diff --git a/backend/app/models/gekanator_question.rb b/backend/app/models/gekanator_question.rb
new file mode 100644
index 0000000..a4c838f
--- /dev/null
+++ b/backend/app/models/gekanator_question.rb
@@ -0,0 +1,19 @@
+class GekanatorQuestion < ApplicationRecord
+ KINDS = ['tag', 'source', 'title', 'original_date'].freeze
+ SOURCES = ['user_suggested', 'ai_generated', 'admin_curated'].freeze
+ STATUSES = ['pending', 'accepted', 'rejected'].freeze
+
+ belongs_to :gekanator_question_suggestion, optional: true
+ belongs_to :created_by, class_name: 'User', optional: true
+
+ validates :kind, presence: true, inclusion: { in: KINDS }
+ validates :source, presence: true, inclusion: { in: SOURCES }
+ validates :status, presence: true, inclusion: { in: STATUSES }
+ validates :text, presence: true, length: { maximum: 1000 }
+ validates :condition, presence: true
+ validates :priority_weight,
+ presence: true,
+ numericality: { greater_than: 0 }
+
+ scope :accepted, -> { where(status: 'accepted') }
+end
diff --git a/backend/app/models/gekanator_question_suggestion.rb b/backend/app/models/gekanator_question_suggestion.rb
index 2159f5e..80c2d43 100644
--- a/backend/app/models/gekanator_question_suggestion.rb
+++ b/backend/app/models/gekanator_question_suggestion.rb
@@ -1,9 +1,11 @@
class GekanatorQuestionSuggestion < ApplicationRecord
- MAX_QUESTIONS_PER_GAME = 1
+ MAX_QUESTIONS_PER_GAME = 3
ANSWERS = ['yes', 'no', 'partial', 'probably_no', 'unknown'].freeze
belongs_to :gekanator_game
belongs_to :user
+ has_many :gekanator_questions, dependent: :nullify
+ has_many :gekanator_ai_runs, dependent: :destroy
validates :question_text, presence: true, length: { maximum: 1000 }
validates :answer, presence: true, inclusion: { in: ANSWERS }
diff --git a/backend/app/services/gekanator/ai_run_budget.rb b/backend/app/services/gekanator/ai_run_budget.rb
new file mode 100644
index 0000000..20115ef
--- /dev/null
+++ b/backend/app/services/gekanator/ai_run_budget.rb
@@ -0,0 +1,22 @@
+module Gekanator
+ class AiRunBudget
+ MONTHLY_LIMIT_JPY = BigDecimal('450').freeze
+ MAX_RUN_ESTIMATED_COST_JPY = BigDecimal('5').freeze
+
+ def self.remaining_monthly_budget_jpy
+ MONTHLY_LIMIT_JPY - monthly_cost_jpy
+ end
+
+ def self.monthly_cost_jpy
+ GekanatorAiRun.this_month.sum(:estimated_cost_jpy)
+ end
+
+ def self.exceeded?
+ monthly_cost_jpy >= MONTHLY_LIMIT_JPY
+ end
+
+ def self.exceeded_after_next_run?
+ monthly_cost_jpy + MAX_RUN_ESTIMATED_COST_JPY >= MONTHLY_LIMIT_JPY
+ end
+ end
+end
diff --git a/backend/app/services/gekanator/question_suggestion_ai_converter.rb b/backend/app/services/gekanator/question_suggestion_ai_converter.rb
new file mode 100644
index 0000000..629732c
--- /dev/null
+++ b/backend/app/services/gekanator/question_suggestion_ai_converter.rb
@@ -0,0 +1,18 @@
+module Gekanator
+ class QuestionSuggestionAiConverter
+ def self.call(...) = new(...).call
+
+ def initialize suggestion:, user:
+ @suggestion = suggestion
+ @user = user
+ end
+
+ def call
+ raise NotImplementedError, 'AI question conversion is not implemented yet.'
+ end
+
+ private
+
+ attr_reader :suggestion, :user
+ end
+end
diff --git a/backend/config/routes.rb b/backend/config/routes.rb
index 3d4b505..c6521e1 100644
--- a/backend/config/routes.rb
+++ b/backend/config/routes.rb
@@ -66,9 +66,14 @@ Rails.application.routes.draw do
namespace :gekanator do
resources :games, only: [:create], controller: '/gekanator_games'
resources :posts, only: [:index], controller: '/gekanator_posts'
+ resources :questions, only: [:index], controller: '/gekanator_questions'
resources :question_suggestions,
only: [:create],
- controller: '/gekanator_question_suggestions'
+ controller: '/gekanator_question_suggestions' do
+ member do
+ post :ai_convert
+ end
+ end
end
resources :users, only: [:create, :update] do
diff --git a/backend/db/migrate/20260609000000_create_gekanator_questions.rb b/backend/db/migrate/20260609000000_create_gekanator_questions.rb
new file mode 100644
index 0000000..26d6fbd
--- /dev/null
+++ b/backend/db/migrate/20260609000000_create_gekanator_questions.rb
@@ -0,0 +1,19 @@
+class CreateGekanatorQuestions < ActiveRecord::Migration[8.0]
+ def change
+ create_table :gekanator_questions do |t|
+ t.string :text, null: false
+ t.string :kind, null: false
+ t.json :condition, null: false
+ t.string :source, null: false, default: 'ai_generated'
+ t.string :status, null: false, default: 'pending'
+ t.float :priority_weight, null: false, default: 1.0
+ t.references :gekanator_question_suggestion,
+ null: true,
+ foreign_key: true
+ t.references :created_by,
+ null: true,
+ foreign_key: { to_table: :users }
+ t.timestamps
+ end
+ end
+end
diff --git a/backend/db/migrate/20260609001000_create_gekanator_ai_runs.rb b/backend/db/migrate/20260609001000_create_gekanator_ai_runs.rb
new file mode 100644
index 0000000..695dd03
--- /dev/null
+++ b/backend/db/migrate/20260609001000_create_gekanator_ai_runs.rb
@@ -0,0 +1,13 @@
+class CreateGekanatorAiRuns < ActiveRecord::Migration[8.0]
+ def change
+ create_table :gekanator_ai_runs do |t|
+ t.string :model, null: false
+ t.integer :input_tokens, null: false, default: 0
+ t.integer :output_tokens, null: false, default: 0
+ t.decimal :estimated_cost_jpy, precision: 8, scale: 3, null: false, default: 0
+ t.string :status, null: false, default: 'pending'
+ t.references :gekanator_question_suggestion, null: false, foreign_key: true
+ t.timestamps
+ end
+ end
+end
diff --git a/backend/db/schema.rb b/backend/db/schema.rb
index f50a921..3cd1798 100644
--- a/backend/db/schema.rb
+++ b/backend/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.0].define(version: 2026_06_08_002000) do
+ActiveRecord::Schema[8.0].define(version: 2026_06_09_001000) do
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "name", null: false
t.string "record_type", null: false
@@ -48,6 +48,18 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_08_002000) do
t.index ["tag_id"], name: "index_deerjikists_on_tag_id"
end
+ create_table "gekanator_ai_runs", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
+ t.string "model", null: false
+ t.integer "input_tokens", default: 0, null: false
+ t.integer "output_tokens", default: 0, null: false
+ t.decimal "estimated_cost_jpy", precision: 8, scale: 3, default: "0.0", null: false
+ t.string "status", default: "pending", null: false
+ t.bigint "gekanator_question_suggestion_id", null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["gekanator_question_suggestion_id"], name: "index_gekanator_ai_runs_on_gekanator_question_suggestion_id"
+ end
+
create_table "gekanator_games", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "user_id", null: false
t.bigint "guessed_post_id", null: false
@@ -75,6 +87,21 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_08_002000) do
t.index ["user_id"], name: "index_gekanator_question_suggestions_on_user_id"
end
+ create_table "gekanator_questions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
+ t.string "text", null: false
+ t.string "kind", null: false
+ t.json "condition", null: false
+ t.string "source", default: "ai_generated", null: false
+ t.string "status", default: "pending", null: false
+ t.float "priority_weight", default: 1.0, null: false
+ t.bigint "gekanator_question_suggestion_id"
+ t.bigint "created_by_id"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["created_by_id"], name: "index_gekanator_questions_on_created_by_id"
+ t.index ["gekanator_question_suggestion_id"], name: "index_gekanator_questions_on_gekanator_question_suggestion_id"
+ end
+
create_table "ip_addresses", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.binary "ip_address", limit: 16, null: false
t.datetime "banned_at"
@@ -164,6 +191,19 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_08_002000) do
t.index ["target_post_id"], name: "index_post_similarities_on_target_post_id"
end
+ create_table "post_tag_sections", primary_key: ["post_id", "tag_id", "begin_ms"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
+ t.bigint "post_id", null: false
+ t.bigint "tag_id", null: false
+ t.integer "begin_ms", null: false
+ t.integer "end_ms", null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["post_id", "begin_ms"], name: "idx_post_tag_sections_post_id_begin_ms"
+ t.index ["tag_id"], name: "fk_rails_8be3847903"
+ t.check_constraint "`begin_ms` < `end_ms`", name: "chk_post_tag_sections_end_ms_after_begin_ms"
+ t.check_constraint "`begin_ms` >= 0", name: "chk_post_tag_sections_begin_ms_natural"
+ end
+
create_table "post_tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "post_id", null: false
t.bigint "tag_id", null: false
@@ -214,8 +254,11 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_08_002000) do
t.datetime "original_created_before"
t.datetime "updated_at", null: false
t.integer "version_no", null: false
+ t.integer "video_ms"
t.index ["uploaded_user_id"], name: "index_posts_on_uploaded_user_id"
t.index ["url"], name: "index_posts_on_url", unique: true
+ t.index ["video_ms", "id"], name: "idx_posts_video_ms_id"
+ t.check_constraint "(`video_ms` is null) or (`video_ms` > 0)", name: "chk_posts_video_ms_positive"
t.check_constraint "`version_no` > 0", name: "chk_posts_version_no_positive"
end
@@ -366,6 +409,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_08_002000) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["expires_at"], name: "index_theatre_watching_users_on_expires_at"
+ t.index ["theatre_id", "expires_at"], name: "idx_on_theatre_id_skip_expires_at_4c8de1dd42"
t.index ["theatre_id", "expires_at"], name: "index_theatre_watching_users_on_theatre_id_and_expires_at"
t.index ["theatre_id"], name: "index_theatre_watching_users_on_theatre_id"
t.index ["user_id"], name: "index_theatre_watching_users_on_user_id"
@@ -505,11 +549,14 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_08_002000) do
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
+ add_foreign_key "gekanator_ai_runs", "gekanator_question_suggestions"
add_foreign_key "gekanator_games", "posts", column: "correct_post_id"
add_foreign_key "gekanator_games", "posts", column: "guessed_post_id"
add_foreign_key "gekanator_games", "users"
add_foreign_key "gekanator_question_suggestions", "gekanator_games", on_delete: :cascade
add_foreign_key "gekanator_question_suggestions", "users"
+ add_foreign_key "gekanator_questions", "gekanator_question_suggestions"
+ add_foreign_key "gekanator_questions", "users", column: "created_by_id"
add_foreign_key "material_versions", "materials"
add_foreign_key "material_versions", "materials", column: "parent_id"
add_foreign_key "material_versions", "tags"
@@ -527,6 +574,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_08_002000) do
add_foreign_key "post_implications", "posts", column: "parent_post_id"
add_foreign_key "post_similarities", "posts"
add_foreign_key "post_similarities", "posts", column: "target_post_id"
+ add_foreign_key "post_tag_sections", "posts"
+ add_foreign_key "post_tag_sections", "tags"
add_foreign_key "post_tags", "posts"
add_foreign_key "post_tags", "tags"
add_foreign_key "post_tags", "users", column: "created_user_id"
diff --git a/frontend/src/lib/gekanator.ts b/frontend/src/lib/gekanator.ts
index 55dfd0c..dcd4dff 100644
--- a/frontend/src/lib/gekanator.ts
+++ b/frontend/src/lib/gekanator.ts
@@ -22,6 +22,12 @@ export type GekanatorQuestionKind =
| 'title'
| 'original_date'
+export type GekanatorQuestionSource =
+ | 'default'
+ | 'user_suggested'
+ | 'ai_generated'
+ | 'admin_curated'
+
export type GekanatorQuestionCondition =
| { type: 'tag'; key: string }
| { type: 'source'; host: string }
@@ -32,17 +38,21 @@ export type GekanatorQuestionCondition =
| { type: 'title-has-ascii' }
export type StoredGekanatorQuestion = {
- id: string
- text: string
- kind: GekanatorQuestionKind
- condition: GekanatorQuestionCondition }
+ id: string
+ text: string
+ kind: GekanatorQuestionKind
+ condition: GekanatorQuestionCondition
+ source?: GekanatorQuestionSource
+ priorityWeight?: number }
export type GekanatorQuestion = {
- id: string
- text: string
- kind: GekanatorQuestionKind
- condition: GekanatorQuestionCondition
- test: (post: Post) => boolean }
+ id: string
+ text: string
+ kind: GekanatorQuestionKind
+ condition: GekanatorQuestionCondition
+ source: GekanatorQuestionSource
+ priorityWeight: number
+ test: (post: Post) => boolean }
const countBy = (values: T[]): Map => {
const counts = new Map ()
@@ -188,6 +198,8 @@ export const restoreGekanatorQuestion = (
question: StoredGekanatorQuestion,
): GekanatorQuestion => ({
...question,
+ source: question.source ?? 'default',
+ priorityWeight: question.priorityWeight ?? 1,
test: (post: Post) => questionMatches (post, question.condition) })
@@ -197,7 +209,9 @@ export const storeGekanatorQuestion = (
id: question.id,
text: question.text,
kind: question.kind,
- condition: question.condition })
+ condition: question.condition,
+ source: question.source,
+ priorityWeight: question.priorityWeight })
export const fetchGekanatorPosts = async (): Promise => {
@@ -206,6 +220,12 @@ export const fetchGekanatorPosts = async (): Promise => {
}
+export const fetchGekanatorQuestions = async (): Promise => {
+ const data = await apiGet<{ questions: StoredGekanatorQuestion[] }> ('/gekanator/questions')
+ return data.questions
+}
+
+
export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
const tagCounts = countBy (posts.flatMap (post =>
post.tags
@@ -248,6 +268,8 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
text: tagQuestionText (category, label),
kind: 'tag' as const,
condition: { type: 'tag' as const, key: String (key) },
+ source: 'default' as const,
+ priorityWeight: 1,
test: (post: Post) => questionableTag (post, String (key)) }
})
@@ -259,6 +281,8 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
text: `${ host } の投稿を思ひ浮かべてゐる?`,
kind: 'source' as const,
condition: { type: 'source' as const, host },
+ source: 'default' as const,
+ priorityWeight: 1,
test: (post: Post) => hostOf (post) === host }))
const originalYearQuestions = usefulEntries (originalYears)
@@ -269,6 +293,8 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
text: `オリジナルの投稿年は ${ year } 年?`,
kind: 'original_date' as const,
condition: { type: 'original-year' as const, year },
+ source: 'default' as const,
+ priorityWeight: 1,
test: (post: Post) => originalYearOf (post) === year }))
const originalMonthQuestions = usefulEntries (originalMonths)
@@ -279,6 +305,8 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
text: `オリジナルの投稿月は ${ month } 月?`,
kind: 'original_date' as const,
condition: { type: 'original-month' as const, month },
+ source: 'default' as const,
+ priorityWeight: 1,
test: (post: Post) => originalMonthOf (post) === month }))
const originalMonthDayQuestions = usefulEntries (originalMonthDays)
@@ -292,6 +320,8 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
text: `オリジナルの投稿日は ${ month } 月 ${ day } 日?`,
kind: 'original_date' as const,
condition: { type: 'original-month-day' as const, monthDay: String (monthDay) },
+ source: 'default' as const,
+ priorityWeight: 1,
test: (post: Post) => originalMonthDayOf (post) === monthDay }
})
@@ -303,12 +333,16 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
condition: {
type: 'title-length-greater-than' as const,
length: titleLengthMedian },
+ source: 'default' as const,
+ priorityWeight: 1,
test: (post: Post) => (post.title?.length ?? 0) > titleLengthMedian },
{
id: 'title:ascii',
text: '題名に英数字が混じってゐる?',
kind: 'title' as const,
condition: { type: 'title-has-ascii' as const },
+ source: 'default' as const,
+ priorityWeight: 1,
test: (post: Post) => /[A-Za-z0-9]/.test (post.title ?? '') }]
.filter (question => {
const yes = posts.filter (post => question.test (post)).length
@@ -354,7 +388,7 @@ export const saveGekanatorQuestionSuggestion = async ({
gekanatorGameId: number
questionText: string
answer: GekanatorAnswerValue
-}): Promise<{ id: number }> =>
+}): Promise<{ id: number; count: number }> =>
await apiPost ('/gekanator/question_suggestions', {
gekanator_game_id: gekanatorGameId,
question_text: questionText,
diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts
index 3c9ac5c..864d0bb 100644
--- a/frontend/src/lib/queryKeys.ts
+++ b/frontend/src/lib/queryKeys.ts
@@ -9,8 +9,9 @@ export const postsKeys = {
['posts', 'changes', p] as const }
export const gekanatorKeys = {
- root: ['gekanator'] as const,
- posts: () => ['gekanator', 'posts'] as const }
+ root: ['gekanator'] as const,
+ posts: () => ['gekanator', 'posts'] as const,
+ questions: () => ['gekanator', 'questions'] as const }
export const tagsKeys = {
root: ['tags'] as const,
diff --git a/frontend/src/pages/GekanatorPage.tsx b/frontend/src/pages/GekanatorPage.tsx
index 2b1443b..7a42884 100644
--- a/frontend/src/pages/GekanatorPage.tsx
+++ b/frontend/src/pages/GekanatorPage.tsx
@@ -6,6 +6,7 @@ import PrefetchLink from '@/components/PrefetchLink'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import { buildGekanatorQuestions,
+ fetchGekanatorQuestions,
fetchGekanatorPosts,
restoreGekanatorQuestion,
saveGekanatorGame,
@@ -83,8 +84,10 @@ type StoredGekanatorGame = {
reviewGuessedPostId: number | null
reviewCorrectPostId: number | null
savedGameId: number | null
+ gameSeed?: string
questionSuggestion: string
- questionSuggestionAnswer: GekanatorAnswerValue }
+ questionSuggestionAnswer: GekanatorAnswerValue
+ questionSuggestionCount?: number }
const answerOptions: AnswerOption[] = [
{ label: 'はい', value: 'yes' },
@@ -104,6 +107,84 @@ const hardMaxQuestions = 80
const softenedAnswerWeight = .35
const confidenceTemperature = 6
const gameStorageKey = 'gekanator:game:v1'
+const maxQuestionSuggestionsPerGame = 3
+
+const sourcePriorityOffset = (question: GekanatorQuestion): number => {
+ switch (question.source)
+ {
+ case 'user_suggested':
+ return -1.2
+ case 'admin_curated':
+ return -0.8
+ case 'ai_generated':
+ return -0.6
+ default:
+ return 0
+ }
+}
+
+
+const priorityWeightOffset = (question: GekanatorQuestion): number =>
+ (question.priorityWeight - 1) * -.8
+
+
+const createGameSeed = (): string => {
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function')
+ return crypto.randomUUID ()
+
+ return `${ Date.now () }:${ Math.random ().toString (36).slice (2) }`
+}
+
+
+const sourcePriorityForMerge = (question: GekanatorQuestion): number => {
+ switch (question.source)
+ {
+ case 'user_suggested':
+ return 3
+ case 'admin_curated':
+ return 3
+ case 'ai_generated':
+ return 3
+ default:
+ return 1
+ }
+}
+
+
+const shouldReplaceMergedQuestion = (
+ current: GekanatorQuestion | undefined,
+ candidate: GekanatorQuestion,
+): boolean => {
+ if (!(current))
+ return true
+
+ const currentSourcePriority = sourcePriorityForMerge (current)
+ const candidateSourcePriority = sourcePriorityForMerge (candidate)
+ if (candidateSourcePriority !== currentSourcePriority)
+ return candidateSourcePriority > currentSourcePriority
+
+ if (candidate.priorityWeight !== current.priorityWeight)
+ return candidate.priorityWeight > current.priorityWeight
+
+ return true
+}
+
+
+const hashString = (value: string): number => {
+ let hash = 2166136261
+
+ for (let i = 0; i < value.length; i += 1)
+ {
+ hash ^= value.charCodeAt (i)
+ hash = Math.imul (hash, 16777619)
+ }
+
+ return hash >>> 0
+}
+
+
+const deterministicUnitFloat = (seed: string): number =>
+ hashString (seed) / 4294967295
const clearStoredGame = (): void => {
@@ -326,7 +407,11 @@ const previewAnswer = ({
const mergeQuestions = (questions: GekanatorQuestion[]): GekanatorQuestion[] => {
const byId = new Map ()
- questions.forEach (question => byId.set (question.id, question))
+ questions.forEach (question => {
+ const current = byId.get (question.id)
+ if (shouldReplaceMergedQuestion (current, question))
+ byId.set (question.id, question)
+ })
return [...byId.values ()]
}
@@ -367,11 +452,13 @@ const chooseQuestion = ({
questions,
scores,
askedIds,
+ gameSeed,
}: {
posts: Post[]
questions: GekanatorQuestion[]
scores: Map
askedIds: Set
+ gameSeed: string
}): GekanatorQuestion | null => {
const scoredPosts = posts
.map (post => ({ post, score: scores.get (post.id) ?? 0 }))
@@ -440,12 +527,16 @@ const chooseQuestion = ({
const tagPenalty = question.kind === 'tag' && nonTagCount < 4 ? .12 : 0
const minSide = candidates.length < 10 ? 1 : Math.max (3, candidates.length * .08)
const narrowPenalty = yes < minSide || no < minSide ? .15 : 0
+ const sourceBonus = sourcePriorityOffset (question)
+ const priorityBonus = priorityWeightOffset (question)
return { question,
score: weightedSplitScore * 100
+ unweightedSplitScore * 8
+ tagPenalty
- + narrowPenalty,
+ + narrowPenalty
+ + sourceBonus
+ + priorityBonus,
narrow: narrowPenalty > 0 }
})
.filter ((item): item is {
@@ -458,8 +549,35 @@ const chooseQuestion = ({
const unansweredQuestions =
questions.filter (question => !(askedIds.has (question.id)))
const ranked = rank (unansweredQuestions, scoredPosts, normalisedWeightedPosts)
+ const pool = (
+ ranked.some (item => !(item.narrow))
+ ? ranked.filter (item => !(item.narrow))
+ : ranked)
+ .slice (0, 12)
- return (ranked.find (item => !(item.narrow)) ?? ranked[0])?.question ?? null
+ if (pool.length === 0)
+ return null
+
+ const bestScore = pool[0]?.score ?? 0
+ const weightedPool = pool.map (item => ({
+ ...item,
+ weight: Math.exp ((bestScore - item.score) / 1.8) }))
+ const totalPoolWeight =
+ weightedPool.reduce ((sum, item) => sum + item.weight, 0) || 1
+ const seed = `${ gameSeed }:${ [...askedIds].sort ().join ('|') }:${
+ weightedPool.map (item => `${ item.question.id }:${ item.score.toFixed (4) }`).join ('|')
+ }`
+ const target = deterministicUnitFloat (seed) * totalPoolWeight
+ let cumulative = 0
+
+ for (const item of weightedPool)
+ {
+ cumulative += item.weight
+ if (target <= cumulative)
+ return item.question
+ }
+
+ return weightedPool[weightedPool.length - 1]?.question ?? null
}
@@ -501,6 +619,8 @@ const expectedAnswerFor = (
const GekanatorPage: FC = () => {
const storedGame = useMemo (loadStoredGame, [])
+ const [gameSeed, setGameSeed] = useState (
+ storedGame?.gameSeed ?? createGameSeed ())
const [phase, setPhase] = useState (storedGame?.phase ?? 'intro')
const [scores, setScores] = useState> (
() => new Map (storedGame?.scores ?? []))
@@ -540,24 +660,37 @@ const GekanatorPage: FC = () => {
storedGame?.questionSuggestion ?? '')
const [questionSuggestionAnswer, setQuestionSuggestionAnswer] =
useState (storedGame?.questionSuggestionAnswer ?? 'yes')
+ const [questionSuggestionCount, setQuestionSuggestionCount] = useState (
+ storedGame?.questionSuggestionCount ?? 0)
const [history, setHistory] = useState ([])
const { data: posts = [], isLoading, error } = useQuery ({
queryKey: gekanatorKeys.posts (),
queryFn: fetchGekanatorPosts })
+ const { data: acceptedQuestions = [], isFetched: acceptedQuestionsFetched } = useQuery ({
+ queryKey: gekanatorKeys.questions (),
+ queryFn: fetchGekanatorQuestions,
+ select: questions => questions.map (restoreGekanatorQuestion) })
useEffect (() => {
- if (posts.length === 0 || storedAskedQuestionBankIds.length === 0)
+ if (
+ posts.length === 0
+ || storedAskedQuestionBankIds.length === 0
+ || !(acceptedQuestionsFetched)
+ )
return
const questionById = new Map (
- buildGekanatorQuestions (posts).map (question => [question.id, question]))
+ mergeQuestions ([
+ ...buildGekanatorQuestions (posts),
+ ...acceptedQuestions])
+ .map (question => [question.id, question]))
setAskedQuestionBank (
storedAskedQuestionBankIds
.map (questionId => questionById.get (questionId))
.filter ((question): question is GekanatorQuestion => question !== undefined))
setStoredAskedQuestionBankIds ([])
- }, [posts, storedAskedQuestionBankIds])
+ }, [posts, storedAskedQuestionBankIds, acceptedQuestions, acceptedQuestionsFetched])
useEffect (() => {
if (!(isStoredPhase (phase)) && answers.length === 0)
@@ -585,8 +718,10 @@ const GekanatorPage: FC = () => {
reviewGuessedPostId,
reviewCorrectPostId,
savedGameId,
+ gameSeed,
questionSuggestion,
- questionSuggestionAnswer }
+ questionSuggestionAnswer,
+ questionSuggestionCount }
try
{
@@ -615,8 +750,10 @@ const GekanatorPage: FC = () => {
reviewGuessedPostId,
reviewCorrectPostId,
savedGameId,
+ gameSeed,
questionSuggestion,
- questionSuggestionAnswer])
+ questionSuggestionAnswer,
+ questionSuggestionCount])
const eligiblePosts = useMemo (
() => candidatePostsFor ({
@@ -627,8 +764,10 @@ const GekanatorPage: FC = () => {
rejectedPostIds }),
[posts, askedQuestionBank, answers, softenedQuestionIds, rejectedPostIds])
const questions = useMemo (
- () => buildGekanatorQuestions (eligiblePosts.length > 1 ? eligiblePosts : posts),
- [eligiblePosts, posts])
+ () => mergeQuestions ([
+ ...buildGekanatorQuestions (eligiblePosts.length > 1 ? eligiblePosts : posts),
+ ...acceptedQuestions]),
+ [acceptedQuestions, eligiblePosts, posts])
const scoringQuestions = useMemo (() => {
return mergeQuestions ([...questions, ...askedQuestionBank])
}, [questions, askedQuestionBank])
@@ -651,7 +790,11 @@ const GekanatorPage: FC = () => {
.slice (0, 3),
[eligiblePosts, scores])
const currentQuestion = chooseQuestion ({
- posts: questionPosts, questions: scoringQuestions, scores, askedIds })
+ posts: questionPosts,
+ questions: scoringQuestions,
+ scores,
+ askedIds,
+ gameSeed })
const answerPreviews = useMemo (
() => currentQuestion
? answerOptions.map (option => previewAnswer ({
@@ -681,10 +824,10 @@ const GekanatorPage: FC = () => {
}})
const questionSuggestionMutation = useMutation ({
mutationFn: saveGekanatorQuestionSuggestion,
- onSuccess: () => {
+ onSuccess: data => {
+ setQuestionSuggestionCount (data.count)
setQuestionSuggestion ('')
setQuestionSuggestionAnswer ('yes')
- reset ()
}})
const reset = () => {
@@ -707,8 +850,10 @@ const GekanatorPage: FC = () => {
setReviewGuessedPostId (null)
setReviewCorrectPostId (null)
setSavedGameId (null)
+ setGameSeed (createGameSeed ())
setQuestionSuggestion ('')
setQuestionSuggestionAnswer ('yes')
+ setQuestionSuggestionCount (0)
setHistory ([])
}
@@ -740,6 +885,7 @@ const GekanatorPage: FC = () => {
let recoveredScoringQuestions = mergeQuestions ([
...buildGekanatorQuestions (
recoveredEligiblePosts.length > 1 ? recoveredEligiblePosts : posts),
+ ...acceptedQuestions,
...nextAskedQuestionBank])
while (
@@ -750,7 +896,8 @@ const GekanatorPage: FC = () => {
posts: recoveredEligiblePosts,
questions: recoveredScoringQuestions,
scores: recoveredScores,
- askedIds: nextAskedIds })))
+ askedIds: nextAskedIds,
+ gameSeed })))
)
{
if (nextAnswers.length >= hardMaxQuestions)
@@ -778,6 +925,7 @@ const GekanatorPage: FC = () => {
recoveredScoringQuestions = mergeQuestions ([
...buildGekanatorQuestions (
recoveredEligiblePosts.length > 1 ? recoveredEligiblePosts : posts),
+ ...acceptedQuestions,
...nextAskedQuestionBank])
}
@@ -934,9 +1082,23 @@ const GekanatorPage: FC = () => {
saveReviewedResult (() => setPhase ('learned'))
}
+ const restartFromQuestionSuggestion = () => {
+ if (savedGameId !== null)
+ {
+ reset ()
+ return
+ }
+
+ saveReviewedResult (reset)
+ }
+
const submitQuestionSuggestion = () => {
const questionText = questionSuggestion.trim ()
- if (!(questionText) || questionSuggestionMutation.isPending)
+ if (
+ !(questionText)
+ || questionSuggestionMutation.isPending
+ || questionSuggestionCount >= maxQuestionSuggestionsPerGame
+ )
return
saveReviewedResult (gekanatorGameId => {
@@ -1008,7 +1170,8 @@ const GekanatorPage: FC = () => {
: nonRejectedPosts,
questions: recovered.scoringQuestions,
scores: recovered.scores,
- askedIds })
+ askedIds,
+ gameSeed })
if (nextQuestion)
{
@@ -1445,6 +1608,9 @@ const GekanatorPage: FC = () => {
質問追加
どんな質問なら見分けられさう?
+
+ 追加済み {questionSuggestionCount} / {maxQuestionSuggestionsPerGame}
+
+ {questionSuggestionCount >= maxQuestionSuggestionsPerGame && (
+
+ このゲームでは質問候補をこれ以上追加できません。
+
)}
{(saveMutation.isError || questionSuggestionMutation.isError) && (
記録できませんでした。通信状態を確認してもう一度試して。
--
2.34.1
From 159ad5ed5af030474f5a1fdb08056623bef0ed49 Mon Sep 17 00:00:00 2001
From: miteruzo
Date: Tue, 9 Jun 2026 23:36:24 +0900
Subject: [PATCH 10/15] #41
---
backend/app/models/gekanator_question.rb | 5 +-
frontend/src/pages/GekanatorPage.tsx | 175 ++++++++++++++++++++++-
2 files changed, 172 insertions(+), 8 deletions(-)
diff --git a/backend/app/models/gekanator_question.rb b/backend/app/models/gekanator_question.rb
index a4c838f..8f9b925 100644
--- a/backend/app/models/gekanator_question.rb
+++ b/backend/app/models/gekanator_question.rb
@@ -13,7 +13,10 @@ class GekanatorQuestion < ApplicationRecord
validates :condition, presence: true
validates :priority_weight,
presence: true,
- numericality: { greater_than: 0 }
+ numericality: {
+ greater_than: 0,
+ less_than_or_equal_to: 3
+ }
scope :accepted, -> { where(status: 'accepted') }
end
diff --git a/frontend/src/pages/GekanatorPage.tsx b/frontend/src/pages/GekanatorPage.tsx
index 7a42884..2eef5b6 100644
--- a/frontend/src/pages/GekanatorPage.tsx
+++ b/frontend/src/pages/GekanatorPage.tsx
@@ -125,7 +125,7 @@ const sourcePriorityOffset = (question: GekanatorQuestion): number => {
const priorityWeightOffset = (question: GekanatorQuestion): number =>
- (question.priorityWeight - 1) * -.8
+ (Math.min (3, Math.max (.2, question.priorityWeight)) - 1) * -.8
const createGameSeed = (): string => {
@@ -447,16 +447,162 @@ const softenNextQuestionIds = ({
}
+type ExclusiveConditionGroup =
+ | 'original-month'
+ | 'original-year'
+ | 'original-month-day'
+ | 'source'
+
+
+const exclusiveConditionGroupFor = (
+ condition: GekanatorQuestion['condition'],
+): ExclusiveConditionGroup | null => {
+ switch (condition.type)
+ {
+ case 'original-month':
+ return 'original-month'
+ case 'original-year':
+ return 'original-year'
+ case 'original-month-day':
+ return 'original-month-day'
+ case 'source':
+ return 'source'
+ default:
+ return null
+ }
+}
+
+
+const sameConditionValue = (
+ left: GekanatorQuestion['condition'],
+ right: GekanatorQuestion['condition'],
+): boolean => {
+ if (left.type !== right.type)
+ return false
+
+ const valueKeyFor = (condition: GekanatorQuestion['condition']): string => {
+ switch (condition.type)
+ {
+ case 'tag':
+ return condition.key
+ case 'source':
+ return condition.host
+ case 'original-year':
+ return String (condition.year)
+ case 'original-month':
+ return String (condition.month)
+ case 'original-month-day':
+ return condition.monthDay
+ case 'title-length-greater-than':
+ return String (condition.length)
+ case 'title-has-ascii':
+ return ''
+ }
+ }
+
+ return valueKeyFor (left) === valueKeyFor (right)
+}
+
+
+const monthForCondition = (
+ condition: GekanatorQuestion['condition'],
+): number | null => {
+ if (condition.type === 'original-month')
+ return condition.month
+
+ if (condition.type !== 'original-month-day')
+ return null
+
+ const month = Number (condition.monthDay.split ('-')[0])
+ return Number.isInteger (month) ? month : null
+}
+
+
+const isMonthCrossMatch = (
+ candidate: GekanatorQuestion['condition'],
+ previous: GekanatorQuestion['condition'],
+): boolean => {
+ const candidateMonth = monthForCondition (candidate)
+ const previousMonth = monthForCondition (previous)
+ if (candidateMonth === null || previousMonth === null)
+ return false
+
+ const sameType = candidate.type === previous.type
+ if (sameType)
+ return false
+
+ return candidateMonth === previousMonth
+}
+
+
+const isExclusiveContradiction = (
+ candidate: GekanatorQuestion['condition'],
+ previous: GekanatorQuestion['condition'],
+): boolean => {
+ const candidateGroup = exclusiveConditionGroupFor (candidate)
+ const previousGroup = exclusiveConditionGroupFor (previous)
+
+ if (candidateGroup !== null && candidateGroup === previousGroup)
+ return !(sameConditionValue (candidate, previous))
+
+ const candidateMonth = monthForCondition (candidate)
+ const previousMonth = monthForCondition (previous)
+ if (candidateMonth !== null && previousMonth !== null)
+ return candidateMonth !== previousMonth
+
+ return false
+}
+
+
+const contradictionPenaltyFor = ({
+ question,
+ answers,
+}: {
+ question: GekanatorQuestion
+ answers: GekanatorAnswerLog[]
+}): number => {
+ return answers.reduce ((sum, answer) => {
+ const previous = answer.questionCondition
+ if (!(previous))
+ return sum
+
+ switch (answer.answer)
+ {
+ case 'yes':
+ return sum + (isExclusiveContradiction (question.condition, previous) ? 100 : 0)
+ case 'partial':
+ return sum + (isExclusiveContradiction (question.condition, previous) ? 25 : 0)
+ case 'no':
+ return sum + (
+ sameConditionValue (question.condition, previous)
+ || isMonthCrossMatch (question.condition, previous)
+ ? 40
+ : 0)
+ case 'probably_no':
+ return sum + (
+ sameConditionValue (question.condition, previous)
+ || isMonthCrossMatch (question.condition, previous)
+ ? 20
+ : 0)
+ default:
+ return sum
+ }
+ }, 0)
+}
+
+
const chooseQuestion = ({
posts,
questions,
scores,
+ answers,
askedIds,
gameSeed,
}: {
posts: Post[]
questions: GekanatorQuestion[]
scores: Map
+ answers: GekanatorAnswerLog[]
askedIds: Set
gameSeed: string
}): GekanatorQuestion | null => {
@@ -527,6 +673,7 @@ const chooseQuestion = ({
const tagPenalty = question.kind === 'tag' && nonTagCount < 4 ? .12 : 0
const minSide = candidates.length < 10 ? 1 : Math.max (3, candidates.length * .08)
const narrowPenalty = yes < minSide || no < minSide ? .15 : 0
+ const contradictionPenalty = contradictionPenaltyFor ({ question, answers })
const sourceBonus = sourcePriorityOffset (question)
const priorityBonus = priorityWeightOffset (question)
@@ -535,6 +682,7 @@ const chooseQuestion = ({
+ unweightedSplitScore * 8
+ tagPenalty
+ narrowPenalty
+ + contradictionPenalty
+ sourceBonus
+ priorityBonus,
narrow: narrowPenalty > 0 }
@@ -666,11 +814,18 @@ const GekanatorPage: FC = () => {
const { data: posts = [], isLoading, error } = useQuery ({
queryKey: gekanatorKeys.posts (),
- queryFn: fetchGekanatorPosts })
- const { data: acceptedQuestions = [], isFetched: acceptedQuestionsFetched } = useQuery ({
+ queryFn: fetchGekanatorPosts,
+ refetchOnWindowFocus: false })
+ const {
+ data: acceptedQuestions = [],
+ isFetched: acceptedQuestionsFetched,
+ isLoading: acceptedQuestionsLoading,
+ error: acceptedQuestionsError
+ } = useQuery ({
queryKey: gekanatorKeys.questions (),
queryFn: fetchGekanatorQuestions,
- select: questions => questions.map (restoreGekanatorQuestion) })
+ select: questions => questions.map (restoreGekanatorQuestion),
+ refetchOnWindowFocus: false })
useEffect (() => {
if (
@@ -793,6 +948,7 @@ const GekanatorPage: FC = () => {
posts: questionPosts,
questions: scoringQuestions,
scores,
+ answers,
askedIds,
gameSeed })
const answerPreviews = useMemo (
@@ -896,6 +1052,7 @@ const GekanatorPage: FC = () => {
posts: recoveredEligiblePosts,
questions: recoveredScoringQuestions,
scores: recoveredScores,
+ answers: nextAnswers,
askedIds: nextAskedIds,
gameSeed })))
)
@@ -1170,6 +1327,7 @@ const GekanatorPage: FC = () => {
: nonRejectedPosts,
questions: recovered.scoringQuestions,
scores: recovered.scores,
+ answers,
askedIds,
gameSeed })
@@ -1229,6 +1387,8 @@ const GekanatorPage: FC = () => {
phase === 'learned' && resultWon
? <>グカカカカwwwww 洗澡鹿は何でもお見通し!>
: <>私は洗澡鹿.質問から投稿を何でも当ててみせるよ>
+ const introLoading = isLoading || acceptedQuestionsLoading
+ const readyToStart = !(introLoading) && acceptedQuestionsFetched && posts.length > 0
return (
@@ -1258,15 +1418,16 @@ const GekanatorPage: FC = () => {
{dialogue}
- {isLoading && (
+ {introLoading && (
{phase === 'intro'
? '投稿を読み込んでゐます...'
: '前回のグカネータ状態を復元してゐます...'}
)}
- {Boolean (error) &&
投稿を読み込めませんでした.
}
+ {(Boolean (error) || Boolean (acceptedQuestionsError))
+ &&
グカネータの質問データを読み込めませんでした.
}
- {phase === 'intro' && !(isLoading) && posts.length > 0 && (
+ {phase === 'intro' && readyToStart && (
+
)}
@@ -1843,9 +2011,81 @@ const GekanatorPage: FC = () => {
)}
)}
+ {phase === 'extra_questions' && (
+
+
+
追加学習
+
追加で 2 問まで答へてください。
+
+
+ {extraQuestionState === 'loading' && (
+
追加質問を読み込んでゐます...
)}
+
+ {extraQuestionState === 'empty' && (
+
追加で学習できる質問はありませんでした。
)}
+
+ {extraQuestionState === 'ready' && (
+
+ {extraQuestions.map ((question, index) => (
+
+
+ 追加質問 {index + 1}
+
+
{question.text}
+
+ {answerOptions.map (option => (
+ ))}
+
+
))}
+
)}
+
+ {extraQuestionAnswersMutation.isError && (
+
+ 学習内容を保存できませんでした。通信状態を確認してもう一度試して。
+
)}
+
+
+
+
+
+
)}
+
{phase === 'learned' && (
-
覚えたよ.次はもっと見通す.
+
{extraQuestionState === 'saved' ? '学習しました。' : '覚えたよ.次はもっと見通す.'}