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

Reviewed-on: #365
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
このコミットはPull リクエスト #365 でマージされました.
このコミットが含まれているのは:
2026-06-12 01:35:31 +09:00
committed by みてるぞ
コミット def6870f06
14個のファイルの変更1077行の追加190行の削除
+77
ファイルの表示
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { apiPost } from '@/lib/api'
import {
buildGekanatorQuestions,
expectedAnswerForQuestion,
restoreGekanatorQuestion,
saveGekanatorExtraQuestionAnswers,
@@ -146,6 +147,23 @@ describe('expectedAnswerForQuestion', () => {
expect(expectedAnswerForQuestion(question, post({ tags: [] }))).toBe('no')
})
it('ignores example answers for direct title facts', () => {
const question: StoredGekanatorQuestion = {
id: 'title:length-at-least:20',
text: 'タイトルは 20 文字以上?',
kind: 'title',
condition: {
type: 'title-length-at-least',
length: 20,
},
exampleAnswers: {
1: 'yes',
},
}
expect(expectedAnswerForQuestion(question, post({ id: 1, title: 'short' }))).toBe('no')
})
})
describe('restoreGekanatorQuestion', () => {
@@ -187,6 +205,65 @@ describe('restoreGekanatorQuestion', () => {
expect(question.test(post({ id: 2 }))).toBe(false)
expect(question.test(post({ id: 3 }))).toBe(false)
})
it('tests a post_similarity question against its configured partial answer', () => {
const question = restoreGekanatorQuestion({
id: 'post-similarity:10',
text: '喜多ちゃんが泣いてる?',
kind: 'post_similarity',
source: 'user_suggested',
priorityWeight: 1.2,
condition: {
type: 'post-similarity',
postId: 999,
answer: 'partial',
threshold: 0.65,
},
exampleAnswers: {
1: 'partial',
2: 'yes',
},
})
expect(question.test(post({ id: 1 }))).toBe(true)
expect(question.test(post({ id: 2 }))).toBe(false)
})
it('normalizes legacy title-length-greater-than questions', () => {
const question = restoreGekanatorQuestion({
id: 'title:length-greater-than:20',
text: '題名が長めの投稿?',
kind: 'title',
condition: {
type: 'title-length-greater-than',
length: 20,
},
})
expect(question.id).toBe('title:length-at-least:21')
expect(question.condition).toEqual({
type: 'title-length-at-least',
length: 21,
})
expect(question.test(post({ title: 'x'.repeat(20) }))).toBe(false)
expect(question.test(post({ title: 'x'.repeat(21) }))).toBe(true)
})
})
describe('buildGekanatorQuestions', () => {
it('builds quantitative title length questions', () => {
const questions = buildGekanatorQuestions([
post({ id: 1, title: 'a' }),
post({ id: 2, title: 'bb' }),
post({ id: 3, title: 'ccc' }),
post({ id: 4, title: 'dddd' }),
])
const titleQuestion = questions.find(question =>
question.condition.type === 'title-length-at-least')
expect(titleQuestion?.text).toMatch(/^タイトルは \d+ 文字以上\?$/)
expect(titleQuestion?.id).toMatch(/^title:length-at-least:\d+$/)
})
})
describe('Gekanator API writers', () => {
+4 -2
ファイルの表示
@@ -372,10 +372,12 @@ export const fetchGekanatorQuestions = async (): Promise<StoredGekanatorQuestion
export const fetchGekanatorExtraQuestions = async (
gameId: number,
gameId: number,
nonce?: string,
): Promise<GekanatorExtraQuestion[]> => {
const data = await apiGet<{ questions: GekanatorExtraQuestion[] }> (
`/gekanator/games/${ gameId }/extra_questions`)
`/gekanator/games/${ gameId }/extra_questions`,
{ params: nonce ? { nonce } : undefined })
return data.questions
}
+151
ファイルの表示
@@ -0,0 +1,151 @@
import { describe, expect, it } from 'vitest'
import {
candidatePostsFor,
hardFilteredPostsForAnswer,
recoverCandidatePosts,
} from '@/lib/gekanatorCandidateRecovery'
import type {
GekanatorAnswerLog,
GekanatorAnswerValue,
GekanatorQuestion,
} from '@/lib/gekanator'
import type { Post } from '@/types'
const post = (id: number): Post => ({
id,
versionNo: 1,
url: `https://example.com/posts/${ id }`,
title: `post ${ id }`,
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,
})
const postSimilarityQuestion = (
id: string,
answers: Record<`${ number }`, GekanatorAnswerValue>,
): GekanatorQuestion => ({
id,
text: `${ id }?`,
kind: 'post_similarity',
condition: {
type: 'post-similarity',
postId: 9999,
answer: 'yes',
threshold: 0.65 },
source: 'user_suggested',
priorityWeight: 1,
exampleAnswers: answers,
test: candidate => answers[String (candidate.id) as `${ number }`] === 'yes',
})
const answer = (
question: GekanatorQuestion,
value: GekanatorAnswerValue,
): GekanatorAnswerLog => ({
questionId: question.id,
questionText: question.text,
questionCondition: question.condition,
answer: value,
originalAnswer: value,
})
describe('candidatePostsFor', () => {
it('lets recovered candidates ignore old answers but not later answers', () => {
const posts = [post (1), post (2), post (3)]
const oldQuestion = postSimilarityQuestion ('old', {
1: 'no',
2: 'yes',
3: 'yes',
})
const laterQuestion = postSimilarityQuestion ('later', {
1: 'no',
2: 'no',
3: 'yes',
})
const candidates = candidatePostsFor ({
posts,
questions: [oldQuestion, laterQuestion],
answers: [answer (oldQuestion, 'yes'), answer (laterQuestion, 'yes')],
softenedQuestionIds: new Set (),
rejectedPostIds: new Set (),
recoveredCandidatePosts: new Map ([
[1, 1],
[3, 1],
]) })
expect(candidates.map (candidate => candidate.id)).toEqual ([3])
})
it('does not let recovered candidates bypass explicit rejected posts', () => {
const posts = [post (1), post (2)]
const question = postSimilarityQuestion ('question', {
1: 'yes',
2: 'yes',
})
const candidates = candidatePostsFor ({
posts,
questions: [question],
answers: [answer (question, 'yes')],
softenedQuestionIds: new Set (),
rejectedPostIds: new Set ([1]),
recoveredCandidatePosts: new Map ([[1, 1]]) })
expect(candidates.map (candidate => candidate.id)).toEqual ([2])
})
})
describe('hardFilteredPostsForAnswer', () => {
it('returns zero candidates without falling back to the original pool', () => {
const posts = [post (1), post (2)]
const question = postSimilarityQuestion ('question', {
1: 'yes',
2: 'yes',
})
expect(hardFilteredPostsForAnswer ({
posts,
question,
answer: 'no',
})).toEqual ([])
})
})
describe('recoverCandidatePosts', () => {
it('recovers high-score non-rejected, non-eligible candidates in staged batches', () => {
const posts = Array.from ({ length: 10 }, (_value, index) => post (index + 1))
const scores = new Map (posts.map (candidate => [candidate.id, candidate.id]))
const recovered = recoverCandidatePosts ({
posts,
scores,
rejectedPostIds: new Set ([10]),
recoveredCandidatePosts: new Map ([[8, 1]]),
eligiblePostIds: new Set ([9]),
answerCountAtRecovery: 2,
recoveryStepCount: 0,
})
expect(recovered?.recoveryStepCount).toBe (1)
expect([...(recovered?.recoveredCandidatePosts.keys () ?? [])])
.toEqual ([8, 7, 6, 5, 4, 3, 2])
expect(recovered?.recoveredCandidatePosts.get (7)).toBe (2)
})
})
+146
ファイルの表示
@@ -0,0 +1,146 @@
import { expectedAnswerForQuestion } from '@/lib/gekanator'
import type {
GekanatorAnswerLog,
GekanatorAnswerValue,
GekanatorQuestion,
} from '@/lib/gekanator'
import type { Post } from '@/types'
export type RecoveredCandidatePost = {
postId: number
answerCountAtRecovery: number }
export const candidatePostsFor = ({
posts,
questions,
answers,
softenedQuestionIds,
rejectedPostIds,
recoveredCandidatePosts,
}: {
posts: Post[]
questions: GekanatorQuestion[]
answers: GekanatorAnswerLog[]
softenedQuestionIds: Set<string>
rejectedPostIds: Set<number>
recoveredCandidatePosts: Map<number, number>
}): Post[] => {
const questionById = new Map (questions.map (question => [question.id, question]))
return posts.filter (post => {
if (rejectedPostIds.has (post.id))
return false
const answerCountAtRecovery = recoveredCandidatePosts.get (post.id)
return answers.every ((answer, index) => {
if (answerCountAtRecovery !== undefined && index < answerCountAtRecovery)
return true
if (softenedQuestionIds.has (answer.questionId))
return true
const question = questionById.get (answer.questionId)
if (!(question))
return true
switch (answer.answer)
{
case 'yes':
case 'no': {
const expected = expectedAnswerForQuestion (question, post)
return expected === null || expected === 'unknown' || expected === answer.answer
}
default:
return true
}
})
})
}
export const hardFilteredPostsForAnswer = ({
posts,
question,
answer,
}: {
posts: Post[]
question: GekanatorQuestion
answer: GekanatorAnswerValue
}): Post[] => {
if (answer === 'unknown')
return posts
return posts.filter (post => {
const expected = expectedAnswerForQuestion (question, post)
return expected === null || expected === 'unknown' || expected === answer
})
}
const concreteAnswerOptions: GekanatorAnswerValue[] = [
'yes',
'no',
'partial',
'probably_no']
export const allConcreteAnswerOptionsExhausted = (
posts: Post[],
question: GekanatorQuestion | null,
): boolean => {
if (!(question))
return false
return concreteAnswerOptions.every (answer =>
hardFilteredPostsForAnswer ({ posts, question, answer }).length === 0)
}
const nextRecoveryBatchSize = (recoveryStepCount: number): number =>
6 * (2 ** recoveryStepCount)
export const recoverCandidatePosts = ({
posts,
scores,
rejectedPostIds,
recoveredCandidatePosts,
eligiblePostIds,
answerCountAtRecovery,
recoveryStepCount,
}: {
posts: Post[]
scores: Map<number, number>
rejectedPostIds: Set<number>
recoveredCandidatePosts: Map<number, number>
eligiblePostIds: Set<number>
answerCountAtRecovery: number
recoveryStepCount: number
}): {
recoveredCandidatePosts: Map<number, number>
recoveryStepCount: number
} | null => {
const recovered = new Map (recoveredCandidatePosts)
const candidates = posts
.filter (post =>
!(rejectedPostIds.has (post.id))
&& !(eligiblePostIds.has (post.id))
&& !(recovered.has (post.id)))
.sort ((a, b) =>
(scores.get (b.id) ?? Number.NEGATIVE_INFINITY)
- (scores.get (a.id) ?? Number.NEGATIVE_INFINITY))
.slice (0, nextRecoveryBatchSize (recoveryStepCount))
if (candidates.length === 0)
return null
candidates.forEach (post => recovered.set (post.id, answerCountAtRecovery))
return {
recoveredCandidatePosts: recovered,
recoveryStepCount: recoveryStepCount + 1 }
}
+167
ファイルの表示
@@ -0,0 +1,167 @@
import { titleLengthMinimumForCondition } from '@/lib/gekanator'
import type {
GekanatorAnswerLog,
GekanatorAnswerValue,
GekanatorQuestion,
} from '@/lib/gekanator'
export const monthForCondition = (
condition: GekanatorQuestion['condition'],
): number | null => {
if (condition.type === 'original-month')
return condition.month
if (condition.type !== 'original-month-day')
return null
const month = Number (condition.monthDay.split ('-')[0])
return Number.isInteger (month) ? month : null
}
const isTitleLengthContradiction = (
candidate: GekanatorQuestion['condition'],
previous: GekanatorQuestion['condition'],
answer: GekanatorAnswerValue,
): boolean => {
const candidateLength = titleLengthMinimumForCondition (candidate)
const previousLength = titleLengthMinimumForCondition (previous)
if (candidateLength === null || previousLength === null)
return false
switch (answer)
{
case 'yes':
return candidateLength <= previousLength
case 'no':
return candidateLength >= previousLength
default:
return false
}
}
const isQuestionRedundantAfterAnswers = (
question: GekanatorQuestion,
answers: GekanatorAnswerLog[],
): boolean => answers.some (answer => {
const previous = answer.questionCondition
return previous !== undefined
&& isTitleLengthContradiction (question.condition, previous, answer.answer)
})
const isSourceFactBlocked = (
candidate: GekanatorQuestion['condition'],
previous: GekanatorQuestion['condition'],
answer: GekanatorAnswerValue,
): boolean => {
if (candidate.type !== 'source' || previous.type !== 'source')
return false
switch (answer)
{
case 'yes':
return true
case 'no':
return candidate.host === previous.host
default:
return false
}
}
const isOriginalYearFactBlocked = (
candidate: GekanatorQuestion['condition'],
previous: GekanatorQuestion['condition'],
answer: GekanatorAnswerValue,
): boolean => {
if (candidate.type !== 'original-year' || previous.type !== 'original-year')
return false
switch (answer)
{
case 'yes':
return true
case 'no':
return candidate.year === previous.year
default:
return false
}
}
const isOriginalMonthFactBlocked = (
candidate: GekanatorQuestion['condition'],
previous: GekanatorQuestion['condition'],
answer: GekanatorAnswerValue,
): boolean => {
switch (answer)
{
case 'yes':
if (previous.type === 'original-month')
{
if (candidate.type === 'original-month')
return true
if (candidate.type === 'original-month-day')
return monthForCondition (candidate) !== previous.month
return false
}
if (previous.type === 'original-month-day')
return candidate.type === 'original-month'
|| candidate.type === 'original-month-day'
return false
case 'no':
if (previous.type === 'original-month')
{
if (candidate.type === 'original-month')
return candidate.month === previous.month
if (candidate.type === 'original-month-day')
return monthForCondition (candidate) === previous.month
return false
}
if (previous.type === 'original-month-day')
return candidate.type === 'original-month-day'
&& candidate.monthDay === previous.monthDay
return false
default:
return false
}
}
const isFactQuestionBlocked = (
candidate: GekanatorQuestion['condition'],
previous: GekanatorQuestion['condition'],
answer: GekanatorAnswerValue,
): boolean => {
if (!(answer === 'yes' || answer === 'no'))
return false
return isSourceFactBlocked (candidate, previous, answer)
|| isOriginalYearFactBlocked (candidate, previous, answer)
|| isOriginalMonthFactBlocked (candidate, previous, answer)
}
export const isQuestionHardFilteredAfterAnswers = (
question: GekanatorQuestion,
answers: GekanatorAnswerLog[],
): boolean => answers.some (answer => {
const previous = answer.questionCondition
if (previous === undefined)
return false
return isQuestionRedundantAfterAnswers (question, [answer])
|| isFactQuestionBlocked (question.condition, previous, answer.answer)
})
+2 -2
ファイルの表示
@@ -12,8 +12,8 @@ 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 }
extraQuestions: (gameId: number, nonce: string) =>
['gekanator', 'games', gameId, 'extra-questions', nonce] as const }
export const tagsKeys = {
root: ['tags'] as const,