グカネータ / 質問パターン見直し (#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行の削除
+44 -22
ファイルの表示
@@ -27,26 +27,20 @@ class GekanatorGamesController < ApplicationController
game = GekanatorGame.find_by(id: params[:id])
return head :not_found unless game
asked_ids = Array(game.answers).filter_map { |answer| answer_question_id(answer) }
existing_example_ids =
GekanatorQuestionExample.where(post_id: game.correct_post_id)
.select(:gekanator_question_id)
# Direct examples only for now; post_similarity-based expansion is deferred.
questions =
GekanatorQuestion
.accepted
.includes(:gekanator_question_examples)
.where(kind: 'post_similarity', source: 'user_suggested')
.where.not(id: existing_example_ids)
.order(priority_weight: :desc, id: :asc)
.to_a
selected = weighted_sample_questions(
questions,
post_id: game.correct_post_id,
limit: 2)
render json: {
questions: questions.filter_map { |question|
json = extra_question_json(question)
next if asked_ids.include?(json[:id].to_s)
next if asked_ids.include?("post-similarity:#{ json[:id] }")
json
}.first(2)
questions: selected.map { |question| extra_question_json(question) }
}
end
@@ -84,11 +78,10 @@ class GekanatorGamesController < ApplicationController
gekanator_question: question,
post: game.correct_post,
user: current_user)
example.assign_attributes(
gekanator_game: game,
example.record_answer!(
answer: item[:answer],
source: 'post_game_extra',
weight: 1.0)
gekanator_game: game)
example.save!
end
end
@@ -107,12 +100,41 @@ class GekanatorGamesController < ApplicationController
}
end
def answer_question_id answer
value = if answer.is_a?(Hash)
answer['question_id'].presence || answer[:question_id].presence ||
answer['questionId'].presence || answer[:questionId].presence
def weighted_sample_questions questions, post_id:, limit:
remaining = questions.uniq(&:id)
selected = []
while selected.length < limit && remaining.any?
weighted =
remaining.map { |question|
[question, selection_weight_for(question, post_id: post_id)]
}
total_weight = weighted.sum { |_question, weight| weight }
break if total_weight <= 0
target = rand * total_weight
cumulative = 0.0
chosen =
weighted.find do |_question, weight|
cumulative += weight
cumulative >= target
end&.first || weighted.first.first
selected << chosen
remaining.reject! { |question| question.id == chosen.id }
end
value&.to_s
selected
end
def selection_weight_for question, post_id:
sample_count =
question.gekanator_question_examples.sum { |example|
next 0 unless example.post_id == post_id
example.sample_count.presence || 1
}
question.priority_weight.to_f / (1.0 + sample_count * 0.15)
end
end
+80
ファイルの表示
@@ -1,5 +1,6 @@
class GekanatorQuestionExample < ApplicationRecord
ANSWERS = GekanatorQuestionSuggestion::ANSWERS
NON_UNKNOWN_ANSWERS = ANSWERS - ['unknown']
SOURCES = ['initial_suggestion', 'post_game_extra'].freeze
belongs_to :gekanator_question
@@ -8,10 +9,89 @@ class GekanatorQuestionExample < ApplicationRecord
belongs_to :gekanator_game, optional: true
validates :answer, presence: true, inclusion: { in: ANSWERS }
validates :answer_counts, presence: true
validates :sample_count,
presence: true,
numericality: {
only_integer: true,
greater_than: 0
}
validates :source, presence: true, inclusion: { in: SOURCES }
validates :weight,
presence: true,
numericality: {
greater_than: 0
}
before_validation :normalize_learning_state
def record_answer!(answer:, source:, gekanator_game: nil)
answer = answer.to_s
raise ArgumentError, 'invalid answer' unless ANSWERS.include?(answer)
counts = normalized_answer_counts
counts[answer] += 1
self.answer_counts = counts
self.sample_count = counts.values.sum
self.gekanator_game = gekanator_game if gekanator_game.present?
self.source = source if new_record?
apply_aggregated_answer!(preferred_answer: answer)
self
end
private
def normalize_learning_state
counts = normalized_answer_counts
if counts.values.sum.zero? && answer.present?
counts[answer] = 1
end
self.answer_counts = counts
self.sample_count = counts.values.sum
apply_aggregated_answer!
end
def apply_aggregated_answer!(preferred_answer: nil)
counts = normalized_answer_counts
known_counts = counts.slice(*NON_UNKNOWN_ANSWERS)
known_total = known_counts.values.sum
if known_total.zero?
self.answer = 'unknown'
self.weight = 0.1
return
else
max_count = known_counts.values.max
candidates = known_counts.select { |_answer, count| count == max_count }.keys
self.answer =
if preferred_answer.present? && candidates.include?(preferred_answer)
preferred_answer
elsif answer.present? && candidates.include?(answer)
answer
else
candidates.first
end
end
consensus = max_count.to_f / known_total
self.weight = Math.sqrt(known_total) * consensus
end
def normalized_answer_counts
base = ANSWERS.index_with(0)
answer_counts.to_h.each do |key, value|
answer_key = key.to_s
next unless ANSWERS.include?(answer_key)
base[answer_key] = value.to_i
end
base
end
end
+7 -4
ファイルの表示
@@ -26,13 +26,16 @@ module Gekanator
},
gekanator_question_suggestion: suggestion,
created_by: user)
GekanatorQuestionExample.create!(
example =
GekanatorQuestionExample.new(
gekanator_question: question,
post: suggestion.gekanator_game.correct_post,
user: user,
gekanator_game: suggestion.gekanator_game,
user: user)
example.record_answer!(
answer: suggestion.answer,
source: 'initial_suggestion')
source: 'initial_suggestion',
gekanator_game: suggestion.gekanator_game)
example.save!
suggestion.update!(processed: true)
question
end
@@ -0,0 +1,40 @@
class AddAnswerStatisticsToGekanatorQuestionExamples < ActiveRecord::Migration[8.0]
class MigrationGekanatorQuestionExample < ApplicationRecord
self.table_name = 'gekanator_question_examples'
end
def up
add_column :gekanator_question_examples,
:answer_counts,
:json,
null: true
add_column :gekanator_question_examples,
:sample_count,
:integer,
null: false,
default: 1
MigrationGekanatorQuestionExample.reset_column_information
MigrationGekanatorQuestionExample.find_each do |example|
counts = {
'yes' => 0,
'no' => 0,
'partial' => 0,
'probably_no' => 0,
'unknown' => 0
}
counts[example.answer] = 1 if counts.key?(example.answer)
example.update_columns(
answer_counts: counts,
sample_count: 1)
end
change_column_null :gekanator_question_examples, :answer_counts, false
end
def down
remove_column :gekanator_question_examples, :sample_count
remove_column :gekanator_question_examples, :answer_counts
end
end
生成ファイル
+3 -1
ファイルの表示
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2026_06_10_000000) do
ActiveRecord::Schema[8.0].define(version: 2026_06_12_000000) do
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "name", null: false
t.string "record_type", null: false
@@ -85,6 +85,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_10_000000) do
t.float "weight", default: 1.0, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.json "answer_counts", null: false
t.integer "sample_count", default: 1, null: false
t.index ["gekanator_game_id"], name: "index_gekanator_question_examples_on_gekanator_game_id"
t.index ["gekanator_question_id", "post_id", "user_id"], name: "idx_gekanator_question_examples_on_question_post_user", unique: true
t.index ["gekanator_question_id"], name: "index_gekanator_question_examples_on_gekanator_question_id"
+39 -15
ファイルの表示
@@ -275,7 +275,7 @@ RSpec.describe 'Gekanator learning API', type: :request do
end
describe 'GET /gekanator/games/:id/extra_questions' do
it 'returns at most two accepted user_suggested post_similarity questions' do
it 'returns at most two accepted user_suggested post_similarity questions without duplicates' do
sign_in_as admin
low = create_post_similarity_question!(
@@ -295,15 +295,14 @@ RSpec.describe 'Gekanator learning API', type: :request do
expect(response).to have_http_status(:ok)
expect(json['questions'].length).to eq(2)
expect(json['questions'].map { _1['id'] }).to eq([high.id, middle.id])
expect(json['questions'].map { _1['id'] }).not_to include(low.id)
expect(json['questions'].map { _1['id'] }.uniq.length).to eq(2)
expect(json['questions'].map { _1['id'] }).to all(be_in([low.id, high.id, middle.id]))
end
it 'does not 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
sign_in_as admin
existing = create_post_similarity_question!(text: 'already learned?')
fresh = create_post_similarity_question!(text: 'fresh?')
GekanatorQuestionExample.create!(
gekanator_question: existing,
@@ -317,15 +316,13 @@ RSpec.describe 'Gekanator learning API', type: :request do
get "/gekanator/games/#{game.id}/extra_questions"
expect(response).to have_http_status(:ok)
expect(json['questions'].map { _1['id'] }).to include(fresh.id)
expect(json['questions'].map { _1['id'] }).not_to include(existing.id)
expect(json['questions'].map { _1['id'] }).to include(existing.id)
end
it 'does not 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
asked = create_post_similarity_question!(text: 'already asked?')
fresh = create_post_similarity_question!(text: 'fresh?')
game.update!(
answers: [
{
@@ -338,15 +335,13 @@ RSpec.describe 'Gekanator learning API', type: :request do
get "/gekanator/games/#{game.id}/extra_questions"
expect(response).to have_http_status(:ok)
expect(json['questions'].map { _1['id'] }).to include(fresh.id)
expect(json['questions'].map { _1['id'] }).not_to include(asked.id)
expect(json['questions'].map { _1['id'] }).to include(asked.id)
end
it 'does not return questions already asked in the game using camelCase questionId' do
it 'can return questions already asked in the game using camelCase questionId' do
sign_in_as admin
asked = create_post_similarity_question!(text: 'already asked?')
fresh = create_post_similarity_question!(text: 'fresh?')
game.update!(
answers: [
{
@@ -359,8 +354,7 @@ RSpec.describe 'Gekanator learning API', type: :request do
get "/gekanator/games/#{game.id}/extra_questions"
expect(response).to have_http_status(:ok)
expect(json['questions'].map { _1['id'] }).to include(fresh.id)
expect(json['questions'].map { _1['id'] }).not_to include(asked.id)
expect(json['questions'].map { _1['id'] }).to include(asked.id)
end
it 'does not return non-accepted, non-user_suggested, or non-post_similarity questions' do
@@ -584,5 +578,35 @@ RSpec.describe 'Gekanator learning API', type: :request do
other_post.id.to_s => 'no'
)
end
it 'normalizes legacy title length questions' do
sign_in_as admin
GekanatorQuestion.create!(
text: '題名が長めの投稿?',
kind: 'title',
source: 'admin_curated',
status: 'accepted',
priority_weight: 1.0,
condition: {
type: 'title-length-greater-than',
length: 20
},
created_by: admin
)
get '/gekanator/questions'
expect(response).to have_http_status(:ok)
question_json = json['questions'].find { _1['id'] == 'title:length-at-least:21' }
expect(question_json).to include(
'text' => 'タイトルは 21 文字以上?',
'kind' => 'title'
)
expect(question_json['condition']).to include(
'type' => 'title-length-at-least',
'length' => 21
)
end
end
end
+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', () => {
+3 -1
ファイルの表示
@@ -373,9 +373,11 @@ export const fetchGekanatorQuestions = async (): Promise<StoredGekanatorQuestion
export const fetchGekanatorExtraQuestions = async (
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,
+154
ファイルの表示
@@ -0,0 +1,154 @@
import { describe, expect, it } from 'vitest'
import { isQuestionHardFilteredAfterAnswers } from '@/lib/gekanatorQuestionFilters'
import type {
GekanatorAnswerLog,
GekanatorAnswerValue,
GekanatorQuestion,
GekanatorQuestionCondition,
} from '@/lib/gekanator'
const question = (
condition: GekanatorQuestionCondition,
): GekanatorQuestion => ({
id: `${ condition.type }:candidate`,
text: 'candidate?',
kind: condition.type === 'source'
? 'source'
: condition.type.startsWith ('original-')
? 'original_date'
: condition.type.startsWith ('title-')
? 'title'
: 'tag',
condition,
source: 'default',
priorityWeight: 1,
test: () => false,
})
const answer = (
condition: GekanatorQuestionCondition,
value: GekanatorAnswerValue,
): GekanatorAnswerLog => ({
questionId: 'previous',
questionText: 'previous?',
questionCondition: condition,
answer: value,
originalAnswer: value,
})
const blocked = (
candidate: GekanatorQuestionCondition,
previous: GekanatorQuestionCondition,
value: GekanatorAnswerValue,
): boolean =>
isQuestionHardFilteredAfterAnswers (question (candidate), [answer (previous, value)])
describe('isQuestionHardFilteredAfterAnswers', () => {
it('blocks only contradictory or redundant month questions after a yes answer', () => {
const previous: GekanatorQuestionCondition = { type: 'original-month', month: 12 }
expect(blocked ({ type: 'original-month', month: 12 }, previous, 'yes')).toBe(true)
expect(blocked ({ type: 'original-month', month: 2 }, previous, 'yes')).toBe(true)
expect(blocked ({ type: 'original-month-day', monthDay: '2-14' }, previous, 'yes'))
.toBe(true)
expect(blocked ({ type: 'original-month-day', monthDay: '12-25' }, previous, 'yes'))
.toBe(false)
expect(blocked ({ type: 'original-year', year: 2024 }, previous, 'yes')).toBe(false)
expect(blocked ({ type: 'source', host: 'example.com' }, previous, 'yes')).toBe(false)
expect(blocked ({ type: 'tag', key: 'character:喜多郁代' }, previous, 'yes')).toBe(false)
})
it('blocks same-month facts after a no answer', () => {
const previous: GekanatorQuestionCondition = { type: 'original-month', month: 12 }
expect(blocked ({ type: 'original-month', month: 12 }, previous, 'no')).toBe(true)
expect(blocked ({ type: 'original-month', month: 2 }, previous, 'no')).toBe(false)
expect(blocked ({ type: 'original-month-day', monthDay: '12-25' }, previous, 'no'))
.toBe(true)
expect(blocked ({ type: 'original-month-day', monthDay: '2-14' }, previous, 'no'))
.toBe(false)
})
it('blocks all month and month-day questions after a month-day yes answer', () => {
const previous: GekanatorQuestionCondition = {
type: 'original-month-day',
monthDay: '12-25',
}
expect(blocked ({ type: 'original-month', month: 12 }, previous, 'yes')).toBe(true)
expect(blocked ({ type: 'original-month', month: 2 }, previous, 'yes')).toBe(true)
expect(blocked ({ type: 'original-month-day', monthDay: '12-25' }, previous, 'yes'))
.toBe(true)
expect(blocked ({ type: 'original-month-day', monthDay: '12-26' }, previous, 'yes'))
.toBe(true)
})
it('blocks the same month-day only after a month-day no answer', () => {
const previous: GekanatorQuestionCondition = {
type: 'original-month-day',
monthDay: '12-25',
}
expect(blocked ({ type: 'original-month-day', monthDay: '12-25' }, previous, 'no'))
.toBe(true)
expect(blocked ({ type: 'original-month-day', monthDay: '12-26' }, previous, 'no'))
.toBe(false)
expect(blocked ({ type: 'original-month', month: 12 }, previous, 'no')).toBe(false)
})
it('blocks year and source as single-value facts', () => {
expect(blocked (
{ type: 'original-year', year: 2025 },
{ type: 'original-year', year: 2024 },
'yes',
)).toBe(true)
expect(blocked (
{ type: 'original-year', year: 2024 },
{ type: 'original-year', year: 2024 },
'no',
)).toBe(true)
expect(blocked (
{ type: 'source', host: 'b.example' },
{ type: 'source', host: 'a.example' },
'yes',
)).toBe(true)
expect(blocked (
{ type: 'source', host: 'b.example' },
{ type: 'source', host: 'a.example' },
'no',
)).toBe(false)
})
it('does not hard-filter partial, probably_no, or unknown fact answers', () => {
const previous: GekanatorQuestionCondition = { type: 'original-month', month: 12 }
const candidate: GekanatorQuestionCondition = { type: 'original-month', month: 2 }
expect(blocked (candidate, previous, 'partial')).toBe(false)
expect(blocked (candidate, previous, 'probably_no')).toBe(false)
expect(blocked (candidate, previous, 'unknown')).toBe(false)
})
it('keeps title-length hard redundancy for yes and no only', () => {
const previous: GekanatorQuestionCondition = {
type: 'title-length-at-least',
length: 30,
}
expect(blocked ({ type: 'title-length-at-least', length: 20 }, previous, 'yes'))
.toBe(true)
expect(blocked ({ type: 'title-length-at-least', length: 40 }, previous, 'yes'))
.toBe(false)
expect(blocked ({ type: 'title-length-at-least', length: 40 }, previous, 'no'))
.toBe(true)
expect(blocked ({ type: 'title-length-at-least', length: 20 }, previous, 'no'))
.toBe(false)
expect(blocked ({ type: 'title-length-at-least', length: 20 }, previous, 'partial'))
.toBe(false)
})
})
+157 -138
ファイルの表示
@@ -17,6 +17,12 @@ import { buildGekanatorQuestions,
saveGekanatorQuestionSuggestion,
storeGekanatorQuestion,
titleLengthMinimumForCondition } from '@/lib/gekanator'
import { allConcreteAnswerOptionsExhausted,
candidatePostsFor,
hardFilteredPostsForAnswer,
recoverCandidatePosts } from '@/lib/gekanatorCandidateRecovery'
import { isQuestionHardFilteredAfterAnswers,
monthForCondition } from '@/lib/gekanatorQuestionFilters'
import { gekanatorKeys } from '@/lib/queryKeys'
import { cn } from '@/lib/utils'
@@ -28,6 +34,7 @@ import type { GekanatorAnswerLog,
GekanatorQuestionCondition,
GekanatorQuestion,
StoredGekanatorQuestion } from '@/lib/gekanator'
import type { RecoveredCandidatePost } from '@/lib/gekanatorCandidateRecovery'
import type { Post } from '@/types'
type Phase =
@@ -63,6 +70,8 @@ type GameSnapshot = {
answers: GekanatorAnswerLog[]
askedIds: Set<string>
softenedQuestionIds: Set<string>
recoveredCandidatePosts: Map<number, number>
recoveryStepCount: number
askedQuestionBank: GekanatorQuestion[]
search: string
selectingCorrectPost: boolean
@@ -79,6 +88,8 @@ type StoredGekanatorGame = {
answers: GekanatorAnswerLog[]
askedIds: string[]
softenedQuestionIds: string[]
recoveredCandidatePosts?: RecoveredCandidatePost[]
recoveryStepCount?: number
askedQuestionBank?: StoredGekanatorQuestion[]
askedQuestionBankIds?: string[]
search: string
@@ -178,6 +189,8 @@ const normalizeStoredGame = (game: StoredGekanatorGame): StoredGekanatorGame =>
askedIds: game.askedIds.map (questionId => normalizeStoredQuestionId (questionId)),
softenedQuestionIds: game.softenedQuestionIds.map (questionId =>
normalizeStoredQuestionId (questionId)),
recoveredCandidatePosts: game.recoveredCandidatePosts ?? [],
recoveryStepCount: game.recoveryStepCount ?? 0,
askedQuestionBank: game.askedQuestionBank?.map (question =>
({
...question,
@@ -280,6 +293,20 @@ const resettableExtraQuestionState = (): {
extraQuestionState: 'idle' })
const recoveredCandidateMapFromStored = (
items: RecoveredCandidatePost[],
): Map<number, number> =>
new Map (items.map (item => [item.postId, item.answerCountAtRecovery]))
const storedRecoveredCandidatesFromMap = (
recoveredCandidatePosts: Map<number, number>,
): RecoveredCandidatePost[] =>
[...recoveredCandidatePosts.entries ()].map (([postId, answerCountAtRecovery]) => ({
postId,
answerCountAtRecovery }))
const deltaFor = (matched: boolean, answer: GekanatorAnswerValue): number => {
switch (answer)
{
@@ -399,48 +426,6 @@ const recalculateScores = ({
}
const candidatePostsFor = ({
posts,
questions,
answers,
softenedQuestionIds,
rejectedPostIds,
}: {
posts: Post[]
questions: GekanatorQuestion[]
answers: GekanatorAnswerLog[]
softenedQuestionIds: Set<string>
rejectedPostIds: Set<number>
}): Post[] => {
const questionById = new Map (questions.map (question => [question.id, question]))
return posts.filter (post => {
if (rejectedPostIds.has (post.id))
return false
return answers.every (answer => {
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
}
})
})
}
const confidencesFor = (posts: Post[], scores: Map<number, number>): Confidence[] => {
if (posts.length === 0)
return []
@@ -489,17 +474,18 @@ const previewAnswer = ({
question: GekanatorQuestion
answer: GekanatorAnswerValue
}): AnswerPreview => {
const hardFilteredPosts =
answer === 'unknown'
? posts
: posts.filter (post => {
const expected = expectedAnswerForQuestion (question, post)
return expected === null || expected === 'unknown' || expected === answer
})
const nextPosts =
hardFilteredPosts.length > 0
? hardFilteredPosts
: posts
const nextPosts = hardFilteredPostsForAnswer ({
posts,
question,
answer })
if (nextPosts.length === 0)
return {
answer,
top: null,
candidateCount: 0,
effectiveCandidates: 0,
entropy: 0 }
const nextScores = new Map (scores)
nextPosts.forEach (post => {
const expected = expectedAnswerForQuestion (question, post)
@@ -628,52 +614,6 @@ const sameConditionValue = (
}
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 isMonthCrossMatch = (
candidate: GekanatorQuestion['condition'],
previous: GekanatorQuestion['condition'],
@@ -808,7 +748,7 @@ const chooseQuestion = ({
return questionsToRank
.map (question => {
if (isQuestionRedundantAfterAnswers (question, answers))
if (isQuestionHardFilteredAfterAnswers (question, answers))
return null
const signature = signatureFor (question, candidates)
@@ -934,6 +874,10 @@ const GekanatorPage: FC = () => {
() => new Set (storedGame?.askedIds ?? []))
const [softenedQuestionIds, setSoftenedQuestionIds] = useState<Set<string>> (
() => new Set (storedGame?.softenedQuestionIds ?? []))
const [recoveredCandidatePosts, setRecoveredCandidatePosts] = useState<Map<number, number>> (
() => recoveredCandidateMapFromStored (storedGame?.recoveredCandidatePosts ?? []))
const [recoveryStepCount, setRecoveryStepCount] = useState (
storedGame?.recoveryStepCount ?? 0)
const [askedQuestionBank, setAskedQuestionBank] = useState<GekanatorQuestion[]> (
() => (storedGame?.askedQuestionBank ?? []).map (restoreGekanatorQuestion))
const [storedAskedQuestionBankIds, setStoredAskedQuestionBankIds] = useState<string[]> (
@@ -1024,6 +968,8 @@ const GekanatorPage: FC = () => {
answers,
askedIds: [...askedIds],
softenedQuestionIds: [...softenedQuestionIds],
recoveredCandidatePosts: storedRecoveredCandidatesFromMap (recoveredCandidatePosts),
recoveryStepCount,
askedQuestionBank: askedQuestionBank.map (storeGekanatorQuestion),
askedQuestionBankIds: storedAskedQuestionBankIds,
search,
@@ -1059,6 +1005,8 @@ const GekanatorPage: FC = () => {
answers,
askedIds,
softenedQuestionIds,
recoveredCandidatePosts,
recoveryStepCount,
askedQuestionBank,
storedAskedQuestionBankIds,
search,
@@ -1086,8 +1034,10 @@ const GekanatorPage: FC = () => {
questions: askedQuestionBank,
answers,
softenedQuestionIds,
rejectedPostIds }),
[posts, askedQuestionBank, answers, softenedQuestionIds, rejectedPostIds])
rejectedPostIds,
recoveredCandidatePosts }),
[posts, askedQuestionBank, answers, softenedQuestionIds,
rejectedPostIds, recoveredCandidatePosts])
const questions = useMemo (
() => mergeQuestions ([
...buildGekanatorQuestions (eligiblePosts.length > 1 ? eligiblePosts : posts),
@@ -1100,14 +1050,14 @@ const GekanatorPage: FC = () => {
() => new Map (scoringQuestions.map (question => [question.id, question])),
[scoringQuestions])
const questionsSinceLastGuess = answers.length - lastGuessQuestionCount
const nonRejectedPosts = useMemo (
const availablePosts = useMemo (
() => posts.filter (post => !(rejectedPostIds.has (post.id))),
[posts, rejectedPostIds])
const questionPosts =
eligiblePosts.length > 1
|| questionsSinceLastGuess >= minQuestionsBeforeCertainGuess
? eligiblePosts
: nonRejectedPosts
: availablePosts
const topScoredPosts = useMemo (
() => eligiblePosts
.map (post => ({ post, score: scores.get (post.id) ?? 0 }))
@@ -1133,7 +1083,7 @@ const GekanatorPage: FC = () => {
const guessablePosts =
eligiblePosts.length > 0
? eligiblePosts
: nonRejectedPosts
: availablePosts
const guess = bestPost (guessablePosts, scores)
const displayedGuess =
posts.find (post => post.id === activeGuessId) ?? guess
@@ -1181,6 +1131,8 @@ const GekanatorPage: FC = () => {
setAnswers ([])
setAskedIds (new Set ())
setSoftenedQuestionIds (new Set ())
setRecoveredCandidatePosts (new Map ())
setRecoveryStepCount (0)
setAskedQuestionBank ([])
setSearch ('')
setSelectingCorrectPost (false)
@@ -1207,14 +1159,26 @@ const GekanatorPage: FC = () => {
nextAskedQuestionBank,
nextSoftenedQuestionIds,
nextRejectedPostIds,
nextRecoveredCandidatePosts,
nextRecoveryStepCount,
allowPreQuestionRecovery,
}: {
nextAnswers: GekanatorAnswerLog[]
nextAskedIds: Set<string>
nextAskedQuestionBank: GekanatorQuestion[]
nextSoftenedQuestionIds: Set<string>
nextRejectedPostIds: Set<number>
nextRecoveredCandidatePosts: Map<number, number>
nextRecoveryStepCount: number
allowPreQuestionRecovery?: boolean
}) => {
let recoveredSoftenedQuestionIds = new Set (nextSoftenedQuestionIds)
let recoveredCandidatePosts = new Map (nextRecoveredCandidatePosts)
let recoveredStepCount = nextRecoveryStepCount
const answerCountAtRecovery =
allowPreQuestionRecovery
? nextAnswers.length
: Math.max (nextAnswers.length - 1, 0)
let recoveredScores = recalculateScores ({
posts,
questions: nextAskedQuestionBank,
@@ -1225,37 +1189,15 @@ const GekanatorPage: FC = () => {
questions: nextAskedQuestionBank,
answers: nextAnswers,
softenedQuestionIds: recoveredSoftenedQuestionIds,
rejectedPostIds: nextRejectedPostIds })
rejectedPostIds: nextRejectedPostIds,
recoveredCandidatePosts })
let recoveredScoringQuestions = mergeQuestions ([
...buildGekanatorQuestions (
recoveredEligiblePosts.length > 1 ? recoveredEligiblePosts : posts),
...acceptedQuestions,
...nextAskedQuestionBank])
while (
recoveredEligiblePosts.length === 0
|| (
recoveredEligiblePosts.length !== 1
&& !(chooseQuestion ({
posts: recoveredEligiblePosts,
questions: recoveredScoringQuestions,
scores: recoveredScores,
answers: nextAnswers,
askedIds: nextAskedIds,
gameSeed })))
)
{
if (nextAnswers.length >= hardMaxQuestions)
break
const softened = softenNextQuestionIds ({
questions: nextAskedQuestionBank,
answers: nextAnswers,
softenedQuestionIds: recoveredSoftenedQuestionIds })
if (!(softened))
break
recoveredSoftenedQuestionIds = softened
const refreshRecoveredState = () => {
recoveredScores = recalculateScores ({
posts,
questions: nextAskedQuestionBank,
@@ -1266,7 +1208,8 @@ const GekanatorPage: FC = () => {
questions: nextAskedQuestionBank,
answers: nextAnswers,
softenedQuestionIds: recoveredSoftenedQuestionIds,
rejectedPostIds: nextRejectedPostIds })
rejectedPostIds: nextRejectedPostIds,
recoveredCandidatePosts })
recoveredScoringQuestions = mergeQuestions ([
...buildGekanatorQuestions (
recoveredEligiblePosts.length > 1 ? recoveredEligiblePosts : posts),
@@ -1274,8 +1217,61 @@ const GekanatorPage: FC = () => {
...nextAskedQuestionBank])
}
const needsPreQuestionRecovery = () => {
if (!(allowPreQuestionRecovery) || recoveredEligiblePosts.length === 0)
return false
const nextQuestion = chooseQuestion ({
posts: recoveredEligiblePosts,
questions: recoveredScoringQuestions,
scores: recoveredScores,
answers: nextAnswers,
askedIds: nextAskedIds,
gameSeed })
return allConcreteAnswerOptionsExhausted (recoveredEligiblePosts, nextQuestion)
}
while (recoveredEligiblePosts.length === 0 || needsPreQuestionRecovery ())
{
const recoveredPosts = recoverCandidatePosts ({
posts,
scores: recoveredScores,
rejectedPostIds: nextRejectedPostIds,
recoveredCandidatePosts,
eligiblePostIds: new Set (recoveredEligiblePosts.map (post => post.id)),
answerCountAtRecovery,
recoveryStepCount: recoveredStepCount })
if (recoveredPosts)
{
recoveredCandidatePosts = recoveredPosts.recoveredCandidatePosts
recoveredStepCount = recoveredPosts.recoveryStepCount
refreshRecoveredState ()
if (recoveredEligiblePosts.length > 0 && !(needsPreQuestionRecovery ()))
break
}
if (
recoveredEligiblePosts.length > 0
|| nextAnswers.length >= hardMaxQuestions
)
break
const softened = softenNextQuestionIds ({
questions: nextAskedQuestionBank,
answers: nextAnswers,
softenedQuestionIds: recoveredSoftenedQuestionIds })
if (!(softened))
break
recoveredSoftenedQuestionIds = softened
refreshRecoveredState ()
}
return {
softenedQuestionIds: recoveredSoftenedQuestionIds,
recoveredCandidatePosts,
recoveryStepCount: recoveredStepCount,
scores: recoveredScores,
eligiblePosts: recoveredEligiblePosts,
scoringQuestions: recoveredScoringQuestions }
@@ -1295,6 +1291,8 @@ const GekanatorPage: FC = () => {
answers: [...answers],
askedIds: new Set (askedIds),
softenedQuestionIds: new Set (softenedQuestionIds),
recoveredCandidatePosts: new Map (recoveredCandidatePosts),
recoveryStepCount,
askedQuestionBank: [...askedQuestionBank],
search,
selectingCorrectPost,
@@ -1319,21 +1317,26 @@ const GekanatorPage: FC = () => {
nextAskedIds,
nextAskedQuestionBank,
nextSoftenedQuestionIds: softenedQuestionIds,
nextRejectedPostIds: rejectedPostIds })
nextRejectedPostIds: rejectedPostIds,
nextRecoveredCandidatePosts: recoveredCandidatePosts,
nextRecoveryStepCount: recoveryStepCount })
const nextSoftenedQuestionIds = recovered.softenedQuestionIds
const nextRecoveredCandidatePosts = recovered.recoveredCandidatePosts
const nextScores = recovered.scores
const nextEligiblePosts = recovered.eligiblePosts
setScores (nextScores)
setAskedIds (nextAskedIds)
setSoftenedQuestionIds (nextSoftenedQuestionIds)
setRecoveredCandidatePosts (nextRecoveredCandidatePosts)
setRecoveryStepCount (recovered.recoveryStepCount)
setAskedQuestionBank (nextAskedQuestionBank)
setAnswers (nextAnswers)
const nextGuessablePosts =
nextEligiblePosts.length > 0
? nextEligiblePosts
: nonRejectedPosts
: availablePosts
const nextGuess = bestPost (nextGuessablePosts, nextScores)
const nextQuestionCount = answers.length + 1
const nextQuestionsSinceLastGuess =
@@ -1469,6 +1472,10 @@ const GekanatorPage: FC = () => {
}
setRejectedPostIds (new Set ([...rejectedPostIds, displayedGuess.id]))
setRecoveredCandidatePosts (
new Map (
[...recoveredCandidatePosts.entries ()].filter (
([postId]) => postId !== displayedGuess.id)))
setActiveGuessId (null)
setSearch ('')
setSelectingCorrectPost (false)
@@ -1486,6 +1493,8 @@ const GekanatorPage: FC = () => {
setAnswers (snapshot.answers)
setAskedIds (snapshot.askedIds)
setSoftenedQuestionIds (snapshot.softenedQuestionIds)
setRecoveredCandidatePosts (snapshot.recoveredCandidatePosts)
setRecoveryStepCount (snapshot.recoveryStepCount)
setAskedQuestionBank (snapshot.askedQuestionBank)
setSearch (snapshot.search)
setSelectingCorrectPost (snapshot.selectingCorrectPost)
@@ -1507,15 +1516,24 @@ const GekanatorPage: FC = () => {
nextAskedIds: askedIds,
nextAskedQuestionBank: askedQuestionBank,
nextSoftenedQuestionIds: softenedQuestionIds,
nextRejectedPostIds: rejectedPostIds })
nextRejectedPostIds: rejectedPostIds,
nextRecoveredCandidatePosts: recoveredCandidatePosts,
nextRecoveryStepCount: recoveryStepCount,
allowPreQuestionRecovery: true })
setSoftenedQuestionIds (recovered.softenedQuestionIds)
setRecoveredCandidatePosts (recovered.recoveredCandidatePosts)
setRecoveryStepCount (recovered.recoveryStepCount)
setScores (recovered.scores)
const recoveredGuessablePosts =
recovered.eligiblePosts.length > 0
? recovered.eligiblePosts
: availablePosts
const nextQuestion = chooseQuestion ({
posts: recovered.eligiblePosts.length > 1
? recovered.eligiblePosts
: nonRejectedPosts,
: availablePosts,
questions: recovered.scoringQuestions,
scores: recovered.scores,
answers,
@@ -1528,7 +1546,7 @@ const GekanatorPage: FC = () => {
return
}
setActiveGuessId (guess?.id ?? null)
setActiveGuessId (bestPost (recoveredGuessablePosts, recovered.scores)?.id ?? null)
setPhase ('guess')
}
@@ -1582,12 +1600,13 @@ const GekanatorPage: FC = () => {
setExtraQuestions ([])
setExtraQuestionAnswers ({ })
setPhase ('extra_questions')
const nonce = createGameSeed ()
try
{
const questions = await queryClient.fetchQuery ({
queryKey: gekanatorKeys.extraQuestions (gameId),
queryFn: () => fetchGekanatorExtraQuestions (gameId) })
queryKey: gekanatorKeys.extraQuestions (gameId, nonce),
queryFn: () => fetchGekanatorExtraQuestions (gameId, nonce) })
setExtraQuestions (questions)
setExtraQuestionState (questions.length > 0 ? 'ready' : 'empty')
}