From be5359eb84a6192dcb86b337ddb54cfe4171a97e Mon Sep 17 00:00:00 2001
From: miteruzo
Date: Tue, 9 Jun 2026 08:17:16 +0900
Subject: [PATCH] #41
---
.../controllers/gekanator_posts_controller.rb | 46 ++++
...kanator_question_suggestions_controller.rb | 3 +-
.../models/gekanator_question_suggestion.rb | 2 +
backend/config/routes.rb | 1 +
...nswer_to_gekanator_question_suggestions.rb | 5 +
backend/db/schema.rb | 3 +-
frontend/src/lib/gekanator.ts | 97 ++++++--
frontend/src/pages/GekanatorPage.tsx | 233 ++++++++++++++++--
8 files changed, 339 insertions(+), 51 deletions(-)
create mode 100644 backend/app/controllers/gekanator_posts_controller.rb
create mode 100644 backend/db/migrate/20260608002000_add_answer_to_gekanator_question_suggestions.rb
diff --git a/backend/app/controllers/gekanator_posts_controller.rb b/backend/app/controllers/gekanator_posts_controller.rb
new file mode 100644
index 0000000..9654920
--- /dev/null
+++ b/backend/app/controllers/gekanator_posts_controller.rb
@@ -0,0 +1,46 @@
+class GekanatorPostsController < ApplicationController
+ def index
+ return head :not_found unless current_user&.admin?
+
+ posts =
+ Post
+ .preload(tags: :tag_name)
+ .with_attached_thumbnail
+ .order(Arel.sql(
+ 'COALESCE(posts.original_created_before - INTERVAL 1 MINUTE, ' \
+ 'posts.original_created_from, posts.created_at) DESC, posts.id DESC'))
+
+ render json: { posts: posts.map { |post| post_json(post) } }
+ end
+
+ private
+
+ def post_json post
+ {
+ id: post.id,
+ url: post.url,
+ title: post.title,
+ thumbnail: thumbnail_url(post),
+ thumbnail_base: post.thumbnail_base,
+ original_created_from: post.original_created_from,
+ original_created_before: post.original_created_before,
+ tags: post.tags.map { |tag| tag_json(tag) }
+ }
+ end
+
+ def tag_json tag
+ {
+ id: tag.id,
+ name: tag.name,
+ category: tag.category
+ }
+ end
+
+ def thumbnail_url post
+ return nil unless post.thumbnail.attached?
+
+ rails_storage_proxy_url(post.thumbnail, only_path: false)
+ rescue
+ nil
+ end
+end
diff --git a/backend/app/controllers/gekanator_question_suggestions_controller.rb b/backend/app/controllers/gekanator_question_suggestions_controller.rb
index bcd1c6a..bf361c6 100644
--- a/backend/app/controllers/gekanator_question_suggestions_controller.rb
+++ b/backend/app/controllers/gekanator_question_suggestions_controller.rb
@@ -8,7 +8,8 @@ class GekanatorQuestionSuggestionsController < ApplicationController
suggestion = GekanatorQuestionSuggestion.new(
gekanator_game: game,
user: current_user,
- question_text: params.require(:question_text))
+ question_text: params.require(:question_text),
+ answer: params.require(:answer))
if suggestion.save
render json: { id: suggestion.id }, status: :created
diff --git a/backend/app/models/gekanator_question_suggestion.rb b/backend/app/models/gekanator_question_suggestion.rb
index 9034ee8..2159f5e 100644
--- a/backend/app/models/gekanator_question_suggestion.rb
+++ b/backend/app/models/gekanator_question_suggestion.rb
@@ -1,10 +1,12 @@
class GekanatorQuestionSuggestion < ApplicationRecord
MAX_QUESTIONS_PER_GAME = 1
+ ANSWERS = ['yes', 'no', 'partial', 'probably_no', 'unknown'].freeze
belongs_to :gekanator_game
belongs_to :user
validates :question_text, presence: true, length: { maximum: 1000 }
+ validates :answer, presence: true, inclusion: { in: ANSWERS }
validates :processed, inclusion: { in: [true, false] }
validate :question_suggestion_limit_per_game, on: :create
diff --git a/backend/config/routes.rb b/backend/config/routes.rb
index a0ec2c8..3d4b505 100644
--- a/backend/config/routes.rb
+++ b/backend/config/routes.rb
@@ -65,6 +65,7 @@ Rails.application.routes.draw do
namespace :gekanator do
resources :games, only: [:create], controller: '/gekanator_games'
+ resources :posts, only: [:index], controller: '/gekanator_posts'
resources :question_suggestions,
only: [:create],
controller: '/gekanator_question_suggestions'
diff --git a/backend/db/migrate/20260608002000_add_answer_to_gekanator_question_suggestions.rb b/backend/db/migrate/20260608002000_add_answer_to_gekanator_question_suggestions.rb
new file mode 100644
index 0000000..104b156
--- /dev/null
+++ b/backend/db/migrate/20260608002000_add_answer_to_gekanator_question_suggestions.rb
@@ -0,0 +1,5 @@
+class AddAnswerToGekanatorQuestionSuggestions < ActiveRecord::Migration[8.0]
+ def change
+ add_column :gekanator_question_suggestions, :answer, :string, null: false
+ end
+end
diff --git a/backend/db/schema.rb b/backend/db/schema.rb
index 986465c..f50a921 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_08_002000) 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
@@ -70,6 +70,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_08_000000) do
t.boolean "processed", default: false, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
+ t.string "answer", null: false
t.index ["gekanator_game_id"], name: "index_gekanator_question_suggestions_on_gekanator_game_id"
t.index ["user_id"], name: "index_gekanator_question_suggestions_on_user_id"
end
diff --git a/frontend/src/lib/gekanator.ts b/frontend/src/lib/gekanator.ts
index 16e7940..55dfd0c 100644
--- a/frontend/src/lib/gekanator.ts
+++ b/frontend/src/lib/gekanator.ts
@@ -1,5 +1,4 @@
-import { apiPost } from '@/lib/api'
-import { fetchPosts } from '@/lib/posts'
+import { apiGet, apiPost } from '@/lib/api'
import type { Post } from '@/types'
@@ -13,6 +12,7 @@ export type GekanatorAnswerValue =
export type GekanatorAnswerLog = {
questionId: string
questionText: string
+ questionCondition?: GekanatorQuestionCondition
answer: GekanatorAnswerValue
originalAnswer: GekanatorAnswerValue }
@@ -22,10 +22,26 @@ export type GekanatorQuestionKind =
| 'title'
| 'original_date'
+export type GekanatorQuestionCondition =
+ | { type: 'tag'; key: string }
+ | { type: 'source'; host: string }
+ | { type: 'original-year'; year: number }
+ | { type: 'original-month'; month: number }
+ | { type: 'original-month-day'; monthDay: string }
+ | { type: 'title-length-greater-than'; length: number }
+ | { type: 'title-has-ascii' }
+
+export type StoredGekanatorQuestion = {
+ id: string
+ text: string
+ kind: GekanatorQuestionKind
+ condition: GekanatorQuestionCondition }
+
export type GekanatorQuestion = {
id: string
text: string
kind: GekanatorQuestionKind
+ condition: GekanatorQuestionCondition
test: (post: Post) => boolean }
const countBy = (values: T[]): Map => {
@@ -144,27 +160,49 @@ const questionableTag = (post: Post, key: string): boolean => {
}
+const questionMatches = (
+ post: Post,
+ condition: GekanatorQuestionCondition,
+): boolean => {
+ switch (condition.type)
+ {
+ case 'tag':
+ return questionableTag (post, condition.key)
+ case 'source':
+ return hostOf (post) === condition.host
+ case 'original-year':
+ return originalYearOf (post) === condition.year
+ case 'original-month':
+ return originalMonthOf (post) === condition.month
+ case 'original-month-day':
+ return originalMonthDayOf (post) === condition.monthDay
+ case 'title-length-greater-than':
+ return (post.title?.length ?? 0) > condition.length
+ case 'title-has-ascii':
+ return /[A-Za-z0-9]/.test (post.title ?? '')
+ }
+}
+
+
+export const restoreGekanatorQuestion = (
+ question: StoredGekanatorQuestion,
+): GekanatorQuestion => ({
+ ...question,
+ test: (post: Post) => questionMatches (post, question.condition) })
+
+
+export const storeGekanatorQuestion = (
+ question: GekanatorQuestion,
+): StoredGekanatorQuestion => ({
+ id: question.id,
+ text: question.text,
+ kind: question.kind,
+ condition: question.condition })
+
+
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
+ const data = await apiGet<{ posts: Post[] }> ('/gekanator/posts')
+ return data.posts
}
@@ -209,6 +247,7 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
id: `tag:${ key }`,
text: tagQuestionText (category, label),
kind: 'tag' as const,
+ condition: { type: 'tag' as const, key: String (key) },
test: (post: Post) => questionableTag (post, String (key)) }
})
@@ -219,6 +258,7 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
id: `source:${ host }`,
text: `${ host } の投稿を思ひ浮かべてゐる?`,
kind: 'source' as const,
+ condition: { type: 'source' as const, host },
test: (post: Post) => hostOf (post) === host }))
const originalYearQuestions = usefulEntries (originalYears)
@@ -228,6 +268,7 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
id: `original-year:${ year }`,
text: `オリジナルの投稿年は ${ year } 年?`,
kind: 'original_date' as const,
+ condition: { type: 'original-year' as const, year },
test: (post: Post) => originalYearOf (post) === year }))
const originalMonthQuestions = usefulEntries (originalMonths)
@@ -237,6 +278,7 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
id: `original-month:${ month }`,
text: `オリジナルの投稿月は ${ month } 月?`,
kind: 'original_date' as const,
+ condition: { type: 'original-month' as const, month },
test: (post: Post) => originalMonthOf (post) === month }))
const originalMonthDayQuestions = usefulEntries (originalMonthDays)
@@ -249,6 +291,7 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
id: `original-month-day:${ monthDay }`,
text: `オリジナルの投稿日は ${ month } 月 ${ day } 日?`,
kind: 'original_date' as const,
+ condition: { type: 'original-month-day' as const, monthDay: String (monthDay) },
test: (post: Post) => originalMonthDayOf (post) === monthDay }
})
@@ -257,11 +300,15 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
id: 'title:long',
text: '題名が長めの投稿?',
kind: 'title' as const,
+ condition: {
+ type: 'title-length-greater-than' as const,
+ length: titleLengthMedian },
test: (post: Post) => (post.title?.length ?? 0) > titleLengthMedian },
{
id: 'title:ascii',
text: '題名に英数字が混じってゐる?',
kind: 'title' as const,
+ condition: { type: 'title-has-ascii' as const },
test: (post: Post) => /[A-Za-z0-9]/.test (post.title ?? '') }]
.filter (question => {
const yes = posts.filter (post => question.test (post)).length
@@ -294,6 +341,7 @@ export const saveGekanatorGame = async ({
answers: answers.map (answer => ({
question_id: answer.questionId,
question_text: answer.questionText,
+ question_condition: answer.questionCondition ?? null,
answer: answer.answer,
original_answer: answer.originalAnswer })) })
@@ -301,10 +349,13 @@ export const saveGekanatorGame = async ({
export const saveGekanatorQuestionSuggestion = async ({
gekanatorGameId,
questionText,
+ answer,
}: {
gekanatorGameId: number
questionText: string
+ answer: GekanatorAnswerValue
}): Promise<{ id: number }> =>
await apiPost ('/gekanator/question_suggestions', {
gekanator_game_id: gekanatorGameId,
- question_text: questionText })
+ question_text: questionText,
+ answer })
diff --git a/frontend/src/pages/GekanatorPage.tsx b/frontend/src/pages/GekanatorPage.tsx
index c600993..2b1443b 100644
--- a/frontend/src/pages/GekanatorPage.tsx
+++ b/frontend/src/pages/GekanatorPage.tsx
@@ -1,5 +1,5 @@
import { useMutation, useQuery } from '@tanstack/react-query'
-import { useMemo, useState } from 'react'
+import { useEffect, useMemo, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import PrefetchLink from '@/components/PrefetchLink'
@@ -7,8 +7,10 @@ import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import { buildGekanatorQuestions,
fetchGekanatorPosts,
+ restoreGekanatorQuestion,
saveGekanatorGame,
- saveGekanatorQuestionSuggestion } from '@/lib/gekanator'
+ saveGekanatorQuestionSuggestion,
+ storeGekanatorQuestion } from '@/lib/gekanator'
import { gekanatorKeys } from '@/lib/queryKeys'
import { cn } from '@/lib/utils'
@@ -16,7 +18,8 @@ import type { FC } from 'react'
import type { GekanatorAnswerLog,
GekanatorAnswerValue,
- GekanatorQuestion } from '@/lib/gekanator'
+ GekanatorQuestion,
+ StoredGekanatorQuestion } from '@/lib/gekanator'
import type { Post } from '@/types'
type Phase =
@@ -61,6 +64,28 @@ type GameSnapshot = {
reviewGuessedPostId: number | null
reviewCorrectPostId: number | null }
+type StoredGekanatorGame = {
+ phase: Phase
+ scores: [number, number][]
+ answers: GekanatorAnswerLog[]
+ askedIds: string[]
+ softenedQuestionIds: string[]
+ askedQuestionBank?: StoredGekanatorQuestion[]
+ askedQuestionBankIds?: string[]
+ search: string
+ selectingCorrectPost: boolean
+ saved: boolean
+ resultWon: boolean | null
+ rejectedPostIds: number[]
+ lastGuessQuestionCount: number
+ lastRejectedGuessId: number | null
+ activeGuessId: number | null
+ reviewGuessedPostId: number | null
+ reviewCorrectPostId: number | null
+ savedGameId: number | null
+ questionSuggestion: string
+ questionSuggestionAnswer: GekanatorAnswerValue }
+
const answerOptions: AnswerOption[] = [
{ label: 'はい', value: 'yes' },
{ label: 'いいえ', value: 'no' },
@@ -78,6 +103,39 @@ const runnerUpMaxPercent = .5
const hardMaxQuestions = 80
const softenedAnswerWeight = .35
const confidenceTemperature = 6
+const gameStorageKey = 'gekanator:game:v1'
+
+
+const clearStoredGame = (): void => {
+ try
+ {
+ sessionStorage.removeItem (gameStorageKey)
+ }
+ catch
+ {
+ return
+ }
+}
+
+
+const loadStoredGame = (): StoredGekanatorGame | null => {
+ try
+ {
+ const raw = sessionStorage.getItem (gameStorageKey)
+ if (!(raw))
+ return null
+
+ return JSON.parse (raw) as StoredGekanatorGame
+ }
+ catch
+ {
+ clearStoredGame ()
+ return null
+ }
+}
+
+
+const isStoredPhase = (phase: Phase): boolean => phase !== 'intro'
const deltaFor = (matched: boolean, answer: GekanatorAnswerValue): number => {
@@ -442,30 +500,124 @@ const expectedAnswerFor = (
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"/>
+