このコミットが含まれているのは:
2026-06-18 00:59:48 +09:00
コミット 3f1c6c135b
7個のファイルの変更987行の追加224行の削除
+1 -1
ファイルの表示
@@ -50,7 +50,7 @@ class GekanatorGamesController < ApplicationController
questions, questions,
post_id: game.correct_post_id, post_id: game.correct_post_id,
user: current_user, user: current_user,
limit: 2) limit: 6)
render json: { render json: {
questions: selected.map { |question| extra_question_json(question) } questions: selected.map { |question| extra_question_json(question) }
+39 -8
ファイルの表示
@@ -475,28 +475,59 @@ RSpec.describe 'Gekanator learning API', type: :request do
end end
describe 'GET /gekanator/games/:id/extra_questions' do describe 'GET /gekanator/games/:id/extra_questions' do
it 'returns at most two accepted user_suggested post_similarity questions without duplicates' do it 'returns at most six accepted user_suggested post_similarity questions without duplicates' do
sign_in_as admin sign_in_as admin
lowest = create_post_similarity_question!(
text: 'lowest?',
priority_weight: 0.5
)
low = create_post_similarity_question!( low = create_post_similarity_question!(
text: 'low?', text: 'low?',
priority_weight: 1.0 priority_weight: 1.0
) )
high = create_post_similarity_question!(
text: 'high?',
priority_weight: 3.0
)
middle = create_post_similarity_question!( middle = create_post_similarity_question!(
text: 'middle?', text: 'middle?',
priority_weight: 1.5
)
medium_high = create_post_similarity_question!(
text: 'medium high?',
priority_weight: 2.0 priority_weight: 2.0
) )
high = create_post_similarity_question!(
text: 'high?',
priority_weight: 2.5
)
higher = create_post_similarity_question!(
text: 'higher?',
priority_weight: 2.8
)
highest = create_post_similarity_question!(
text: 'highest?',
priority_weight: 3.0
)
overflow = create_post_similarity_question!(
text: 'overflow?',
priority_weight: 2.2
)
get "/gekanator/games/#{game.id}/extra_questions" get "/gekanator/games/#{game.id}/extra_questions"
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json['questions'].length).to eq(2) expect(json['questions'].length).to eq(6)
expect(json['questions'].map { _1['id'] }.uniq.length).to eq(2) expect(json['questions'].map { _1['id'] }.uniq.length).to eq(6)
expect(json['questions'].map { _1['id'] }).to all(be_in([low.id, high.id, middle.id])) expect(json['questions'].map { _1['id'] }).to all(
be_in([
lowest.id,
low.id,
middle.id,
medium_high.id,
high.id,
higher.id,
highest.id,
overflow.id,
])
)
end end
it 'can return questions that already have an example for the correct post' do it 'can return questions that already have an example for the correct post' do
+29 -1
ファイルの表示
@@ -4,6 +4,7 @@ import { apiPost } from '@/lib/api'
import { import {
buildGekanatorQuestions, buildGekanatorQuestions,
expectedAnswerForQuestion, expectedAnswerForQuestion,
learnedSemanticSideForPost,
questionIdForCondition, questionIdForCondition,
restoreGekanatorQuestion, restoreGekanatorQuestion,
saveGekanatorExtraQuestionAnswers, saveGekanatorExtraQuestionAnswers,
@@ -188,6 +189,33 @@ describe('expectedAnswerForQuestion', () => {
}) })
}) })
describe('learnedSemanticSideForPost', () => {
it('classifies post_similarity examples as positive, negative, or unknown', () => {
const question: StoredGekanatorQuestion = {
id: 'post-similarity:10',
text: '喜多ちゃんが泣いてる?',
kind: 'post_similarity',
source: 'user_suggested',
priorityWeight: 1.2,
condition: {
type: 'post-similarity',
postId: 123,
answer: 'partial',
threshold: 0.65,
},
exampleAnswers: {
1: 'yes',
2: 'probably_no',
},
}
expect(learnedSemanticSideForPost(question, post({ id: 1 }))).toBe('positive')
expect(learnedSemanticSideForPost(question, post({ id: 2 }))).toBe('negative')
expect(learnedSemanticSideForPost(question, post({ id: 3 }))).toBe('unknown')
expect(learnedSemanticSideForPost(question, post({ id: 123 }))).toBe('positive')
})
})
describe('restoreGekanatorQuestion', () => { describe('restoreGekanatorQuestion', () => {
it('uses default source and priority weight when omitted', () => { it('uses default source and priority weight when omitted', () => {
const question = restoreGekanatorQuestion({ const question = restoreGekanatorQuestion({
@@ -248,7 +276,7 @@ describe('restoreGekanatorQuestion', () => {
}) })
expect(question.test(post({ id: 1 }))).toBe(true) expect(question.test(post({ id: 1 }))).toBe(true)
expect(question.test(post({ id: 2 }))).toBe(false) expect(question.test(post({ id: 2 }))).toBe(true)
}) })
it('normalizes legacy title-length-greater-than questions', () => { it('normalizes legacy title-length-greater-than questions', () => {
+50 -7
ファイルの表示
@@ -9,11 +9,24 @@ export type GekanatorAnswerValue =
| 'probably_no' | 'probably_no'
| 'unknown' | 'unknown'
export type LearnedSemanticSide =
| 'positive'
| 'negative'
| 'unknown'
export type GekanatorQuestionPurpose =
| 'effective_user_suggested'
| 'learning_user_suggested'
| 'normal'
export type GekanatorAnswerLog = { export type GekanatorAnswerLog = {
questionId: string questionId: string
questionText: string questionText: string
questionCondition?: GekanatorQuestionCondition questionCondition?: GekanatorQuestionCondition
questionMode?: 'normal' | 'winning_run' questionMode?: 'normal' | 'winning_run'
questionPurpose?: GekanatorQuestionPurpose
effectiveQuestion?: boolean
learningQuestion?: boolean
answer: GekanatorAnswerValue answer: GekanatorAnswerValue
originalAnswer: GekanatorAnswerValue } originalAnswer: GekanatorAnswerValue }
@@ -163,6 +176,26 @@ const directExampleAnswerFor = (
return null return null
} }
export const isLearnedSemanticQuestion = (
question: StoredGekanatorQuestion | GekanatorQuestion,
): boolean =>
question.kind === 'post_similarity'
&& question.source === 'user_suggested'
export const learnedSemanticSideForAnswer = (
answer: GekanatorAnswerValue | null,
): LearnedSemanticSide => {
if (answer === 'yes' || answer === 'partial')
return 'positive'
if (answer === 'no' || answer === 'probably_no')
return 'negative'
return 'unknown'
}
const countBy = <T extends string | number> (values: T[]): Map<T, number> => { const countBy = <T extends string | number> (values: T[]): Map<T, number> => {
const counts = new Map<T, number> () const counts = new Map<T, number> ()
values.forEach (value => counts.set (value, (counts.get (value) ?? 0) + 1)) values.forEach (value => counts.set (value, (counts.get (value) ?? 0) + 1))
@@ -285,8 +318,8 @@ const questionMatches = (
): boolean => { ): boolean => {
const directAnswer = directExampleAnswerFor (question, post) const directAnswer = directExampleAnswerFor (question, post)
if (directAnswer) if (directAnswer)
return question.condition.type === 'post-similarity' return question.kind === 'post_similarity'
? directAnswer === question.condition.answer ? learnedSemanticSideForAnswer (directAnswer) === 'positive'
: directAnswer === 'yes' : directAnswer === 'yes'
switch (question.condition.type) switch (question.condition.type)
@@ -328,6 +361,11 @@ export const expectedAnswerForQuestion = (
switch (question.condition.type) switch (question.condition.type)
{ {
case 'post-similarity':
if (question.condition.postId === post.id)
return question.condition.answer
return null
case 'tag': case 'tag':
case 'source': case 'source':
case 'original-year': case 'original-year':
@@ -338,12 +376,17 @@ export const expectedAnswerForQuestion = (
case 'title-has-ascii': case 'title-has-ascii':
case 'title-contains': case 'title-contains':
return questionMatches (post, question) ? 'yes' : 'no' return questionMatches (post, question) ? 'yes' : 'no'
case 'post-similarity':
return null
} }
} }
export const learnedSemanticSideForPost = (
question: StoredGekanatorQuestion | GekanatorQuestion | undefined,
post: Post | null,
): LearnedSemanticSide =>
learnedSemanticSideForAnswer (expectedAnswerForQuestion (question, post))
export const restoreGekanatorQuestion = ( export const restoreGekanatorQuestion = (
question: StoredGekanatorQuestion, question: StoredGekanatorQuestion,
): GekanatorQuestion => { ): GekanatorQuestion => {
@@ -423,15 +466,15 @@ export const buildGekanatorQuestions = (
const originalYears = countBy ( const originalYears = countBy (
posts posts
.map (originalYearOf) .map (originalYearOf)
.filter ((year): year is number => year !== null)) .filter ((year): year is number => year != null))
const originalMonths = countBy ( const originalMonths = countBy (
posts posts
.map (originalMonthOf) .map (originalMonthOf)
.filter ((month): month is number => month !== null)) .filter ((month): month is number => month != null))
const originalMonthDays = countBy ( const originalMonthDays = countBy (
posts posts
.map (originalMonthDayOf) .map (originalMonthDayOf)
.filter ((monthDay): monthDay is string => monthDay !== null)) .filter ((monthDay): monthDay is string => monthDay != null))
const titleLengthMedian = median (posts.map (post => post.title?.length ?? 0)) const titleLengthMedian = median (posts.map (post => post.title?.length ?? 0))
const titleWordCounts = const titleWordCounts =
includeTitleContains includeTitleContains
+23 -10
ファイルの表示
@@ -11,6 +11,7 @@ import type {
GekanatorAnswerValue, GekanatorAnswerValue,
GekanatorQuestion, GekanatorQuestion,
} from '@/lib/gekanator' } from '@/lib/gekanator'
import type { RecoveredCandidateState } from '@/lib/gekanatorCandidateRecovery'
import type { Post } from '@/types' import type { Post } from '@/types'
@@ -78,6 +79,15 @@ const answer = (
}) })
const recoveredState = (
answerCountAtRecovery: number,
scoreAtRecovery = 0,
): RecoveredCandidateState => ({
answerCountAtRecovery,
scoreAtRecovery,
})
describe('candidatePostsFor', () => { describe('candidatePostsFor', () => {
it('does not hard-filter semantic post_similarity answers', () => { it('does not hard-filter semantic post_similarity answers', () => {
const posts = [post (1), post (2), post (3)] const posts = [post (1), post (2), post (3)]
@@ -99,8 +109,8 @@ describe('candidatePostsFor', () => {
softenedQuestionIds: new Set (), softenedQuestionIds: new Set (),
rejectedPostIds: new Set (), rejectedPostIds: new Set (),
recoveredCandidatePosts: new Map ([ recoveredCandidatePosts: new Map ([
[1, 1], [1, recoveredState (1)],
[3, 1], [3, recoveredState (1)],
]) }) ]) })
expect(candidates.map (candidate => candidate.id)).toEqual ([1, 2, 3]) expect(candidates.map (candidate => candidate.id)).toEqual ([1, 2, 3])
@@ -122,8 +132,8 @@ describe('candidatePostsFor', () => {
softenedQuestionIds: new Set (), softenedQuestionIds: new Set (),
rejectedPostIds: new Set (), rejectedPostIds: new Set (),
recoveredCandidatePosts: new Map ([ recoveredCandidatePosts: new Map ([
[1, 1], [1, recoveredState (1)],
[3, 1], [3, recoveredState (1)],
]) }) ]) })
expect(candidates.map (candidate => candidate.id)).toEqual ([3]) expect(candidates.map (candidate => candidate.id)).toEqual ([3])
@@ -142,7 +152,7 @@ describe('candidatePostsFor', () => {
answers: [answer (question, 'yes')], answers: [answer (question, 'yes')],
softenedQuestionIds: new Set (), softenedQuestionIds: new Set (),
rejectedPostIds: new Set ([1]), rejectedPostIds: new Set ([1]),
recoveredCandidatePosts: new Map ([[1, 1]]) }) recoveredCandidatePosts: new Map ([[1, recoveredState (1)]]) })
expect(candidates.map (candidate => candidate.id)).toEqual ([2]) expect(candidates.map (candidate => candidate.id)).toEqual ([2])
}) })
@@ -209,7 +219,7 @@ describe('recoverCandidatePosts', () => {
posts, posts,
scores, scores,
rejectedPostIds: new Set ([10]), rejectedPostIds: new Set ([10]),
recoveredCandidatePosts: new Map ([[8, 1]]), recoveredCandidatePosts: new Map ([[8, recoveredState (1, 8)]]),
eligiblePostIds: new Set ([9]), eligiblePostIds: new Set ([9]),
answerCountAtRecovery: 2, answerCountAtRecovery: 2,
recoveryStepCount: 0, recoveryStepCount: 0,
@@ -218,7 +228,10 @@ describe('recoverCandidatePosts', () => {
expect(recovered?.recoveryStepCount).toBe (1) expect(recovered?.recoveryStepCount).toBe (1)
expect([...(recovered?.recoveredCandidatePosts.keys () ?? [])]) expect([...(recovered?.recoveredCandidatePosts.keys () ?? [])])
.toEqual ([8, 7, 6, 5, 4]) .toEqual ([8, 7, 6, 5, 4])
expect(recovered?.recoveredCandidatePosts.get (7)).toBe (2) expect(recovered?.recoveredCandidatePosts.get (7)).toEqual ({
answerCountAtRecovery: 2,
scoreAtRecovery: 7,
})
}) })
it('does not add posts when recovered and eligible candidates already hit the target', () => { it('does not add posts when recovered and eligible candidates already hit the target', () => {
@@ -230,9 +243,9 @@ describe('recoverCandidatePosts', () => {
scores, scores,
rejectedPostIds: new Set (), rejectedPostIds: new Set (),
recoveredCandidatePosts: new Map ([ recoveredCandidatePosts: new Map ([
[1, 1], [1, recoveredState (1, 1)],
[2, 1], [2, recoveredState (1, 2)],
[3, 1], [3, recoveredState (1, 3)],
]), ]),
eligiblePostIds: new Set ([4, 5, 6]), eligiblePostIds: new Set ([4, 5, 6]),
answerCountAtRecovery: 2, answerCountAtRecovery: 2,
+28 -16
ファイルの表示
@@ -1,15 +1,21 @@
import { expectedAnswerForQuestion } from '@/lib/gekanator' import { isLearnedSemanticQuestion,
learnedSemanticSideForPost } from '@/lib/gekanator'
import type { GekanatorAnswerLog, GekanatorAnswerValue, GekanatorQuestion } from '@/lib/gekanator' import type { GekanatorAnswerLog, GekanatorAnswerValue, GekanatorQuestion } from '@/lib/gekanator'
import type { Post } from '@/types' import type { Post } from '@/types'
export type RecoveredCandidatePost = { export type RecoveredCandidatePost = {
postId: number postId: number
answerCountAtRecovery: number } answerCountAtRecovery: number
scoreAtRecovery: number }
export type RecoveredCandidateState = {
answerCountAtRecovery: number
scoreAtRecovery: number }
const questionIsFactLikeForHardFiltering = (question: GekanatorQuestion): boolean => const questionSupportsAnswerBasedHardFiltering = (question: GekanatorQuestion): boolean =>
!(question.kind === 'post_similarity' !(isLearnedSemanticQuestion (question)
|| (question.kind === 'tag' || (question.kind === 'tag'
&& question.condition.type === 'tag' && question.condition.type === 'tag'
&& !(question.condition.key.startsWith ('nico:')))) && !(question.condition.key.startsWith ('nico:'))))
@@ -26,7 +32,7 @@ export const candidatePostsFor = (
answers: GekanatorAnswerLog[] answers: GekanatorAnswerLog[]
softenedQuestionIds: Set<string> softenedQuestionIds: Set<string>
rejectedPostIds: Set<number> rejectedPostIds: Set<number>
recoveredCandidatePosts: Map<number, number> }, recoveredCandidatePosts: Map<number, RecoveredCandidateState> },
): Post[] => { ): Post[] => {
const questionById = new Map (questions.map (question => [question.id, question])) const questionById = new Map (questions.map (question => [question.id, question]))
@@ -34,10 +40,10 @@ export const candidatePostsFor = (
if (rejectedPostIds.has (post.id)) if (rejectedPostIds.has (post.id))
return false return false
const answerCountAtRecovery = recoveredCandidatePosts.get (post.id) const recoveredCandidate = recoveredCandidatePosts.get (post.id)
return answers.every ((answer, index) => { return answers.every ((answer, index) => {
if (answerCountAtRecovery != null && index < answerCountAtRecovery) if (recoveredCandidate != null && index < recoveredCandidate.answerCountAtRecovery)
return true return true
if (softenedQuestionIds.has (answer.questionId)) if (softenedQuestionIds.has (answer.questionId))
@@ -46,7 +52,7 @@ export const candidatePostsFor = (
const question = questionById.get (answer.questionId) const question = questionById.get (answer.questionId)
if (!(question)) if (!(question))
return true return true
if (!(questionIsFactLikeForHardFiltering (question))) if (!(questionSupportsAnswerBasedHardFiltering (question)))
return true return true
switch (answer.answer) switch (answer.answer)
@@ -54,8 +60,10 @@ export const candidatePostsFor = (
case 'yes': case 'yes':
case 'no': case 'no':
{ {
const expected = expectedAnswerForQuestion (question, post) const expected = learnedSemanticSideForPost (question, post)
return expected === null || expected === 'unknown' || expected === answer.answer return expected === 'unknown'
|| (answer.answer === 'yes' && expected === 'positive')
|| (answer.answer === 'no' && expected === 'negative')
} }
default: default:
return true return true
@@ -70,15 +78,17 @@ export const hardFilteredPostsForAnswer = (
question: GekanatorQuestion question: GekanatorQuestion
answer: GekanatorAnswerValue }, answer: GekanatorAnswerValue },
): Post[] => { ): Post[] => {
if (!(questionIsFactLikeForHardFiltering (question))) if (!(questionSupportsAnswerBasedHardFiltering (question)))
return posts return posts
if (!(answer === 'yes' || answer === 'no')) if (!(answer === 'yes' || answer === 'no'))
return posts return posts
return posts.filter (post => { return posts.filter (post => {
const expected = expectedAnswerForQuestion (question, post) const side = learnedSemanticSideForPost (question, post)
return expected == null || expected === 'unknown' || expected === answer return side === 'unknown'
|| (answer === 'yes' && side === 'positive')
|| (answer === 'no' && side === 'negative')
}) })
} }
@@ -112,11 +122,11 @@ export const recoverCandidatePosts = (
recoveryStepCount }: { posts: Post[] recoveryStepCount }: { posts: Post[]
scores: Map<number, number> scores: Map<number, number>
rejectedPostIds: Set<number> rejectedPostIds: Set<number>
recoveredCandidatePosts: Map<number, number> recoveredCandidatePosts: Map<number, RecoveredCandidateState>
eligiblePostIds: Set<number> eligiblePostIds: Set<number>
answerCountAtRecovery: number answerCountAtRecovery: number
recoveryStepCount: number }, recoveryStepCount: number },
): { recoveredCandidatePosts: Map<number, number> ): { recoveredCandidatePosts: Map<number, RecoveredCandidateState>
recoveryStepCount: number } | null => { recoveryStepCount: number } | null => {
const recovered = new Map (recoveredCandidatePosts) const recovered = new Map (recoveredCandidatePosts)
const targetSize = nextRecoveryTargetSize (recoveryStepCount) const targetSize = nextRecoveryTargetSize (recoveryStepCount)
@@ -140,7 +150,9 @@ export const recoverCandidatePosts = (
if (candidates.length === 0) if (candidates.length === 0)
return null return null
candidates.forEach (post => recovered.set (post.id, answerCountAtRecovery)) candidates.forEach (post => recovered.set (post.id, {
answerCountAtRecovery,
scoreAtRecovery: scores.get (post.id) ?? 0 }))
return { recoveredCandidatePosts: recovered, return { recoveredCandidatePosts: recovered,
recoveryStepCount: recoveryStepCount + 1 } recoveryStepCount: recoveryStepCount + 1 }
ファイル差分が大きすぎるため省略します 差分を読込み