Reviewed-on: #377 Co-authored-by: miteruzo <miteruzo@naver.com> Co-committed-by: miteruzo <miteruzo@naver.com>
このコミットはPull リクエスト #377 でマージされました.
このコミットが含まれているのは:
@@ -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) }
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe TagNameSanitisationRule, type: :model do
|
RSpec.describe TagNameSanitisationRule, type: :model do
|
||||||
|
before do
|
||||||
|
described_class.unscoped.delete_all
|
||||||
|
end
|
||||||
|
|
||||||
describe '.sanitise' do
|
describe '.sanitise' do
|
||||||
before do
|
before do
|
||||||
described_class.create!(priority: 10, source_pattern: '_', replacement: '')
|
described_class.create!(priority: 10, source_pattern: '_', replacement: '')
|
||||||
|
|||||||
@@ -206,6 +206,40 @@ RSpec.describe 'Gekanator learning API', type: :request do
|
|||||||
expect(example.gekanator_game_id).to eq(json['id'])
|
expect(example.gekanator_game_id).to eq(json['id'])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'learns accepted post_similarity answers from main game logs' do
|
||||||
|
sign_in_as admin
|
||||||
|
|
||||||
|
question = create_post_similarity_question!(text: '泣いてる?')
|
||||||
|
|
||||||
|
expect {
|
||||||
|
post '/gekanator/games', params: {
|
||||||
|
guessed_post_id: guessed_post.id,
|
||||||
|
correct_post_id: correct_post.id,
|
||||||
|
answers: [
|
||||||
|
{
|
||||||
|
question_id: "post-similarity:#{question.id}",
|
||||||
|
question_text: '泣いてる?',
|
||||||
|
answer: 'partial',
|
||||||
|
original_answer: 'partial'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}.to change { GekanatorQuestionExample.count }.by(1)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:created)
|
||||||
|
expect(json['learned_example_count']).to eq(1)
|
||||||
|
|
||||||
|
example = GekanatorQuestionExample.last
|
||||||
|
expect(example).to have_attributes(
|
||||||
|
gekanator_question_id: question.id,
|
||||||
|
post_id: correct_post.id,
|
||||||
|
user_id: admin.id,
|
||||||
|
answer: 'partial',
|
||||||
|
source: 'post_game_answer'
|
||||||
|
)
|
||||||
|
expect(example.gekanator_game_id).to eq(json['id'])
|
||||||
|
end
|
||||||
|
|
||||||
it 'does not learn fact questions or nico tag questions from main game logs' do
|
it 'does not learn fact questions or nico tag questions from main game logs' do
|
||||||
sign_in_as admin
|
sign_in_as admin
|
||||||
|
|
||||||
@@ -475,28 +509,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
|
||||||
@@ -519,6 +584,37 @@ RSpec.describe 'Gekanator learning API', type: :request do
|
|||||||
expect(json['questions'].map { _1['id'] }).to include(existing.id)
|
expect(json['questions'].map { _1['id'] }).to include(existing.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'prioritizes questions the current user has not answered' do
|
||||||
|
sign_in_as admin
|
||||||
|
|
||||||
|
answered = create_post_similarity_question!(
|
||||||
|
text: 'already answered?',
|
||||||
|
priority_weight: 3.0
|
||||||
|
)
|
||||||
|
GekanatorQuestionExample.create!(
|
||||||
|
gekanator_question: answered,
|
||||||
|
post: other_post,
|
||||||
|
user: admin,
|
||||||
|
answer: 'yes',
|
||||||
|
source: 'post_game_extra'
|
||||||
|
)
|
||||||
|
|
||||||
|
unanswered =
|
||||||
|
6.times.map { |index|
|
||||||
|
create_post_similarity_question!(
|
||||||
|
text: "unanswered #{index}?",
|
||||||
|
priority_weight: 0.5
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
get "/gekanator/games/#{game.id}/extra_questions"
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(json['questions'].map { _1['id'] }).to match_array(
|
||||||
|
unanswered.map(&:id)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
it 'can return questions already asked in the game using snake_case question_id' do
|
it 'can return questions already asked in the game using snake_case question_id' do
|
||||||
sign_in_as admin
|
sign_in_as admin
|
||||||
|
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
@@ -372,6 +400,10 @@ describe('Gekanator API writers', () => {
|
|||||||
type: 'tag',
|
type: 'tag',
|
||||||
key: 'character:喜多郁代',
|
key: 'character:喜多郁代',
|
||||||
},
|
},
|
||||||
|
questionMode: 'normal',
|
||||||
|
questionPurpose: 'effective_user_suggested',
|
||||||
|
effectiveQuestion: true,
|
||||||
|
learningQuestion: false,
|
||||||
answer: 'yes',
|
answer: 'yes',
|
||||||
originalAnswer: 'partial',
|
originalAnswer: 'partial',
|
||||||
},
|
},
|
||||||
@@ -396,6 +428,10 @@ describe('Gekanator API writers', () => {
|
|||||||
type: 'tag',
|
type: 'tag',
|
||||||
key: 'character:喜多郁代',
|
key: 'character:喜多郁代',
|
||||||
},
|
},
|
||||||
|
question_mode: 'normal',
|
||||||
|
question_purpose: 'effective_user_suggested',
|
||||||
|
effective_question: true,
|
||||||
|
learning_question: false,
|
||||||
answer: 'yes',
|
answer: 'yes',
|
||||||
original_answer: 'partial',
|
original_answer: 'partial',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -593,6 +636,10 @@ export const saveGekanatorGame = async ({
|
|||||||
question_id: answer.questionId,
|
question_id: answer.questionId,
|
||||||
question_text: answer.questionText,
|
question_text: answer.questionText,
|
||||||
question_condition: answer.questionCondition ?? null,
|
question_condition: answer.questionCondition ?? null,
|
||||||
|
question_mode: answer.questionMode,
|
||||||
|
question_purpose: answer.questionPurpose,
|
||||||
|
effective_question: answer.effectiveQuestion,
|
||||||
|
learning_question: answer.learningQuestion,
|
||||||
answer: answer.answer,
|
answer: answer.answer,
|
||||||
original_answer: answer.originalAnswer })) })
|
original_answer: answer.originalAnswer })) })
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -60,6 +60,14 @@ const gekanatorBackdropSource = gekanatorPageSource.slice (
|
|||||||
gekanatorPageSource.indexOf ('const GekanatorBackdrop'),
|
gekanatorPageSource.indexOf ('const GekanatorBackdrop'),
|
||||||
gekanatorPageSource.indexOf ('const expectedAnswerFor'))
|
gekanatorPageSource.indexOf ('const expectedAnswerFor'))
|
||||||
|
|
||||||
|
const gekanatorChooseQuestionSource = gekanatorPageSource.slice (
|
||||||
|
gekanatorPageSource.indexOf ('const chooseQuestion'),
|
||||||
|
gekanatorPageSource.indexOf ('const winningRunPriorityFor'))
|
||||||
|
|
||||||
|
const gekanatorFallbackQuestionSource = gekanatorPageSource.slice (
|
||||||
|
gekanatorPageSource.indexOf ('const chooseFallbackQuestion'),
|
||||||
|
gekanatorPageSource.indexOf ('const shouldEnterGuessPhase'))
|
||||||
|
|
||||||
|
|
||||||
describe('GekanatorBackdrop regression structure', () => {
|
describe('GekanatorBackdrop regression structure', () => {
|
||||||
it('keeps displayedBackdropMode as the render-time source of truth', () => {
|
it('keeps displayedBackdropMode as the render-time source of truth', () => {
|
||||||
@@ -103,6 +111,30 @@ describe('GekanatorBackdrop regression structure', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
describe('Gekanator question selection regression structure', () => {
|
||||||
|
it('prefers normal questions after user_suggested quota has been met', () => {
|
||||||
|
const normalFallbackIndex = gekanatorChooseQuestionSource.indexOf (
|
||||||
|
'else if (normalPool.length > 0)')
|
||||||
|
const effectiveFallbackIndex = gekanatorChooseQuestionSource.indexOf (
|
||||||
|
'else if (effectiveUserSuggestedPool.length > 0)')
|
||||||
|
|
||||||
|
expect(normalFallbackIndex).toBeGreaterThan(0)
|
||||||
|
expect(effectiveFallbackIndex).toBeGreaterThan(0)
|
||||||
|
expect(normalFallbackIndex).toBeLessThan(effectiveFallbackIndex)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not let fallback questions bypass user_suggested purpose tracking', () => {
|
||||||
|
expect(gekanatorFallbackQuestionSource).toContain (
|
||||||
|
"question.source !== 'user_suggested'")
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not show a fixed extra-question count in the extra learning UI', () => {
|
||||||
|
expect(gekanatorPageSource).not.toContain ('追加で 2 問まで答えてください。')
|
||||||
|
expect(gekanatorPageSource).toContain ('追加で質問に答えてください。')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
describe('isQuestionHardFilteredAfterAnswers', () => {
|
describe('isQuestionHardFilteredAfterAnswers', () => {
|
||||||
it('blocks only contradictory or redundant month questions after a yes answer', () => {
|
it('blocks only contradictory or redundant month questions after a yes answer', () => {
|
||||||
const previous: GekanatorQuestionCondition = { type: 'original-month', month: 12 }
|
const previous: GekanatorQuestionCondition = { type: 'original-month', month: 12 }
|
||||||
|
|||||||
+780
-144
ファイル差分が大きすぎるため省略します
差分を読込み
新しい課題から参照
ユーザをブロックする