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

Reviewed-on: #362
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
このコミットはPull リクエスト #362 でマージされました.
このコミットが含まれているのは:
2026-06-10 23:33:56 +09:00
committed by みてるぞ
コミット 37ade2a988
31個のファイルの変更4330行の追加12行の削除
+16 -1
ファイルの表示
@@ -18,6 +18,7 @@ import MaterialListPage from '@/pages/materials/MaterialListPage'
import MaterialNewPage from '@/pages/materials/MaterialNewPage'
// import MaterialSearchPage from '@/pages/materials/MaterialSearchPage'
import MorePage from '@/pages/MorePage'
import GekanatorPage from '@/pages/GekanatorPage'
import NicoTagListPage from '@/pages/tags/NicoTagListPage'
import NotFound from '@/pages/NotFound'
import TOSPage from '@/pages/TOSPage.mdx'
@@ -39,7 +40,7 @@ import WikiHistoryPage from '@/pages/wiki/WikiHistoryPage'
import WikiNewPage from '@/pages/wiki/WikiNewPage'
import WikiSearchPage from '@/pages/wiki/WikiSearchPage'
import type { Dispatch, FC, SetStateAction } from 'react'
import type { Dispatch, FC, ReactNode, SetStateAction } from 'react'
import type { User } from '@/types'
@@ -80,6 +81,10 @@ const RouteTransitionWrapper = ({ user, setUser }: {
<Route path="/users/settings" element={<SettingPage user={user} setUser={setUser}/>}/>
<Route path="/settings" element={<Navigate to="/users/settings" replace/>}/>
<Route path="/tos" element={<TOSPage/>}/>
<Route path="/gekanator" element={
<AdminOnly user={user}>
<GekanatorPage/>
</AdminOnly>}/>
<Route path="/more" element={<MorePage/>}/>
<Route path="*" element={<NotFound/>}/>
</Routes>
@@ -87,6 +92,16 @@ const RouteTransitionWrapper = ({ user, setUser }: {
}
const AdminOnly = ({ user, children }: {
user: User | null
children: ReactNode }) => {
if (user?.role !== 'admin')
return <NotFound/>
return <>{children}</>
}
const PostDetailRoute = ({ user }: { user: User | null }) => {
const location = useLocation ()
const key = location.pathname
+296
ファイルの表示
@@ -0,0 +1,296 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { apiPost } from '@/lib/api'
import {
expectedAnswerForQuestion,
restoreGekanatorQuestion,
saveGekanatorExtraQuestionAnswers,
saveGekanatorGame,
saveGekanatorQuestionSuggestion,
} from '@/lib/gekanator'
import type {
GekanatorAnswerLog,
StoredGekanatorQuestion,
} from '@/lib/gekanator'
import type { Post } from '@/types'
vi.mock('@/lib/api', () => ({
apiGet: vi.fn(),
apiPost: vi.fn(),
}))
const mockedApiPost = vi.mocked(apiPost)
const post = (overrides: Partial<Post> = {}): Post => ({
id: 1,
url: 'https://example.com/posts/1',
title: 'post title',
thumbnail: null,
thumbnailBase: null,
tags: [],
viewed: false,
related: [],
originalCreatedFrom: null,
originalCreatedBefore: null,
createdAt: '2026-06-10T00:00:00.000Z',
updatedAt: '2026-06-10T00:00:00.000Z',
uploadedUser: null,
...overrides,
})
describe('expectedAnswerForQuestion', () => {
it('returns a direct example answer when present', () => {
const question: StoredGekanatorQuestion = {
id: 'post-similarity:10',
text: '喜多ちゃんが泣いてる?',
kind: 'post_similarity',
source: 'user_suggested',
priorityWeight: 1.2,
condition: {
type: 'post-similarity',
postId: 999,
answer: 'yes',
threshold: 0.65,
},
exampleAnswers: {
1: 'partial',
},
}
expect(expectedAnswerForQuestion(question, post({ id: 1 }))).toBe('partial')
})
it('returns the condition answer for the original post_similarity post', () => {
const question: StoredGekanatorQuestion = {
id: 'post-similarity:10',
text: '喜多ちゃんが泣いてる?',
kind: 'post_similarity',
source: 'user_suggested',
priorityWeight: 1.2,
condition: {
type: 'post-similarity',
postId: 123,
answer: 'probably_no',
threshold: 0.65,
},
}
expect(expectedAnswerForQuestion(question, post({ id: 123 }))).toBe('probably_no')
})
it('returns null for an unrelated post_similarity post without examples', () => {
const question: StoredGekanatorQuestion = {
id: 'post-similarity:10',
text: '喜多ちゃんが泣いてる?',
kind: 'post_similarity',
source: 'user_suggested',
priorityWeight: 1.2,
condition: {
type: 'post-similarity',
postId: 123,
answer: 'yes',
threshold: 0.65,
},
}
expect(expectedAnswerForQuestion(question, post({ id: 456 }))).toBeNull()
})
it('returns yes for a matching tag question', () => {
const question: StoredGekanatorQuestion = {
id: 'tag:character:喜多郁代',
text: '喜多ちゃんが関係してる?',
kind: 'tag',
condition: {
type: 'tag',
key: 'character:喜多郁代',
},
}
expect(
expectedAnswerForQuestion(
question,
post({
tags: [
{
id: 1,
name: '喜多郁代',
category: 'character',
aliases: [],
parents: [],
postCount: 1,
createdAt: '2026-06-10T00:00:00.000Z',
updatedAt: '2026-06-10T00:00:00.000Z',
hasWiki: false,
materialId: null,
},
],
}),
),
).toBe('yes')
})
it('returns no for a non-matching tag question', () => {
const question: StoredGekanatorQuestion = {
id: 'tag:character:喜多郁代',
text: '喜多ちゃんが関係してる?',
kind: 'tag',
condition: {
type: 'tag',
key: 'character:喜多郁代',
},
}
expect(expectedAnswerForQuestion(question, post({ tags: [] }))).toBe('no')
})
})
describe('restoreGekanatorQuestion', () => {
it('uses default source and priority weight when omitted', () => {
const question = restoreGekanatorQuestion({
id: 'tag:character:喜多郁代',
text: '喜多ちゃんが関係してる?',
kind: 'tag',
condition: {
type: 'tag',
key: 'character:喜多郁代',
},
})
expect(question.source).toBe('default')
expect(question.priorityWeight).toBe(1)
})
it('tests a post_similarity question using direct examples', () => {
const question = restoreGekanatorQuestion({
id: 'post-similarity:10',
text: '喜多ちゃんが泣いてる?',
kind: 'post_similarity',
source: 'user_suggested',
priorityWeight: 1.2,
condition: {
type: 'post-similarity',
postId: 999,
answer: 'yes',
threshold: 0.65,
},
exampleAnswers: {
1: 'yes',
2: 'no',
},
})
expect(question.test(post({ id: 1 }))).toBe(true)
expect(question.test(post({ id: 2 }))).toBe(false)
expect(question.test(post({ id: 3 }))).toBe(false)
})
})
describe('Gekanator API writers', () => {
beforeEach(() => {
mockedApiPost.mockReset()
})
it('sends game results using snake_case request keys', async () => {
mockedApiPost.mockResolvedValue({ id: 100 })
const answers: GekanatorAnswerLog[] = [
{
questionId: 'tag:character:喜多郁代',
questionText: '喜多ちゃんが関係してる?',
questionCondition: {
type: 'tag',
key: 'character:喜多郁代',
},
answer: 'yes',
originalAnswer: 'partial',
},
]
await expect(
saveGekanatorGame({
guessedPostId: 1,
correctPostId: 2,
answers,
}),
).resolves.toEqual({ id: 100 })
expect(mockedApiPost).toHaveBeenCalledWith('/gekanator/games', {
guessed_post_id: 1,
correct_post_id: 2,
answers: [
{
question_id: 'tag:character:喜多郁代',
question_text: '喜多ちゃんが関係してる?',
question_condition: {
type: 'tag',
key: 'character:喜多郁代',
},
answer: 'yes',
original_answer: 'partial',
},
],
})
})
it('sends question suggestions using snake_case request keys', async () => {
mockedApiPost.mockResolvedValue({
id: 10,
count: 1,
})
await expect(
saveGekanatorQuestionSuggestion({
gekanatorGameId: 100,
questionText: '喜多ちゃんが泣いてる?',
answer: 'yes',
}),
).resolves.toEqual({
id: 10,
count: 1,
})
expect(mockedApiPost).toHaveBeenCalledWith('/gekanator/question_suggestions', {
gekanator_game_id: 100,
question_text: '喜多ちゃんが泣いてる?',
answer: 'yes',
})
})
it('sends extra question answers using snake_case request keys', async () => {
mockedApiPost.mockResolvedValue({
count: 2,
})
await saveGekanatorExtraQuestionAnswers({
gameId: 100,
answers: [
{
questionId: 10,
answer: 'yes',
},
{
questionId: 11,
answer: 'probably_no',
},
],
})
expect(mockedApiPost).toHaveBeenCalledWith(
'/gekanator/games/100/extra_question_answers',
{
answers: [
{
question_id: 10,
answer: 'yes',
},
{
question_id: 11,
answer: 'probably_no',
},
],
},
)
})
})
+483
ファイルの表示
@@ -0,0 +1,483 @@
import { apiGet, apiPost } from '@/lib/api'
import type { Post } from '@/types'
export type GekanatorAnswerValue =
| 'yes'
| 'no'
| 'partial'
| 'probably_no'
| 'unknown'
export type GekanatorAnswerLog = {
questionId: string
questionText: string
questionCondition?: GekanatorQuestionCondition
answer: GekanatorAnswerValue
originalAnswer: GekanatorAnswerValue }
export type GekanatorQuestionKind =
| 'tag'
| 'source'
| 'title'
| 'original_date'
| 'post_similarity'
export type GekanatorQuestionSource =
| 'default'
| 'user_suggested'
| 'ai_generated'
| 'admin_curated'
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' }
| {
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
text: string
kind: GekanatorQuestionKind
condition: GekanatorQuestionCondition
source?: GekanatorQuestionSource
priorityWeight?: number
exampleAnswers?: Record<`${ number }`, GekanatorAnswerValue> }
export type GekanatorQuestion = {
id: string
text: string
kind: GekanatorQuestionKind
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))
return counts
}
const median = (values: number[]): number => {
const sorted = [...values].sort ((a, b) => a - b)
return sorted[Math.floor (sorted.length / 2)] ?? 0
}
const hostOf = (post: Post): string | null => {
try
{
return new URL (post.url).hostname.replace (/^www\./, '')
}
catch
{
return 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 originalDateOf = (post: Post): Date | 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
}
const originalMonthOf = (post: Post): number | null => {
const date = originalDateOf (post)
if (!(date))
return null
return date.getMonth () + 1
}
const originalMonthDayOf = (post: Post): string | null => {
const date = originalDateOf (post)
if (!(date))
return null
return `${ date.getMonth () + 1 }-${ date.getDate () }`
}
const tagQuestionKey = ({ category, name }: { category: string; name: string }): string =>
`${ category }:${ name }`
const tagFromQuestionKey = (key: string): { category: string; name: string } => {
const [category, ...rest] = key.split (':')
return { category: category ?? '', name: rest.join (':') }
}
const nicoTagLabel = (name: string): string => name.replace (/^nico:/, '')
const tagQuestionText = (category: string, label: string): string => {
switch (category)
{
case 'deerjikist':
return `作者・ニジラーとして「${ label }」に関係してゐる?`
case 'meme':
return `元ネタ・ミームとして「${ label }」に関係しさう?`
case 'character':
return `${ label }」といふキャラクターが関係してゐる?`
case 'material':
return `素材として「${ label }」に関係してゐる?`
case 'nico':
return `ニコニコに「${ label }」といふタグが付いてゐる?`
case 'general':
case 'meta':
default:
return `内容として「${ label }」に関係しさう?`
}
}
const questionableTag = (post: Post, key: string): boolean => {
const { category, name } = tagFromQuestionKey (key)
return (
post.tags.some (tag =>
tag.name === name
&& tag.category === category
&& !(tag.category === 'meta')
&& !(tag.name.includes ('タグ希望'))
&& !(tag.name.includes ('bot操作'))))
}
const questionMatches = (
post: Post,
question: StoredGekanatorQuestion,
): boolean => {
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, question.condition.key)
case 'source':
return hostOf (post) === question.condition.host
case 'original-year':
return originalYearOf (post) === question.condition.year
case 'original-month':
return originalMonthOf (post) === question.condition.month
case 'original-month-day':
return originalMonthDayOf (post) === question.condition.monthDay
case 'title-length-greater-than':
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
}
}
export const restoreGekanatorQuestion = (
question: StoredGekanatorQuestion,
): GekanatorQuestion => ({
...question,
source: question.source ?? 'default',
priorityWeight: question.priorityWeight ?? 1,
test: (post: Post) => questionMatches (post, question) })
export const storeGekanatorQuestion = (
question: GekanatorQuestion,
): StoredGekanatorQuestion => ({
id: question.id,
text: question.text,
kind: question.kind,
condition: question.condition,
source: question.source,
priorityWeight: question.priorityWeight,
exampleAnswers: question.exampleAnswers })
export const fetchGekanatorPosts = async (): Promise<Post[]> => {
const data = await apiGet<{ posts: Post[] }> ('/gekanator/posts')
return data.posts
}
export const fetchGekanatorQuestions = async (): Promise<StoredGekanatorQuestion[]> => {
const data = await apiGet<{ questions: StoredGekanatorQuestion[] }> ('/gekanator/questions')
return data.questions
}
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
.filter (tag =>
!(tag.category === 'meta')
&& !(tag.name.includes ('タグ希望'))
&& !(tag.name.includes ('bot操作')))
.map (tag => tagQuestionKey (tag))))
const hosts = countBy (posts.map (hostOf).filter ((host): host is string => Boolean (host)))
const originalYears = countBy (
posts
.map (originalYearOf)
.filter ((year): year is number => year !== null))
const originalMonths = countBy (
posts
.map (originalMonthOf)
.filter ((month): month is number => month !== null))
const originalMonthDays = countBy (
posts
.map (originalMonthDayOf)
.filter ((monthDay): monthDay is string => monthDay !== null))
const titleLengthMedian = median (posts.map (post => post.title?.length ?? 0))
const usefulEntries = <T extends string | number> (counts: Map<T, number>) =>
[...counts.entries ()]
.filter (([, count]) => count > 0 && count < posts.length)
.sort ((a, b) => Math.abs (posts.length / 2 - a[1])
- Math.abs (posts.length / 2 - b[1]))
.slice (0, 80)
const tagQuestions = usefulEntries (tagCounts)
.filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7))
.slice (0, 80)
.map (([key]) => {
const { category, name } = tagFromQuestionKey (String (key))
const label = category === 'nico' ? nicoTagLabel (name) : name
return {
id: `tag:${ key }`,
text: tagQuestionText (category, label),
kind: 'tag' as const,
condition: { type: 'tag' as const, key: String (key) },
source: 'default' as const,
priorityWeight: 1,
test: (post: Post) => questionableTag (post, String (key)) }
})
const sourceQuestions = usefulEntries (hosts)
.filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7))
.slice (0, 20)
.map (([host]) => ({
id: `source:${ host }`,
text: `${ host } の投稿を思ひ浮かべてゐる?`,
kind: 'source' as const,
condition: { type: 'source' as const, host },
source: 'default' as const,
priorityWeight: 1,
test: (post: Post) => hostOf (post) === host }))
const originalYearQuestions = usefulEntries (originalYears)
.filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7))
.slice (0, 20)
.map (([year]) => ({
id: `original-year:${ year }`,
text: `オリジナルの投稿年は ${ year } 年?`,
kind: 'original_date' as const,
condition: { type: 'original-year' as const, year },
source: 'default' as const,
priorityWeight: 1,
test: (post: Post) => originalYearOf (post) === year }))
const originalMonthQuestions = usefulEntries (originalMonths)
.filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7))
.slice (0, 20)
.map (([month]) => ({
id: `original-month:${ month }`,
text: `オリジナルの投稿月は ${ month } 月?`,
kind: 'original_date' as const,
condition: { type: 'original-month' as const, month },
source: 'default' as const,
priorityWeight: 1,
test: (post: Post) => originalMonthOf (post) === month }))
const originalMonthDayQuestions = usefulEntries (originalMonthDays)
.filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7))
.slice (0, 20)
.map (([monthDay]) => {
const [month, day] = String (monthDay).split ('-')
return {
id: `original-month-day:${ monthDay }`,
text: `オリジナルの投稿日は ${ month }${ day } 日?`,
kind: 'original_date' as const,
condition: { type: 'original-month-day' as const, monthDay: String (monthDay) },
source: 'default' as const,
priorityWeight: 1,
test: (post: Post) => originalMonthDayOf (post) === monthDay }
})
const titleQuestions = [
{
id: 'title:long',
text: '題名が長めの投稿?',
kind: 'title' as const,
condition: {
type: 'title-length-greater-than' as const,
length: titleLengthMedian },
source: 'default' as const,
priorityWeight: 1,
test: (post: Post) => (post.title?.length ?? 0) > titleLengthMedian },
{
id: 'title:ascii',
text: '題名に英数字が混じってゐる?',
kind: 'title' as const,
condition: { type: 'title-has-ascii' as const },
source: 'default' as const,
priorityWeight: 1,
test: (post: Post) => /[A-Za-z0-9]/.test (post.title ?? '') }]
.filter (question => {
const yes = posts.filter (post => question.test (post)).length
const no = posts.length - yes
return yes >= 2 && no >= 2 && yes <= posts.length * .7 && no <= posts.length * .7
})
return [
...sourceQuestions,
...originalYearQuestions,
...originalMonthQuestions,
...originalMonthDayQuestions,
...titleQuestions,
...tagQuestions]
}
export const saveGekanatorGame = async ({
guessedPostId,
correctPostId,
answers,
}: {
guessedPostId: number
correctPostId: number
answers: GekanatorAnswerLog[]
}): Promise<{ id: number }> =>
await apiPost ('/gekanator/games', {
guessed_post_id: guessedPostId,
correct_post_id: correctPostId,
answers: answers.map (answer => ({
question_id: answer.questionId,
question_text: answer.questionText,
question_condition: answer.questionCondition ?? null,
answer: answer.answer,
original_answer: answer.originalAnswer })) })
export const saveGekanatorQuestionSuggestion = async ({
gekanatorGameId,
questionText,
answer,
}: {
gekanatorGameId: number
questionText: string
answer: GekanatorAnswerValue
}): Promise<{ id: number; count: number }> =>
await apiPost ('/gekanator/question_suggestions', {
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 })) })
+7
ファイルの表示
@@ -8,6 +8,13 @@ export const postsKeys = {
changes: (p: { post?: string; tag?: string; page: number; limit: number }) =>
['posts', 'changes', p] as const }
export const gekanatorKeys = {
root: ['gekanator'] as const,
posts: () => ['gekanator', 'posts'] as const,
questions: () => ['gekanator', 'questions'] as const,
extraQuestions: (gameId: number) =>
['gekanator', 'games', gameId, 'extra-questions'] as const }
export const tagsKeys = {
root: ['tags'] as const,
index: (p: FetchTagsParams) => ['tags', 'index', p] as const,
ファイル差分が大きすぎるため省略します 差分を読込み