このコミットが含まれているのは:
2026-06-10 20:02:08 +09:00
コミット 7fe7dbd909
14個のファイルの変更606行の追加41行の削除
+99 -11
ファイルの表示
@@ -21,6 +21,7 @@ export type GekanatorQuestionKind =
| 'source'
| 'title'
| 'original_date'
| 'post_similarity'
export type GekanatorQuestionSource =
| 'default'
@@ -36,6 +37,18 @@ export type GekanatorQuestionCondition =
| { type: 'original-month-day'; monthDay: string }
| { type: 'title-length-greater-than'; length: number }
| { type: 'title-has-ascii' }
| {
type: 'post-similarity'
postId: number
answer: GekanatorAnswerValue
threshold: number
}
export type GekanatorExtraQuestion = {
id: number
text: string
source: GekanatorQuestionSource
priorityWeight: number }
export type StoredGekanatorQuestion = {
id: string
@@ -43,7 +56,8 @@ export type StoredGekanatorQuestion = {
kind: GekanatorQuestionKind
condition: GekanatorQuestionCondition
source?: GekanatorQuestionSource
priorityWeight?: number }
priorityWeight?: number
exampleAnswers?: Record<`${ number }`, GekanatorAnswerValue> }
export type GekanatorQuestion = {
id: string
@@ -52,8 +66,24 @@ export type GekanatorQuestion = {
condition: GekanatorQuestionCondition
source: GekanatorQuestionSource
priorityWeight: number
exampleAnswers?: Record<`${ number }`, GekanatorAnswerValue>
test: (post: Post) => boolean }
const directExampleAnswerFor = (
question: StoredGekanatorQuestion,
post: Post,
): GekanatorAnswerValue | null => {
const direct = question.exampleAnswers?.[String (post.id) as `${ number }`]
if (direct)
return direct
if (question.condition.type === 'post-similarity' && question.condition.postId === post.id)
return question.condition.answer
return null
}
const countBy = <T extends string | number> (values: T[]): Map<T, number> => {
const counts = new Map<T, number> ()
values.forEach (value => counts.set (value, (counts.get (value) ?? 0) + 1))
@@ -172,24 +202,59 @@ const questionableTag = (post: Post, key: string): boolean => {
const questionMatches = (
post: Post,
condition: GekanatorQuestionCondition,
question: StoredGekanatorQuestion,
): boolean => {
switch (condition.type)
const directAnswer = directExampleAnswerFor (question, post)
if (directAnswer)
return question.condition.type === 'post-similarity'
? directAnswer === question.condition.answer
: directAnswer === 'yes'
switch (question.condition.type)
{
case 'tag':
return questionableTag (post, condition.key)
return questionableTag (post, question.condition.key)
case 'source':
return hostOf (post) === condition.host
return hostOf (post) === question.condition.host
case 'original-year':
return originalYearOf (post) === condition.year
return originalYearOf (post) === question.condition.year
case 'original-month':
return originalMonthOf (post) === condition.month
return originalMonthOf (post) === question.condition.month
case 'original-month-day':
return originalMonthDayOf (post) === condition.monthDay
return originalMonthDayOf (post) === question.condition.monthDay
case 'title-length-greater-than':
return (post.title?.length ?? 0) > condition.length
return (post.title?.length ?? 0) > question.condition.length
case 'title-has-ascii':
return /[A-Za-z0-9]/.test (post.title ?? '')
case 'post-similarity':
return false
}
}
export const expectedAnswerForQuestion = (
question: StoredGekanatorQuestion | GekanatorQuestion | undefined,
post: Post | null,
): GekanatorAnswerValue | null => {
if (!(question) || !(post))
return null
const directAnswer = directExampleAnswerFor (question, post)
if (directAnswer)
return directAnswer
switch (question.condition.type)
{
case 'tag':
case 'source':
case 'original-year':
case 'original-month':
case 'original-month-day':
case 'title-length-greater-than':
case 'title-has-ascii':
return questionMatches (post, question) ? 'yes' : 'no'
case 'post-similarity':
return null
}
}
@@ -200,7 +265,7 @@ export const restoreGekanatorQuestion = (
...question,
source: question.source ?? 'default',
priorityWeight: question.priorityWeight ?? 1,
test: (post: Post) => questionMatches (post, question.condition) })
test: (post: Post) => questionMatches (post, question) })
export const storeGekanatorQuestion = (
@@ -211,7 +276,8 @@ export const storeGekanatorQuestion = (
kind: question.kind,
condition: question.condition,
source: question.source,
priorityWeight: question.priorityWeight })
priorityWeight: question.priorityWeight,
exampleAnswers: question.exampleAnswers })
export const fetchGekanatorPosts = async (): Promise<Post[]> => {
@@ -226,6 +292,15 @@ export const fetchGekanatorQuestions = async (): Promise<StoredGekanatorQuestion
}
export const fetchGekanatorExtraQuestions = async (
gameId: number,
): Promise<GekanatorExtraQuestion[]> => {
const data = await apiGet<{ questions: GekanatorExtraQuestion[] }> (
`/gekanator/games/${ gameId }/extra_questions`)
return data.questions
}
export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
const tagCounts = countBy (posts.flatMap (post =>
post.tags
@@ -393,3 +468,16 @@ export const saveGekanatorQuestionSuggestion = async ({
gekanator_game_id: gekanatorGameId,
question_text: questionText,
answer })
export const saveGekanatorExtraQuestionAnswers = async ({
gameId,
answers,
}: {
gameId: number
answers: { questionId: number; answer: GekanatorAnswerValue }[]
}) =>
await apiPost (`/gekanator/games/${ gameId }/extra_question_answers`, {
answers: answers.map (item => ({
question_id: item.questionId,
answer: item.answer })) })
+3 -1
ファイルの表示
@@ -11,7 +11,9 @@ export const postsKeys = {
export const gekanatorKeys = {
root: ['gekanator'] as const,
posts: () => ['gekanator', 'posts'] as const,
questions: () => ['gekanator', 'questions'] as const }
questions: () => ['gekanator', 'questions'] as const,
extraQuestions: (gameId: number) =>
['gekanator', 'games', gameId, 'extra-questions'] as const }
export const tagsKeys = {
root: ['tags'] as const,
+262 -22
ファイルの表示
@@ -1,4 +1,4 @@
import { useMutation, useQuery } from '@tanstack/react-query'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useMemo, useState } from 'react'
import { Helmet } from 'react-helmet-async'
@@ -6,9 +6,12 @@ import PrefetchLink from '@/components/PrefetchLink'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import { buildGekanatorQuestions,
expectedAnswerForQuestion,
fetchGekanatorExtraQuestions,
fetchGekanatorQuestions,
fetchGekanatorPosts,
restoreGekanatorQuestion,
saveGekanatorExtraQuestionAnswers,
saveGekanatorGame,
saveGekanatorQuestionSuggestion,
storeGekanatorQuestion } from '@/lib/gekanator'
@@ -19,6 +22,7 @@ import type { FC } from 'react'
import type { GekanatorAnswerLog,
GekanatorAnswerValue,
GekanatorExtraQuestion,
GekanatorQuestion,
StoredGekanatorQuestion } from '@/lib/gekanator'
import type { Post } from '@/types'
@@ -31,6 +35,7 @@ type Phase =
| 'end'
| 'review'
| 'question_suggestion'
| 'extra_questions'
| 'learned'
type AnswerOption = {
@@ -87,7 +92,10 @@ type StoredGekanatorGame = {
gameSeed?: string
questionSuggestion: string
questionSuggestionAnswer: GekanatorAnswerValue
questionSuggestionCount?: number }
questionSuggestionCount?: number
extraQuestions?: GekanatorExtraQuestion[]
extraQuestionAnswers?: Record<string, GekanatorAnswerValue>
extraQuestionState?: 'idle' | 'loading' | 'ready' | 'empty' | 'saved' }
const answerOptions: AnswerOption[] = [
{ label: 'はい', value: 'yes' },
@@ -219,6 +227,16 @@ const loadStoredGame = (): StoredGekanatorGame | null => {
const isStoredPhase = (phase: Phase): boolean => phase !== 'intro'
const resettableExtraQuestionState = (): {
extraQuestions: GekanatorExtraQuestion[]
extraQuestionAnswers: Record<string, GekanatorAnswerValue>
extraQuestionState: 'idle'
} => ({
extraQuestions: [],
extraQuestionAnswers: { },
extraQuestionState: 'idle' })
const deltaFor = (matched: boolean, answer: GekanatorAnswerValue): number => {
switch (answer)
{
@@ -236,6 +254,55 @@ const deltaFor = (matched: boolean, answer: GekanatorAnswerValue): number => {
}
const answerScalarFor = (
answer: GekanatorAnswerValue | null,
): number | null => {
switch (answer)
{
case 'yes':
return 1
case 'partial':
return .5
case 'probably_no':
return -.5
case 'no':
return -1
case 'unknown':
case null:
return null
}
}
const deltaForExpectedAnswer = (
expected: GekanatorAnswerValue | null,
answer: GekanatorAnswerValue,
): number => {
if (answer === 'unknown' || expected === null || expected === 'unknown')
return 0
if (expected === 'yes' || expected === 'no')
return deltaFor (expected === 'yes', answer)
const expectedScalar = answerScalarFor (expected)
const answerScalar = answerScalarFor (answer)
if (expectedScalar === null || answerScalar === null)
return 0
const distance = Math.abs (expectedScalar - answerScalar)
if (distance >= 2)
return -4
if (distance >= 1.5)
return -2
if (distance >= 1)
return 0
if (distance >= .5)
return 2
return 4
}
const answerWeightFor = (
questionId: string,
softenedQuestionIds: Set<string>,
@@ -277,10 +344,11 @@ const recalculateScores = ({
const weight = answerWeightFor (answer.questionId, softenedQuestionIds)
posts.forEach (post => {
const expected = expectedAnswerForQuestion (question, post)
nextScores.set (
post.id,
(nextScores.get (post.id) ?? 0)
+ deltaFor (question.test (post), answer.answer) * weight)
+ deltaForExpectedAnswer (expected, answer.answer) * weight)
})
})
@@ -318,9 +386,10 @@ const candidatePostsFor = ({
switch (answer.answer)
{
case 'yes':
return question.test (post)
case 'no':
return !(question.test (post))
case 'no': {
const expected = expectedAnswerForQuestion (question, post)
return expected === null || expected === 'unknown' || expected === answer.answer
}
default:
return true
}
@@ -378,20 +447,19 @@ const previewAnswer = ({
answer: GekanatorAnswerValue
}): AnswerPreview => {
const hardFilteredPosts =
answer === 'yes'
? posts.filter (post => question.test (post))
: answer === 'no'
? posts.filter (post => !(question.test (post)))
: posts
answer === 'unknown'
? posts
: posts.filter (post => expectedAnswerForQuestion (question, post) === answer)
const nextPosts =
(answer === 'yes' || answer === 'no') && hardFilteredPosts.length > 0
answer !== 'unknown' && hardFilteredPosts.length > 0
? hardFilteredPosts
: posts
const nextScores = new Map (scores)
nextPosts.forEach (post => {
const expected = expectedAnswerForQuestion (question, post)
nextScores.set (
post.id,
(nextScores.get (post.id) ?? 0) + deltaFor (question.test (post), answer))
(nextScores.get (post.id) ?? 0) + deltaForExpectedAnswer (expected, answer))
})
const confidences = confidencesFor (nextPosts, nextScores)
@@ -497,6 +565,8 @@ const sameConditionValue = (
return String (condition.length)
case 'title-has-ascii':
return ''
case 'post-similarity':
return `${ condition.postId }:${ condition.answer }:${ condition.threshold }`
}
}
@@ -757,16 +827,13 @@ const PostMiniCard: FC<{ post: Post }> = ({ post }) => (
const expectedAnswerFor = (
question: GekanatorQuestion | undefined,
correctPost: Post | null,
): GekanatorAnswerValue | null => {
if (!(question) || !(correctPost))
return null
return question.test (correctPost) ? 'yes' : 'no'
}
): GekanatorAnswerValue | null =>
expectedAnswerForQuestion (question, correctPost)
const GekanatorPage: FC = () => {
const storedGame = useMemo (loadStoredGame, [])
const queryClient = useQueryClient ()
const [gameSeed, setGameSeed] = useState (
storedGame?.gameSeed ?? createGameSeed ())
const [phase, setPhase] = useState<Phase> (storedGame?.phase ?? 'intro')
@@ -810,6 +877,14 @@ const GekanatorPage: FC = () => {
useState<GekanatorAnswerValue> (storedGame?.questionSuggestionAnswer ?? 'yes')
const [questionSuggestionCount, setQuestionSuggestionCount] = useState (
storedGame?.questionSuggestionCount ?? 0)
const [extraQuestions, setExtraQuestions] = useState<GekanatorExtraQuestion[]> (
storedGame?.extraQuestions ?? [])
const [extraQuestionAnswers, setExtraQuestionAnswers] =
useState<Record<string, GekanatorAnswerValue>> (
storedGame?.extraQuestionAnswers ?? { })
const [extraQuestionState, setExtraQuestionState] = useState<
'idle' | 'loading' | 'ready' | 'empty' | 'saved'
> (storedGame?.extraQuestionState ?? 'idle')
const [history, setHistory] = useState<GameSnapshot[]> ([])
const { data: posts = [], isLoading, error } = useQuery ({
@@ -876,7 +951,10 @@ const GekanatorPage: FC = () => {
gameSeed,
questionSuggestion,
questionSuggestionAnswer,
questionSuggestionCount }
questionSuggestionCount,
extraQuestions,
extraQuestionAnswers,
extraQuestionState }
try
{
@@ -908,7 +986,10 @@ const GekanatorPage: FC = () => {
gameSeed,
questionSuggestion,
questionSuggestionAnswer,
questionSuggestionCount])
questionSuggestionCount,
extraQuestions,
extraQuestionAnswers,
extraQuestionState])
const eligiblePosts = useMemo (
() => candidatePostsFor ({
@@ -985,10 +1066,25 @@ const GekanatorPage: FC = () => {
setQuestionSuggestion ('')
setQuestionSuggestionAnswer ('yes')
}})
const extraQuestionAnswersMutation = useMutation ({
mutationFn: saveGekanatorExtraQuestionAnswers,
onSuccess: () => {
setExtraQuestionState ('saved')
setPhase ('learned')
}})
const resetExtraQuestionState = () => {
const next = resettableExtraQuestionState ()
setExtraQuestions (next.extraQuestions)
setExtraQuestionAnswers (next.extraQuestionAnswers)
setExtraQuestionState (next.extraQuestionState)
extraQuestionAnswersMutation.reset ()
}
const reset = () => {
clearStoredGame ()
saveMutation.reset ()
questionSuggestionMutation.reset ()
setPhase ('intro')
setScores (new Map ())
setAnswers ([])
@@ -1010,6 +1106,7 @@ const GekanatorPage: FC = () => {
setQuestionSuggestion ('')
setQuestionSuggestionAnswer ('yes')
setQuestionSuggestionCount (0)
resetExtraQuestionState ()
setHistory ([])
}
@@ -1188,6 +1285,7 @@ const GekanatorPage: FC = () => {
saveMutation.reset ()
questionSuggestionMutation.reset ()
resetExtraQuestionState ()
setSaved (false)
setSavedGameId (null)
setReviewGuessedPostId (guessedPostId)
@@ -1203,6 +1301,7 @@ const GekanatorPage: FC = () => {
saveMutation.reset ()
questionSuggestionMutation.reset ()
resetExtraQuestionState ()
setSaved (false)
setSavedGameId (null)
setSelectingCorrectPost (false)
@@ -1236,6 +1335,7 @@ const GekanatorPage: FC = () => {
}
const saveAndLearn = () => {
resetExtraQuestionState ()
saveReviewedResult (() => setPhase ('learned'))
}
@@ -1344,6 +1444,7 @@ const GekanatorPage: FC = () => {
const correctAnswerAt = (index: number, value: GekanatorAnswerValue) => {
setSaved (false)
setSavedGameId (null)
resetExtraQuestionState ()
setAnswers (answers.map ((answer, i) =>
i === index ? { ...answer, answer: value } : answer))
}
@@ -1353,6 +1454,7 @@ const GekanatorPage: FC = () => {
{
setSaved (false)
setSavedGameId (null)
resetExtraQuestionState ()
setReviewCorrectPostId (post.id)
setSelectingCorrectPost (false)
setSearch ('')
@@ -1383,6 +1485,60 @@ const GekanatorPage: FC = () => {
})
.slice (0, 20)
const loadExtraQuestions = async (gameId: number) => {
extraQuestionAnswersMutation.reset ()
setExtraQuestionState ('loading')
setExtraQuestions ([])
setExtraQuestionAnswers ({ })
setPhase ('extra_questions')
try
{
const questions = await queryClient.fetchQuery ({
queryKey: gekanatorKeys.extraQuestions (gameId),
queryFn: () => fetchGekanatorExtraQuestions (gameId) })
setExtraQuestions (questions)
setExtraQuestionState (questions.length > 0 ? 'ready' : 'empty')
}
catch
{
setExtraQuestionState ('empty')
}
}
const startExtraQuestions = () => {
if (reviewCorrectPostId === null || saveMutation.isPending)
return
saveReviewedResult (gameId => {
void loadExtraQuestions (gameId)
})
}
const answerExtraQuestion = (
questionId: number,
value: GekanatorAnswerValue,
) => {
setExtraQuestionAnswers ({
...extraQuestionAnswers,
[String (questionId)]: value })
}
const saveExtraQuestions = () => {
if (
savedGameId === null
|| extraQuestionAnswersMutation.isPending
|| extraQuestions.some (question => !(extraQuestionAnswers[String (question.id)]))
)
return
extraQuestionAnswersMutation.mutate ({
gameId: savedGameId,
answers: extraQuestions.map (question => ({
questionId: question.id,
answer: extraQuestionAnswers[String (question.id)] })) })
}
const dialogue =
phase === 'learned' && resultWon
? <>wwwww <ruby>鹿<rt></rt></ruby>!</>
@@ -1650,6 +1806,18 @@ const GekanatorPage: FC = () => {
onClick={() => setPhase ('question_suggestion')}>
</button>
<button
type="button"
className="rounded border border-yellow-300 px-4 py-2
hover:bg-yellow-100 dark:border-red-700
dark:hover:bg-red-900 disabled:opacity-50"
disabled={reviewCorrectPostId === null
|| saveMutation.isPending
|| extraQuestionState === 'loading'
|| extraQuestionAnswersMutation.isPending}
onClick={startExtraQuestions}>
</button>
</div>
</div>)}
@@ -1843,9 +2011,81 @@ const GekanatorPage: FC = () => {
</p>)}
</div>)}
{phase === 'extra_questions' && (
<div className="space-y-4">
<div>
<p className="text-sm text-neutral-500"></p>
<p className="text-xl font-bold"> 2 </p>
</div>
{extraQuestionState === 'loading' && (
<p>...</p>)}
{extraQuestionState === 'empty' && (
<p></p>)}
{extraQuestionState === 'ready' && (
<div className="space-y-3">
{extraQuestions.map ((question, index) => (
<div
key={question.id}
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">{question.text}</div>
<div className="mt-3 flex flex-wrap gap-2">
{answerOptions.map (option => (
<button
key={option.value}
type="button"
className={cn (
'rounded border px-3 py-2',
extraQuestionAnswers[String (question.id)] === option.value
? 'border-pink-600 bg-pink-600 text-white'
: 'border-yellow-300 hover:bg-yellow-100 dark:border-red-700 dark:hover:bg-red-900')}
onClick={() => answerExtraQuestion (question.id, option.value)}>
{option.label}
</button>))}
</div>
</div>))}
</div>)}
{extraQuestionAnswersMutation.isError && (
<p className="text-sm text-red-600">
</p>)}
<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={extraQuestionAnswersMutation.isPending}
onClick={() => setPhase ('end')}>
</button>
<button
type="button"
className="rounded bg-pink-600 px-4 py-2 font-bold text-white
hover:bg-pink-500 disabled:opacity-50"
disabled={
extraQuestionState !== 'ready'
|| extraQuestionAnswersMutation.isPending
|| extraQuestions.some (
question => !(extraQuestionAnswers[String (question.id)]))
}
onClick={saveExtraQuestions}>
</button>
</div>
</div>)}
{phase === 'learned' && (
<div className="space-y-3">
<p></p>
<p>{extraQuestionState === 'saved' ? '学習しました。' : '覚えたよ.次はもっと見通す.'}</p>
<button
type="button"
className="rounded bg-pink-600 px-4 py-2 font-bold text-white