グカネータ作成 (#041) #362

マージ済み
みてるぞ が 13 個のコミットを feature/041 から main へマージ 2026-06-10 23:33:57 +09:00
8個のファイルの変更339行の追加51行の削除
コミット be5359eb84 の変更だけを表示してゐます - すべてのコミットを表示
+46
ファイルの表示
@@ -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
+2 -1
ファイルの表示
@@ -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
+2
ファイルの表示
@@ -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
+1
ファイルの表示
@@ -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'
@@ -0,0 +1,5 @@
class AddAnswerToGekanatorQuestionSuggestions < ActiveRecord::Migration[8.0]
def change
add_column :gekanator_question_suggestions, :answer, :string, null: false
end
end
生成ファイル
+2 -1
ファイルの表示
@@ -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
+74 -23
ファイルの表示
@@ -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 = <T extends string | number> (values: T[]): Map<T, number> => {
@@ -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<Post[]> => {
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 })
+207 -26
ファイルの表示
@@ -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<Phase> ('intro')
const [scores, setScores] = useState<Map<number, number>> (new Map ())
const [answers, setAnswers] = useState<GekanatorAnswerLog[]> ([])
const [askedIds, setAskedIds] = useState<Set<string>> (new Set ())
const [softenedQuestionIds, setSoftenedQuestionIds] = useState<Set<string>> (new Set ())
const [askedQuestionBank, setAskedQuestionBank] = useState<GekanatorQuestion[]> ([])
const [search, setSearch] = useState ('')
const [selectingCorrectPost, setSelectingCorrectPost] = useState (false)
const [saved, setSaved] = useState (false)
const [resultWon, setResultWon] = useState<boolean | null> (null)
const [rejectedPostIds, setRejectedPostIds] = useState<Set<number>> (new Set ())
const [lastGuessQuestionCount, setLastGuessQuestionCount] = useState (0)
const [lastRejectedGuessId, setLastRejectedGuessId] = useState<number | null> (null)
const [activeGuessId, setActiveGuessId] = useState<number | null> (null)
const [reviewGuessedPostId, setReviewGuessedPostId] = useState<number | null> (null)
const [reviewCorrectPostId, setReviewCorrectPostId] = useState<number | null> (null)
const [savedGameId, setSavedGameId] = useState<number | null> (null)
const [questionSuggestion, setQuestionSuggestion] = useState ('')
const storedGame = useMemo (loadStoredGame, [])
const [phase, setPhase] = useState<Phase> (storedGame?.phase ?? 'intro')
const [scores, setScores] = useState<Map<number, number>> (
() => new Map (storedGame?.scores ?? []))
const [answers, setAnswers] = useState<GekanatorAnswerLog[]> (
storedGame?.answers ?? [])
const [askedIds, setAskedIds] = useState<Set<string>> (
() => new Set (storedGame?.askedIds ?? []))
const [softenedQuestionIds, setSoftenedQuestionIds] = useState<Set<string>> (
() => new Set (storedGame?.softenedQuestionIds ?? []))
const [askedQuestionBank, setAskedQuestionBank] = useState<GekanatorQuestion[]> (
() => (storedGame?.askedQuestionBank ?? []).map (restoreGekanatorQuestion))
const [storedAskedQuestionBankIds, setStoredAskedQuestionBankIds] = useState<string[]> (
(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<boolean | null> (
storedGame?.resultWon ?? null)
const [rejectedPostIds, setRejectedPostIds] = useState<Set<number>> (
() => new Set (storedGame?.rejectedPostIds ?? []))
const [lastGuessQuestionCount, setLastGuessQuestionCount] = useState (
storedGame?.lastGuessQuestionCount ?? 0)
const [lastRejectedGuessId, setLastRejectedGuessId] = useState<number | null> (
storedGame?.lastRejectedGuessId ?? null)
const [activeGuessId, setActiveGuessId] = useState<number | null> (
storedGame?.activeGuessId ?? null)
const [reviewGuessedPostId, setReviewGuessedPostId] = useState<number | null> (
storedGame?.reviewGuessedPostId ?? null)
const [reviewCorrectPostId, setReviewCorrectPostId] = useState<number | null> (
storedGame?.reviewCorrectPostId ?? null)
const [savedGameId, setSavedGameId] = useState<number | null> (
storedGame?.savedGameId ?? null)
const [questionSuggestion, setQuestionSuggestion] = useState (
storedGame?.questionSuggestion ?? '')
const [questionSuggestionAnswer, setQuestionSuggestionAnswer] =
useState<GekanatorAnswerValue> (storedGame?.questionSuggestionAnswer ?? 'yes')
const [history, setHistory] = useState<GameSnapshot[]> ([])
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}
</p>
{isLoading && <p>稿...</p>}
{isLoading && (
<p>
{phase === 'intro'
? '投稿を読み込んでゐます...'
: '前回のグカネータ状態を復元してゐます...'}
</p>)}
{Boolean (error) && <p>稿</p>}
{phase === 'intro' && !(isLoading) && posts.length > 0 && (
@@ -1008,7 +1172,7 @@ const GekanatorPage: FC = () => {
</div>
</div>)}
{phase === 'question' && !(currentQuestion) && (
{!(isLoading) && phase === 'question' && !(currentQuestion) && (
<div className="space-y-4">
<p className="text-xl font-bold">
@@ -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')}>
</button>
@@ -1290,13 +1455,29 @@ const GekanatorPage: FC = () => {
bg-white px-3 py-2 dark:border-red-700
dark:bg-red-950"/>
</label>
<label className="block space-y-2">
<span className="font-bold">稿</span>
<select
value={questionSuggestionAnswer}
className="rounded border border-yellow-300 bg-white px-2 py-1
dark:border-red-700 dark:bg-red-950"
onChange={ev =>
setQuestionSuggestionAnswer (
ev.target.value as GekanatorAnswerValue)}>
{answerOptions.map (option => (
<option key={option.value} value={option.value}>
{option.label}
</option>))}
</select>
</label>
<div className="flex flex-wrap gap-2">
<button
type="button"
className="rounded border border-neutral-300 px-4 py-2
hover:bg-neutral-100 dark:border-neutral-700
dark:hover:bg-red-900"
disabled={saveMutation.isPending || questionSuggestionMutation.isPending}
disabled={saveMutation.isPending
|| questionSuggestionMutation.isPending}
onClick={() => setPhase ('end')}>
</button>