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> (new Map ()) - const [answers, setAnswers] = useState ([]) - const [askedIds, setAskedIds] = useState> (new Set ()) - const [softenedQuestionIds, setSoftenedQuestionIds] = useState> (new Set ()) - 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 [savedGameId, setSavedGameId] = useState (null) - const [questionSuggestion, setQuestionSuggestion] = useState ('') + const storedGame = useMemo (loadStoredGame, []) + const [phase, setPhase] = useState (storedGame?.phase ?? 'intro') + const [scores, setScores] = useState> ( + () => new Map (storedGame?.scores ?? [])) + const [answers, setAnswers] = useState ( + storedGame?.answers ?? []) + const [askedIds, setAskedIds] = useState> ( + () => new Set (storedGame?.askedIds ?? [])) + const [softenedQuestionIds, setSoftenedQuestionIds] = useState> ( + () => new Set (storedGame?.softenedQuestionIds ?? [])) + const [askedQuestionBank, setAskedQuestionBank] = useState ( + () => (storedGame?.askedQuestionBank ?? []).map (restoreGekanatorQuestion)) + const [storedAskedQuestionBankIds, setStoredAskedQuestionBankIds] = useState ( + (storedGame?.askedQuestionBank?.length ?? 0) > 0 + ? [] + : storedGame?.askedQuestionBankIds ?? []) + const [search, setSearch] = useState (storedGame?.search ?? '') + const [selectingCorrectPost, setSelectingCorrectPost] = useState ( + storedGame?.selectingCorrectPost ?? false) + const [saved, setSaved] = useState (storedGame?.saved ?? false) + const [resultWon, setResultWon] = useState ( + storedGame?.resultWon ?? null) + const [rejectedPostIds, setRejectedPostIds] = useState> ( + () => new Set (storedGame?.rejectedPostIds ?? [])) + const [lastGuessQuestionCount, setLastGuessQuestionCount] = useState ( + storedGame?.lastGuessQuestionCount ?? 0) + const [lastRejectedGuessId, setLastRejectedGuessId] = useState ( + storedGame?.lastRejectedGuessId ?? null) + const [activeGuessId, setActiveGuessId] = useState ( + storedGame?.activeGuessId ?? null) + const [reviewGuessedPostId, setReviewGuessedPostId] = useState ( + storedGame?.reviewGuessedPostId ?? null) + const [reviewCorrectPostId, setReviewCorrectPostId] = useState ( + storedGame?.reviewCorrectPostId ?? null) + const [savedGameId, setSavedGameId] = useState ( + storedGame?.savedGameId ?? null) + const [questionSuggestion, setQuestionSuggestion] = useState ( + storedGame?.questionSuggestion ?? '') + const [questionSuggestionAnswer, setQuestionSuggestionAnswer] = + useState (storedGame?.questionSuggestionAnswer ?? 'yes') const [history, setHistory] = useState ([]) const { data: posts = [], isLoading, error } = useQuery ({ queryKey: gekanatorKeys.posts (), queryFn: fetchGekanatorPosts }) + useEffect (() => { + if (posts.length === 0 || storedAskedQuestionBankIds.length === 0) + return + + const questionById = new Map ( + buildGekanatorQuestions (posts).map (question => [question.id, question])) + setAskedQuestionBank ( + storedAskedQuestionBankIds + .map (questionId => questionById.get (questionId)) + .filter ((question): question is GekanatorQuestion => question !== undefined)) + setStoredAskedQuestionBankIds ([]) + }, [posts, storedAskedQuestionBankIds]) + + useEffect (() => { + if (!(isStoredPhase (phase)) && answers.length === 0) + { + clearStoredGame () + return + } + + const stored: StoredGekanatorGame = { + phase, + scores: [...scores.entries ()], + answers, + askedIds: [...askedIds], + softenedQuestionIds: [...softenedQuestionIds], + askedQuestionBank: askedQuestionBank.map (storeGekanatorQuestion), + askedQuestionBankIds: storedAskedQuestionBankIds, + search, + selectingCorrectPost, + saved, + resultWon, + rejectedPostIds: [...rejectedPostIds], + lastGuessQuestionCount, + lastRejectedGuessId, + activeGuessId, + reviewGuessedPostId, + reviewCorrectPostId, + savedGameId, + questionSuggestion, + questionSuggestionAnswer } + + try + { + sessionStorage.setItem (gameStorageKey, JSON.stringify (stored)) + } + catch + { + return + } + }, [ + phase, + scores, + answers, + askedIds, + softenedQuestionIds, + askedQuestionBank, + storedAskedQuestionBankIds, + search, + selectingCorrectPost, + saved, + resultWon, + rejectedPostIds, + lastGuessQuestionCount, + lastRejectedGuessId, + activeGuessId, + reviewGuessedPostId, + reviewCorrectPostId, + savedGameId, + questionSuggestion, + questionSuggestionAnswer]) + const eligiblePosts = useMemo ( () => candidatePostsFor ({ posts, @@ -531,10 +683,12 @@ const GekanatorPage: FC = () => { mutationFn: saveGekanatorQuestionSuggestion, onSuccess: () => { setQuestionSuggestion ('') + setQuestionSuggestionAnswer ('yes') reset () }}) const reset = () => { + clearStoredGame () saveMutation.reset () setPhase ('intro') setScores (new Map ()) @@ -554,6 +708,7 @@ const GekanatorPage: FC = () => { setReviewCorrectPostId (null) setSavedGameId (null) setQuestionSuggestion ('') + setQuestionSuggestionAnswer ('yes') setHistory ([]) } @@ -659,6 +814,7 @@ const GekanatorPage: FC = () => { const nextAnswers = [...answers, { questionId: currentQuestion.id, questionText: currentQuestion.text, + questionCondition: currentQuestion.condition, answer: value, originalAnswer: value }] const nextAskedIds = new Set ([...askedIds, currentQuestion.id]) @@ -784,7 +940,10 @@ const GekanatorPage: FC = () => { return saveReviewedResult (gekanatorGameId => { - questionSuggestionMutation.mutate ({ gekanatorGameId, questionText }) + questionSuggestionMutation.mutate ({ + gekanatorGameId, + questionText, + answer: questionSuggestionAnswer }) }) } @@ -936,7 +1095,12 @@ const GekanatorPage: FC = () => { {dialogue}

- {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"/> +