diff --git a/AGENTS.md b/AGENTS.md index a3a0ed1..58422fe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -125,6 +125,64 @@ npm run preview - TypeScript and TSX use 4-space logical indentation. - In TypeScript and TSX only, replace every leading run of 8 spaces with a tab. - Tabs are only for leading indentation, never for spaces after non-space text. +- TypeScript and TSX imports may stay on one line if they remain within the + line limit; do not expand short type-only imports mechanically. +- In TypeScript and TSX, when a function takes one destructured object + argument plus an inline type, prefer this shape when it fits locally: + +```ts +const helper = ( + { value, flag }: { value: string + flag: boolean }, +): Result => { + // ... +} +``` + +- In TypeScript and TSX, put `switch` case block braces on their own lines + when a case needs a lexical block: + +```ts +case 'yes': +case 'no': + { + const expected = valueFor (item) + return expected == null || expected === answer + } +``` + +- In TypeScript and TSX, use `value == null` and `value != null` as the + default nullish checks. Do not use `=== null`, `=== undefined`, + `!== null`, or `!== undefined`. +- If code appears to need a distinction between `null` and `undefined`, treat + that as a design smell and revise the logic to avoid the distinction. + External library APIs that explicitly require distinguishing the two are the + only exception. +- In TypeScript and TSX, keep short arrays on one line when they fit under the + line limit; break arrays only when readability or line length requires it. +- In TypeScript and TSX, when a ternary expression is split across multiple + lines, align `?` and `:` with the condition expression. Do not indent `?` and + `:` one extra level under the condition. + +```ts +const value = + condition + ? consequent + : alternate +``` + +- In TypeScript and TSX, keep short ternary expressions on one line when they + fit cleanly under the line limit. +- In TypeScript and TSX, prefer ternary expressions for simple conditional + value selection. Do not replace a clear ternary with `if` statements, and do + not introduce immediately invoked functions just to avoid or reformat a + ternary expression. +- In TypeScript and TSX, do not write `let` followed by later `if` assignments + when the value can be expressed as a single `const` initializer. Prefer + `const` because it prevents accidental later reassignment. +- When fixing formatting, change formatting only. Do not change expression + structure, control flow, or variable mutability unless the requested style + explicitly requires it. - Do not add production dependencies without explicit approval. - Do not create, modify, or run tests unless the user explicitly asks for test work. When the user asks for tests, keep working and rerun them until diff --git a/backend/app/controllers/gekanator_games_controller.rb b/backend/app/controllers/gekanator_games_controller.rb index 48f9624..9144ed6 100644 --- a/backend/app/controllers/gekanator_games_controller.rb +++ b/backend/app/controllers/gekanator_games_controller.rb @@ -14,11 +14,24 @@ class GekanatorGamesController < ApplicationController question_count: answers.length, answers:) - if game.save - render json: { id: game.id }, status: :created - else + if game.invalid? render json: { errors: game.errors.full_messages }, status: :unprocessable_entity + return end + + learned_example_count = 0 + + ActiveRecord::Base.transaction do + game.save! + learned_example_count = learn_answers_from_game!(game) + end + + render json: { + id: game.id, + learned_example_count: + }, status: :created + rescue ActiveRecord::RecordInvalid => e + render json: { errors: e.record.errors.full_messages }, status: :unprocessable_entity end def extra_questions @@ -32,10 +45,12 @@ class GekanatorGamesController < ApplicationController .where(kind: 'post_similarity', source: 'user_suggested') .to_a - selected = weighted_sample_questions( - questions, - post_id: game.correct_post_id, - limit: 2) + selected = + prioritized_extra_questions( + questions, + post_id: game.correct_post_id, + user: current_user, + limit: 2) render json: { questions: selected.map { |question| extra_question_json(question) } @@ -96,6 +111,23 @@ class GekanatorGamesController < ApplicationController } end + def prioritized_extra_questions questions, post_id:, user:, limit: + answered_question_ids = + GekanatorQuestionExample + .where(user:, gekanator_question_id: questions.map(&:id)) + .distinct + .pluck(:gekanator_question_id) + unanswered, answered = + questions.partition { |question| !answered_question_ids.include?(question.id) } + selected = weighted_sample_questions(unanswered, post_id:, limit:) + return selected if selected.length >= limit + + selected + weighted_sample_questions( + answered.reject { |question| selected.any? { _1.id == question.id } }, + post_id:, + limit: limit - selected.length) + end + def weighted_sample_questions questions, post_id:, limit: remaining = questions.uniq(&:id) selected = [] @@ -145,4 +177,96 @@ class GekanatorGamesController < ApplicationController game end + + def learn_answers_from_game! game + correct_post = game.correct_post + return 0 if correct_post.blank? + + accepted_questions = + GekanatorQuestion + .accepted + .index_by { |question| public_question_id_for(question) } + learned_count = 0 + + Array(game.answers).each do |answer| + answer_value = answer['answer'].to_s + next if answer_value.blank? || answer_value == 'unknown' + + question_id = game_answer_question_id(answer) + next if question_id.blank? + + question = accepted_questions[question_id.to_s] + next unless learnable_game_answer_question?(question) + + example = + GekanatorQuestionExample.find_or_initialize_by( + gekanator_question: question, + post: correct_post, + user: current_user) + example.record_answer!( + answer: answer_value, + source: 'post_game_answer', + gekanator_game: game) + example.save! + learned_count += 1 + end + + learned_count + end + + def public_question_id_for question + condition = normalize_condition(question.condition) + + case condition[:type] + when 'tag' + "tag:#{condition[:key]}" + when 'source' + "source:#{condition[:host]}" + when 'original-year' + "original-year:#{condition[:year]}" + when 'original-month' + "original-month:#{condition[:month]}" + when 'original-month-day' + "original-month-day:#{condition[:monthDay] || condition[:month_day]}" + when 'title-length-at-least' + "title:length-at-least:#{condition[:length]}" + when 'title-length-greater-than' + "title:length-at-least:#{condition[:length].to_i + 1}" + when 'title-has-ascii' + 'title:ascii' + when 'title-contains' + "title:contains:#{condition[:text]}" + when 'post-similarity' + "post-similarity:#{question.id}" + else + "catalog:#{question.id}" + end + end + + def normalize_condition condition + json = condition.deep_dup.as_json + + if json['type'] == 'original-month-day' && json['monthDay'].blank? + json['monthDay'] = json.delete('month_day') + end + + json.deep_symbolize_keys + end + + def learnable_game_answer_question? question + return false if question.nil? + return true if question.kind == 'post_similarity' + return false unless question.kind == 'tag' + + condition = normalize_condition(question.condition) + key = condition[:key].to_s + !key.start_with?('nico:') + end + + def game_answer_question_id answer + answer['question_id'] || + answer[:question_id] || + answer['questionId'] || + answer[:questionId] + end end diff --git a/backend/app/controllers/gekanator_posts_controller.rb b/backend/app/controllers/gekanator_posts_controller.rb index 235b1c9..e7be9b8 100644 --- a/backend/app/controllers/gekanator_posts_controller.rb +++ b/backend/app/controllers/gekanator_posts_controller.rb @@ -2,7 +2,7 @@ class GekanatorPostsController < ApplicationController def index posts = Post - .preload(tags: :tag_name) + .preload(:post_similarities, tags: :tag_name) .with_attached_thumbnail .order(Arel.sql( 'COALESCE(posts.original_created_before - INTERVAL 1 MINUTE, ' \ @@ -22,6 +22,12 @@ class GekanatorPostsController < ApplicationController thumbnail_base: post.thumbnail_base, original_created_from: post.original_created_from, original_created_before: post.original_created_before, + post_similarity_edges: post.post_similarities.map { |similarity| + { + target_post_id: similarity.target_post_id, + cos: similarity.cos.to_f + } + }, tags: post.tags.map { |tag| tag_json(tag) } } end diff --git a/backend/app/controllers/gekanator_question_suggestions_controller.rb b/backend/app/controllers/gekanator_question_suggestions_controller.rb index 8f6c943..a73bfca 100644 --- a/backend/app/controllers/gekanator_question_suggestions_controller.rb +++ b/backend/app/controllers/gekanator_question_suggestions_controller.rb @@ -8,6 +8,35 @@ class GekanatorQuestionSuggestionsController < ApplicationController return head :not_found end + existing_question_id = params[:existing_question_id].presence + if existing_question_id + question = GekanatorQuestion.accepted.find_by(id: existing_question_id) + return head :not_found unless question + unless learnable_existing_question?(question) + return render_validation_error fields: { existing_question_id: ['質問が不正です.'] } + end + + example = + GekanatorQuestionExample.find_or_initialize_by( + gekanator_question: question, + post: game.correct_post, + user: current_user) + example.record_answer!( + answer: params.require(:answer), + source: 'post_game_extra', + gekanator_game: game) + + if example.save + render json: { + id: question.id, + count: game.question_suggestions.count + }, status: :created + else + render_validation_error example + end + return + end + suggestion = GekanatorQuestionSuggestion.new( gekanator_game: game, user: current_user, @@ -53,4 +82,14 @@ class GekanatorQuestionSuggestionsController < ApplicationController rescue NotImplementedError head :not_implemented end + + private + + def learnable_existing_question? question + return true if question.kind == 'post_similarity' + return false unless question.kind == 'tag' + + key = question.condition.as_json['key'].to_s + !key.start_with?('nico:') + end end diff --git a/backend/app/controllers/gekanator_questions_controller.rb b/backend/app/controllers/gekanator_questions_controller.rb index 159bf19..d4843fd 100644 --- a/backend/app/controllers/gekanator_questions_controller.rb +++ b/backend/app/controllers/gekanator_questions_controller.rb @@ -16,6 +16,7 @@ class GekanatorQuestionsController < ApplicationController def question_json question condition = condition_json(question.condition).deep_symbolize_keys json = { + record_id: question.id, id: question_id_for(question, condition), text: question_text_for(question, condition), kind: question.kind, @@ -23,7 +24,7 @@ class GekanatorQuestionsController < ApplicationController source: question.source, priority_weight: question.priority_weight } - if question.kind == 'post_similarity' + if question.kind == 'post_similarity' || question.kind == 'tag' json[:example_answers] = example_answers_json(question) end json diff --git a/backend/app/models/gekanator_question_example.rb b/backend/app/models/gekanator_question_example.rb index 2b4bfed..40698e9 100644 --- a/backend/app/models/gekanator_question_example.rb +++ b/backend/app/models/gekanator_question_example.rb @@ -1,7 +1,7 @@ class GekanatorQuestionExample < ApplicationRecord ANSWERS = GekanatorQuestionSuggestion::ANSWERS NON_UNKNOWN_ANSWERS = ANSWERS - ['unknown'] - SOURCES = ['initial_suggestion', 'post_game_extra'].freeze + SOURCES = ['initial_suggestion', 'post_game_answer', 'post_game_extra'].freeze belongs_to :gekanator_question belongs_to :post @@ -35,7 +35,7 @@ class GekanatorQuestionExample < ApplicationRecord 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? + self.source = source apply_aggregated_answer!(preferred_answer: answer) self diff --git a/backend/app/models/gekanator_question_suggestion.rb b/backend/app/models/gekanator_question_suggestion.rb index 80c2d43..4ff51a2 100644 --- a/backend/app/models/gekanator_question_suggestion.rb +++ b/backend/app/models/gekanator_question_suggestion.rb @@ -1,5 +1,4 @@ class GekanatorQuestionSuggestion < ApplicationRecord - MAX_QUESTIONS_PER_GAME = 3 ANSWERS = ['yes', 'no', 'partial', 'probably_no', 'unknown'].freeze belongs_to :gekanator_game @@ -10,16 +9,4 @@ class GekanatorQuestionSuggestion < ApplicationRecord validates :question_text, presence: true, length: { maximum: 1000 } validates :answer, presence: true, inclusion: { in: ANSWERS } validates :processed, inclusion: { in: [true, false] } - validate :question_suggestion_limit_per_game, on: :create - - private - - def question_suggestion_limit_per_game - return if gekanator_game_id.blank? - - count = GekanatorQuestionSuggestion.where(gekanator_game_id:).count - if count >= MAX_QUESTIONS_PER_GAME - errors.add(:base, '質問追加数を超えてゐます.') - end - end end diff --git a/backend/spec/requests/gekanator_learning_spec.rb b/backend/spec/requests/gekanator_learning_spec.rb index 7ae3f1f..9b71440 100644 --- a/backend/spec/requests/gekanator_learning_spec.rb +++ b/backend/spec/requests/gekanator_learning_spec.rb @@ -151,6 +151,154 @@ RSpec.describe 'Gekanator learning API', type: :request do expect(response).to have_http_status(:unauthorized) end + + it 'learns accepted non-nico tag answers from camelCase main game logs' do + sign_in_as admin + + tag_question = GekanatorQuestion.create!( + text: 'MAD 要素がある?', + kind: 'tag', + source: 'admin_curated', + status: 'accepted', + priority_weight: 1.0, + condition: { type: 'tag', key: 'meme:MAD' }, + created_by: admin + ) + + expect { + post '/gekanator/games', params: { + guessed_post_id: guessed_post.id, + correct_post_id: correct_post.id, + answers: [ + { + questionId: 'tag:meme:MAD', + question_text: 'MAD 要素がある?', + answer: 'yes', + original_answer: 'yes' + }, + { + questionId: 'tag:meme:missing', + question_text: '存在しない質問?', + answer: 'yes', + original_answer: 'yes' + }, + { + questionId: 'tag:meme:MAD', + question_text: 'MAD 要素がある?', + answer: 'unknown', + original_answer: 'unknown' + } + ] + } + }.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: tag_question.id, + post_id: correct_post.id, + user_id: admin.id, + answer: 'yes', + 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 + sign_in_as admin + + [ + { + text: 'example.com 由来?', + kind: 'source', + condition: { type: 'source', host: 'example.com' } + }, + { + text: '題名に結束バンドを含む?', + kind: 'title', + condition: { type: 'title-contains', text: '結束バンド' } + }, + { + text: '2024 年投稿?', + kind: 'original_date', + condition: { type: 'original-year', year: 2024 } + }, + { + text: 'ニコニコにぼっちタグ?', + kind: 'tag', + condition: { type: 'tag', key: 'nico:ぼっち' } + } + ].each do |attributes| + GekanatorQuestion.create!( + text: attributes[:text], + kind: attributes[:kind], + source: 'admin_curated', + status: 'accepted', + priority_weight: 1.0, + condition: attributes[:condition], + created_by: admin + ) + end + + expect { + post '/gekanator/games', params: { + guessed_post_id: guessed_post.id, + correct_post_id: correct_post.id, + answers: [ + { question_id: 'source:example.com', answer: 'yes' }, + { question_id: 'title:contains:結束バンド', answer: 'yes' }, + { question_id: 'original-year:2024', answer: 'yes' }, + { question_id: 'tag:nico:ぼっち', answer: 'yes' } + ] + } + }.not_to change { GekanatorQuestionExample.count } + + expect(response).to have_http_status(:created) + expect(json['learned_example_count']).to eq(0) + end + + it 'updates an existing main game example instead of duplicating it' do + sign_in_as admin + + tag_question = GekanatorQuestion.create!( + text: '喜多ちゃんが関係してる?', + kind: 'tag', + source: 'admin_curated', + status: 'accepted', + priority_weight: 1.0, + condition: { type: 'tag', key: 'character:喜多郁代' }, + created_by: admin + ) + existing = GekanatorQuestionExample.create!( + gekanator_question: tag_question, + post: correct_post, + user: admin, + answer: 'no', + source: 'post_game_answer', + weight: 1.0 + ) + + expect { + post '/gekanator/games', params: { + guessed_post_id: guessed_post.id, + correct_post_id: correct_post.id, + answers: [ + { + question_id: 'tag:character:喜多郁代', + answer: 'yes', + original_answer: 'yes' + } + ] + } + }.not_to change { GekanatorQuestionExample.count } + + expect(response).to have_http_status(:created) + expect(json['learned_example_count']).to eq(1) + expect(existing.reload.answer).to eq('yes') + expect(existing.sample_count).to eq(2) + end end describe 'POST /gekanator/question_suggestions' do @@ -249,7 +397,7 @@ RSpec.describe 'Gekanator learning API', type: :request do expect(GekanatorQuestionSuggestion.last.processed).to eq(false) end - it 'limits suggestions to three per game' do + it 'allows more than three suggestions per game' do sign_in_as admin 3.times do |i| @@ -267,9 +415,10 @@ RSpec.describe 'Gekanator learning API', type: :request do question_text: 'fourth question?', answer: 'yes' } - }.not_to change { GekanatorQuestionSuggestion.count } + }.to change { GekanatorQuestionSuggestion.count }.by(1) - expect(response).to have_http_status(:unprocessable_entity) + expect(response).to have_http_status(:created) + expect(json['count']).to eq(4) end it 'allows a non-admin user to suggest a question for their own game' do diff --git a/frontend/public/gekanator/mascot-celebrate.png b/frontend/public/gekanator/mascot-celebrate.png deleted file mode 100644 index 6af3e9f..0000000 Binary files a/frontend/public/gekanator/mascot-celebrate.png and /dev/null differ diff --git a/frontend/public/gekanator/mascot-confident.png b/frontend/public/gekanator/mascot-confident.png deleted file mode 100644 index cd34892..0000000 Binary files a/frontend/public/gekanator/mascot-confident.png and /dev/null differ diff --git a/frontend/public/gekanator/mascot-failed.png b/frontend/public/gekanator/mascot-failed.png deleted file mode 100644 index 8ff846a..0000000 Binary files a/frontend/public/gekanator/mascot-failed.png and /dev/null differ diff --git a/frontend/public/gekanator/mascot-idle.png b/frontend/public/gekanator/mascot-idle.png deleted file mode 100644 index 127028e..0000000 Binary files a/frontend/public/gekanator/mascot-idle.png and /dev/null differ diff --git a/frontend/public/gekanator/mascot-thinking-far.png b/frontend/public/gekanator/mascot-thinking-far.png deleted file mode 100644 index f7d38f6..0000000 Binary files a/frontend/public/gekanator/mascot-thinking-far.png and /dev/null differ diff --git a/frontend/public/gekanator/mascot-thinking-mid.png b/frontend/public/gekanator/mascot-thinking-mid.png deleted file mode 100644 index b71afae..0000000 Binary files a/frontend/public/gekanator/mascot-thinking-mid.png and /dev/null differ diff --git a/frontend/public/gekanator/mascot-thinking-near.png b/frontend/public/gekanator/mascot-thinking-near.png deleted file mode 100644 index 3538a0e..0000000 Binary files a/frontend/public/gekanator/mascot-thinking-near.png and /dev/null differ diff --git a/frontend/src/lib/gekanator.ts b/frontend/src/lib/gekanator.ts index 0604c8c..0643451 100644 --- a/frontend/src/lib/gekanator.ts +++ b/frontend/src/lib/gekanator.ts @@ -62,6 +62,7 @@ export type GekanatorExtraQuestion = { priorityWeight: number } export type StoredGekanatorQuestion = { + recordId?: number id: string text: string kind: GekanatorQuestionKind @@ -71,6 +72,7 @@ export type StoredGekanatorQuestion = { exampleAnswers?: Record<`${ number }`, GekanatorAnswerValue> } export type GekanatorQuestion = { + recordId?: number id: string text: string kind: GekanatorQuestionKind @@ -148,7 +150,7 @@ const directExampleAnswerFor = ( question: StoredGekanatorQuestion, post: Post, ): GekanatorAnswerValue | null => { - if (question.kind !== 'post_similarity') + if (question.kind !== 'post_similarity' && question.kind !== 'tag') return null const direct = question.exampleAnswers?.[String (post.id) as `${ number }`] @@ -348,6 +350,7 @@ export const restoreGekanatorQuestion = ( const normalizedCondition = normalizeTitleLengthCondition (question.condition) const normalizedQuestion = { ...question, + recordId: question.recordId, id: normalizedCondition.type === 'title-length-at-least' ? `title:length-at-least:${ normalizedCondition.length }` : question.id, @@ -367,6 +370,7 @@ export const storeGekanatorQuestion = ( id: question.condition.type === 'title-length-greater-than' ? `title:length-at-least:${ question.condition.length + 1 }` : question.id, + recordId: question.recordId, text: question.text, kind: question.kind, condition: normalizeTitleLengthCondition (question.condition), @@ -581,7 +585,7 @@ export const saveGekanatorGame = async ({ guessedPostId: number correctPostId: number answers: GekanatorAnswerLog[] -}): Promise<{ id: number }> => +}): Promise<{ id: number; learnedExampleCount: number }> => await apiPost ('/gekanator/games', { guessed_post_id: guessedPostId, correct_post_id: correctPostId, @@ -595,15 +599,18 @@ export const saveGekanatorGame = async ({ export const saveGekanatorQuestionSuggestion = async ({ gekanatorGameId, + existingQuestionId, questionText, answer, }: { gekanatorGameId: number - questionText: string + existingQuestionId?: number + questionText?: string answer: GekanatorAnswerValue }): Promise<{ id: number; count: number }> => await apiPost ('/gekanator/question_suggestions', { gekanator_game_id: gekanatorGameId, + existing_question_id: existingQuestionId, question_text: questionText, answer }) diff --git a/frontend/src/lib/gekanatorCandidateRecovery.test.ts b/frontend/src/lib/gekanatorCandidateRecovery.test.ts index 8e06f91..221fb4c 100644 --- a/frontend/src/lib/gekanatorCandidateRecovery.test.ts +++ b/frontend/src/lib/gekanatorCandidateRecovery.test.ts @@ -51,6 +51,21 @@ const postSimilarityQuestion = ( }) +const sourceQuestion = ( + host: string, +): GekanatorQuestion => ({ + id: `source:${ host }`, + text: `${ host }?`, + kind: 'source', + condition: { + type: 'source', + host }, + source: 'default', + priorityWeight: 1, + test: candidate => new URL (candidate.url).hostname === host, +}) + + const answer = ( question: GekanatorQuestion, value: GekanatorAnswerValue, @@ -64,7 +79,7 @@ const answer = ( describe('candidatePostsFor', () => { - it('lets recovered candidates ignore old answers but not later answers', () => { + it('does not hard-filter semantic post_similarity answers', () => { const posts = [post (1), post (2), post (3)] const oldQuestion = postSimilarityQuestion ('old', { 1: 'no', @@ -77,6 +92,29 @@ describe('candidatePostsFor', () => { 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 ([1, 2, 3]) + }) + + it('lets recovered candidates ignore old fact answers but not later fact answers', () => { + const posts = [ + { ...post (1), url: 'https://other.example/posts/1' }, + post (2), + { ...post (3), url: 'https://example.com/posts/3' }, + ] + const oldQuestion = sourceQuestion ('old.example.com') + const laterQuestion = sourceQuestion ('example.com') + const candidates = candidatePostsFor ({ posts, questions: [oldQuestion, laterQuestion], @@ -112,7 +150,7 @@ describe('candidatePostsFor', () => { describe('hardFilteredPostsForAnswer', () => { - it('returns zero candidates without falling back to the original pool', () => { + it('keeps the original pool for semantic post_similarity answers', () => { const posts = [post (1), post (2)] const question = postSimilarityQuestion ('question', { 1: 'yes', @@ -123,7 +161,41 @@ describe('hardFilteredPostsForAnswer', () => { posts, question, answer: 'no', - })).toEqual ([]) + })).toEqual (posts) + }) + + it('hard-filters fact answers only for yes and no', () => { + const posts = [ + { ...post (1), url: 'https://example.com/posts/1' }, + { ...post (2), url: 'https://other.example/posts/2' }, + ] + const question = sourceQuestion ('example.com') + + expect(hardFilteredPostsForAnswer ({ + posts, + question, + answer: 'yes', + }).map (candidate => candidate.id)).toEqual ([1]) + expect(hardFilteredPostsForAnswer ({ + posts, + question, + answer: 'no', + }).map (candidate => candidate.id)).toEqual ([2]) + expect(hardFilteredPostsForAnswer ({ + posts, + question, + answer: 'partial', + })).toEqual (posts) + expect(hardFilteredPostsForAnswer ({ + posts, + question, + answer: 'probably_no', + })).toEqual (posts) + expect(hardFilteredPostsForAnswer ({ + posts, + question, + answer: 'unknown', + })).toEqual (posts) }) }) diff --git a/frontend/src/lib/gekanatorCandidateRecovery.ts b/frontend/src/lib/gekanatorCandidateRecovery.ts index 024ad0c..4d9cf93 100644 --- a/frontend/src/lib/gekanatorCandidateRecovery.ts +++ b/frontend/src/lib/gekanatorCandidateRecovery.ts @@ -1,33 +1,33 @@ import { expectedAnswerForQuestion } from '@/lib/gekanator' -import type { - GekanatorAnswerLog, - GekanatorAnswerValue, - GekanatorQuestion, -} 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 - rejectedPostIds: Set - recoveredCandidatePosts: Map -}): Post[] => { +const questionIsFactLikeForHardFiltering = (question: GekanatorQuestion): boolean => + !(question.kind === 'post_similarity' + || (question.kind === 'tag' + && question.condition.type === 'tag' + && !(question.condition.key.startsWith ('nico:')))) + + +export const candidatePostsFor = ( + { posts, + questions, + answers, + softenedQuestionIds, + rejectedPostIds, + recoveredCandidatePosts }: { posts: Post[] + questions: GekanatorQuestion[] + answers: GekanatorAnswerLog[] + softenedQuestionIds: Set + rejectedPostIds: Set + recoveredCandidatePosts: Map }, +): Post[] => { const questionById = new Map (questions.map (question => [question.id, question])) return posts.filter (post => { @@ -37,7 +37,7 @@ export const candidatePostsFor = ({ const answerCountAtRecovery = recoveredCandidatePosts.get (post.id) return answers.every ((answer, index) => { - if (answerCountAtRecovery !== undefined && index < answerCountAtRecovery) + if (answerCountAtRecovery != null && index < answerCountAtRecovery) return true if (softenedQuestionIds.has (answer.questionId)) @@ -46,14 +46,17 @@ export const candidatePostsFor = ({ const question = questionById.get (answer.questionId) if (!(question)) return true + if (!(questionIsFactLikeForHardFiltering (question))) + return true switch (answer.answer) { case 'yes': - case 'no': { - const expected = expectedAnswerForQuestion (question, post) - return expected === null || expected === 'unknown' || expected === answer.answer - } + case 'no': + { + const expected = expectedAnswerForQuestion (question, post) + return expected === null || expected === 'unknown' || expected === answer.answer + } default: return true } @@ -62,30 +65,25 @@ export const candidatePostsFor = ({ } -export const hardFilteredPostsForAnswer = ({ - posts, - question, - answer, -}: { - posts: Post[] - question: GekanatorQuestion - answer: GekanatorAnswerValue -}): Post[] => { - if (answer === 'unknown') +export const hardFilteredPostsForAnswer = ( + { posts, question, answer }: { posts: Post[] + question: GekanatorQuestion + answer: GekanatorAnswerValue }, +): Post[] => { + if (!(questionIsFactLikeForHardFiltering (question))) + return posts + + if (!(answer === 'yes' || answer === 'no')) return posts return posts.filter (post => { const expected = expectedAnswerForQuestion (question, post) - return expected === null || expected === 'unknown' || expected === answer + return expected == null || expected === 'unknown' || expected === answer }) } -const concreteAnswerOptions: GekanatorAnswerValue[] = [ - 'yes', - 'no', - 'partial', - 'probably_no'] +const concreteAnswerOptions: GekanatorAnswerValue[] = ['yes', 'no', 'partial', 'probably_no'] export const allConcreteAnswerOptionsExhausted = ( @@ -104,45 +102,39 @@ const nextRecoveryTargetSize = (recoveryStepCount: number): number => 6 * (2 ** recoveryStepCount) -export const recoverCandidatePosts = ({ - posts, - scores, - rejectedPostIds, - recoveredCandidatePosts, - eligiblePostIds, - answerCountAtRecovery, - recoveryStepCount, -}: { - posts: Post[] - scores: Map - rejectedPostIds: Set - recoveredCandidatePosts: Map - eligiblePostIds: Set - answerCountAtRecovery: number - recoveryStepCount: number -}): { - recoveredCandidatePosts: Map - recoveryStepCount: number -} | null => { +export const recoverCandidatePosts = ( + { posts, + scores, + rejectedPostIds, + recoveredCandidatePosts, + eligiblePostIds, + answerCountAtRecovery, + recoveryStepCount }: { posts: Post[] + scores: Map + rejectedPostIds: Set + recoveredCandidatePosts: Map + eligiblePostIds: Set + answerCountAtRecovery: number + recoveryStepCount: number }, +): { recoveredCandidatePosts: Map + recoveryStepCount: number } | null => { const recovered = new Map (recoveredCandidatePosts) const targetSize = nextRecoveryTargetSize (recoveryStepCount) - const countedPostIds = new Set ([ - ...eligiblePostIds, - ...recovered.keys ()]) + const countedPostIds = new Set ([...eligiblePostIds, ...recovered.keys ()]) const addCount = targetSize - countedPostIds.size if (addCount <= 0) - return { - recoveredCandidatePosts: recovered, - recoveryStepCount: recoveryStepCount + 1 } + { + return { recoveredCandidatePosts: recovered, + recoveryStepCount: recoveryStepCount + 1 } + } - 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)) + 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, addCount) if (candidates.length === 0) @@ -150,7 +142,6 @@ export const recoverCandidatePosts = ({ candidates.forEach (post => recovered.set (post.id, answerCountAtRecovery)) - return { - recoveredCandidatePosts: recovered, - recoveryStepCount: recoveryStepCount + 1 } + return { recoveredCandidatePosts: recovered, + recoveryStepCount: recoveryStepCount + 1 } } diff --git a/frontend/src/pages/GekanatorPage.test.tsx b/frontend/src/pages/GekanatorPage.test.tsx index e3c4bfa..ce98ff5 100644 --- a/frontend/src/pages/GekanatorPage.test.tsx +++ b/frontend/src/pages/GekanatorPage.test.tsx @@ -1,3 +1,6 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' + import { describe, expect, it } from 'vitest' import { isQuestionHardFilteredAfterAnswers } from '@/lib/gekanatorQuestionFilters' @@ -49,6 +52,57 @@ const blocked = ( isQuestionHardFilteredAfterAnswers (question (candidate), [answer (previous, value)]) +const gekanatorPageSource = readFileSync ( + resolve (process.cwd (), 'src/pages/GekanatorPage.tsx'), + 'utf8') + +const gekanatorBackdropSource = gekanatorPageSource.slice ( + gekanatorPageSource.indexOf ('const GekanatorBackdrop'), + gekanatorPageSource.indexOf ('const expectedAnswerFor')) + + +describe('GekanatorBackdrop regression structure', () => { + it('keeps displayedBackdropMode as the render-time source of truth', () => { + expect(gekanatorBackdropSource).not.toContain ('isLeavingGuessBackdrop') + expect(gekanatorBackdropSource).not.toContain ('renderBackdropMode') + expect(gekanatorBackdropSource).not.toContain ('renderWinningRunCount') + expect(gekanatorBackdropSource).not.toContain ('renderThumbnails') + expect(gekanatorBackdropSource).not.toContain ('renderIsCrossfading') + + expect(gekanatorBackdropSource).toContain ( + "const renderedSettings = settingsForMode (displayedBackdropMode)") + expect(gekanatorBackdropSource).toContain ( + 'scaleForMode (displayedBackdropMode, displayedWinningRunCount)') + expect(gekanatorBackdropSource).toContain ( + "backdropMode === 'guess' || displayedBackdropMode === 'guess'") + }) + + it('does not split guess into a separate renderer or force a remount', () => { + expect(gekanatorBackdropSource).not.toContain ('renderStaticGuessBackdrop') + expect(gekanatorBackdropSource).not.toContain ('guessZoomAnimationKey') + expect(gekanatorBackdropSource).not.toContain ('shouldAnimateGuessZoomIn') + expect(gekanatorBackdropSource).not.toContain ('previousBackdropModeRef') + expect(gekanatorBackdropSource).not.toContain ( + 'if (isGuessPresentation && guessThumbnail)') + }) + + it('keeps tile keys independent from backdrop mode', () => { + expect(gekanatorBackdropSource).toContain ('key={duplicate}') + expect(gekanatorBackdropSource).toContain ('key={`${ duplicate }:${ index }`}') + expect(gekanatorBackdropSource).not.toMatch (/key=\{`\$\{\s*mode/) + expect(gekanatorBackdropSource).not.toMatch (/key=\{`\$\{\s*displayedBackdropMode/) + }) + + it('keeps guess on the shared scale, x, and y animation path', () => { + expect(gekanatorBackdropSource).toContain ('animate={{ scale: renderedScale') + expect(gekanatorBackdropSource).toContain ( + "x: displayedBackdropMode === 'guess' ? guessFocusOffset.x : '0%'") + expect(gekanatorBackdropSource).toContain ( + "y: displayedBackdropMode === 'guess' ? guessFocusOffset.y : '0%'") + }) +}) + + describe('isQuestionHardFilteredAfterAnswers', () => { it('blocks only contradictory or redundant month questions after a yes answer', () => { const previous: GekanatorQuestionCondition = { type: 'original-month', month: 12 } diff --git a/frontend/src/pages/GekanatorPage.tsx b/frontend/src/pages/GekanatorPage.tsx index 3c59931..2cbe7a5 100644 --- a/frontend/src/pages/GekanatorPage.tsx +++ b/frontend/src/pages/GekanatorPage.tsx @@ -25,6 +25,7 @@ import { gekanatorKeys } from '@/lib/queryKeys' import { cn } from '@/lib/utils' import type { FC } from 'react' +import type { Transition } from 'framer-motion' import type { GekanatorAnswerLog, GekanatorAnswerValue, @@ -108,7 +109,11 @@ type StoredGekanatorGame = { reviewGuessedPostId: number | null reviewCorrectPostId: number | null savedGameId: number | null + learnedExampleCount?: number | null gameSeed?: string + questionSuggestionEntryMode?: 'search' | 'new' + questionSuggestionSearch?: string + questionSuggestionSelectedId?: number | null questionSuggestion: string questionSuggestionAnswer: GekanatorAnswerValue questionSuggestionCount?: number @@ -134,6 +139,14 @@ type QuestionMode = | 'normal' | null +type QuestionBuildMode = + | 'split' + | 'confirmation' + +type QuestionSuggestionEntryMode = + | 'search' + | 'new' + type MascotState = | 'idle' | 'thinking_far' @@ -161,8 +174,12 @@ const confidenceTemperature = 6 const gameStorageKey = 'gekanator:game:v1' const recentGamesStorageKey = 'gekanator:recent-games:v1' const backgroundMotionStorageKey = 'gekanator:background-motion:v1' -const maxQuestionSuggestionsPerGame = 3 const maxStoredRecentGames = 12 +const specialOriginalMonthDayLabels: Record = { + '1-1': '元日', + '12-31': '大晦日', + '12-3': '12月3日', + '5-29': '5月29日' } const mascotAssetByState: Record = { idle: '/assets/gekanator/mascot-idle.png', thinking_far: '/assets/gekanator/mascot-thinking-far.png', @@ -227,16 +244,23 @@ const normalizeStoredQuestionId = ( const normalizeStoredGame = (game: StoredGekanatorGame): StoredGekanatorGame => ({ ...game, - answers: game.answers.map (answer => ({ - ...answer, - questionId: normalizeStoredQuestionId (answer.questionId, - answer.questionCondition), - questionMode: ((answer.questionMode === 'winning_run' || answer.questionMode === 'normal') - ? answer.questionMode - : undefined), - questionCondition: (answer.questionCondition - ? normalizeTitleLengthCondition (answer.questionCondition) - : undefined) })), + answers: game.answers.map (answer => { + const questionMode = + answer.questionMode === 'winning_run' || answer.questionMode === 'normal' + ? answer.questionMode + : undefined + const questionCondition = + answer.questionCondition + ? normalizeTitleLengthCondition (answer.questionCondition) + : undefined + + return { + ...answer, + questionId: normalizeStoredQuestionId (answer.questionId, + answer.questionCondition), + questionMode, + questionCondition } + }), askedIds: game.askedIds.map (questionId => normalizeStoredQuestionId (questionId)), softenedQuestionIds: (game.softenedQuestionIds .map (questionId => normalizeStoredQuestionId (questionId))), @@ -244,6 +268,10 @@ const normalizeStoredGame = (game: StoredGekanatorGame): StoredGekanatorGame => recoveryStepCount: game.recoveryStepCount ?? 0, winningRunTargetId: game.winningRunTargetId ?? null, winningRunStartAnswerCount: game.winningRunStartAnswerCount ?? null, + learnedExampleCount: game.learnedExampleCount ?? null, + questionSuggestionEntryMode: game.questionSuggestionEntryMode ?? 'search', + questionSuggestionSearch: game.questionSuggestionSearch ?? '', + questionSuggestionSelectedId: game.questionSuggestionSelectedId ?? null, askedQuestionBank: game.askedQuestionBank?.map (question => ({ ...question, id: normalizeStoredQuestionId (question.id, question.condition), @@ -287,20 +315,19 @@ const shouldReplaceMergedQuestion = ( const hashString = (value: string): number => { - let hash = 2166136261 + let hash = 2_166_136_261 for (let i = 0; i < value.length; ++i) { hash ^= value.charCodeAt (i) - hash = Math.imul (hash, 16777619) + hash = Math.imul (hash, 16_777_619) } return hash >>> 0 } -const deterministicUnitFloat = (seed: string): number => - hashString (seed) / 4294967295 +const deterministicUnitFloat = (seed: string): number => hashString (seed) / 4_294_967_295 const clearStoredGame = (): void => { @@ -346,16 +373,17 @@ const loadRecentGames = (): RecentGameSummary[] => { if (!(Array.isArray (parsed))) return [] - return parsed - .filter ((item): item is RecentGameSummary => + return ( + parsed + .filter ((item): item is RecentGameSummary => ( typeof item === 'object' - && item !== null + && item != null && Number.isInteger ((item as RecentGameSummary).correctPostId) - && (((item as RecentGameSummary).firstQuestionId === null) + && (((item as RecentGameSummary).firstQuestionId == null) || typeof (item as RecentGameSummary).firstQuestionId === 'string') - && Number.isFinite ((item as RecentGameSummary).savedAt)) + && Number.isFinite ((item as RecentGameSummary).savedAt))) .sort ((a, b) => b.savedAt - a.savedAt) - .slice (0, maxStoredRecentGames) + .slice (0, maxStoredRecentGames)) } catch { @@ -367,13 +395,12 @@ const loadRecentGames = (): RecentGameSummary[] => { const storeRecentGameSummary = ( summary: RecentGameSummary, ): RecentGameSummary[] => { - const next = [ - summary, - ...loadRecentGames ().filter (item => - item.savedAt !== summary.savedAt - && !( - item.correctPostId === summary.correctPostId - && item.firstQuestionId === summary.firstQuestionId))] + const next = + [summary, + ...loadRecentGames ().filter (item => (item.savedAt !== summary.savedAt + && !(item.correctPostId === summary.correctPostId + && (item.firstQuestionId + === summary.firstQuestionId))))] .slice (0, maxStoredRecentGames) try @@ -409,11 +436,10 @@ const loadBackgroundMotionMode = (): BackgroundMotionMode => { const resettableExtraQuestionState = (): { extraQuestions: GekanatorExtraQuestion[] extraQuestionAnswers: Record - extraQuestionState: 'idle' -} => ({ - extraQuestions: [], - extraQuestionAnswers: { }, - extraQuestionState: 'idle' }) + extraQuestionState: 'idle' } => ( + { extraQuestions: [], + extraQuestionAnswers: { }, + extraQuestionState: 'idle' }) const recoveredCandidateMapFromStored = ( @@ -425,82 +451,29 @@ const recoveredCandidateMapFromStored = ( const storedRecoveredCandidatesFromMap = ( recoveredCandidatePosts: Map, ): RecoveredCandidatePost[] => - [...recoveredCandidatePosts.entries ()].map (([postId, answerCountAtRecovery]) => ({ - postId, - answerCountAtRecovery })) + [...recoveredCandidatePosts.entries ()] + .map (([postId, answerCountAtRecovery]) => ({ postId, answerCountAtRecovery })) -const deltaFor = (matched: boolean, answer: GekanatorAnswerValue): number => { +const baseDeltaForAnswer = (answer: GekanatorAnswerValue): number => { switch (answer) { case 'yes': - return matched ? 4 : -4 + return 4 case 'no': - return matched ? -4 : 4 - case 'partial': - return matched ? 2 : -1 - case 'probably_no': - return matched ? -2 : 2 - case 'unknown': - return 0 - } -} - - -const answerScalarFor = ( - answer: GekanatorAnswerValue | null, -): number | null => { - switch (answer) - { - case 'yes': - return 1 - case 'partial': - return .5 - case 'probably_no': - return -.5 - case 'no': - return -1 - case 'unknown': - case null: - return null - } -} - - -const deltaForExpectedAnswer = ( - expected: GekanatorAnswerValue | null, - answer: GekanatorAnswerValue, -): number => { - if (answer === 'unknown' || expected === null || expected === 'unknown') - return 0 - - if (expected === 'yes' || expected === 'no') - return deltaFor (expected === 'yes', answer) - - const expectedScalar = answerScalarFor (expected) - const answerScalar = answerScalarFor (answer) - if (expectedScalar === null || answerScalar === null) - return 0 - - const distance = Math.abs (expectedScalar - answerScalar) - if (distance >= 2) return -4 - if (distance >= 1.5) - return -2 - if (distance >= 1) - return 0 - if (distance >= .5) + case 'partial': return 2 - - return 4 + case 'probably_no': + return -2 + case 'unknown': + return 0 + } } const distributionEntropy = (weights: number[]): number => - weights.reduce ((sum, weight) => - weight <= 0 - ? sum - : sum - weight * Math.log2 (weight), 0) + weights.reduce ((sum, weight) => weight <= 0 ? sum : sum - weight * Math.log2 (weight), 0) const questionCategoryPenalty = ( @@ -509,10 +482,12 @@ const questionCategoryPenalty = ( repeatPenalty: number, ): number => { const earlyFactor = Math.max (0, (3 - answerCount) / 3) - const titleLengthPenalty = - titleLengthMinimumForCondition (question.condition) === null - ? 0 - : (answerCount === 0 ? 8 : 3.5) * earlyFactor + const titleLengthPenalty = (() => { + if (titleLengthMinimumForCondition (question.condition) == null) + return 0 + + return (answerCount === 0 ? 8 : 3.5) * earlyFactor + }) () switch (question.kind) { @@ -684,29 +659,29 @@ const buildMaterialIndex = ( const originalValue = post.originalCreatedFrom || post.originalCreatedBefore const date = originalValue - ? new Date (originalValue) - : null + ? new Date (originalValue) + : null const validDate = date && !(Number.isNaN (date.getTime ())) - ? date - : null + ? date + : null const originalYear = validDate?.getFullYear () ?? null const originalMonth = validDate - ? validDate.getMonth () + 1 - : null + ? validDate.getMonth () + 1 + : null const originalMonthDay = validDate - ? `${ validDate.getMonth () + 1 }-${ validDate.getDate () }` - : null + ? `${ validDate.getMonth () + 1 }-${ validDate.getDate () }` + : null originalYearByPostId.set (post.id, originalYear) originalMonthByPostId.set (post.id, originalMonth) originalMonthDayByPostId.set (post.id, originalMonthDay) - if (originalYear !== null) + if (originalYear != null) addPostIdToIndex (postIdsByOriginalYear, originalYear, post.id) - if (originalMonth !== null) + if (originalMonth != null) addPostIdToIndex (postIdsByOriginalMonth, originalMonth, post.id) - if (originalMonthDay !== null) + if (originalMonthDay != null) addPostIdToIndex (postIdsByOriginalMonthDay, originalMonthDay, post.id) const titleLength = post.title?.length ?? 0 @@ -742,20 +717,144 @@ const indexedQuestionTextForTag = (key: string): string => { switch (category) { case 'deerjikist': - return `ニジラーとして「${ label }」に関係している?` + return `${ label }(敬称略)がコンテンツ作成に関与した?` case 'meme': return `『${ label }』に関係しそう?` case 'character': - return `「${ label }」というキャラクターが関係している?` + return `${ label }が登場する?` case 'material': - return `素材「${ label }」に関係している?` + return `${ label }が使われている?` case 'nico': return `ニコニコに「${ label }」というタグがついている?` default: - return `「${ label }」が含まれる?` + return `${ label }の要素が含まれる?` } } +const specialOriginalMonthDayLabelFor = (monthDay: string): string | null => + specialOriginalMonthDayLabels[monthDay] ?? null + + +const originalDateQuestionTextFor = ( + condition: Extract< + GekanatorQuestionCondition, + { type: 'original-year' | 'original-month' | 'original-month-day' } + >, +): string => { + switch (condition.type) + { + case 'original-year': + return `オリジナルの投稿年は ${ condition.year } 年?` + case 'original-month': + return `オリジナルの投稿月は ${ condition.month } 月?` + case 'original-month-day': + { + const label = specialOriginalMonthDayLabelFor (condition.monthDay) + if (label) + return `${ label }に投稿された?` + + const [month, day] = condition.monthDay.split ('-') + return `オリジナルの投稿日は ${ month } 月 ${ day } 日?` + } + } +} + + +const humanPriorityOffsetFor = (question: GekanatorQuestion): number => { + switch (question.kind) + { + case 'tag': + return -6 + case 'source': + return -2.5 + case 'post_similarity': + if (question.source === 'user_suggested' || question.source === 'admin_curated') + return -3.5 + + return -1.5 + case 'original_date': + switch (question.condition.type) + { + case 'original-year': + return -2 + case 'original-month-day': + return specialOriginalMonthDayLabelFor (question.condition.monthDay) ? -1.4 : 6 + case 'original-month': + return 7 + default: + return 0 + } + case 'title': + switch (question.condition.type) + { + case 'title-contains': + return -1.8 + case 'title-has-ascii': + return 10 + case 'title-length-at-least': + case 'title-length-greater-than': + return 9 + default: + return 0 + } + default: + return 0 + } +} + + +const isLearnableTagKey = (key: string): boolean => !(key.startsWith ('nico:')) + + +// `post_similarities` is the score-propagation graph, not the question kind. +const questionUsesPostSimilarityGraphForScoring = ( + question: GekanatorQuestion, +): boolean => + question.kind === 'post_similarity' + || (question.kind === 'tag' + && question.condition.type === 'tag' + && !(question.condition.key.startsWith ('nico:'))) + + +const questionIsFactLikeForHardFiltering = ( + question: GekanatorQuestion, +): boolean => !(questionUsesPostSimilarityGraphForScoring (question)) + + +const isLearnableQuestionForUserAnswer = (question: GekanatorQuestion): boolean => + question.kind === 'post_similarity' + || (question.kind === 'tag' + && question.condition.type === 'tag' + && isLearnableTagKey (question.condition.key)) + + +const usesLearnedTagExamples = (question: GekanatorQuestion): boolean => + question.kind === 'tag' + && question.condition.type === 'tag' + && Boolean (question.exampleAnswers) + && Object.keys (question.exampleAnswers ?? { }).length > 0 + + +const searchedQuestionsFor = ( + questions: GekanatorQuestion[], + search: string, +): GekanatorQuestion[] => { + const needle = search.trim () + if (!(needle)) + return [] + + const normalizedNeedle = needle.toLowerCase () + const prefixMatches = questions.filter (question => + isLearnableQuestionForUserAnswer (question) + && question.text.toLowerCase ().startsWith (normalizedNeedle)) + const partialMatches = questions.filter (question => + isLearnableQuestionForUserAnswer (question) + && !(question.text.toLowerCase ().startsWith (normalizedNeedle)) + && question.text.toLowerCase ().includes (normalizedNeedle)) + + return [...prefixMatches, ...partialMatches].slice (0, 20) +} + const matchingPostIdsForCondition = ({ condition, materialIndex, @@ -783,7 +882,7 @@ const matchingPostIdsForCondition = ({ case 'title-length-greater-than': { const threshold = titleLengthMinimumForCondition (condition) - if (threshold === null) + if (threshold == null) return new Set () const cached = materialIndex.titleLengthThresholdCache.get (threshold) @@ -829,10 +928,24 @@ const matchingPostIdsForQuestion = ({ question, dynamicMatchIndex, }: QuestionMatchResolver): Set => { + if (usesLearnedTagExamples (question)) + { + const matched = matchIndex.get (question.id) ?? dynamicMatchIndex?.get (question.id) + if (matched) + return matched + + const computed = new Set ( + posts + .filter (post => question.test (post)) + .map (post => post.id)) + dynamicMatchIndex?.set (question.id, computed) + return computed + } + const byCondition = matchingPostIdsForCondition ({ condition: question.condition, materialIndex }) - if (byCondition !== null) + if (byCondition != null) return byCondition const matched = matchIndex.get (question.id) ?? dynamicMatchIndex?.get (question.id) @@ -949,6 +1062,9 @@ const postIdsForHardAnswer = ( matchIndex: GekanatorMatchIndex dynamicMatchIndex?: GekanatorMatchIndex }, ): number[] => { + if (!(questionIsFactLikeForHardFiltering (question))) + return candidateIds + if (answer === 'unknown' || answer === 'partial' || answer === 'probably_no') @@ -979,6 +1095,73 @@ const postIdsForHardAnswer = ( return candidateIds } + +const applyQuestionAnswerDeltaToScores = ({ + posts, + question, + answer, + weight, + nextScores, + materialIndex, + matchIndex, + dynamicMatchIndex, +}: { + posts: Post[] + question: GekanatorQuestion + answer: GekanatorAnswerValue + weight: number + nextScores: Map + materialIndex: GekanatorQuestionMaterialIndex + matchIndex: GekanatorMatchIndex + dynamicMatchIndex: GekanatorMatchIndex +}): void => { + const baseDelta = baseDeltaForAnswer (answer) * weight + if (baseDelta === 0) + return + + const matched = matchingPostIdsForQuestion ({ + posts, + materialIndex, + matchIndex, + question, + dynamicMatchIndex }) + + matched.forEach (postId => { + nextScores.set ( + postId, + (nextScores.get (postId) ?? 0) + baseDelta) + }) + + if (!(questionUsesPostSimilarityGraphForScoring (question))) + return + + // `post_similarities` is the propagation graph. Directly matched posts + // get only the base delta; only non-direct neighbors get `base delta * cos`. + // When several matched posts point at the same neighbor, keep the largest + // absolute propagated contribution instead of summing all of them. + const propagatedDeltaByPostId = new Map () + matched.forEach (postId => { + const post = materialIndex.postById.get (postId) + post?.postSimilarityEdges?.forEach (edge => { + if (!Number.isFinite (edge.cos) || edge.cos <= 0) + return + if (matched.has (edge.targetPostId)) + return + + const propagatedDelta = baseDelta * edge.cos + const current = propagatedDeltaByPostId.get (edge.targetPostId) + if (current == null || Math.abs (propagatedDelta) > Math.abs (current)) + propagatedDeltaByPostId.set (edge.targetPostId, propagatedDelta) + }) + }) + + propagatedDeltaByPostId.forEach ((propagatedDelta, postId) => { + nextScores.set ( + postId, + (nextScores.get (postId) ?? 0) + propagatedDelta) + }) +} + const buildIndexedQuestion = ( { condition, text, @@ -1015,18 +1198,30 @@ const rankedEntriesForCounts = ( const buildQuestionsForCandidateIds = ( { candidateIds, materialIndex, - acceptedQuestions }: { candidateIds: number[] + acceptedQuestions, + mode = 'split', + confirmationPostId = null }: { candidateIds: number[] materialIndex: GekanatorQuestionMaterialIndex - acceptedQuestions: GekanatorQuestion[] }, + acceptedQuestions: GekanatorQuestion[] + mode?: QuestionBuildMode + confirmationPostId?: number | null }, ): GekanatorQuestion[] => { const total = candidateIds.length - if (total === 0) + const confirmationPost = (() => { + if (confirmationPostId == null) + return null + + return materialIndex.postById.get (confirmationPostId) ?? null + }) () + + if (mode === 'split' && total === 0) + return acceptedQuestions + if (mode === 'confirmation' && confirmationPost == null) return acceptedQuestions const tagCounts = new Map () const hostCounts = new Map () const yearCounts = new Map () - const monthCounts = new Map () const monthDayCounts = new Map () const titleTermCounts = new Map () const titleLengths: number[] = [] @@ -1039,11 +1234,8 @@ const buildQuestionsForCandidateIds = ( if (host) hostCounts.set (host, (hostCounts.get (host) ?? 0) + 1) const year = materialIndex.originalYearByPostId.get (postId) - if (year !== null && year !== undefined) + if (year != null) yearCounts.set (year, (yearCounts.get (year) ?? 0) + 1) - const month = materialIndex.originalMonthByPostId.get (postId) - if (month !== null && month !== undefined) - monthCounts.set (month, (monthCounts.get (month) ?? 0) + 1) const monthDay = materialIndex.originalMonthDayByPostId.get (postId) if (monthDay) monthDayCounts.set (monthDay, (monthDayCounts.get (monthDay) ?? 0) + 1) @@ -1062,86 +1254,169 @@ const buildQuestionsForCandidateIds = ( const titleLengthMedian = sortedLengths[Math.floor (sortedLengths.length / 2)] ?? 0 const questions: GekanatorQuestion[] = [] + const addQuestion = (question: GekanatorQuestion | null) => { + if (question) + questions.push (question) + } + const buildDateQuestion = ( + condition: Extract< + GekanatorQuestionCondition, + { type: 'original-year' | 'original-month' | 'original-month-day' } + >, + ): GekanatorQuestion => { + const priorityWeight = (() => { + if (condition.type === 'original-year') + return 1.04 + if (condition.type === 'original-month-day') + return 1.01 - rankedEntriesForCounts ({ counts: hostCounts, total, cap: factCap }) - .forEach (([host]) => { - questions.push (buildIndexedQuestion ({ + return .92 + }) () + + return buildIndexedQuestion ({ + condition, + text: originalDateQuestionTextFor (condition), + kind: 'original_date', + priorityWeight, + materialIndex }) + } + const specialMonthDays = rankedEntriesForCounts ({ + counts: monthDayCounts, + total, + cap: factCap + }).filter (([monthDay]) => specialOriginalMonthDayLabelFor (String (monthDay)) != null) + + if (mode === 'split') + { + rankedEntriesForCounts ({ counts: tagCounts, total, cap: tagCap }) + .forEach (([key]) => { + addQuestion (buildIndexedQuestion ({ + condition: { type: 'tag', key }, + text: indexedQuestionTextForTag (key), + kind: 'tag', + priorityWeight: 1.08, + materialIndex })) + }) + + rankedEntriesForCounts ({ counts: hostCounts, total, cap: factCap }) + .forEach (([host]) => { + addQuestion (buildIndexedQuestion ({ + condition: { type: 'source', host }, + text: `${ host } の投稿を思い浮かべている?`, + kind: 'source', + priorityWeight: 1.02, + materialIndex })) + }) + + rankedEntriesForCounts ({ counts: yearCounts, total, cap: factCap }) + .forEach (([year]) => { + addQuestion (buildDateQuestion ({ + type: 'original-year', + year })) + }) + + specialMonthDays.forEach (([monthDay]) => { + addQuestion (buildDateQuestion ({ + type: 'original-month-day', + monthDay: String (monthDay) })) + }) + + rankedEntriesForCounts ({ counts: titleTermCounts, total, cap: titleTermCap }) + .filter (([term]) => String (term).length <= 24) + .forEach (([term]) => { + addQuestion (buildIndexedQuestion ({ + condition: { type: 'title-contains', text: String (term) }, + text: `タイトルに「${ term }」が含まれる?`, + kind: 'title', + priorityWeight: .99, + materialIndex })) + }) + + if (titleLengthMedian > 0) + addQuestion (buildIndexedQuestion ({ + condition: { + type: 'title-length-at-least', + length: titleLengthMedian }, + text: `タイトルは ${ titleLengthMedian } 文字以上?`, + kind: 'title', + priorityWeight: .72, + materialIndex })) + + if (asciiCount > 0 && asciiCount < total) + addQuestion (buildIndexedQuestion ({ + condition: { type: 'title-has-ascii' }, + text: 'タイトルに英数字が混じっている?', + kind: 'title', + priorityWeight: .68, + materialIndex })) + } + else if (confirmationPost) + { + const targetPostId = confirmationPost.id + const host = materialIndex.hostByPostId.get (targetPostId) + const year = materialIndex.originalYearByPostId.get (targetPostId) + const monthDay = materialIndex.originalMonthDayByPostId.get (targetPostId) + const titleTerms = materialIndex.titleTermsByPostId.get (targetPostId) ?? [] + const titleLength = materialIndex.titleLengthByPostId.get (targetPostId) ?? 0 + const tagKeys = materialIndex.tagKeysByPostId.get (targetPostId) ?? [] + + if (host) + addQuestion (buildIndexedQuestion ({ condition: { type: 'source', host }, text: `${ host } の投稿を思い浮かべている?`, kind: 'source', - priorityWeight: 1, + priorityWeight: 1.02, materialIndex })) - }) + if (year != null) + addQuestion (buildDateQuestion ({ + type: 'original-year', + year })) + if (monthDay && specialOriginalMonthDayLabelFor (monthDay)) + addQuestion (buildDateQuestion ({ + type: 'original-month-day', + monthDay })) - rankedEntriesForCounts ({ counts: yearCounts, total, cap: factCap }) - .forEach (([year]) => { - questions.push (buildIndexedQuestion ({ - condition: { type: 'original-year', year }, - text: `オリジナルの投稿年は ${ year } 年?`, - kind: 'original_date', - priorityWeight: 1, - materialIndex })) - }) + tagKeys + .slice (0, 20) + .forEach (key => { + addQuestion (buildIndexedQuestion ({ + condition: { type: 'tag', key }, + text: indexedQuestionTextForTag (key), + kind: 'tag', + priorityWeight: 1.08, + materialIndex })) + }) - rankedEntriesForCounts ({ counts: monthCounts, total, cap: factCap }) - .forEach (([month]) => { - questions.push (buildIndexedQuestion ({ - condition: { type: 'original-month', month }, - text: `オリジナルの投稿月は ${ month } 月?`, - kind: 'original_date', - priorityWeight: 1, - materialIndex })) - }) + titleTerms + .filter (term => term.length <= 24) + .slice (0, 8) + .forEach (term => { + addQuestion (buildIndexedQuestion ({ + condition: { type: 'title-contains', text: term }, + text: `タイトルに「${ term }」が含まれる?`, + kind: 'title', + priorityWeight: .99, + materialIndex })) + }) - rankedEntriesForCounts ({ counts: monthDayCounts, total, cap: factCap }) - .forEach (([monthDay]) => { - const [month, day] = String (monthDay).split ('-') - questions.push (buildIndexedQuestion ({ - condition: { type: 'original-month-day', monthDay: String (monthDay) }, - text: `オリジナルの投稿日は ${ month } 月 ${ day } 日?`, - kind: 'original_date', - priorityWeight: 1, - materialIndex })) - }) - - if (titleLengthMedian > 0) - questions.push (buildIndexedQuestion ({ - condition: { - type: 'title-length-at-least', - length: titleLengthMedian }, - text: `タイトルは ${ titleLengthMedian } 文字以上?`, - kind: 'title', - priorityWeight: 1, - materialIndex })) - - if (asciiCount > 0 && asciiCount < total) - questions.push (buildIndexedQuestion ({ - condition: { type: 'title-has-ascii' }, - text: '題名に英数字が混じっている?', - kind: 'title', - priorityWeight: 1, - materialIndex })) - - rankedEntriesForCounts ({ counts: tagCounts, total, cap: tagCap }) - .forEach (([key]) => { - questions.push (buildIndexedQuestion ({ - condition: { type: 'tag', key }, - text: indexedQuestionTextForTag (key), - kind: 'tag', - priorityWeight: 1, - materialIndex })) - }) - - rankedEntriesForCounts ({ counts: titleTermCounts, total, cap: titleTermCap }) - .filter (([term]) => String (term).length <= 24) - .forEach (([term]) => { - questions.push (buildIndexedQuestion ({ - condition: { type: 'title-contains', text: String (term) }, - text: `題名に「${ term }」が含まれる?`, + if (titleLength > 0) + addQuestion (buildIndexedQuestion ({ + condition: { + type: 'title-length-at-least', + length: titleLength }, + text: `タイトルは ${ titleLength } 文字以上?`, kind: 'title', - priorityWeight: .96, + priorityWeight: .72, materialIndex })) - }) + + if (materialIndex.titleAsciiPostIds.has (targetPostId)) + addQuestion (buildIndexedQuestion ({ + condition: { type: 'title-has-ascii' }, + text: 'タイトルに英数字が混じっている?', + kind: 'title', + priorityWeight: .68, + materialIndex })) + } return mergeQuestions ([...questions, ...acceptedQuestions]) } @@ -1166,6 +1441,8 @@ const candidatePostsForState = ({ recoveredCandidatePosts: Map }): Post[] => { const dynamicMatchIndex = new Map> () + const answerAllowsHardFilter = (answer: GekanatorAnswerValue): boolean => + answer === 'yes' || answer === 'no' return posts.filter (post => { if (rejectedPostIds.has (post.id)) @@ -1174,11 +1451,11 @@ const candidatePostsForState = ({ const answerCountAtRecovery = recoveredCandidatePosts.get (post.id) return answers.every ((answer, index) => { - if (answerCountAtRecovery !== undefined && index < answerCountAtRecovery) + if (answerCountAtRecovery != null && index < answerCountAtRecovery) return true if (softenedQuestionIds.has (answer.questionId)) return true - if (!(answer.answer === 'yes' || answer.answer === 'no')) + if (!(answerAllowsHardFilter (answer.answer))) return true const question = questionById.get (answer.questionId) @@ -1186,26 +1463,43 @@ const candidatePostsForState = ({ if (!(condition)) return true - const matched = question - ? matchingPostIdsForQuestion ({ + const matched = (() => { + if (question) + return matchingPostIdsForQuestion ({ posts, materialIndex, matchIndex, question, dynamicMatchIndex }) - : matchingPostIdsForCondition ({ - condition, - materialIndex }) - if (matched !== null) - return answer.answer === 'yes' - ? matched.has (post.id) - : !(matched.has (post.id)) + + return matchingPostIdsForCondition ({ + condition, + materialIndex }) + }) () + const useExpectedAnswer = + question != null + && usesLearnedTagExamples (question) + if (question && !(questionIsFactLikeForHardFiltering (question))) + return true + + if (matched != null) + { + if (useExpectedAnswer) + { + const expected = expectedAnswerForQuestion (question, post) + return expected == null + || expected === 'unknown' + || expected === answer.answer + } + + return answer.answer === 'yes' ? matched.has (post.id) : !(matched.has (post.id)) + } if (!(question)) return true const expected = expectedAnswerForQuestion (question, post) - return expected === null || expected === 'unknown' || expected === answer.answer + return expected == null || expected === 'unknown' || expected === answer.answer }) }) } @@ -1271,17 +1565,25 @@ const recalculateScores = ({ matchIndex, question, dynamicMatchIndex }) - posts.forEach (post => { - const expected = - matched.has (post.id) - ? 'yes' - : question.condition.type === 'post-similarity' - ? expectedAnswerForQuestion (question, post) - : 'no' + if (questionUsesPostSimilarityGraphForScoring (question)) + { + applyQuestionAnswerDeltaToScores ({ + posts, + question, + answer: answer.answer, + weight, + nextScores, + materialIndex, + matchIndex, + dynamicMatchIndex }) + return + } + + matched.forEach (postId => { nextScores.set ( - post.id, - (nextScores.get (post.id) ?? 0) - + deltaForExpectedAnswer (expected, answer.answer) * weight) + postId, + (nextScores.get (postId) ?? 0) + + baseDeltaForAnswer (answer.answer) * weight) }) }) @@ -1353,7 +1655,7 @@ const previewAnswer = ({ dynamicMatchIndex }) const nextPosts = nextPostIds .map (postId => postById.get (postId)) - .filter ((post): post is Post => post !== undefined) + .filter ((post): post is Post => post != null) if (nextPosts.length === 0) return { answer, @@ -1363,12 +1665,15 @@ const previewAnswer = ({ entropy: 0 } const nextScores = new Map (scores) - nextPosts.forEach (post => { - const expected = expectedAnswerForQuestion (question, post) - nextScores.set ( - post.id, - (nextScores.get (post.id) ?? 0) + deltaForExpectedAnswer (expected, answer)) - }) + applyQuestionAnswerDeltaToScores ({ + posts, + question, + answer, + weight: 1, + nextScores, + materialIndex, + matchIndex, + dynamicMatchIndex }) const confidences = confidencesFor (nextPosts, nextScores) @@ -1411,7 +1716,7 @@ const softenNextQuestionIds = ({ .filter ((item): item is { answer: GekanatorAnswerLog question: GekanatorQuestion } => - item.question !== undefined + item.question != null && item.answer.answer !== 'unknown' && !(softenedQuestionIds.has (item.answer.questionId))) .sort ((a, b) => questionDifficulty (b.question) - questionDifficulty (a.question))[0] @@ -1455,9 +1760,9 @@ const sameConditionValue = ( ): boolean => { const leftTitleLength = titleLengthMinimumForCondition (left) const rightTitleLength = titleLengthMinimumForCondition (right) - if (leftTitleLength !== null || rightTitleLength !== null) - return leftTitleLength !== null - && rightTitleLength !== null + if (leftTitleLength != null || rightTitleLength != null) + return leftTitleLength != null + && rightTitleLength != null && leftTitleLength === rightTitleLength if (left.type !== right.type) @@ -1498,7 +1803,7 @@ const isMonthCrossMatch = ( ): boolean => { const candidateMonth = monthForCondition (candidate) const previousMonth = monthForCondition (previous) - if (candidateMonth === null || previousMonth === null) + if (candidateMonth == null || previousMonth == null) return false const sameType = candidate.type === previous.type @@ -1516,12 +1821,12 @@ const isExclusiveContradiction = ( const candidateGroup = exclusiveConditionGroupFor (candidate) const previousGroup = exclusiveConditionGroupFor (previous) - if (candidateGroup !== null && candidateGroup === previousGroup) + if (candidateGroup != null && candidateGroup === previousGroup) return !(sameConditionValue (candidate, previous)) const candidateMonth = monthForCondition (candidate) const previousMonth = monthForCondition (previous) - if (candidateMonth !== null && previousMonth !== null) + if (candidateMonth != null && previousMonth != null) return candidateMonth !== previousMonth return false @@ -1547,17 +1852,21 @@ const contradictionPenaltyFor = ({ case 'partial': return sum + (isExclusiveContradiction (question.condition, previous) ? 25 : 0) case 'no': - return sum + ( + if ( sameConditionValue (question.condition, previous) - || isMonthCrossMatch (question.condition, previous) - ? 40 - : 0) + || isMonthCrossMatch (question.condition, previous) + ) + return sum + 40 + + return sum case 'probably_no': - return sum + ( + if ( sameConditionValue (question.condition, previous) - || isMonthCrossMatch (question.condition, previous) - ? 20 - : 0) + || isMonthCrossMatch (question.condition, previous) + ) + return sum + 20 + + return sum default: return sum } @@ -1708,30 +2017,37 @@ const chooseQuestion = ( const minSide = candidates.length < 10 ? 1 : Math.max (3, candidates.length * .08) const narrowPenalty = yes < minSide || no < minSide ? .15 : 0 const contradictionPenalty = contradictionPenaltyFor ({ question, answers }) + const humanOffset = humanPriorityOffsetFor (question) const sourceBonus = sourcePriorityOffset (question) const priorityBonus = priorityWeightOffset (question) - const repeatPenalty = - answers.length === 0 - ? (recentFirstQuestionPenaltyById.get (question.id) ?? 0) * 4.5 - : 0 + const repeatPenalty = (() => { + if (answers.length === 0) + return (recentFirstQuestionPenaltyById.get (question.id) ?? 0) * 4.5 + + return 0 + }) () const categoryPenalty = questionCategoryPenalty ( question, answers.length, repeatPenalty) - const priorSplitScore = - priorWeightTotal <= 0 - ? null - : Math.abs ( - .5 - ( - priorEntries.reduce ( - (sum, [postId, weight]) => { - return sum + (matched.has (postId) ? weight : 0) - }, - 0) / priorWeightTotal)) - const priorBonus = - priorSplitScore === null - ? 0 - : Math.max (0, .22 - priorSplitScore) * -18 + const priorSplitScore = (() => { + if (priorWeightTotal <= 0) + return null + + return Math.abs ( + .5 - ( + priorEntries.reduce ( + (sum, [postId, weight]) => { + return sum + (matched.has (postId) ? weight : 0) + }, + 0) / priorWeightTotal)) + }) () + const priorBonus = (() => { + if (priorSplitScore == null) + return 0 + + return Math.max (0, .22 - priorSplitScore) * -18 + }) () const infoGainBonus = -Math.min (1.2, infoGain) * 4 return { question, @@ -1740,6 +2056,7 @@ const chooseQuestion = ( + tagPenalty + narrowPenalty + contradictionPenalty + + humanOffset + sourceBonus + priorityBonus + categoryPenalty @@ -1750,7 +2067,7 @@ const chooseQuestion = ( .filter ((item): item is { question: GekanatorQuestion score: number - narrow: boolean } => item !== null && Number.isFinite (item.score)) + narrow: boolean } => item != null && Number.isFinite (item.score)) .sort ((a, b) => a.score - b.score) } @@ -1758,9 +2075,7 @@ const chooseQuestion = ( questions.filter (question => !(askedIds.has (question.id))) const ranked = rank (unansweredQuestions, scoredPosts, normalisedWeightedPosts) const pool = ( - ranked.some (item => !(item.narrow)) - ? ranked.filter (item => !(item.narrow)) - : ranked) + ranked.some (item => !(item.narrow)) ? ranked.filter (item => !(item.narrow)) : ranked) .slice (0, 16) if (pool.length === 0) @@ -1789,237 +2104,9 @@ const chooseQuestion = ( } -const directWinningRunExampleAnswerFor = ( - question: GekanatorQuestion, - targetPost: Post, -): GekanatorAnswerValue | null => - question.kind !== 'post_similarity' - ? null - : question.exampleAnswers?.[String (targetPost.id) as `${ number }`] ?? null - - -const winningRunTagText = ( - category: string, - name: string, -): string => { - switch (category) - { - case 'nico': - return `ニコニコに「${ name.replace (/^nico:/, '') }」タグがついている?` - default: - return `「${ name }」タグがついている?` - } -} - - -const winningRunHostOf = (post: Post): string | null => { - try - { - return new URL (post.url).hostname.replace (/^www\./, '') - } - catch - { - return null - } -} - - -const winningRunOriginalDateOf = (post: Post): Date | null => { - const value = post.originalCreatedFrom || post.originalCreatedBefore - if (!(value)) - return null - - const date = new Date (value) - return Number.isNaN (date.getTime ()) ? null : date -} - - -const winningRunCandidateQuestionsFor = (targetPost: Post): GekanatorQuestion[] => { - const questions: GekanatorQuestion[] = [] - const addQuestion = (question: GekanatorQuestion | null) => { - if (question) - questions.push (question) - } - const title = targetPost.title ?? '' - const titleWords = - Array.from ( - new Set ( - title.match ( - /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}A-Za-z0-9]{2,}/gu) - ?? [])) - .filter (word => word.length <= 24) - .slice (0, 8) - const host = winningRunHostOf (targetPost) - const originalDate = winningRunOriginalDateOf (targetPost) - const originalYear = originalDate?.getFullYear () ?? null - const originalMonth = originalDate?.getMonth () ?? null - const originalDay = originalDate?.getDate () ?? null - const monthDay = - originalMonth === null || originalDay === null - ? null - : `${ originalMonth + 1 }-${ originalDay }` - const titleLength = title.length - - addQuestion ( - host === null - ? null - : { - id: questionIdForCondition ({ type: 'source', host }), - text: `${ host } の投稿を思い浮かべている?`, - kind: 'source', - condition: { type: 'source', host }, - source: 'default', - priorityWeight: 1.1, - test: post => winningRunHostOf (post) === host }) - addQuestion ( - originalYear === null - ? null - : { - id: questionIdForCondition ({ - type: 'original-year', - year: originalYear }), - text: `オリジナルの投稿年は ${ originalYear } 年?`, - kind: 'original_date', - condition: { type: 'original-year', year: originalYear }, - source: 'default', - priorityWeight: 1.05, - test: post => winningRunOriginalDateOf (post)?.getFullYear () === originalYear }) - addQuestion ( - originalMonth === null - ? null - : { - id: questionIdForCondition ({ - type: 'original-month', - month: originalMonth + 1 }), - text: `オリジナルの投稿月は ${ originalMonth + 1 } 月?`, - kind: 'original_date', - condition: { type: 'original-month', month: originalMonth + 1 }, - source: 'default', - priorityWeight: 1.02, - test: post => winningRunOriginalDateOf (post)?.getMonth () === originalMonth }) - addQuestion ( - monthDay === null - ? null - : { - id: questionIdForCondition ({ type: 'original-month-day', monthDay }), - text: `オリジナルの投稿日は ${ originalMonth! + 1 } 月 ${ originalDay! } 日?`, - kind: 'original_date', - condition: { type: 'original-month-day', monthDay }, - source: 'default', - priorityWeight: .98, - test: post => { - const postDate = winningRunOriginalDateOf (post) - return postDate !== null - && `${ postDate.getMonth () + 1 }-${ postDate.getDate () }` === monthDay - } }) - const winningRunTitleLengths = [ - Math.max (1, titleLength - 4), - titleLength, - titleLength + 4] - .filter ((length: number, index: number, values: number[]) => - titleLength > 0 - && length > 0 - && values.indexOf (length) === index) - winningRunTitleLengths.forEach ((length: number, index: number) => { - addQuestion ({ - id: questionIdForCondition ({ - type: 'title-length-at-least', - length }), - text: `タイトルは ${ length } 文字以上?`, - kind: 'title', - condition: { type: 'title-length-at-least', length }, - source: 'default', - priorityWeight: index === 1 ? 1.08 : 1.01, - test: post => (post.title?.length ?? 0) >= length }) - }) - addQuestion ({ - id: questionIdForCondition ({ type: 'title-has-ascii' }), - text: '題名に英数字が混じっている?', - kind: 'title', - condition: { type: 'title-has-ascii' }, - source: 'default', - priorityWeight: .96, - test: post => /[A-Za-z0-9]/.test (post.title ?? '') }) - titleWords.forEach (word => { - addQuestion ({ - id: questionIdForCondition ({ type: 'title-contains', text: word }), - text: `題名に「${ word }」が含まれる?`, - kind: 'title', - condition: { type: 'title-contains', text: word }, - source: 'default', - priorityWeight: 1.07, - test: post => (post.title ?? '').includes (word) }) - }) - - targetPost.tags - .filter (tag => - tag.category !== 'meta' - && !(tag.name.includes ('タグ希望')) - && !(tag.name.includes ('bot操作'))) - .slice (0, 20) - .forEach (tag => { - addQuestion ({ - id: questionIdForCondition ({ - type: 'tag', - key: `${ tag.category }:${ tag.name }` }), - text: winningRunTagText (tag.category, tag.name), - kind: 'tag', - condition: { type: 'tag', key: `${ tag.category }:${ tag.name }` }, - source: 'default', - priorityWeight: 1.12, - test: post => post.tags.some (candidate => - candidate.category === tag.category - && candidate.name === tag.name - && candidate.category !== 'meta' - && !(candidate.name.includes ('タグ希望')) - && !(candidate.name.includes ('bot操作'))) }) - }) - - void ([ - { - answer: 'yes' as const, - threshold: .9, - text: 'その投稿そのものと言ってよさそう?' }, - { - answer: 'partial' as const, - threshold: .6, - text: 'かなり近いイメージ?' }, - { - answer: 'no' as const, - threshold: .25, - text: '少し違う印象もある?' }]).forEach ((item, index) => { - addQuestion ({ - id: `winning-run:post-similarity:${ targetPost.id }:${ item.answer }:${ item.threshold }`, - text: item.text, - kind: 'post_similarity', - condition: { - type: 'post-similarity', - postId: targetPost.id, - answer: item.answer, - threshold: item.threshold }, - source: 'default', - priorityWeight: 1 - index * .04, - exampleAnswers: { - [String (targetPost.id) as `${ number }`]: item.answer }, - test: post => post.id === targetPost.id }) - }) - - return questions -} - - const winningRunPriorityFor = ( - question: GekanatorQuestion, expected: GekanatorAnswerValue, - targetPost: Post, ): number | null => { - if (question.kind === 'post_similarity') - { - const directAnswer = directWinningRunExampleAnswerFor (question, targetPost) - if (directAnswer === null) - return null - } - if (expected === 'yes') return 0 if (expected === 'partial') @@ -2036,6 +2123,7 @@ const chooseWinningRunQuestion = ({ targetPost, answers, askedIds, + acceptedQuestions, materialIndex, matchIndex, }: { @@ -2043,11 +2131,18 @@ const chooseWinningRunQuestion = ({ targetPost: Post answers: GekanatorAnswerLog[] askedIds: Set + acceptedQuestions: GekanatorQuestion[] materialIndex: GekanatorQuestionMaterialIndex matchIndex: GekanatorMatchIndex }): GekanatorQuestion | null => { const dynamicMatchIndex = new Map> () - const ranked = mergeQuestions (winningRunCandidateQuestionsFor (targetPost)) + const ranked = buildQuestionsForCandidateIds ({ + candidateIds: posts.map (post => post.id), + materialIndex, + acceptedQuestions, + mode: 'confirmation', + confirmationPostId: targetPost.id + }) .filter (question => { if (askedIds.has (question.id)) return false @@ -2055,15 +2150,12 @@ const chooseWinningRunQuestion = ({ return false const expected = expectedAnswerForQuestion (question, targetPost) - return expected !== null && expected !== 'unknown' + return expected != null && expected !== 'unknown' }) .map (question => { const expected = expectedAnswerForQuestion (question, targetPost) - const priority = - expected === null - ? null - : winningRunPriorityFor (question, expected, targetPost) - if (priority === null) + const priority = expected == null ? null : winningRunPriorityFor (expected) + if (priority == null) return null const yesCount = matchingPostCountInIds ({ @@ -2074,23 +2166,26 @@ const chooseWinningRunQuestion = ({ question, dynamicMatchIndex }) const matchingCount = - expected === 'yes' || expected === 'partial' - ? yesCount - : posts.length - yesCount + expected === 'yes' || expected === 'partial' ? yesCount : posts.length - yesCount return { question, priority, + humanOffset: humanPriorityOffsetFor (question), matchingCount } }) .filter ((item): item is { question: GekanatorQuestion priority: number - matchingCount: number } => item !== null) + humanOffset: number + matchingCount: number } => item != null) .sort ((a, b) => { if (a.priority !== b.priority) return a.priority - b.priority + if (a.humanOffset !== b.humanOffset) + return a.humanOffset - b.humanOffset + if (a.question.priorityWeight !== b.question.priorityWeight) return b.question.priorityWeight - a.question.priorityWeight @@ -2129,16 +2224,22 @@ const chooseFallbackQuestion = ({ if (posts.length === 0) return null + const candidateIds = posts.map (post => post.id) const fallbackPosts = posts .map (post => ({ post, score: scores.get (post.id) ?? 0 })) .sort ((a, b) => b.score - a.score) .slice (0, Math.min (6, posts.length)) .map (item => item.post) const fallbackQuestions = mergeQuestions ( - fallbackPosts.flatMap (post => winningRunCandidateQuestionsFor (post))) + fallbackPosts.flatMap (post => buildQuestionsForCandidateIds ({ + candidateIds, + materialIndex, + acceptedQuestions: [], + mode: 'confirmation', + confirmationPostId: post.id + }))) .slice (0, 32) const dynamicMatchIndex = new Map> () - const candidateIds = posts.map (post => post.id) const ranked = mergeQuestions ([ ...questions, ...fallbackQuestions]) @@ -2160,13 +2261,18 @@ const chooseFallbackQuestion = ({ return { question, knownCount: candidateIds.length, - balance: Math.abs (yesCount - noCount) } + balance: Math.abs (yesCount - noCount), + humanOffset: humanPriorityOffsetFor (question) } }) .filter ((item): item is { question: GekanatorQuestion knownCount: number - balance: number } => item !== null) + balance: number + humanOffset: number } => item != null) .sort ((a, b) => { + if (a.humanOffset !== b.humanOffset) + return a.humanOffset - b.humanOffset + if (a.balance !== b.balance) return a.balance - b.balance @@ -2195,18 +2301,21 @@ const isWinningRunActive = ( winningRunTargetId: number | null, winningRunStartAnswerCount: number | null, ): boolean => - winningRunTargetId !== null && winningRunStartAnswerCount !== null + winningRunTargetId != null && winningRunStartAnswerCount != null const winningRunQuestionCount = ( answers: GekanatorAnswerLog[], winningRunStartAnswerCount: number | null, -): number => winningRunStartAnswerCount === null - ? 0 - : answers - .slice (winningRunStartAnswerCount) - .filter (answer => answer.questionMode === 'winning_run') - .length +): number => { + if (winningRunStartAnswerCount == null) + return 0 + + return answers + .slice (winningRunStartAnswerCount) + .filter (answer => answer.questionMode === 'winning_run') + .length +} const nextQuestionPlanFor = ( @@ -2245,10 +2354,7 @@ const nextQuestionPlanFor = ( questionMode: QuestionMode winningRunTargetId: number | null winningRunStartAnswerCount: number | null } => { - const guessablePosts = - eligiblePosts.length > 0 - ? eligiblePosts - : availablePosts + const guessablePosts = eligiblePosts.length > 0 ? eligiblePosts : availablePosts const checkpointGuess = answers.length > 0 @@ -2276,33 +2382,37 @@ const nextQuestionPlanFor = ( winningRunStartAnswerCount } } - const nextWinningRunTargetId = - eligiblePosts.length === 1 - ? eligiblePosts[0]?.id ?? null - : null - const nextWinningRunStartAnswerCount = - nextWinningRunTargetId === null - ? null - : ((isWinningRunActive (winningRunTargetId, winningRunStartAnswerCount) - && winningRunTargetId === nextWinningRunTargetId - && winningRunStartAnswerCount !== null) - ? winningRunStartAnswerCount - : answers.length) - const nextWinningRunTargetPost = - nextWinningRunTargetId === null - ? null - : posts.find (post => post.id === nextWinningRunTargetId) ?? null + const nextWinningRunTargetId = eligiblePosts.length === 1 ? eligiblePosts[0]?.id ?? null : null + const nextWinningRunStartAnswerCount = (() => { + if (nextWinningRunTargetId == null) + return null + if ( + isWinningRunActive (winningRunTargetId, winningRunStartAnswerCount) + && winningRunTargetId === nextWinningRunTargetId + && winningRunStartAnswerCount != null + ) + return winningRunStartAnswerCount + + return answers.length + }) () + const nextWinningRunTargetPost = (() => { + if (nextWinningRunTargetId == null) + return null + + return posts.find (post => post.id === nextWinningRunTargetId) ?? null + }) () const buildQuestionsForPosts = (scopePosts: Post[]): GekanatorQuestion[] => buildQuestionsForCandidateIds ({ candidateIds: scopePosts.map (post => post.id), materialIndex, - acceptedQuestions }) + acceptedQuestions, + mode: 'split' }) if (eligiblePosts.length === 1) { const winningRunFinished = - nextWinningRunTargetId !== null - && nextWinningRunStartAnswerCount !== null + nextWinningRunTargetId != null + && nextWinningRunStartAnswerCount != null && eligiblePosts[0]?.id === nextWinningRunTargetId && winningRunQuestionCount ( answers, @@ -2315,7 +2425,7 @@ const nextQuestionPlanFor = ( questionMode: null, winningRunTargetId: nextWinningRunTargetId, winningRunStartAnswerCount: nextWinningRunStartAnswerCount } - if (!(nextWinningRunTargetPost) || nextWinningRunStartAnswerCount === null) + if (!(nextWinningRunTargetPost) || nextWinningRunStartAnswerCount == null) return { question: null, guess: null, @@ -2329,6 +2439,7 @@ const nextQuestionPlanFor = ( targetPost: nextWinningRunTargetPost, answers, askedIds, + acceptedQuestions, materialIndex, matchIndex }) if (winningRunQuestion) @@ -2488,12 +2599,12 @@ const backgroundPostsFor = ({ }): Post[] => { const focusPosts = phase === 'end' || phase === 'review' || phase === 'learned' - ? [reviewCorrectPost, reviewGuessedPost].filter ((post): post is Post => post !== null) - : phase === 'guess' - ? [displayedGuess, ...eligiblePosts].filter ((post): post is Post => post !== null) - : eligiblePosts.length > 0 - ? eligiblePosts - : availablePosts + ? [reviewCorrectPost, reviewGuessedPost].filter ((post): post is Post => post != null) + : phase === 'guess' + ? [displayedGuess, ...eligiblePosts].filter ((post): post is Post => post != null) + : eligiblePosts.length > 0 + ? eligiblePosts + : availablePosts return [...new Map (focusPosts.map (post => [post.id, post])).values ()] } @@ -2542,20 +2653,20 @@ const GekanatorBackdrop: FC<{ { x: -33.333333, y: -33.333333 }], []) const guessThumbnail = - phase === 'guess' && displayedGuess - ? backgroundThumbnailUrl (displayedGuess) - : null + phase === 'guess' && displayedGuess ? backgroundThumbnailUrl (displayedGuess) : null const isWinningRunBackdrop = !(guessThumbnail) && phase === 'question' - && winningRunTargetPost !== null + && winningRunTargetPost != null && Boolean (backgroundThumbnailUrl (winningRunTargetPost)) - const backdropMode = - guessThumbnail - ? 'guess' - : isWinningRunBackdrop - ? 'winning_run' - : 'normal' + const backdropMode = (() => { + if (guessThumbnail) + return 'guess' + if (isWinningRunBackdrop) + return 'winning_run' + + return 'normal' + }) () const normalVisiblePosts = useMemo ( () => posts @@ -2573,9 +2684,10 @@ const GekanatorBackdrop: FC<{ if (mode === 'winning_run' || mode === 'guess') return { columns: 8, rows: 8, opacity: motionMode === 'calm' ? .18 : .24 } - return motionMode === 'calm' - ? { columns: 7, rows: 7, opacity: .14 } - : { columns: 10, rows: 10, opacity: .2 } + if (motionMode === 'calm') + return { columns: 7, rows: 7, opacity: .14 } + + return { columns: 10, rows: 10, opacity: .2 } }, [motionMode]) @@ -2631,16 +2743,19 @@ const GekanatorBackdrop: FC<{ ?? directions[0], [visualSeed, directions]) - const marqueeDuration = - backdropMode === 'winning_run' - ? motionMode === 'calm' ? 28 : 20 - : motionMode === 'calm' ? 34 : 24 + const marqueeDuration = (() => { + if (backdropMode === 'winning_run') + return motionMode === 'calm' ? 28 : 20 + + return motionMode === 'calm' ? 34 : 24 + }) () const tileFlipDuration = motionMode === 'calm' ? .6 : .45 const x = useMotionValue (0) const y = useMotionValue (0) const marqueeTransform = useMotionTemplate`translate(${ x }%, ${ y }%)` const [activeDirection, setActiveDirection] = useState (nextDirection) const activeDirectionRef = useRef (activeDirection) + const guessAnimationControlsRef = useRef[]> ([]) const flipTimerRef = useRef (null) const [displayedBackdropMode, setDisplayedBackdropMode] = useState<'normal' | 'winning_run' | 'guess'> (backdropMode) @@ -2654,14 +2769,18 @@ const GekanatorBackdrop: FC<{ nextThumbnails) const [flipVisualSeed, setFlipVisualSeed] = useState (visualSeed) const [isFlippingTiles, setIsFlippingTiles] = useState (false) + const renderedSettings = settingsForMode (displayedBackdropMode) - const renderedTileCount = - renderedSettings.columns * renderedSettings.rows + const renderedTileCount = renderedSettings.columns * renderedSettings.rows const renderedScale = scaleForMode (displayedBackdropMode, displayedWinningRunCount) + const isGuessPresentation = backdropMode === 'guess' || displayedBackdropMode === 'guess' useEffect (() => { + guessAnimationControlsRef.current.forEach (control => control.stop ()) + guessAnimationControlsRef.current = [] + if (motionMode === 'off') return @@ -2674,9 +2793,11 @@ const GekanatorBackdrop: FC<{ const controls = [ animate (x, 0, { duration, ease }), animate (y, 0, { duration, ease })] + guessAnimationControlsRef.current = controls return () => { controls.forEach (control => control.stop ()) + guessAnimationControlsRef.current = [] } }, [isGuessPresentation, motionMode, visualSeed, x, y]) @@ -2693,13 +2814,21 @@ const GekanatorBackdrop: FC<{ if (motionMode === 'off' || nextThumbnails.length === 0) { + guessAnimationControlsRef.current.forEach (control => control.stop ()) + guessAnimationControlsRef.current = [] x.set (0) y.set (0) return } if (isGuessPresentation) - return + { + guessAnimationControlsRef.current.forEach (control => control.stop ()) + guessAnimationControlsRef.current = [] + x.set (0) + y.set (0) + return + } const speed = 33.333333 / marqueeDuration let animationFrame: number @@ -2717,8 +2846,7 @@ const GekanatorBackdrop: FC<{ animationFrame = window.requestAnimationFrame (tick) return () => window.cancelAnimationFrame (animationFrame) - }, [ - x, + }, [x, y, marqueeDuration, motionMode, @@ -2731,63 +2859,66 @@ const GekanatorBackdrop: FC<{ setActiveDirection (nextDirection) } - if (flipTimerRef.current !== null) { - window.clearTimeout (flipTimerRef.current) - flipTimerRef.current = null - } + if (flipTimerRef.current != null) + { + window.clearTimeout (flipTimerRef.current) + flipTimerRef.current = null + } - if (motionMode === 'off') { - applyDirection () - setIsFlippingTiles (false) - setFlipVisualSeed (visualSeed) - return - } + if (motionMode === 'off') + { + applyDirection () + setIsFlippingTiles (false) + setFlipVisualSeed (visualSeed) + return + } - if (backdropMode === 'guess' && guessThumbnail) { - setIsFlippingTiles (false) - setDisplayedBackdropMode ('guess') - setDisplayedWinningRunCount (winningRunQuestionCount) - setDisplayedThumbnails (nextThumbnails) - setFromThumbnails (nextThumbnails) - setToThumbnails (nextThumbnails) - setFlipVisualSeed (visualSeed) - return - } + if (backdropMode === 'guess' && guessThumbnail) + { + setIsFlippingTiles (false) + setDisplayedBackdropMode ('guess') + setDisplayedWinningRunCount (winningRunQuestionCount) + setDisplayedThumbnails (nextThumbnails) + setFromThumbnails (nextThumbnails) + setToThumbnails (nextThumbnails) + setFlipVisualSeed (visualSeed) + return + } - if ( - displayedBackdropMode === 'winning_run' - && backdropMode === 'winning_run' - ) { - applyDirection () - setDisplayedBackdropMode ('winning_run') - setDisplayedWinningRunCount (winningRunQuestionCount) - setDisplayedThumbnails (nextThumbnails) - setFromThumbnails (nextThumbnails) - setToThumbnails (nextThumbnails) - setIsFlippingTiles (false) - setFlipVisualSeed (visualSeed) - return - } + if (displayedBackdropMode === 'winning_run' + && backdropMode === 'winning_run') + { + applyDirection () + setDisplayedBackdropMode ('winning_run') + setDisplayedWinningRunCount (winningRunQuestionCount) + setDisplayedThumbnails (nextThumbnails) + setFromThumbnails (nextThumbnails) + setToThumbnails (nextThumbnails) + setIsFlippingTiles (false) + setFlipVisualSeed (visualSeed) + return + } - if (nextThumbnails.length === 0) { - applyDirection () - setIsFlippingTiles (false) - setFlipVisualSeed (visualSeed) - return - } + if (nextThumbnails.length === 0) + { + applyDirection () + setIsFlippingTiles (false) + setFlipVisualSeed (visualSeed) + return + } const sameTiles = displayedThumbnails.length === nextThumbnails.length - && displayedThumbnails.every ( - (thumbnail, index) => thumbnail === nextThumbnails[index]) - if (sameTiles && flipVisualSeed === visualSeed) { - if ( - activeDirection.x !== nextDirection.x - || activeDirection.y !== nextDirection.y - ) - applyDirection () - return - } + && displayedThumbnails.every ((thumbnail, index) => thumbnail === nextThumbnails[index]) + + if (sameTiles && flipVisualSeed === visualSeed) + { + if (activeDirection.x !== nextDirection.x + || activeDirection.y !== nextDirection.y) + applyDirection () + + return + } const currentThumbnails = displayedThumbnails.length > 0 ? displayedThumbnails : nextThumbnails @@ -2809,13 +2940,13 @@ const GekanatorBackdrop: FC<{ }, tileFlipDuration * 1000) return () => { - if (flipTimerRef.current !== null) { - window.clearTimeout (flipTimerRef.current) - flipTimerRef.current = null - } + if (flipTimerRef.current != null) + { + window.clearTimeout (flipTimerRef.current) + flipTimerRef.current = null + } } - }, [ - motionMode, + }, [motionMode, backdropMode, displayedBackdropMode, guessThumbnail, @@ -2835,6 +2966,13 @@ const GekanatorBackdrop: FC<{
) + const backdropTransition: Transition = (() => { + if (displayedBackdropMode === 'winning_run' || displayedBackdropMode === 'guess') + return { duration: motionMode === 'calm' ? .95 : .75, ease: [.16, 1, .3, 1] } + + return { duration: .2 } + }) () + return (
@@ -2849,11 +2987,7 @@ const GekanatorBackdrop: FC<{ animate={{ scale: renderedScale, x: displayedBackdropMode === 'guess' ? guessFocusOffset.x : '0%', y: displayedBackdropMode === 'guess' ? guessFocusOffset.y : '0%' }} - transition={(displayedBackdropMode === 'winning_run' - || displayedBackdropMode === 'guess') - ? { duration: motionMode === 'calm' ? .95 : .75, - ease: [.16, 1, .3, 1] } - : { duration: .2 }}> + transition={backdropTransition}> {Array.from ({ length: 9 }, (_, duplicate) => { const column = duplicate % 3 const row = Math.floor (duplicate / 3) @@ -2879,20 +3013,24 @@ const GekanatorBackdrop: FC<{ index % Math.max (displayedThumbnails.length, 1)] const frontThumbnail = isFlippingTiles - ? fromThumbnails[index % Math.max (fromThumbnails.length, 1)] - : currentThumbnail + ? fromThumbnails[index % Math.max (fromThumbnails.length, 1)] + : currentThumbnail const backThumbnail = isFlippingTiles - ? toThumbnails[index % Math.max (toThumbnails.length, 1)] - : currentThumbnail + ? toThumbnails[index % Math.max (toThumbnails.length, 1)] + : currentThumbnail const thumbnail = displayedBackdropMode === 'winning_run' || displayedBackdropMode === 'guess' - ? nextThumbnails[index % Math.max (nextThumbnails.length, 1)] - : currentThumbnail + ? nextThumbnails[index % Math.max (nextThumbnails.length, 1)] + : currentThumbnail if (!(thumbnail) || !(frontThumbnail) || !(backThumbnail)) return null + const imageSource = ['intro', 'end'].includes (phase) ? mascotAsset : thumbnail + const showStaticTile = + displayedBackdropMode !== 'normal' || !(isFlippingTiles) + return ( - {(displayedBackdropMode !== 'normal' || !(isFlippingTiles)) - ? ( + {showStaticTile && ( ) - : ( + } + {!(showStaticTile) && ( = ({ user }) => { const storedGame = useMemo (loadStoredGame, []) - const hasStoredRestore = storedGame !== null && isStoredPhase (storedGame.phase) + const hasStoredRestore = storedGame != null && isStoredPhase (storedGame.phase) const queryClient = useQueryClient () const isAdmin = user?.role === 'admin' - const canPersistGame = user !== null + const canPersistGame = user != null const [recentGames, setRecentGames] = useState ( () => loadRecentGames ()) const [backgroundMotionMode, setBackgroundMotionMode] = useState ( @@ -2986,9 +3122,12 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { const [askedQuestionBank, setAskedQuestionBank] = useState ( () => (storedGame?.askedQuestionBank ?? []).map (restoreGekanatorQuestion)) const [storedAskedQuestionBankIds, setStoredAskedQuestionBankIds] = useState ( - (storedGame?.askedQuestionBank?.length ?? 0) > 0 - ? [] - : storedGame?.askedQuestionBankIds ?? []) + () => { + if ((storedGame?.askedQuestionBank?.length ?? 0) > 0) + return [] + + return storedGame?.askedQuestionBankIds ?? [] + }) const [search, setSearch] = useState (storedGame?.search ?? '') const [selectingCorrectPost, setSelectingCorrectPost] = useState ( storedGame?.selectingCorrectPost ?? false) @@ -3015,6 +3154,15 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { storedGame?.reviewCorrectPostId ?? null) const [savedGameId, setSavedGameId] = useState ( storedGame?.savedGameId ?? null) + const [learnedExampleCount, setLearnedExampleCount] = useState ( + storedGame?.learnedExampleCount ?? null) + const [questionSuggestionEntryMode, setQuestionSuggestionEntryMode] = + useState ( + storedGame?.questionSuggestionEntryMode ?? 'search') + const [questionSuggestionSearch, setQuestionSuggestionSearch] = useState ( + storedGame?.questionSuggestionSearch ?? '') + const [questionSuggestionSelectedId, setQuestionSuggestionSelectedId] = + useState (storedGame?.questionSuggestionSelectedId ?? null) const [questionSuggestion, setQuestionSuggestion] = useState ( storedGame?.questionSuggestion ?? '') const [questionSuggestionAnswer, setQuestionSuggestionAnswer] = @@ -3051,11 +3199,9 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { [posts, acceptedQuestions]) useEffect (() => { - if ( - posts.length === 0 + if (posts.length === 0 || storedAskedQuestionBankIds.length === 0 - || !(acceptedQuestionsFetched) - ) + || !(acceptedQuestionsFetched)) return const questionById = new Map ( @@ -3066,10 +3212,9 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { setAskedQuestionBank ( storedAskedQuestionBankIds .map (questionId => questionById.get (questionId)) - .filter ((question): question is GekanatorQuestion => question !== undefined)) + .filter ((question): question is GekanatorQuestion => question != null)) setStoredAskedQuestionBankIds ([]) - }, [ - posts, + }, [posts, storedAskedQuestionBankIds, acceptedQuestionsFetched, askedQuestionBank, @@ -3109,7 +3254,11 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { reviewGuessedPostId, reviewCorrectPostId, savedGameId, + learnedExampleCount, gameSeed, + questionSuggestionEntryMode, + questionSuggestionSearch, + questionSuggestionSelectedId, questionSuggestion, questionSuggestionAnswer, questionSuggestionCount, @@ -3149,7 +3298,11 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { reviewGuessedPostId, reviewCorrectPostId, savedGameId, + learnedExampleCount, gameSeed, + questionSuggestionEntryMode, + questionSuggestionSearch, + questionSuggestionSelectedId, questionSuggestion, questionSuggestionAnswer, questionSuggestionCount, @@ -3202,6 +3355,36 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { const scoringQuestionById = useMemo ( () => new Map (scoringQuestions.map (question => [question.id, question])), [scoringQuestions]) + const searchableSuggestedQuestions = useMemo ( + () => searchedQuestionsFor (acceptedQuestions, questionSuggestionSearch), + [acceptedQuestions, questionSuggestionSearch]) + const selectedSuggestedQuestion = useMemo ( + () => acceptedQuestions.find ( + question => question.recordId === questionSuggestionSelectedId) + ?? null, + [acceptedQuestions, questionSuggestionSelectedId]) + const canSubmitQuestionSuggestion = useMemo (() => { + if (!(canPersistGame) || reviewCorrectPostId == null) + return false + + if (questionSuggestionEntryMode === 'search') + return selectedSuggestedQuestion != null + + return questionSuggestion.trim () !== '' + }, [ + canPersistGame, + reviewCorrectPostId, + questionSuggestionEntryMode, + selectedSuggestedQuestion, + questionSuggestion]) + const canShowNewQuestionSuggestionButton = useMemo (() => { + return questionSuggestionEntryMode === 'search' + && questionSuggestionSearch.trim () !== '' + && searchableSuggestedQuestions.length === 0 + }, [ + questionSuggestionEntryMode, + questionSuggestionSearch, + searchableSuggestedQuestions.length]) const recentFirstQuestionPenaltyById = useMemo (() => { const penalties = new Map () @@ -3244,9 +3427,12 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { userPriorWeights, materialIndex, acceptedQuestionMatchIndex, lastGuessQuestionCount, winningRunTargetId, winningRunStartAnswerCount]) const winningRunTargetPost = useMemo ( - () => questionPlan.winningRunTargetId === null - ? null - : posts.find (post => post.id === questionPlan.winningRunTargetId) ?? null, + () => { + if (questionPlan.winningRunTargetId == null) + return null + + return posts.find (post => post.id === questionPlan.winningRunTargetId) ?? null + }, [posts, questionPlan.winningRunTargetId]) const winningRunQuestionsAsked = winningRunQuestionCount ( answers, @@ -3258,7 +3444,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { && winningRunQuestionsAsked < winningRunQuestionLimit && eligiblePosts.length === 1 && eligiblePosts[0]?.id === questionPlan.winningRunTargetId - && winningRunTargetPost !== null + && winningRunTargetPost != null const topScoredPosts = useMemo ( () => eligiblePosts .map (post => ({ post, score: scores.get (post.id) ?? 0 })) @@ -3267,21 +3453,21 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { [eligiblePosts, scores]) const currentQuestion = questionPlan.question const answerPreviews = useMemo ( - () => isAdmin && currentQuestion - ? answerOptions.map (option => previewAnswer ({ - posts: eligiblePosts, - scores, - question: currentQuestion, - answer: option.value, - materialIndex, - matchIndex: acceptedQuestionMatchIndex })) - : [], + () => { + if (!(isAdmin) || !(currentQuestion)) + return [] + + return answerOptions.map (option => previewAnswer ({ + posts: eligiblePosts, + scores, + question: currentQuestion, + answer: option.value, + materialIndex, + matchIndex: acceptedQuestionMatchIndex })) + }, [isAdmin, currentQuestion, eligiblePosts, materialIndex, acceptedQuestionMatchIndex, scores]) - const guessablePosts = - eligiblePosts.length > 0 - ? eligiblePosts - : availablePosts + const guessablePosts = eligiblePosts.length > 0 ? eligiblePosts : availablePosts const guessConfidences = useMemo ( () => confidencesFor (guessablePosts, scores), [guessablePosts, scores]) @@ -3293,17 +3479,22 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { posts.find (post => post.id === reviewGuessedPostId) ?? null const reviewCorrectPost = posts.find (post => post.id === reviewCorrectPostId) ?? null - const effectiveResultWon = - resultWon - ?? ((reviewGuessedPostId !== null && reviewCorrectPostId !== null) - ? reviewGuessedPostId === reviewCorrectPostId - : null) - const effectiveBackgroundMotionMode = - backgroundMotionMode === 'off' - ? 'off' - : (prefersReducedMotion - ? 'calm' - : backgroundMotionMode) + const effectiveResultWon = (() => { + if (resultWon != null) + return resultWon + if (reviewGuessedPostId == null || reviewCorrectPostId == null) + return null + + return reviewGuessedPostId === reviewCorrectPostId + }) () + const effectiveBackgroundMotionMode = (() => { + if (backgroundMotionMode === 'off') + return 'off' + if (prefersReducedMotion) + return 'calm' + + return backgroundMotionMode + }) () const backgroundPosts = useMemo ( () => backgroundPostsFor ({ phase, @@ -3333,6 +3524,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { savedAt: Date.now () })) setSaved (true) setSavedGameId (data.id) + setLearnedExampleCount (data.learnedExampleCount) setResultWon (variables.guessedPostId === variables.correctPostId) }}) const questionSuggestionMutation = useMutation ({ @@ -3340,6 +3532,9 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { onSuccess: async data => { await queryClient.refetchQueries ({ queryKey: gekanatorKeys.questions () }) setQuestionSuggestionCount (data.count) + setQuestionSuggestionEntryMode ('search') + setQuestionSuggestionSearch ('') + setQuestionSuggestionSelectedId (null) setQuestionSuggestion ('') setQuestionSuggestionAnswer ('yes') }}) @@ -3386,7 +3581,11 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { setReviewGuessedPostId (null) setReviewCorrectPostId (null) setSavedGameId (null) + setLearnedExampleCount (null) setGameSeed (createGameSeed ()) + setQuestionSuggestionEntryMode ('search') + setQuestionSuggestionSearch ('') + setQuestionSuggestionSelectedId (null) setQuestionSuggestion ('') setQuestionSuggestionAnswer ('yes') setQuestionSuggestionCount (0) @@ -3423,10 +3622,12 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { let recoveredStepCount = nextRecoveryStepCount const nextAskedQuestionById = new Map (nextAskedQuestionBank.map (question => [question.id, question])) - const answerCountAtRecovery = - allowPreQuestionRecovery - ? nextAnswers.length - : Math.max (nextAnswers.length - 1, 0) + const answerCountAtRecovery = (() => { + if (allowPreQuestionRecovery) + return nextAnswers.length + + return Math.max (nextAnswers.length - 1, 0) + }) () let recoveredScores = recalculateScores ({ posts, questions: nextAskedQuestionBank, @@ -3446,7 +3647,8 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { let recoveredQuestions = buildQuestionsForCandidateIds ({ candidateIds: recoveredEligiblePosts.map (post => post.id), materialIndex, - acceptedQuestions }) + acceptedQuestions, + mode: 'split' }) let recoveredScoringQuestions = mergeQuestions ([ ...recoveredQuestions, ...nextAskedQuestionBank]) @@ -3471,7 +3673,8 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { recoveredQuestions = buildQuestionsForCandidateIds ({ candidateIds: recoveredEligiblePosts.map (post => post.id), materialIndex, - acceptedQuestions }) + acceptedQuestions, + mode: 'split' }) recoveredScoringQuestions = mergeQuestions ([ ...recoveredQuestions, ...nextAskedQuestionBank]) @@ -3700,12 +3903,14 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { } const finishGame = (correctPostId: number) => { - const guessedPostId = - phase === 'end' || phase === 'review' - ? reviewGuessedPostId - : phase === 'continue' - ? lastRejectedGuessId ?? displayedGuess?.id - : displayedGuess?.id ?? lastRejectedGuessId + const guessedPostId = (() => { + if (phase === 'end' || phase === 'review') + return reviewGuessedPostId + if (phase === 'continue') + return lastRejectedGuessId ?? displayedGuess?.id + + return displayedGuess?.id ?? lastRejectedGuessId + }) () if (!(guessedPostId)) return @@ -3714,6 +3919,10 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { resetExtraQuestionState () setSaved (false) setSavedGameId (null) + setLearnedExampleCount (null) + setQuestionSuggestionEntryMode ('search') + setQuestionSuggestionSearch ('') + setQuestionSuggestionSelectedId (null) setReviewGuessedPostId (guessedPostId) setReviewCorrectPostId (correctPostId) setSearch ('') @@ -3722,7 +3931,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { } const startReview = () => { - if (reviewGuessedPostId === null || reviewCorrectPostId === null) + if (reviewGuessedPostId == null || reviewCorrectPostId == null) return saveMutation.reset () @@ -3730,6 +3939,10 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { resetExtraQuestionState () setSaved (false) setSavedGameId (null) + setLearnedExampleCount (null) + setQuestionSuggestionEntryMode ('search') + setQuestionSuggestionSearch ('') + setQuestionSuggestionSelectedId (null) setSelectingCorrectPost (false) setSearch ('') setPhase ('review') @@ -3738,13 +3951,13 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { const saveReviewedResult = (onSuccess: (gameId: number) => void) => { if ( !(canPersistGame) - || reviewGuessedPostId === null - || reviewCorrectPostId === null + || reviewGuessedPostId == null + || reviewCorrectPostId == null || saveMutation.isPending ) return - if (savedGameId !== null) + if (savedGameId != null) { onSuccess (savedGameId) return @@ -3758,6 +3971,12 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { } const saveAndReset = () => { + if (saveMutation.isError) + { + reset () + return + } + if (!(canPersistGame)) { reset () @@ -3780,18 +3999,15 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { const submitQuestionSuggestion = () => { const questionText = questionSuggestion.trim () - if ( - !(canPersistGame) - || !(questionText) - || questionSuggestionMutation.isPending - || questionSuggestionCount >= maxQuestionSuggestionsPerGame - ) + const selectedQuestion = selectedSuggestedQuestion + if (!(canSubmitQuestionSuggestion) || questionSuggestionMutation.isPending) return saveReviewedResult (gekanatorGameId => { questionSuggestionMutation.mutate ({ gekanatorGameId, - questionText, + existingQuestionId: selectedQuestion?.recordId, + questionText: selectedQuestion ? undefined : questionText, answer: questionSuggestionAnswer }) }) } @@ -3799,7 +4015,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { const saveExtraQuestions = () => { if ( !(canPersistGame) - || savedGameId === null + || savedGameId == null || extraQuestionAnswersMutation.isPending || extraQuestions.some (question => !(extraQuestionAnswers[String (question.id)])) ) @@ -3923,6 +4139,10 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { const correctAnswerAt = (index: number, value: GekanatorAnswerValue) => { setSaved (false) setSavedGameId (null) + setLearnedExampleCount (null) + setQuestionSuggestionEntryMode ('search') + setQuestionSuggestionSearch ('') + setQuestionSuggestionSelectedId (null) resetExtraQuestionState () setAnswers (answers.map ((answer, i) => i === index ? { ...answer, answer: value } : answer)) @@ -3933,6 +4153,10 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { { setSaved (false) setSavedGameId (null) + setLearnedExampleCount (null) + setQuestionSuggestionEntryMode ('search') + setQuestionSuggestionSearch ('') + setQuestionSuggestionSelectedId (null) resetExtraQuestionState () setReviewCorrectPostId (post.id) setSelectingCorrectPost (false) @@ -3987,7 +4211,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { } const startExtraQuestions = () => { - if (reviewCorrectPostId === null || saveMutation.isPending) + if (reviewCorrectPostId == null || saveMutation.isPending) return saveReviewedResult (gameId => { @@ -4016,6 +4240,18 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { const resultDialogue = effectiveResultWon ? winDialogue : loseDialogue const dialogue = phase === 'learned' ? resultDialogue : introDialogue + const introLoadingMessage = + phase === 'intro' ? '投稿を読み込んでいます……' : '前回のグカネータ状態を復元しています……' + const questionSuggestionTitle = + questionSuggestionEntryMode === 'search' ? 'まず既存質問を探してください。' : '新しい質問を追加します。' + const saveStatusMessage = (() => { + if (!(saved) || learnedExampleCount == null) + return null + if (learnedExampleCount <= 0) + return null + + return `${ learnedExampleCount }件の回答を学習しました` + }) () const introLoading = isLoading || acceptedQuestionsLoading const readyToStart = @@ -4134,18 +4370,23 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { {[{ mode: 'off' as const, label: 'オフ' }, { mode: 'on' as const, label: 'オン' }] - .map (({ mode, label }) => ( - ))} + .map (({ mode, label }) => { + const modeClass = + backgroundMotionMode === mode + ? 'bg-pink-600 text-white' + : 'text-neutral-600 hover:bg-yellow-100 dark:text-neutral-300 dark:hover:bg-red-900' + + return ( + ) + })} {prefersReducedMotion && effectiveBackgroundMotionMode !== 'off' && ( 端末設定により控えめ表示 @@ -4176,9 +4417,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { {introLoading && (

- {phase === 'intro' - ? '投稿を読み込んでいます……' - : '前回のグカネータ状態を復元しています……'} + {introLoadingMessage}

)} {(Boolean (error) || Boolean (acceptedQuestionsError)) &&

グカネータの質問データを読み込めませんでした.

} @@ -4241,7 +4480,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { {' / '} recoveryStepCount: {recoveryStepCount} {' / '} - currentQuestion===null: {String (currentQuestion === null)} + currentQuestion===null: {String (currentQuestion == null)}
{topScoredPosts.length > 0 && (
@@ -4330,7 +4569,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { {' / '} recoveryStepCount: {recoveryStepCount} {' / '} - currentQuestion===null: {String (currentQuestion === null)} + currentQuestion===null: {String (currentQuestion == null)}
)}
@@ -4413,9 +4652,9 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
正解の投稿
- {reviewCorrectPost - ? - :

正解投稿を選んでください。

} + {reviewCorrectPost && } + {!(reviewCorrectPost) && ( +

正解投稿を選んでください。

)} @@ -4460,7 +4703,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { dark:hover:bg-red-900 disabled:opacity-50" disabled={ !(canPersistGame) - || reviewCorrectPostId === null + || reviewCorrectPostId == null || saveMutation.isPending } onClick={startReview}> @@ -4483,7 +4726,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { hover:bg-yellow-100 dark:border-red-700 dark:hover:bg-red-900 disabled:opacity-50" disabled={!(canPersistGame) - || reviewCorrectPostId === null + || reviewCorrectPostId == null || saveMutation.isPending || extraQuestionState === 'loading' || extraQuestionAnswersMutation.isPending} @@ -4507,9 +4750,9 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
正解の投稿
- {reviewCorrectPost - ? - :

正解投稿を選んでください。

} + {reviewCorrectPost && } + {!(reviewCorrectPost) && ( +

正解投稿を選んでください。

)}