このコミットが含まれているのは:
2026-06-09 01:29:43 +09:00
コミット a1ea35a7ec
8個のファイルの変更306行の追加90行の削除
+214 -85
ファイルの表示
@@ -7,7 +7,8 @@ import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import { buildGekanatorQuestions,
fetchGekanatorPosts,
saveGekanatorGame } from '@/lib/gekanator'
saveGekanatorGame,
saveGekanatorQuestionSuggestion } from '@/lib/gekanator'
import { gekanatorKeys } from '@/lib/queryKeys'
import { cn } from '@/lib/utils'
@@ -25,6 +26,7 @@ type Phase =
| 'continue'
| 'end'
| 'review'
| 'question_suggestion'
| 'learned'
type AnswerOption = {
@@ -66,6 +68,9 @@ const answerOptions: AnswerOption[] = [
{ label: 'たぶんいいえ', value: 'probably_no' },
{ label: 'わからない', value: 'unknown' }]
const answerLabelFor = (value: GekanatorAnswerValue): string =>
answerOptions.find (option => option.value === value)?.label ?? value
const questionsBetweenGuesses = 25
const minQuestionsBeforeCertainGuess = 5
const certainGuessPercent = 99.5
@@ -313,10 +318,14 @@ const chooseQuestion = ({
const scoredPosts = posts
.map (post => ({ post, score: scores.get (post.id) ?? 0 }))
.sort ((a, b) => b.score - a.score)
const topScore = scoredPosts[0]?.score ?? 0
const nearTopCandidates = scoredPosts.filter (item => topScore - item.score <= 2)
const focusCandidates =
nearTopCandidates.length >= 2 && nearTopCandidates.length <= 8 ? nearTopCandidates : []
const maxScore = scoredPosts[0]?.score ?? 0
const weightedPosts = scoredPosts.map (item => ({
...item,
weight: Math.exp ((item.score - maxScore) / confidenceTemperature) }))
const totalWeight =
weightedPosts.reduce ((sum, item) => sum + item.weight, 0) || 1
const normalisedWeightedPosts =
weightedPosts.map (item => ({ ...item, weight: item.weight / totalWeight }))
const signatureFor = (
question: GekanatorQuestion,
@@ -342,9 +351,9 @@ const chooseQuestion = ({
}
const rank = (
questionsToRank: GekanatorQuestion[],
candidates: { post: Post; score: number }[],
focusCandidates: { post: Post; score: number }[],
questionsToRank: GekanatorQuestion[],
candidates: { post: Post; score: number }[],
weightedCandidates: { post: Post; score: number; weight: number }[],
) => {
const redundant = redundantSignatures (candidates)
const nonTagCount =
@@ -361,49 +370,36 @@ const chooseQuestion = ({
if (yes === 0 || no === 0)
return null
const focusSignature = signatureFor (question, focusCandidates)
const focusYes = focusSignature.split ('').filter (value => value === '1').length
const focusNo = focusCandidates.length - focusYes
const separatesFocus = focusCandidates.length >= 2 && focusYes > 0 && focusNo > 0
const yesWeight = weightedCandidates.reduce (
(sum, item) => sum + (question.test (item.post) ? item.weight : 0),
0)
const noWeight = 1 - yesWeight
if (yesWeight <= 0 || noWeight <= 0)
return null
const splitScore = Math.abs (candidates.length / 2 - yes)
const focusSplitScore = focusCandidates.length >= 2
? Math.abs (focusCandidates.length / 2 - focusYes)
: 0
const tagPenalty = question.kind === 'tag' && nonTagCount < 4 ? 12 : 0
const weightedSplitScore = Math.abs (.5 - yesWeight)
const unweightedSplitScore = Math.abs (candidates.length / 2 - yes) / candidates.length
const tagPenalty = question.kind === 'tag' && nonTagCount < 4 ? .12 : 0
const minSide = candidates.length < 10 ? 1 : Math.max (3, candidates.length * .08)
const narrowPenalty = yes < minSide || no < minSide ? candidates.length : 0
const focusPenalty =
focusCandidates.length >= 2 && !(separatesFocus) ? candidates.length * 10 : 0
const narrowPenalty = yes < minSide || no < minSide ? .15 : 0
return { question,
score: focusPenalty
+ focusSplitScore * 20
+ splitScore
score: weightedSplitScore * 100
+ unweightedSplitScore * 8
+ tagPenalty
+ narrowPenalty,
narrow: narrowPenalty > 0,
separatesFocus }
narrow: narrowPenalty > 0 }
})
.filter ((item): item is {
question: GekanatorQuestion
score: number
narrow: boolean
separatesFocus: boolean } => item !== null && Number.isFinite (item.score))
question: GekanatorQuestion
score: number
narrow: boolean } => item !== null && Number.isFinite (item.score))
.sort ((a, b) => a.score - b.score)
}
const unansweredQuestions =
questions.filter (question => !(askedIds.has (question.id)))
const ranked = rank (unansweredQuestions, scoredPosts, focusCandidates)
if (focusCandidates.length >= 2)
return (
ranked.find (item => item.separatesFocus && !(item.narrow))
?? ranked.find (item => item.separatesFocus)
?? ranked.find (item => !(item.narrow))
?? ranked[0]
)?.question ?? null
const ranked = rank (unansweredQuestions, scoredPosts, normalisedWeightedPosts)
return (ranked.find (item => !(item.narrow)) ?? ranked[0])?.question ?? null
}
@@ -434,6 +430,17 @@ const PostMiniCard: FC<{ post: Post }> = ({ post }) => (
</div>)
const expectedAnswerFor = (
question: GekanatorQuestion | undefined,
correctPost: Post | null,
): GekanatorAnswerValue | null => {
if (!(question) || !(correctPost))
return null
return question.test (correctPost) ? 'yes' : 'no'
}
const GekanatorPage: FC = () => {
const [phase, setPhase] = useState<Phase> ('intro')
const [scores, setScores] = useState<Map<number, number>> (new Map ())
@@ -451,6 +458,8 @@ const GekanatorPage: FC = () => {
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 [history, setHistory] = useState<GameSnapshot[]> ([])
const { data: posts = [], isLoading, error } = useQuery ({
@@ -471,6 +480,9 @@ const GekanatorPage: FC = () => {
const scoringQuestions = useMemo (() => {
return mergeQuestions ([...questions, ...askedQuestionBank])
}, [questions, askedQuestionBank])
const scoringQuestionById = useMemo (
() => new Map (scoringQuestions.map (question => [question.id, question])),
[scoringQuestions])
const questionsSinceLastGuess = answers.length - lastGuessQuestionCount
const nonRejectedPosts = useMemo (
() => posts.filter (post => !(rejectedPostIds.has (post.id))),
@@ -510,10 +522,16 @@ const GekanatorPage: FC = () => {
posts.find (post => post.id === reviewCorrectPostId) ?? null
const saveMutation = useMutation ({
mutationFn: saveGekanatorGame,
onSuccess: () => {
onSuccess: (data, variables) => {
setSaved (true)
setResultWon (reviewGuessedPostId === reviewCorrectPostId)
setPhase ('learned')
setSavedGameId (data.id)
setResultWon (variables.guessedPostId === variables.correctPostId)
}})
const questionSuggestionMutation = useMutation ({
mutationFn: saveGekanatorQuestionSuggestion,
onSuccess: () => {
setQuestionSuggestion ('')
reset ()
}})
const reset = () => {
@@ -534,6 +552,8 @@ const GekanatorPage: FC = () => {
setActiveGuessId (null)
setReviewGuessedPostId (null)
setReviewCorrectPostId (null)
setSavedGameId (null)
setQuestionSuggestion ('')
setHistory ([])
}
@@ -637,9 +657,10 @@ const GekanatorPage: FC = () => {
reviewGuessedPostId,
reviewCorrectPostId }])
const nextAnswers = [...answers, {
questionId: currentQuestion.id,
questionText: currentQuestion.text,
answer: value }]
questionId: currentQuestion.id,
questionText: currentQuestion.text,
answer: value,
originalAnswer: value }]
const nextAskedIds = new Set ([...askedIds, currentQuestion.id])
const nextAskedQuestionBank = [
...askedQuestionBank.filter (question => question.id !== currentQuestion.id),
@@ -705,6 +726,9 @@ const GekanatorPage: FC = () => {
return
saveMutation.reset ()
questionSuggestionMutation.reset ()
setSaved (false)
setSavedGameId (null)
setReviewGuessedPostId (guessedPostId)
setReviewCorrectPostId (correctPostId)
setSearch ('')
@@ -717,24 +741,51 @@ const GekanatorPage: FC = () => {
return
saveMutation.reset ()
questionSuggestionMutation.reset ()
setSaved (false)
setSavedGameId (null)
setSelectingCorrectPost (false)
setSearch ('')
setPhase ('review')
}
const saveReviewedResult = () => {
const saveReviewedResult = (onSuccess: (gameId: number) => void) => {
if (
reviewGuessedPostId === null
|| reviewCorrectPostId === null
|| saveMutation.isPending
|| saved
)
return
if (savedGameId !== null)
{
onSuccess (savedGameId)
return
}
saveMutation.mutate ({
guessedPostId: reviewGuessedPostId,
correctPostId: reviewCorrectPostId,
answers })
answers },
{ onSuccess: data => onSuccess (data.id) })
}
const saveAndReset = () => {
saveReviewedResult (reset)
}
const saveAndLearn = () => {
saveReviewedResult (() => setPhase ('learned'))
}
const submitQuestionSuggestion = () => {
const questionText = questionSuggestion.trim ()
if (!(questionText) || questionSuggestionMutation.isPending)
return
saveReviewedResult (gekanatorGameId => {
questionSuggestionMutation.mutate ({ gekanatorGameId, questionText })
})
}
const rejectGuess = () => {
@@ -811,6 +862,8 @@ const GekanatorPage: FC = () => {
}
const correctAnswerAt = (index: number, value: GekanatorAnswerValue) => {
setSaved (false)
setSavedGameId (null)
setAnswers (answers.map ((answer, i) =>
i === index ? { ...answer, answer: value } : answer))
}
@@ -818,6 +871,8 @@ const GekanatorPage: FC = () => {
const selectCorrectPost = (post: Post) => {
if (phase === 'review')
{
setSaved (false)
setSavedGameId (null)
setReviewCorrectPostId (post.id)
setSelectingCorrectPost (false)
setSearch ('')
@@ -1006,7 +1061,7 @@ const GekanatorPage: FC = () => {
</div>
{saveMutation.isError && (
<p className="text-sm text-red-600">
</p>)}
</div>)}
@@ -1045,7 +1100,7 @@ const GekanatorPage: FC = () => {
<div className="space-y-4">
<div>
<p className="text-sm text-neutral-500"></p>
<p className="text-xl font-bold"></p>
<p className="text-xl font-bold">wwwww</p>
</div>
{reviewGuessedPost && (
@@ -1076,7 +1131,7 @@ const GekanatorPage: FC = () => {
{saveMutation.isError && (
<p className="text-sm text-red-600">
</p>)}
<div className="flex flex-wrap gap-2">
@@ -1084,20 +1139,27 @@ const GekanatorPage: FC = () => {
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}>
disabled={reviewCorrectPostId === null || saveMutation.isPending}
onClick={saveAndReset}>
</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={reviewCorrectPostId === null || saveMutation.isPending || saved}
disabled={reviewCorrectPostId === null || saveMutation.isPending}
onClick={startReview}>
</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={saveMutation.isPending || questionSuggestionMutation.isPending}
onClick={() => setPhase ('question_suggestion')}>
</button>
</div>
</div>)}
@@ -1133,29 +1195,49 @@ const GekanatorPage: FC = () => {
<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>))}
{answers.map ((answer, index) => {
const expectedAnswer = expectedAnswerFor (
scoringQuestionById.get (answer.questionId),
reviewCorrectPost)
return (
<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>
<div className="mt-2 grid gap-1 text-sm md:grid-cols-3">
<div>
<span className="text-neutral-500">: </span>
{expectedAnswer ? answerLabelFor (expectedAnswer) : '不明'}
</div>
<div>
<span className="text-neutral-500">: </span>
{answerLabelFor (answer.originalAnswer)}
</div>
<label className="block">
<span className="text-neutral-500">: </span>
<select
value={answer.answer}
className="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>
</label>
</div>
</div>)
})}
</div>
</div>
@@ -1166,7 +1248,7 @@ const GekanatorPage: FC = () => {
{saveMutation.isError && (
<p className="text-sm text-red-600">
</p>)}
<div className="flex flex-wrap gap-2">
@@ -1175,10 +1257,12 @@ const GekanatorPage: FC = () => {
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
reviewCorrectPostId === null
|| saveMutation.isPending
|| questionSuggestionMutation.isPending
}
onClick={saveReviewedResult}>
onClick={saveAndLearn}>
</button>
<button
type="button"
@@ -1191,6 +1275,51 @@ const GekanatorPage: FC = () => {
</div>
</div>)}
{phase === 'question_suggestion' && (
<div className="space-y-4">
<div>
<p className="text-sm text-neutral-500"></p>
<p className="text-xl font-bold">?</p>
</div>
<label className="block space-y-2">
<span className="font-bold"></span>
<textarea
value={questionSuggestion}
onChange={ev => setQuestionSuggestion (ev.target.value)}
className="min-h-24 w-full rounded border border-yellow-300
bg-white px-3 py-2 dark:border-red-700
dark:bg-red-950"/>
</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}
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={
reviewCorrectPostId === null
|| questionSuggestion.trim () === ''
|| saveMutation.isPending
|| questionSuggestionMutation.isPending
}
onClick={submitQuestionSuggestion}>
</button>
</div>
{(saveMutation.isError || questionSuggestionMutation.isError) && (
<p className="text-sm text-red-600">
</p>)}
</div>)}
{phase === 'learned' && (
<div className="space-y-3">
<p></p>
@@ -1233,7 +1362,7 @@ const GekanatorPage: FC = () => {
{search.trim () && filteredPosts.length === 0 && '見つかりません.'}
{saveMutation.isError && (
<p className="text-sm text-red-600">
</p>)}
</div>
</section>)}