グカネータ / 質問パターン見直し (#41) #365

マージ済み
みてるぞ が 20 個のコミットを feature/041 から main へマージ 2026-06-12 01:35:32 +09:00
5個のファイルの変更468行の追加240行の削除
コミット de21141f5a の変更だけを表示してゐます - すべてのコミットを表示
+1 -1
ファイルの表示
@@ -8,7 +8,7 @@ class GekanatorGamesController < ApplicationController
user: current_user, user: current_user,
guessed_post_id: params.require(:guessed_post_id), guessed_post_id: params.require(:guessed_post_id),
correct_post_id: params[:correct_post_id].presence, correct_post_id: params[:correct_post_id].presence,
won: bool?(:won), won: params[:guessed_post_id].to_i == params[:correct_post_id].to_i,
question_count: params.require(:question_count), question_count: params.require(:question_count),
answers:) answers:)
+1 -9
ファイルの表示
@@ -4,15 +4,7 @@ class GekanatorGame < ApplicationRecord
belongs_to :correct_post, class_name: 'Post', optional: true belongs_to :correct_post, class_name: 'Post', optional: true
validates :answers, presence: true validates :answers, presence: true
validates :correct_post, presence: true
validates :question_count, numericality: { greater_than_or_equal_to: 0 } validates :question_count, numericality: { greater_than_or_equal_to: 0 }
validates :won, inclusion: { in: [true, false] } 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 end
+7 -7
ファイルの表示
@@ -11,7 +11,7 @@ RSpec.describe 'Gekanator games API', type: :request do
post '/gekanator/games', params: { post '/gekanator/games', params: {
guessed_post_id: guessed_post.id, guessed_post_id: guessed_post.id,
won: true, correct_post_id: guessed_post.id,
question_count: 3, question_count: 3,
answers: [{ question_id: 'tag:1', answer: 'yes' }] } answers: [{ question_id: 'tag:1', answer: 'yes' }] }
@@ -19,7 +19,7 @@ RSpec.describe 'Gekanator games API', type: :request do
game = GekanatorGame.find(json['id']) game = GekanatorGame.find(json['id'])
expect(game.user).to eq(user) expect(game.user).to eq(user)
expect(game.guessed_post).to eq(guessed_post) expect(game.guessed_post).to eq(guessed_post)
expect(game.correct_post).to be_nil expect(game.correct_post).to eq(guessed_post)
expect(game.won).to eq(true) expect(game.won).to eq(true)
expect(game.answers).to eq([{ 'question_id' => 'tag:1', 'answer' => 'yes' }]) expect(game.answers).to eq([{ 'question_id' => 'tag:1', 'answer' => 'yes' }])
end end
@@ -30,20 +30,20 @@ RSpec.describe 'Gekanator games API', type: :request do
post '/gekanator/games', params: { post '/gekanator/games', params: {
guessed_post_id: guessed_post.id, guessed_post_id: guessed_post.id,
correct_post_id: correct_post.id, correct_post_id: correct_post.id,
won: false,
question_count: 4, question_count: 4,
answers: [{ question_id: 'tag:1', answer: 'no' }] } answers: [{ question_id: 'tag:1', answer: 'no' }] }
expect(response).to have_http_status(:created) expect(response).to have_http_status(:created)
expect(GekanatorGame.find(json['id']).correct_post).to eq(correct_post) game = GekanatorGame.find(json['id'])
expect(game.correct_post).to eq(correct_post)
expect(game.won).to eq(false)
end end
it 'rejects a lost game without the correct post' do it 'rejects a game without the correct post' do
sign_in_as user sign_in_as user
post '/gekanator/games', params: { post '/gekanator/games', params: {
guessed_post_id: guessed_post.id, guessed_post_id: guessed_post.id,
won: false,
question_count: 4, question_count: 4,
answers: [{ question_id: 'tag:1', answer: 'no' }] } answers: [{ question_id: 'tag:1', answer: 'no' }] }
@@ -53,7 +53,7 @@ RSpec.describe 'Gekanator games API', type: :request do
it 'requires a user' do it 'requires a user' do
post '/gekanator/games', params: { post '/gekanator/games', params: {
guessed_post_id: guessed_post.id, guessed_post_id: guessed_post.id,
won: true, correct_post_id: guessed_post.id,
question_count: 1, question_count: 1,
answers: [] } answers: [] }
+44 -56
ファイルの表示
@@ -17,11 +17,9 @@ export type GekanatorAnswerLog = {
export type GekanatorQuestionKind = export type GekanatorQuestionKind =
| 'tag' | 'tag'
| 'title'
| 'date'
| 'media'
| 'source' | 'source'
| 'structure' | 'title'
| 'original_date'
export type GekanatorQuestion = { export type GekanatorQuestion = {
id: string id: string
@@ -54,6 +52,19 @@ const hostOf = (post: Post): string | null => {
} }
const originalYearOf = (post: Post): number | null => {
const value = post.originalCreatedFrom || post.originalCreatedBefore
if (!(value))
return null
const date = new Date (value)
if (Number.isNaN (date.getTime ()))
return null
return date.getFullYear ()
}
const tagQuestionKey = ({ category, name }: { category: string; name: string }): string => const tagQuestionKey = ({ category, name }: { category: string; name: string }): string =>
`${ category }:${ name }` `${ category }:${ name }`
@@ -113,9 +124,11 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
&& !(tag.name.includes ('bot操作'))) && !(tag.name.includes ('bot操作')))
.map (tag => tagQuestionKey (tag)))) .map (tag => tagQuestionKey (tag))))
const hosts = countBy (posts.map (hostOf).filter ((host): host is string => Boolean (host))) const hosts = countBy (posts.map (hostOf).filter ((host): host is string => Boolean (host)))
const tagMedian = median (posts.map (post => post.tags.length)) const originalYears = countBy (
posts
.map (originalYearOf)
.filter ((year): year is number => year !== null))
const titleLengthMedian = median (posts.map (post => post.title?.length ?? 0)) const titleLengthMedian = median (posts.map (post => post.title?.length ?? 0))
const currentYear = new Date ().getFullYear ()
const usefulEntries = <T extends string | number> (counts: Map<T, number>) => const usefulEntries = <T extends string | number> (counts: Map<T, number>) =>
[...counts.entries ()] [...counts.entries ()]
@@ -149,58 +162,36 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
kind: 'source' as const, kind: 'source' as const,
test: (post: Post) => hostOf (post) === host })) test: (post: Post) => hostOf (post) === host }))
return [ const originalYearQuestions = usefulEntries (originalYears)
...sourceQuestions, .filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7))
{ .slice (0, 20)
id: 'title:present', .map (([year]) => ({
text: '題名が付いてゐる投稿?', id: `original-year:${ year }`,
kind: 'title', text: `オリジナルの投稿年は ${ year } 年?`,
test: post => Boolean (post.title) }, kind: 'original_date' as const,
test: (post: Post) => originalYearOf (post) === year }))
const titleQuestions = [
{ {
id: 'title:long', id: 'title:long',
text: '題名が長めの投稿?', text: '題名が長めの投稿?',
kind: 'title', kind: 'title' as const,
test: post => (post.title?.length ?? 0) > titleLengthMedian }, test: (post: Post) => (post.title?.length ?? 0) > titleLengthMedian },
{ {
id: 'title:ascii', id: 'title:ascii',
text: '題名に英数字が混じってゐる?', text: '題名に英数字が混じってゐる?',
kind: 'title', kind: 'title' as const,
test: post => /[A-Za-z0-9]/.test (post.title ?? '') }, test: (post: Post) => /[A-Za-z0-9]/.test (post.title ?? '') }]
{ .filter (question => {
id: 'media:thumbnail', const yes = posts.filter (post => question.test (post)).length
text: 'ぱっと見でサムネが付いてゐる投稿?', const no = posts.length - yes
kind: 'media', return yes >= 2 && no >= 2 && yes <= posts.length * .7 && no <= posts.length * .7
test: post => Boolean (post.thumbnail || post.thumbnailBase) }, })
{
id: 'media:video-source', return [
text: '動画として見られる投稿?', ...sourceQuestions,
kind: 'media', ...originalYearQuestions,
test: post => /nicovideo|youtube|youtu\.be/.test (post.url) }, ...titleQuestions,
{
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] ...tagQuestions]
} }
@@ -208,18 +199,15 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
export const saveGekanatorGame = async ({ export const saveGekanatorGame = async ({
guessedPostId, guessedPostId,
correctPostId, correctPostId,
won,
answers, answers,
}: { }: {
guessedPostId: number guessedPostId: number
correctPostId: number | null correctPostId: number
won: boolean
answers: GekanatorAnswerLog[] answers: GekanatorAnswerLog[]
}): Promise<{ id: number }> => }): Promise<{ id: number }> =>
await apiPost ('/gekanator/games', { await apiPost ('/gekanator/games', {
guessed_post_id: guessedPostId, guessed_post_id: guessedPostId,
correct_post_id: correctPostId, correct_post_id: correctPostId,
won,
question_count: answers.length, question_count: answers.length,
answers: answers.map (answer => ({ answers: answers.map (answer => ({
question_id: answer.questionId, question_id: answer.questionId,
+402 -154
ファイルの表示
@@ -18,7 +18,7 @@ import type { GekanatorAnswerLog,
GekanatorQuestion } from '@/lib/gekanator' GekanatorQuestion } from '@/lib/gekanator'
import type { Post } from '@/types' import type { Post } from '@/types'
type Phase = 'intro' | 'question' | 'guess' | 'continue' | 'learned' type Phase = 'intro' | 'question' | 'guess' | 'continue' | 'review' | 'learned'
type AnswerOption = { type AnswerOption = {
label: string label: string
@@ -41,14 +41,16 @@ type GameSnapshot = {
scores: Map<number, number> scores: Map<number, number>
answers: GekanatorAnswerLog[] answers: GekanatorAnswerLog[]
askedIds: Set<string> askedIds: Set<string>
candidateIds: Set<number> | null
softenedQuestionIds: Set<string> softenedQuestionIds: Set<string>
questionBank: GekanatorQuestion[] askedQuestionBank: GekanatorQuestion[]
search: string search: string
selectingCorrectPost: boolean
rejectedPostIds: Set<number> rejectedPostIds: Set<number>
lastGuessQuestionCount: number lastGuessQuestionCount: number
lastRejectedGuessId: number | null lastRejectedGuessId: number | null
activeGuessId: number | null } activeGuessId: number | null
reviewGuessedPostId: number | null
reviewCorrectPostId: number | null }
const answerOptions: AnswerOption[] = [ const answerOptions: AnswerOption[] = [
{ label: 'はい', value: 'yes' }, { label: 'はい', value: 'yes' },
@@ -58,6 +60,9 @@ const answerOptions: AnswerOption[] = [
{ label: 'わからない', value: 'unknown' }] { label: 'わからない', value: 'unknown' }]
const questionsBetweenGuesses = 25 const questionsBetweenGuesses = 25
const minQuestionsBeforeCertainGuess = 5
const certainGuessPercent = 99.5
const runnerUpMaxPercent = .5
const hardMaxQuestions = 80 const hardMaxQuestions = 80
const softenedAnswerWeight = .35 const softenedAnswerWeight = .35
const confidenceTemperature = 6 const confidenceTemperature = 6
@@ -87,18 +92,14 @@ const answerWeightFor = (
const questionDifficulty = (question: GekanatorQuestion): number => { 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') if (question.kind === 'source')
return 4 return 4
if (question.kind === 'original_date')
return 4
if (question.kind === 'title')
return 4
if (question.kind === 'tag') if (question.kind === 'tag')
return 3 return 3
if (question.kind === 'title' || question.kind === 'structure')
return 2
return 1 return 1
} }
@@ -136,6 +137,47 @@ const recalculateScores = ({
} }
const candidatePostsFor = ({
posts,
questions,
answers,
softenedQuestionIds,
rejectedPostIds,
}: {
posts: Post[]
questions: GekanatorQuestion[]
answers: GekanatorAnswerLog[]
softenedQuestionIds: Set<string>
rejectedPostIds: Set<number>
}): Post[] => {
const questionById = new Map (questions.map (question => [question.id, question]))
return posts.filter (post => {
if (rejectedPostIds.has (post.id))
return false
return answers.every (answer => {
if (softenedQuestionIds.has (answer.questionId))
return true
const question = questionById.get (answer.questionId)
if (!(question))
return true
switch (answer.answer)
{
case 'yes':
return question.test (post)
case 'no':
return !(question.test (post))
default:
return true
}
})
})
}
const confidencesFor = (posts: Post[], scores: Map<number, number>): Confidence[] => { const confidencesFor = (posts: Post[], scores: Map<number, number>): Confidence[] => {
if (posts.length === 0) if (posts.length === 0)
return [] return []
@@ -306,26 +348,12 @@ const chooseQuestion = ({
return null return null
const splitScore = Math.abs (candidates.length / 2 - yes) const splitScore = Math.abs (candidates.length / 2 - yes)
const answerPreviews = answerOptions.map (option => const tagPenalty = question.kind === 'tag' ? 0 : 20
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 minSide = candidates.length < 10 ? 1 : Math.max (3, candidates.length * .08)
const narrowPenalty = yes < minSide || no < minSide ? candidates.length : 0 const narrowPenalty = yes < minSide || no < minSide ? candidates.length : 0
return { question, return { question,
score: splitScore + expectedEntropy + expectedCandidateCount / 8 score: splitScore + tagPenalty + narrowPenalty,
+ kindPenalty + tagPenalty + narrowPenalty,
narrow: narrowPenalty > 0 } narrow: narrowPenalty > 0 }
}) })
.filter ((item): item is { .filter ((item): item is {
@@ -373,34 +401,47 @@ const GekanatorPage: FC = () => {
const [scores, setScores] = useState<Map<number, number>> (new Map ()) const [scores, setScores] = useState<Map<number, number>> (new Map ())
const [answers, setAnswers] = useState<GekanatorAnswerLog[]> ([]) const [answers, setAnswers] = useState<GekanatorAnswerLog[]> ([])
const [askedIds, setAskedIds] = useState<Set<string>> (new Set ()) const [askedIds, setAskedIds] = useState<Set<string>> (new Set ())
const [candidateIds, setCandidateIds] = useState<Set<number> | null> (null)
const [softenedQuestionIds, setSoftenedQuestionIds] = useState<Set<string>> (new Set ()) const [softenedQuestionIds, setSoftenedQuestionIds] = useState<Set<string>> (new Set ())
const [questionBank, setQuestionBank] = useState<GekanatorQuestion[]> ([]) const [askedQuestionBank, setAskedQuestionBank] = useState<GekanatorQuestion[]> ([])
const [search, setSearch] = useState ('') const [search, setSearch] = useState ('')
const [selectingCorrectPost, setSelectingCorrectPost] = useState (false)
const [saved, setSaved] = useState (false) const [saved, setSaved] = useState (false)
const [resultWon, setResultWon] = useState<boolean | null> (null) const [resultWon, setResultWon] = useState<boolean | null> (null)
const [rejectedPostIds, setRejectedPostIds] = useState<Set<number>> (new Set ()) const [rejectedPostIds, setRejectedPostIds] = useState<Set<number>> (new Set ())
const [lastGuessQuestionCount, setLastGuessQuestionCount] = useState (0) const [lastGuessQuestionCount, setLastGuessQuestionCount] = useState (0)
const [lastRejectedGuessId, setLastRejectedGuessId] = useState<number | null> (null) const [lastRejectedGuessId, setLastRejectedGuessId] = useState<number | null> (null)
const [activeGuessId, setActiveGuessId] = 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 [history, setHistory] = useState<GameSnapshot[]> ([]) const [history, setHistory] = useState<GameSnapshot[]> ([])
const { data: posts = [], isLoading, error } = useQuery ({ const { data: posts = [], isLoading, error } = useQuery ({
queryKey: gekanatorKeys.posts (), queryKey: gekanatorKeys.posts (),
queryFn: fetchGekanatorPosts }) queryFn: fetchGekanatorPosts })
const candidatePosts = useMemo (
() => posts.filter (post => candidateIds === null || candidateIds.has (post.id)),
[posts, candidateIds])
const eligiblePosts = useMemo ( const eligiblePosts = useMemo (
() => candidatePosts.filter (post => !(rejectedPostIds.has (post.id))), () => candidatePostsFor ({
[candidatePosts, rejectedPostIds]) posts,
questions: askedQuestionBank,
answers,
softenedQuestionIds,
rejectedPostIds }),
[posts, askedQuestionBank, answers, softenedQuestionIds, rejectedPostIds])
const questions = useMemo ( const questions = useMemo (
() => buildGekanatorQuestions (eligiblePosts.length > 1 ? eligiblePosts : posts), () => buildGekanatorQuestions (eligiblePosts.length > 1 ? eligiblePosts : posts),
[eligiblePosts, posts]) [eligiblePosts, posts])
const scoringQuestions = useMemo (() => { const scoringQuestions = useMemo (() => {
return mergeQuestions ([...questions, ...questionBank]) return mergeQuestions ([...questions, ...askedQuestionBank])
}, [questions, questionBank]) }, [questions, askedQuestionBank])
const questionsSinceLastGuess = answers.length - lastGuessQuestionCount
const nonRejectedPosts = useMemo (
() => posts.filter (post => !(rejectedPostIds.has (post.id))),
[posts, rejectedPostIds])
const questionPosts =
eligiblePosts.length > 1
|| questionsSinceLastGuess >= minQuestionsBeforeCertainGuess
? eligiblePosts
: nonRejectedPosts
const topScoredPosts = useMemo ( const topScoredPosts = useMemo (
() => eligiblePosts () => eligiblePosts
.map (post => ({ post, score: scores.get (post.id) ?? 0 })) .map (post => ({ post, score: scores.get (post.id) ?? 0 }))
@@ -408,7 +449,7 @@ const GekanatorPage: FC = () => {
.slice (0, 3), .slice (0, 3),
[eligiblePosts, scores]) [eligiblePosts, scores])
const currentQuestion = chooseQuestion ({ const currentQuestion = chooseQuestion ({
posts: eligiblePosts, questions: scoringQuestions, scores, askedIds }) posts: questionPosts, questions: scoringQuestions, scores, askedIds })
const answerPreviews = useMemo ( const answerPreviews = useMemo (
() => currentQuestion () => currentQuestion
? answerOptions.map (option => previewAnswer ({ ? answerOptions.map (option => previewAnswer ({
@@ -418,29 +459,122 @@ const GekanatorPage: FC = () => {
answer: option.value })) answer: option.value }))
: [], : [],
[currentQuestion, eligiblePosts, scores]) [currentQuestion, eligiblePosts, scores])
const guess = bestPost (eligiblePosts, scores) const guessablePosts =
eligiblePosts.length > 0
? eligiblePosts
: nonRejectedPosts
const guess = bestPost (guessablePosts, scores)
const displayedGuess = const displayedGuess =
posts.find (post => post.id === activeGuessId) ?? guess posts.find (post => post.id === activeGuessId) ?? guess
const saveMutation = useMutation ({ mutationFn: saveGekanatorGame }) const reviewGuessedPost =
posts.find (post => post.id === reviewGuessedPostId) ?? null
const reviewCorrectPost =
posts.find (post => post.id === reviewCorrectPostId) ?? null
const saveMutation = useMutation ({
mutationFn: saveGekanatorGame,
onSuccess: () => {
setSaved (true)
setResultWon (reviewGuessedPostId === reviewCorrectPostId)
setPhase ('learned')
}})
const reset = () => { const reset = () => {
saveMutation.reset ()
setPhase ('intro') setPhase ('intro')
setScores (new Map ()) setScores (new Map ())
setAnswers ([]) setAnswers ([])
setAskedIds (new Set ()) setAskedIds (new Set ())
setCandidateIds (null)
setSoftenedQuestionIds (new Set ()) setSoftenedQuestionIds (new Set ())
setQuestionBank ([]) setAskedQuestionBank ([])
setSearch ('') setSearch ('')
setSelectingCorrectPost (false)
setSaved (false) setSaved (false)
setResultWon (null) setResultWon (null)
setRejectedPostIds (new Set ()) setRejectedPostIds (new Set ())
setLastGuessQuestionCount (0) setLastGuessQuestionCount (0)
setLastRejectedGuessId (null) setLastRejectedGuessId (null)
setActiveGuessId (null) setActiveGuessId (null)
setReviewGuessedPostId (null)
setReviewCorrectPostId (null)
setHistory ([]) setHistory ([])
} }
const recoverQuestionState = ({
nextAnswers,
nextAskedIds,
nextAskedQuestionBank,
nextSoftenedQuestionIds,
nextRejectedPostIds,
}: {
nextAnswers: GekanatorAnswerLog[]
nextAskedIds: Set<string>
nextAskedQuestionBank: GekanatorQuestion[]
nextSoftenedQuestionIds: Set<string>
nextRejectedPostIds: Set<number>
}) => {
let recoveredSoftenedQuestionIds = new Set (nextSoftenedQuestionIds)
let recoveredScores = recalculateScores ({
posts,
questions: nextAskedQuestionBank,
answers: nextAnswers,
softenedQuestionIds: recoveredSoftenedQuestionIds })
let recoveredEligiblePosts = candidatePostsFor ({
posts,
questions: nextAskedQuestionBank,
answers: nextAnswers,
softenedQuestionIds: recoveredSoftenedQuestionIds,
rejectedPostIds: nextRejectedPostIds })
let recoveredScoringQuestions = mergeQuestions ([
...buildGekanatorQuestions (
recoveredEligiblePosts.length > 1 ? recoveredEligiblePosts : posts),
...nextAskedQuestionBank])
while (
recoveredEligiblePosts.length === 0
|| (
recoveredEligiblePosts.length !== 1
&& !(chooseQuestion ({
posts: recoveredEligiblePosts,
questions: recoveredScoringQuestions,
scores: recoveredScores,
askedIds: nextAskedIds })))
)
{
if (nextAnswers.length >= hardMaxQuestions)
break
const softened = softenNextQuestionIds ({
questions: nextAskedQuestionBank,
answers: nextAnswers,
softenedQuestionIds: recoveredSoftenedQuestionIds })
if (!(softened))
break
recoveredSoftenedQuestionIds = softened
recoveredScores = recalculateScores ({
posts,
questions: nextAskedQuestionBank,
answers: nextAnswers,
softenedQuestionIds: recoveredSoftenedQuestionIds })
recoveredEligiblePosts = candidatePostsFor ({
posts,
questions: nextAskedQuestionBank,
answers: nextAnswers,
softenedQuestionIds: recoveredSoftenedQuestionIds,
rejectedPostIds: nextRejectedPostIds })
recoveredScoringQuestions = mergeQuestions ([
...buildGekanatorQuestions (
recoveredEligiblePosts.length > 1 ? recoveredEligiblePosts : posts),
...nextAskedQuestionBank])
}
return {
softenedQuestionIds: recoveredSoftenedQuestionIds,
scores: recoveredScores,
eligiblePosts: recoveredEligiblePosts,
scoringQuestions: recoveredScoringQuestions }
}
const answer = (value: GekanatorAnswerValue) => { const answer = (value: GekanatorAnswerValue) => {
if (!(currentQuestion)) if (!(currentQuestion))
{ {
@@ -454,92 +588,66 @@ const GekanatorPage: FC = () => {
scores: new Map (scores), scores: new Map (scores),
answers: [...answers], answers: [...answers],
askedIds: new Set (askedIds), askedIds: new Set (askedIds),
candidateIds: candidateIds === null ? null : new Set (candidateIds),
softenedQuestionIds: new Set (softenedQuestionIds), softenedQuestionIds: new Set (softenedQuestionIds),
questionBank: [...questionBank], askedQuestionBank: [...askedQuestionBank],
search, search,
selectingCorrectPost,
rejectedPostIds: new Set (rejectedPostIds), rejectedPostIds: new Set (rejectedPostIds),
lastGuessQuestionCount, lastGuessQuestionCount,
lastRejectedGuessId, lastRejectedGuessId,
activeGuessId }]) activeGuessId,
reviewGuessedPostId,
reviewCorrectPostId }])
const nextAnswers = [...answers, { const nextAnswers = [...answers, {
questionId: currentQuestion.id, questionId: currentQuestion.id,
questionText: currentQuestion.text, questionText: currentQuestion.text,
answer: value }] answer: value }]
const nextAskedIds = new Set ([...askedIds, currentQuestion.id]) const nextAskedIds = new Set ([...askedIds, currentQuestion.id])
const nextQuestionBank = [ const nextAskedQuestionBank = [
...questionBank.filter (question => question.id !== currentQuestion.id), ...askedQuestionBank.filter (question => question.id !== currentQuestion.id),
currentQuestion] currentQuestion]
const hardFilteredPosts = const recovered = recoverQuestionState ({
value === 'yes' nextAnswers,
? eligiblePosts.filter (post => currentQuestion.test (post)) nextAskedIds,
: value === 'no' nextAskedQuestionBank,
? eligiblePosts.filter (post => !(currentQuestion.test (post))) nextSoftenedQuestionIds: softenedQuestionIds,
: eligiblePosts nextRejectedPostIds: rejectedPostIds })
let nextCandidateIds = const nextSoftenedQuestionIds = recovered.softenedQuestionIds
(value === 'yes' || value === 'no') && hardFilteredPosts.length > 0 const nextScores = recovered.scores
? new Set (hardFilteredPosts.map (post => post.id)) const nextEligiblePosts = recovered.eligiblePosts
: candidateIds
let nextSoftenedQuestionIds = new Set (softenedQuestionIds)
let nextScores = recalculateScores ({
posts,
questions: nextQuestionBank,
answers: nextAnswers,
softenedQuestionIds: nextSoftenedQuestionIds })
let nextEligiblePosts =
posts.filter (post =>
(nextCandidateIds === null || nextCandidateIds.has (post.id))
&& !(rejectedPostIds.has (post.id)))
let nextScoringQuestions = mergeQuestions ([
...buildGekanatorQuestions (nextEligiblePosts.length > 1 ? nextEligiblePosts : posts),
...nextQuestionBank])
while (
nextAnswers.length < hardMaxQuestions
&& nextEligiblePosts.length > 1
&& !(chooseQuestion ({
posts: nextEligiblePosts,
questions: nextScoringQuestions,
scores: nextScores,
askedIds: nextAskedIds }))
)
{
const softened = softenNextQuestionIds ({
questions: nextQuestionBank,
answers: nextAnswers,
softenedQuestionIds: nextSoftenedQuestionIds })
if (!(softened))
break
nextSoftenedQuestionIds = softened
nextCandidateIds = null
nextEligiblePosts = posts.filter (post => !(rejectedPostIds.has (post.id)))
nextScoringQuestions = mergeQuestions ([
...buildGekanatorQuestions (nextEligiblePosts),
...nextQuestionBank])
nextScores = recalculateScores ({
posts,
questions: nextQuestionBank,
answers: nextAnswers,
softenedQuestionIds: nextSoftenedQuestionIds })
}
setScores (nextScores) setScores (nextScores)
setAskedIds (nextAskedIds) setAskedIds (nextAskedIds)
setCandidateIds (nextCandidateIds)
setSoftenedQuestionIds (nextSoftenedQuestionIds) setSoftenedQuestionIds (nextSoftenedQuestionIds)
setQuestionBank (nextQuestionBank) setAskedQuestionBank (nextAskedQuestionBank)
setAnswers (nextAnswers) setAnswers (nextAnswers)
const nextGuess = bestPost (nextEligiblePosts, nextScores) const nextGuessablePosts =
nextEligiblePosts.length > 0
? nextEligiblePosts
: nonRejectedPosts
const nextGuess = bestPost (nextGuessablePosts, nextScores)
const nextQuestionCount = answers.length + 1 const nextQuestionCount = answers.length + 1
const definitelyKnown = nextEligiblePosts.length === 1 const nextQuestionsSinceLastGuess =
const enoughQuestions = nextQuestionCount - lastGuessQuestionCount
nextQuestionCount - lastGuessQuestionCount >= questionsBetweenGuesses const nextConfidences = confidencesFor (nextGuessablePosts, nextScores)
const topConfidence = nextConfidences[0] ?? null
const runnerUpConfidence = nextConfidences[1] ?? null
const structurallyCertain = nextEligiblePosts.length === 1
const statisticallyCertain =
topConfidence !== null
&& topConfidence.percent >= certainGuessPercent
&& (runnerUpConfidence === null
|| runnerUpConfidence.percent <= runnerUpMaxPercent)
const canGuessByQuestionCount =
nextQuestionsSinceLastGuess >= questionsBetweenGuesses
const canGuessEarlyByConfidence =
nextQuestionsSinceLastGuess >= minQuestionsBeforeCertainGuess
&& (structurallyCertain || statisticallyCertain)
const shouldGuess = const shouldGuess =
nextQuestionCount >= hardMaxQuestions nextQuestionCount >= hardMaxQuestions
|| definitelyKnown || canGuessByQuestionCount
|| enoughQuestions || canGuessEarlyByConfidence
if (shouldGuess) if (shouldGuess)
{ {
setActiveGuessId (nextGuess?.id ?? null) setActiveGuessId (nextGuess?.id ?? null)
@@ -548,20 +656,35 @@ const GekanatorPage: FC = () => {
} }
} }
const saveResult = (won: boolean, correctPostId: number | null) => { const startReview = (correctPostId: number) => {
const guessedPostId = const guessedPostId =
won ? displayedGuess?.id : lastRejectedGuessId ?? displayedGuess?.id phase === 'continue'
if (!(guessedPostId) || saved) ? lastRejectedGuessId ?? displayedGuess?.id
: displayedGuess?.id ?? lastRejectedGuessId
if (!(guessedPostId))
return
saveMutation.reset ()
setReviewGuessedPostId (guessedPostId)
setReviewCorrectPostId (correctPostId)
setSearch ('')
setSelectingCorrectPost (false)
setPhase ('review')
}
const saveReviewedResult = () => {
if (
reviewGuessedPostId === null
|| reviewCorrectPostId === null
|| saveMutation.isPending
|| saved
)
return return
setSaved (true)
setResultWon (won)
saveMutation.mutate ({ saveMutation.mutate ({
guessedPostId, guessedPostId: reviewGuessedPostId,
correctPostId, correctPostId: reviewCorrectPostId,
won,
answers }) answers })
setPhase ('learned')
} }
const rejectGuess = () => { const rejectGuess = () => {
@@ -571,13 +694,14 @@ const GekanatorPage: FC = () => {
setLastRejectedGuessId (displayedGuess.id) setLastRejectedGuessId (displayedGuess.id)
if (answers.length >= hardMaxQuestions) if (answers.length >= hardMaxQuestions)
{ {
setSearch (' ') setSelectingCorrectPost (true)
return return
} }
setRejectedPostIds (new Set ([...rejectedPostIds, displayedGuess.id])) setRejectedPostIds (new Set ([...rejectedPostIds, displayedGuess.id]))
setActiveGuessId (null) setActiveGuessId (null)
setSearch ('') setSearch ('')
setSelectingCorrectPost (false)
setLastGuessQuestionCount (answers.length) setLastGuessQuestionCount (answers.length)
setPhase ('continue') setPhase ('continue')
} }
@@ -591,30 +715,66 @@ const GekanatorPage: FC = () => {
setScores (snapshot.scores) setScores (snapshot.scores)
setAnswers (snapshot.answers) setAnswers (snapshot.answers)
setAskedIds (snapshot.askedIds) setAskedIds (snapshot.askedIds)
setCandidateIds (snapshot.candidateIds)
setSoftenedQuestionIds (snapshot.softenedQuestionIds) setSoftenedQuestionIds (snapshot.softenedQuestionIds)
setQuestionBank (snapshot.questionBank) setAskedQuestionBank (snapshot.askedQuestionBank)
setSearch (snapshot.search) setSearch (snapshot.search)
setSelectingCorrectPost (snapshot.selectingCorrectPost)
setRejectedPostIds (snapshot.rejectedPostIds) setRejectedPostIds (snapshot.rejectedPostIds)
setLastGuessQuestionCount (snapshot.lastGuessQuestionCount) setLastGuessQuestionCount (snapshot.lastGuessQuestionCount)
setLastRejectedGuessId (snapshot.lastRejectedGuessId) setLastRejectedGuessId (snapshot.lastRejectedGuessId)
setActiveGuessId (snapshot.activeGuessId) setActiveGuessId (snapshot.activeGuessId)
setReviewGuessedPostId (snapshot.reviewGuessedPostId)
setReviewCorrectPostId (snapshot.reviewCorrectPostId)
setHistory (history.slice (0, -1)) setHistory (history.slice (0, -1))
} }
const softenAndContinue = () => { const continueGame = () => {
const softened = softenNextQuestionIds ({ setSearch ('')
questions: scoringQuestions, answers, softenedQuestionIds }) setSelectingCorrectPost (false)
if (!(softened))
return
setSoftenedQuestionIds (softened) const recovered = recoverQuestionState ({
setCandidateIds (null) nextAnswers: answers,
setScores ( nextAskedIds: askedIds,
recalculateScores ({ posts, nextAskedQuestionBank: askedQuestionBank,
questions: scoringQuestions, nextSoftenedQuestionIds: softenedQuestionIds,
answers, nextRejectedPostIds: rejectedPostIds })
softenedQuestionIds: softened }))
setSoftenedQuestionIds (recovered.softenedQuestionIds)
setScores (recovered.scores)
const nextQuestion = chooseQuestion ({
posts: recovered.eligiblePosts.length > 1
? recovered.eligiblePosts
: nonRejectedPosts,
questions: recovered.scoringQuestions,
scores: recovered.scores,
askedIds })
if (nextQuestion)
{
setPhase ('question')
return
}
setActiveGuessId (guess?.id ?? null)
setPhase ('guess')
}
const correctAnswerAt = (index: number, value: GekanatorAnswerValue) => {
setAnswers (answers.map ((answer, i) =>
i === index ? { ...answer, answer: value } : answer))
}
const selectCorrectPost = (post: Post) => {
if (phase === 'review')
{
setReviewCorrectPostId (post.id)
setSelectingCorrectPost (false)
setSearch ('')
return
}
startReview (post.id)
} }
const filteredPosts = posts const filteredPosts = posts
@@ -646,6 +806,7 @@ const GekanatorPage: FC = () => {
return ( return (
<MainArea className="bg-yellow-50 dark:bg-red-975"> <MainArea className="bg-yellow-50 dark:bg-red-975">
<Helmet> <Helmet>
<meta name="robots" content="noindex"/>
<title>{`グカネータ | ${ SITE_TITLE }`}</title> <title>{`グカネータ | ${ SITE_TITLE }`}</title>
</Helmet> </Helmet>
@@ -745,10 +906,8 @@ const GekanatorPage: FC = () => {
{phase === 'question' && !(currentQuestion) && ( {phase === 'question' && !(currentQuestion) && (
<div className="space-y-4"> <div className="space-y-4">
<p className="text-xl font-bold"> <p className="text-xl font-bold">
</p> </p>
{answers.length >= hardMaxQuestions || eligiblePosts.length <= 1
? (
<button <button
type="button" type="button"
className="rounded border border-yellow-300 px-4 py-2 className="rounded border border-yellow-300 px-4 py-2
@@ -758,16 +917,8 @@ const GekanatorPage: FC = () => {
setActiveGuessId (guess?.id ?? null) setActiveGuessId (guess?.id ?? null)
setPhase ('guess') setPhase ('guess')
}}> }}>
</button>) </button>
: (
<button
type="button"
className="rounded bg-pink-600 px-4 py-2 font-bold text-white
hover:bg-pink-500"
onClick={softenAndContinue}>
</button>)}
</div>)} </div>)}
{phase === 'guess' && displayedGuess && ( {phase === 'guess' && displayedGuess && (
@@ -779,7 +930,10 @@ const GekanatorPage: FC = () => {
type="button" type="button"
className="rounded bg-pink-600 px-4 py-2 font-bold text-white className="rounded bg-pink-600 px-4 py-2 font-bold text-white
hover:bg-pink-500" hover:bg-pink-500"
onClick={() => saveResult (true, null)}> onClick={() => {
if (displayedGuess)
startReview (displayedGuess.id)
}}>
</button> </button>
<button <button
@@ -800,6 +954,10 @@ const GekanatorPage: FC = () => {
</button>)} </button>)}
</div> </div>
{saveMutation.isError && (
<p className="text-sm text-red-600">
</p>)}
</div>)} </div>)}
{phase === 'continue' && ( {phase === 'continue' && (
@@ -810,7 +968,7 @@ const GekanatorPage: FC = () => {
type="button" type="button"
className="rounded bg-pink-600 px-4 py-2 font-bold text-white className="rounded bg-pink-600 px-4 py-2 font-bold text-white
hover:bg-pink-500" hover:bg-pink-500"
onClick={() => setPhase ('question')}> onClick={continueGame}>
</button> </button>
<button <button
@@ -818,7 +976,7 @@ const GekanatorPage: FC = () => {
className="rounded border border-yellow-300 px-4 py-2 className="rounded border border-yellow-300 px-4 py-2
hover:bg-yellow-100 dark:border-red-700 hover:bg-yellow-100 dark:border-red-700
dark:hover:bg-red-900" dark:hover:bg-red-900"
onClick={() => setSearch (' ')}> onClick={() => setSelectingCorrectPost (true)}>
</button> </button>
{history.length > 0 && ( {history.length > 0 && (
@@ -833,13 +991,98 @@ const GekanatorPage: FC = () => {
</div> </div>
</div>)} </div>)}
{phase === 'review' && (
<div className="space-y-4">
<div>
<p className="text-sm text-neutral-500"></p>
<p className="text-xl font-bold"></p>
</div>
{reviewGuessedPost && (
<div className="space-y-2">
<div className="font-bold">稿</div>
<PostMiniCard post={reviewGuessedPost}/>
</div>)}
<div className="space-y-2">
<div className="font-bold">稿</div>
{reviewCorrectPost
? <PostMiniCard post={reviewCorrectPost}/>
: <p className="text-sm text-red-600">稿</p>}
<button
type="button"
className="rounded border border-yellow-300 px-3 py-2
hover:bg-yellow-100 dark:border-red-700
dark:hover:bg-red-900"
onClick={() => setSelectingCorrectPost (true)}>
稿
</button>
</div>
<div className="space-y-2">
<div className="font-bold"></div>
<div className="space-y-2">
{answers.map ((answer, index) => (
<div
key={`${ answer.questionId }:${ index }`}
className="rounded border border-yellow-100 p-3
dark:border-red-900">
<div className="text-sm text-neutral-600 dark:text-neutral-300">
{index + 1}
</div>
<div className="font-bold">{answer.questionText}</div>
<select
value={answer.answer}
className="mt-2 rounded border border-yellow-300 bg-white px-2 py-1
dark:border-red-700 dark:bg-red-950"
onChange={ev =>
correctAnswerAt (
index,
ev.target.value as GekanatorAnswerValue)}>
{answerOptions.map (option => (
<option key={option.value} value={option.value}>
{option.label}
</option>))}
</select>
</div>))}
</div>
</div>
{reviewGuessedPostId !== null && reviewCorrectPostId !== null && (
<p className="text-sm text-neutral-600 dark:text-neutral-300">
: {reviewGuessedPostId === reviewCorrectPostId ? '当たり' : '違ひ'}
</p>)}
{saveMutation.isError && (
<p className="text-sm text-red-600">
</p>)}
<div className="flex flex-wrap gap-2">
<button
type="button"
className="rounded bg-pink-600 px-4 py-2 font-bold text-white
hover:bg-pink-500 disabled:opacity-50"
disabled={
reviewCorrectPostId === null || saveMutation.isPending || saved
}
onClick={saveReviewedResult}>
</button>
<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"
onClick={() => setPhase ('guess')}>
</button>
</div>
</div>)}
{phase === 'learned' && ( {phase === 'learned' && (
<div className="space-y-3"> <div className="space-y-3">
<p></p> <p></p>
{saveMutation.isError && (
<p className="text-sm text-red-600">
</p>)}
<button <button
type="button" type="button"
className="rounded bg-pink-600 px-4 py-2 font-bold text-white className="rounded bg-pink-600 px-4 py-2 font-bold text-white
@@ -852,7 +1095,8 @@ const GekanatorPage: FC = () => {
</div> </div>
</section> </section>
{['guess', 'continue', 'question'].includes (phase) && search !== '' && ( {['guess', 'continue', 'question', 'review'].includes (phase)
&& selectingCorrectPost && (
<section className="rounded-lg border border-yellow-300 bg-white p-4 <section className="rounded-lg border border-yellow-300 bg-white p-4
dark:border-red-800 dark:bg-red-950"> dark:border-red-800 dark:bg-red-950">
<label className="block space-y-2"> <label className="block space-y-2">
@@ -872,10 +1116,14 @@ const GekanatorPage: FC = () => {
className={cn ('block w-full rounded border border-yellow-200 p-3', className={cn ('block w-full rounded border border-yellow-200 p-3',
'text-left hover:bg-yellow-100', 'text-left hover:bg-yellow-100',
'dark:border-red-800 dark:hover:bg-red-900')} 'dark:border-red-800 dark:hover:bg-red-900')}
onClick={() => saveResult (false, post.id)}> onClick={() => selectCorrectPost (post)}>
<PostMiniCard post={post}/> <PostMiniCard post={post}/>
</button>))} </button>))}
{search.trim () && filteredPosts.length === 0 && '見つかりません.'} {search.trim () && filteredPosts.length === 0 && '見つかりません.'}
{saveMutation.isError && (
<p className="text-sm text-red-600">
</p>)}
</div> </div>
</section>)} </section>)}
</div> </div>