From ffebce36b99fb66f4956ff3e326ac2a42a1124cd Mon Sep 17 00:00:00 2001 From: miteruzo Date: Tue, 16 Jun 2026 00:34:48 +0900 Subject: [PATCH] #371 --- .../controllers/gekanator_games_controller.rb | 127 +- .../controllers/gekanator_posts_controller.rb | 8 +- ...kanator_question_suggestions_controller.rb | 39 + .../gekanator_questions_controller.rb | 3 +- .../app/models/gekanator_question_example.rb | 4 +- .../models/gekanator_question_suggestion.rb | 13 - frontend/src/lib/gekanator.ts | 13 +- .../src/lib/gekanatorCandidateRecovery.ts | 15 + frontend/src/pages/GekanatorPage.tsx | 1255 ++++++++++------- frontend/src/types.ts | 4 + 10 files changed, 940 insertions(+), 541 deletions(-) diff --git a/backend/app/controllers/gekanator_games_controller.rb b/backend/app/controllers/gekanator_games_controller.rb index 48f9624..d4c4d3b 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,85 @@ 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 = accepted_questions[answer['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 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 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/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.ts b/frontend/src/lib/gekanatorCandidateRecovery.ts index 024ad0c..8635bfa 100644 --- a/frontend/src/lib/gekanatorCandidateRecovery.ts +++ b/frontend/src/lib/gekanatorCandidateRecovery.ts @@ -13,6 +13,16 @@ export type RecoveredCandidatePost = { answerCountAtRecovery: number } +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, @@ -46,6 +56,8 @@ export const candidatePostsFor = ({ const question = questionById.get (answer.questionId) if (!(question)) return true + if (!(questionIsFactLikeForHardFiltering (question))) + return true switch (answer.answer) { @@ -71,6 +83,9 @@ export const hardFilteredPostsForAnswer = ({ question: GekanatorQuestion answer: GekanatorAnswerValue }): Post[] => { + if (!(questionIsFactLikeForHardFiltering (question))) + return posts + if (answer === 'unknown') return posts diff --git a/frontend/src/pages/GekanatorPage.tsx b/frontend/src/pages/GekanatorPage.tsx index 3c59931..598a4ae 100644 --- a/frontend/src/pages/GekanatorPage.tsx +++ b/frontend/src/pages/GekanatorPage.tsx @@ -108,7 +108,10 @@ type StoredGekanatorGame = { reviewGuessedPostId: number | null reviewCorrectPostId: number | null savedGameId: number | null + learnedExampleCount?: number | null gameSeed?: string + questionSuggestionSearch?: string + questionSuggestionSelectedId?: number | null questionSuggestion: string questionSuggestionAnswer: GekanatorAnswerValue questionSuggestionCount?: number @@ -134,6 +137,10 @@ type QuestionMode = | 'normal' | null +type QuestionBuildMode = + | 'split' + | 'confirmation' + type MascotState = | 'idle' | 'thinking_far' @@ -161,8 +168,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', @@ -244,6 +255,9 @@ const normalizeStoredGame = (game: StoredGekanatorGame): StoredGekanatorGame => recoveryStepCount: game.recoveryStepCount ?? 0, winningRunTargetId: game.winningRunTargetId ?? null, winningRunStartAnswerCount: game.winningRunStartAnswerCount ?? null, + learnedExampleCount: game.learnedExampleCount ?? null, + questionSuggestionSearch: game.questionSuggestionSearch ?? '', + questionSuggestionSelectedId: game.questionSuggestionSelectedId ?? null, askedQuestionBank: game.askedQuestionBank?.map (question => ({ ...question, id: normalizeStoredQuestionId (question.id, question.condition), @@ -430,69 +444,20 @@ const storedRecoveredCandidatesFromMap = ( 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 + } } @@ -742,20 +707,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': + return ( + question.source === 'user_suggested' || question.source === 'admin_curated' + ? -3.5 + : -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, @@ -829,6 +918,20 @@ 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 }) @@ -949,6 +1052,9 @@ const postIdsForHardAnswer = ( matchIndex: GekanatorMatchIndex dynamicMatchIndex?: GekanatorMatchIndex }, ): number[] => { + if (!(questionIsFactLikeForHardFiltering (question))) + return candidateIds + if (answer === 'unknown' || answer === 'partial' || answer === 'probably_no') @@ -979,6 +1085,59 @@ 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 + + matched.forEach (postId => { + const post = materialIndex.postById.get (postId) + post?.postSimilarityEdges?.forEach (edge => { + if (!Number.isFinite (edge.cos) || edge.cos <= 0) + return + + nextScores.set ( + edge.targetPostId, + (nextScores.get (edge.targetPostId) ?? 0) + baseDelta * edge.cos) + }) + }) +} + const buildIndexedQuestion = ( { condition, text, @@ -1015,18 +1174,28 @@ 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 = + confirmationPostId === null + ? null + : 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[] = [] @@ -1041,9 +1210,6 @@ const buildQuestionsForCandidateIds = ( const year = materialIndex.originalYearByPostId.get (postId) if (year !== null && year !== undefined) 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 +1228,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 => buildIndexedQuestion ({ + condition, + text: originalDateQuestionTextFor (condition), + kind: 'original_date', + priorityWeight: + condition.type === 'original-year' + ? 1.04 + : condition.type === 'original-month-day' + ? 1.01 + : .92, + materialIndex }) + const specialMonthDays = rankedEntriesForCounts ({ + counts: monthDayCounts, + total, + cap: factCap + }).filter (([monthDay]) => specialOriginalMonthDayLabelFor (String (monthDay)) !== null) - rankedEntriesForCounts ({ counts: hostCounts, total, cap: factCap }) - .forEach (([host]) => { - questions.push (buildIndexedQuestion ({ - condition: { type: 'source', host }, - text: `${ host } の投稿を思い浮かべている?`, - kind: 'source', - priorityWeight: 1, - materialIndex })) + 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: yearCounts, total, cap: factCap }) - .forEach (([year]) => { - questions.push (buildIndexedQuestion ({ - condition: { type: 'original-year', year }, - text: `オリジナルの投稿年は ${ year } 年?`, - kind: 'original_date', - priorityWeight: 1, - materialIndex })) - }) + 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 })) + }) - rankedEntriesForCounts ({ counts: monthCounts, total, cap: factCap }) - .forEach (([month]) => { - questions.push (buildIndexedQuestion ({ - condition: { type: 'original-month', month }, - text: `オリジナルの投稿月は ${ month } 月?`, - kind: 'original_date', - priorityWeight: 1, - 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 (titleLengthMedian > 0) + addQuestion (buildIndexedQuestion ({ + condition: { + type: 'title-length-at-least', + length: titleLengthMedian }, + text: `タイトルは ${ titleLengthMedian } 文字以上?`, kind: 'title', - priorityWeight: .96, + 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) ?? [] + + addQuestion ( + host + ? buildIndexedQuestion ({ + condition: { type: 'source', host }, + text: `${ host } の投稿を思い浮かべている?`, + kind: 'source', + priorityWeight: 1.02, + materialIndex }) + : null) + addQuestion ( + year !== null && year !== undefined + ? buildDateQuestion ({ + type: 'original-year', + year }) + : null) + addQuestion ( + monthDay && specialOriginalMonthDayLabelFor (monthDay) + ? buildDateQuestion ({ + type: 'original-month-day', + monthDay }) + : null) + + tagKeys + .slice (0, 20) + .forEach (key => { + addQuestion (buildIndexedQuestion ({ + condition: { type: 'tag', key }, + text: indexedQuestionTextForTag (key), + kind: 'tag', + priorityWeight: 1.08, + 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 })) + }) + + if (titleLength > 0) + addQuestion (buildIndexedQuestion ({ + condition: { + type: 'title-length-at-least', + length: titleLength }, + text: `タイトルは ${ titleLength } 文字以上?`, + kind: 'title', + 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]) } @@ -1196,10 +1445,26 @@ const candidatePostsForState = ({ : matchingPostIdsForCondition ({ condition, materialIndex }) + const useExpectedAnswer = + question !== undefined + && usesLearnedTagExamples (question) + if (question && !(questionIsFactLikeForHardFiltering (question))) + return true + if (matched !== null) - return answer.answer === 'yes' - ? matched.has (post.id) - : !(matched.has (post.id)) + { + 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 @@ -1271,17 +1536,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) }) }) @@ -1363,12 +1636,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) @@ -1708,6 +1984,7 @@ 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 = @@ -1740,6 +2017,7 @@ const chooseQuestion = ( + tagPenalty + narrowPenalty + contradictionPenalty + + humanOffset + sourceBonus + priorityBonus + categoryPenalty @@ -1789,237 +2067,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 +2086,7 @@ const chooseWinningRunQuestion = ({ targetPost, answers, askedIds, + acceptedQuestions, materialIndex, matchIndex, }: { @@ -2043,11 +2094,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 @@ -2062,7 +2120,7 @@ const chooseWinningRunQuestion = ({ const priority = expected === null ? null - : winningRunPriorityFor (question, expected, targetPost) + : winningRunPriorityFor (expected) if (priority === null) return null @@ -2081,16 +2139,21 @@ const chooseWinningRunQuestion = ({ return { question, priority, + humanOffset: humanPriorityOffsetFor (question), matchingCount } }) .filter ((item): item is { question: GekanatorQuestion priority: number + 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 +2192,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 +2229,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 @@ -2296,7 +2370,8 @@ const nextQuestionPlanFor = ( buildQuestionsForCandidateIds ({ candidateIds: scopePosts.map (post => post.id), materialIndex, - acceptedQuestions }) + acceptedQuestions, + mode: 'split' }) if (eligiblePosts.length === 1) { @@ -2329,6 +2404,7 @@ const nextQuestionPlanFor = ( targetPost: nextWinningRunTargetPost, answers, askedIds, + acceptedQuestions, materialIndex, matchIndex }) if (winningRunQuestion) @@ -2654,12 +2730,14 @@ const GekanatorBackdrop: FC<{ nextThumbnails) const [flipVisualSeed, setFlipVisualSeed] = useState (visualSeed) const [isFlippingTiles, setIsFlippingTiles] = useState (false) + const [isCrossfading, setIsCrossfading] = useState (false) const renderedSettings = settingsForMode (displayedBackdropMode) const renderedTileCount = renderedSettings.columns * renderedSettings.rows const renderedScale = scaleForMode (displayedBackdropMode, displayedWinningRunCount) const isGuessPresentation = - backdropMode === 'guess' || displayedBackdropMode === 'guess' + phase === 'guess' || backdropMode === 'guess' || displayedBackdropMode === 'guess' + const crossfadeDuration = motionMode === 'calm' ? .95 : .75 useEffect (() => { if (motionMode === 'off') @@ -2739,12 +2817,14 @@ const GekanatorBackdrop: FC<{ if (motionMode === 'off') { applyDirection () setIsFlippingTiles (false) + setIsCrossfading (false) setFlipVisualSeed (visualSeed) return } if (backdropMode === 'guess' && guessThumbnail) { setIsFlippingTiles (false) + setIsCrossfading (false) setDisplayedBackdropMode ('guess') setDisplayedWinningRunCount (winningRunQuestionCount) setDisplayedThumbnails (nextThumbnails) @@ -2765,6 +2845,7 @@ const GekanatorBackdrop: FC<{ setFromThumbnails (nextThumbnails) setToThumbnails (nextThumbnails) setIsFlippingTiles (false) + setIsCrossfading (false) setFlipVisualSeed (visualSeed) return } @@ -2772,6 +2853,7 @@ const GekanatorBackdrop: FC<{ if (nextThumbnails.length === 0) { applyDirection () setIsFlippingTiles (false) + setIsCrossfading (false) setFlipVisualSeed (visualSeed) return } @@ -2794,7 +2876,10 @@ const GekanatorBackdrop: FC<{ setFromThumbnails (currentThumbnails) setToThumbnails (nextThumbnails) - setIsFlippingTiles (true) + const shouldCrossfade = + displayedBackdropMode === 'winning_run' && backdropMode === 'normal' + setIsCrossfading (shouldCrossfade) + setIsFlippingTiles (!(shouldCrossfade)) flipTimerRef.current = window.setTimeout (() => { setDisplayedBackdropMode (backdropMode) @@ -2803,10 +2888,11 @@ const GekanatorBackdrop: FC<{ setFromThumbnails (nextThumbnails) setToThumbnails (nextThumbnails) setIsFlippingTiles (false) + setIsCrossfading (false) applyDirection () setFlipVisualSeed (visualSeed) flipTimerRef.current = null - }, tileFlipDuration * 1000) + }, (shouldCrossfade ? crossfadeDuration : tileFlipDuration) * 1000) return () => { if (flipTimerRef.current !== null) { @@ -2826,10 +2912,124 @@ const GekanatorBackdrop: FC<{ visualSeed, activeDirection, winningRunQuestionCount, + crossfadeDuration, tileFlipDuration, x, y]) + const renderTileSet = ({ + mode, + thumbnails, + settings, + tileCount, + scale, + opacity, + withFlip, + }: { + mode: 'normal' | 'winning_run' | 'guess' + thumbnails: string[] + settings: { columns: number; rows: number; opacity: number } + tileCount: number + scale: number + opacity?: number + withFlip?: boolean + }) => ( + + {Array.from ({ length: 9 }, (_, duplicate) => { + const column = duplicate % 3 + const row = Math.floor (duplicate / 3) + + return ( + + {Array.from ({ length: tileCount }, (_, index) => { + const currentThumbnail = + thumbnails[index % Math.max (thumbnails.length, 1)] + const frontThumbnail = + withFlip + ? fromThumbnails[index % Math.max (fromThumbnails.length, 1)] + : currentThumbnail + const backThumbnail = + withFlip + ? toThumbnails[index % Math.max (toThumbnails.length, 1)] + : currentThumbnail + const thumbnail = currentThumbnail + if (!(thumbnail) || !(frontThumbnail) || !(backThumbnail)) + return null + + return ( + + {(mode !== 'normal' || !(withFlip)) + ? ( + ) + : ( + + + + )} + ) + })} + ) + })} + ) + if (motionMode === 'off' || nextThumbnails.length === 0) return (
- {Array.from ({ length: 9 }, (_, duplicate) => { - const column = duplicate % 3 - const row = Math.floor (duplicate / 3) - - return ( - - {Array.from ({ length: renderedTileCount }, (_, index) => { - const currentThumbnail = - displayedThumbnails[ - index % Math.max (displayedThumbnails.length, 1)] - const frontThumbnail = - isFlippingTiles - ? fromThumbnails[index % Math.max (fromThumbnails.length, 1)] - : currentThumbnail - const backThumbnail = - isFlippingTiles - ? toThumbnails[index % Math.max (toThumbnails.length, 1)] - : currentThumbnail - const thumbnail = - displayedBackdropMode === 'winning_run' - || displayedBackdropMode === 'guess' - ? nextThumbnails[index % Math.max (nextThumbnails.length, 1)] - : currentThumbnail - if (!(thumbnail) || !(frontThumbnail) || !(backThumbnail)) - return null - - return ( - - {(displayedBackdropMode !== 'normal' || !(isFlippingTiles)) - ? ( - ) - : ( - - - - )} - ) - })} - ) - })} + className="relative h-full w-full"> + {isCrossfading + ? ( + <> + {renderTileSet ({ + mode: displayedBackdropMode, + thumbnails: fromThumbnails, + settings: renderedSettings, + tileCount: renderedTileCount, + scale: renderedScale, + opacity: 0 })} + {renderTileSet ({ + mode: backdropMode, + thumbnails: toThumbnails, + settings: targetSettings, + tileCount: targetTileCount, + scale: scaleForMode (backdropMode, winningRunQuestionCount), + opacity: 1 })} + ) + : renderTileSet ({ + mode: displayedBackdropMode, + thumbnails: displayedThumbnails, + settings: renderedSettings, + tileCount: renderedTileCount, + scale: renderedScale, + withFlip: isFlippingTiles })}
@@ -3015,6 +3148,12 @@ 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 [questionSuggestionSearch, setQuestionSuggestionSearch] = useState ( + storedGame?.questionSuggestionSearch ?? '') + const [questionSuggestionSelectedId, setQuestionSuggestionSelectedId] = + useState (storedGame?.questionSuggestionSelectedId ?? null) const [questionSuggestion, setQuestionSuggestion] = useState ( storedGame?.questionSuggestion ?? '') const [questionSuggestionAnswer, setQuestionSuggestionAnswer] = @@ -3109,7 +3248,10 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { reviewGuessedPostId, reviewCorrectPostId, savedGameId, + learnedExampleCount, gameSeed, + questionSuggestionSearch, + questionSuggestionSelectedId, questionSuggestion, questionSuggestionAnswer, questionSuggestionCount, @@ -3149,7 +3291,10 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { reviewGuessedPostId, reviewCorrectPostId, savedGameId, + learnedExampleCount, gameSeed, + questionSuggestionSearch, + questionSuggestionSelectedId, questionSuggestion, questionSuggestionAnswer, questionSuggestionCount, @@ -3202,6 +3347,14 @@ 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 recentFirstQuestionPenaltyById = useMemo (() => { const penalties = new Map () @@ -3333,6 +3486,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 +3494,8 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { onSuccess: async data => { await queryClient.refetchQueries ({ queryKey: gekanatorKeys.questions () }) setQuestionSuggestionCount (data.count) + setQuestionSuggestionSearch ('') + setQuestionSuggestionSelectedId (null) setQuestionSuggestion ('') setQuestionSuggestionAnswer ('yes') }}) @@ -3386,7 +3542,10 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { setReviewGuessedPostId (null) setReviewCorrectPostId (null) setSavedGameId (null) + setLearnedExampleCount (null) setGameSeed (createGameSeed ()) + setQuestionSuggestionSearch ('') + setQuestionSuggestionSelectedId (null) setQuestionSuggestion ('') setQuestionSuggestionAnswer ('yes') setQuestionSuggestionCount (0) @@ -3446,7 +3605,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 +3631,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]) @@ -3714,6 +3875,9 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { resetExtraQuestionState () setSaved (false) setSavedGameId (null) + setLearnedExampleCount (null) + setQuestionSuggestionSearch ('') + setQuestionSuggestionSelectedId (null) setReviewGuessedPostId (guessedPostId) setReviewCorrectPostId (correctPostId) setSearch ('') @@ -3730,6 +3894,9 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { resetExtraQuestionState () setSaved (false) setSavedGameId (null) + setLearnedExampleCount (null) + setQuestionSuggestionSearch ('') + setQuestionSuggestionSelectedId (null) setSelectingCorrectPost (false) setSearch ('') setPhase ('review') @@ -3780,18 +3947,19 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { const submitQuestionSuggestion = () => { const questionText = questionSuggestion.trim () + const selectedQuestion = selectedSuggestedQuestion if ( !(canPersistGame) - || !(questionText) + || (selectedQuestion === null && !(questionText)) || questionSuggestionMutation.isPending - || questionSuggestionCount >= maxQuestionSuggestionsPerGame ) return saveReviewedResult (gekanatorGameId => { questionSuggestionMutation.mutate ({ gekanatorGameId, - questionText, + existingQuestionId: selectedQuestion?.recordId, + questionText: selectedQuestion ? undefined : questionText, answer: questionSuggestionAnswer }) }) } @@ -3923,6 +4091,9 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { const correctAnswerAt = (index: number, value: GekanatorAnswerValue) => { setSaved (false) setSavedGameId (null) + setLearnedExampleCount (null) + setQuestionSuggestionSearch ('') + setQuestionSuggestionSelectedId (null) resetExtraQuestionState () setAnswers (answers.map ((answer, i) => i === index ? { ...answer, answer: value } : answer)) @@ -3933,6 +4104,9 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { { setSaved (false) setSavedGameId (null) + setLearnedExampleCount (null) + setQuestionSuggestionSearch ('') + setQuestionSuggestionSelectedId (null) resetExtraQuestionState () setReviewCorrectPostId (post.id) setSelectingCorrectPost (false) @@ -4016,6 +4190,13 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { const resultDialogue = effectiveResultWon ? winDialogue : loseDialogue const dialogue = phase === 'learned' ? resultDialogue : introDialogue + const saveStatusMessage = + saved + && learnedExampleCount !== null + ? learnedExampleCount > 0 + ? `${ learnedExampleCount }件の回答を学習しました` + : null + : null const introLoading = isLoading || acceptedQuestionsLoading const readyToStart = @@ -4435,6 +4616,10 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {

記録できませんでした。通信状態を確認してもう一度試して。

)} + {saveStatusMessage && ( +

+ {saveStatusMessage} +

)} {!(canPersistGame) && (

未ログインのため今回の結果は保存されません。 @@ -4579,6 +4764,10 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {

記録できませんでした。通信状態を確認してもう一度試して。

)} + {saveStatusMessage && ( +

+ {saveStatusMessage} +

)}
))} +
+ )} +
+
新規質問