diff --git a/backend/app/controllers/gekanator_games_controller.rb b/backend/app/controllers/gekanator_games_controller.rb index affd42e..c1b297e 100644 --- a/backend/app/controllers/gekanator_games_controller.rb +++ b/backend/app/controllers/gekanator_games_controller.rb @@ -20,4 +20,91 @@ class GekanatorGamesController < ApplicationController render json: { errors: game.errors.full_messages }, status: :unprocessable_entity end end + + def extra_questions + return head :not_found unless current_user&.admin? + + game = GekanatorGame.find_by(id: params[:id]) + return head :not_found unless game + + asked_ids = Array(game.answers).filter_map { |answer| + answer['questionId'] || answer[:questionId] + } + existing_example_ids = + GekanatorQuestionExample.where(post_id: game.correct_post_id) + .select(:gekanator_question_id) + questions = + GekanatorQuestion + .accepted + .where(kind: 'post_similarity', source: 'user_suggested') + .where.not(id: existing_example_ids) + .order(priority_weight: :desc, id: :asc) + + render json: { + questions: questions.filter_map { |question| + json = extra_question_json(question) + next if asked_ids.include?(json[:id].to_s) + next if asked_ids.include?("post-similarity:#{ json[:id] }") + + json + }.first(2) + } + end + + def extra_question_answers + return head :not_found unless current_user&.admin? + + game = GekanatorGame.find_by(id: params[:id]) + return head :not_found unless game + + answer_params = params.require(:answers) + if !answer_params.is_a?(Array) + return render_validation_error fields: { answers: ['配列で指定してください.'] } + end + + answers = answer_params.map { |answer| + { + question_id: answer.require(:question_id), + answer: answer.require(:answer) + } + } + questions = GekanatorQuestion.where(id: answers.map { _1[:question_id] }) + question_by_id = questions.index_by(&:id) + if questions.length != answers.length + return render_validation_error fields: { answers: ['質問が見つかりません.'] } + end + if questions.any? { |question| question.status != 'accepted' || question.kind != 'post_similarity' } + return render_validation_error fields: { answers: ['質問が不正です.'] } + end + + ActiveRecord::Base.transaction do + answers.each do |item| + question = question_by_id[item[:question_id]] + example = + GekanatorQuestionExample.find_or_initialize_by( + gekanator_question: question, + post: game.correct_post, + user: current_user) + example.assign_attributes( + gekanator_game: game, + answer: item[:answer], + source: 'post_game_extra', + weight: 1.0) + example.save! + end + end + + render json: { count: answers.length }, status: :created + end + + private + + def extra_question_json question + { + id: question.id, + text: question.text, + source: question.source, + priority_weight: question.priority_weight + } + end end diff --git a/backend/app/controllers/gekanator_question_suggestions_controller.rb b/backend/app/controllers/gekanator_question_suggestions_controller.rb index a16e29b..d3f83aa 100644 --- a/backend/app/controllers/gekanator_question_suggestions_controller.rb +++ b/backend/app/controllers/gekanator_question_suggestions_controller.rb @@ -11,7 +11,14 @@ class GekanatorQuestionSuggestionsController < ApplicationController question_text: params.require(:question_text), answer: params.require(:answer)) - if suggestion.save + if suggestion.valid? + ActiveRecord::Base.transaction do + suggestion.save! + Gekanator::QuestionSuggestionPromoter.call( + suggestion: suggestion, + user: current_user) + end + render json: { id: suggestion.id, count: game.question_suggestions.count diff --git a/backend/app/controllers/gekanator_questions_controller.rb b/backend/app/controllers/gekanator_questions_controller.rb index 1a61d46..407b0f9 100644 --- a/backend/app/controllers/gekanator_questions_controller.rb +++ b/backend/app/controllers/gekanator_questions_controller.rb @@ -2,7 +2,11 @@ class GekanatorQuestionsController < ApplicationController def index return head :not_found unless current_user&.admin? - questions = GekanatorQuestion.accepted.order(priority_weight: :desc, id: :asc) + questions = + GekanatorQuestion + .accepted + .includes(:gekanator_question_examples) + .order(priority_weight: :desc, id: :asc) render json: { questions: questions.map { |question| question_json(question) } @@ -12,7 +16,7 @@ class GekanatorQuestionsController < ApplicationController private def question_json question - { + json = { id: question_id_for(question), text: question.text, kind: question.kind, @@ -20,6 +24,10 @@ class GekanatorQuestionsController < ApplicationController source: question.source, priority_weight: question.priority_weight } + if question.kind == 'post_similarity' + json[:example_answers] = example_answers_json(question) + end + json end def question_id_for question @@ -40,6 +48,8 @@ class GekanatorQuestionsController < ApplicationController "title:length-greater-than:#{ condition[:length] }" when 'title-has-ascii' 'title:ascii' + when 'post-similarity' + "post-similarity:#{ question.id }" else "catalog:#{ question.id }" end @@ -54,4 +64,20 @@ class GekanatorQuestionsController < ApplicationController json end + + def example_answers_json question + question + .gekanator_question_examples + .group_by(&:post_id) + .transform_values { |examples| aggregate_answer(examples) } + end + + def aggregate_answer examples + examples + .group_by(&:answer) + .map { |answer, grouped| [answer, grouped.sum(&:weight), grouped.max_by(&:updated_at)&.updated_at] } + .sort_by { |(_answer, weight, updated_at)| [-weight, -(updated_at&.to_f || 0)] } + .first + &.first + end end diff --git a/backend/app/models/gekanator_game.rb b/backend/app/models/gekanator_game.rb index ae10c83..bf223d8 100644 --- a/backend/app/models/gekanator_game.rb +++ b/backend/app/models/gekanator_game.rb @@ -5,6 +5,9 @@ class GekanatorGame < ApplicationRecord has_many :question_suggestions, class_name: 'GekanatorQuestionSuggestion', dependent: :delete_all + has_many :question_examples, + class_name: 'GekanatorQuestionExample', + dependent: :delete_all validates :answers, presence: true validates :question_count, numericality: { greater_than_or_equal_to: 0 } diff --git a/backend/app/models/gekanator_question.rb b/backend/app/models/gekanator_question.rb index 8f9b925..719da6e 100644 --- a/backend/app/models/gekanator_question.rb +++ b/backend/app/models/gekanator_question.rb @@ -1,10 +1,11 @@ class GekanatorQuestion < ApplicationRecord - KINDS = ['tag', 'source', 'title', 'original_date'].freeze + KINDS = ['tag', 'source', 'title', 'original_date', 'post_similarity'].freeze SOURCES = ['user_suggested', 'ai_generated', 'admin_curated'].freeze - STATUSES = ['pending', 'accepted', 'rejected'].freeze + STATUSES = ['pending', 'accepted', 'rejected', 'disabled'].freeze belongs_to :gekanator_question_suggestion, optional: true belongs_to :created_by, class_name: 'User', optional: true + has_many :gekanator_question_examples, dependent: :delete_all validates :kind, presence: true, inclusion: { in: KINDS } validates :source, presence: true, inclusion: { in: SOURCES } diff --git a/backend/app/models/gekanator_question_example.rb b/backend/app/models/gekanator_question_example.rb new file mode 100644 index 0000000..5e55cbd --- /dev/null +++ b/backend/app/models/gekanator_question_example.rb @@ -0,0 +1,17 @@ +class GekanatorQuestionExample < ApplicationRecord + ANSWERS = GekanatorQuestionSuggestion::ANSWERS + SOURCES = ['initial_suggestion', 'post_game_extra'].freeze + + belongs_to :gekanator_question + belongs_to :post + belongs_to :user + belongs_to :gekanator_game, optional: true + + validates :answer, presence: true, inclusion: { in: ANSWERS } + validates :source, presence: true, inclusion: { in: SOURCES } + validates :weight, + presence: true, + numericality: { + greater_than: 0 + } +end diff --git a/backend/app/models/post.rb b/backend/app/models/post.rb index 15db1c5..6a27183 100644 --- a/backend/app/models/post.rb +++ b/backend/app/models/post.rb @@ -21,6 +21,7 @@ class Post < ApplicationRecord foreign_key: :correct_post_id, dependent: :delete_all, inverse_of: :correct_post + has_many :gekanator_question_examples, dependent: :delete_all has_many :parent_post_implications, class_name: 'PostImplication', diff --git a/backend/app/services/gekanator/question_suggestion_promoter.rb b/backend/app/services/gekanator/question_suggestion_promoter.rb new file mode 100644 index 0000000..d99b1bd --- /dev/null +++ b/backend/app/services/gekanator/question_suggestion_promoter.rb @@ -0,0 +1,48 @@ +module Gekanator + class QuestionSuggestionPromoter + def self.call(...) = new(...).call + + def initialize suggestion:, user: + @suggestion = suggestion + @user = user + end + + def call + suggestion.with_lock do + return promoted_question if suggestion.processed? + + question = GekanatorQuestion.create!( + text: suggestion.question_text, + kind: 'post_similarity', + source: 'user_suggested', + status: 'accepted', + priority_weight: 1.2, + condition: { + type: 'post-similarity', + postId: suggestion.gekanator_game.correct_post_id, + answer: suggestion.answer, + threshold: 0.65 + }, + gekanator_question_suggestion: suggestion, + created_by: user) + GekanatorQuestionExample.create!( + gekanator_question: question, + post: suggestion.gekanator_game.correct_post, + user: user, + gekanator_game: suggestion.gekanator_game, + answer: suggestion.answer, + source: 'initial_suggestion') + suggestion.update!(processed: true) + question + end + end + + private + + attr_reader :suggestion, :user + + def promoted_question + suggestion.gekanator_questions.order(id: :desc).first + end + end +end diff --git a/backend/config/routes.rb b/backend/config/routes.rb index c6521e1..f30959b 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -64,7 +64,12 @@ Rails.application.routes.draw do end namespace :gekanator do - resources :games, only: [:create], controller: '/gekanator_games' + resources :games, only: [:create], controller: '/gekanator_games' do + member do + get :extra_questions + post :extra_question_answers + end + end resources :posts, only: [:index], controller: '/gekanator_posts' resources :questions, only: [:index], controller: '/gekanator_questions' resources :question_suggestions, diff --git a/backend/db/migrate/20260610000000_create_gekanator_question_examples.rb b/backend/db/migrate/20260610000000_create_gekanator_question_examples.rb new file mode 100644 index 0000000..f115067 --- /dev/null +++ b/backend/db/migrate/20260610000000_create_gekanator_question_examples.rb @@ -0,0 +1,19 @@ +class CreateGekanatorQuestionExamples < ActiveRecord::Migration[8.0] + def change + create_table :gekanator_question_examples do |t| + t.references :gekanator_question, null: false, foreign_key: true + t.references :post, null: false, foreign_key: true + t.references :user, null: false, foreign_key: true + t.references :gekanator_game, null: true, foreign_key: true + t.string :answer, null: false + t.string :source, null: false, default: 'post_game_extra' + t.float :weight, null: false, default: 1.0 + t.timestamps + end + + add_index :gekanator_question_examples, + [:gekanator_question_id, :post_id, :user_id], + unique: true, + name: 'idx_gekanator_question_examples_on_question_post_user' + end +end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index 3cd1798..a7d7dac 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_06_09_001000) do +ActiveRecord::Schema[8.0].define(version: 2026_06_10_000000) do create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false @@ -75,6 +75,23 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_09_001000) do t.check_constraint "`question_count` >= 0", name: "chk_gekanator_games_question_count_nonnegative" end + create_table "gekanator_question_examples", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.bigint "gekanator_question_id", null: false + t.bigint "post_id", null: false + t.bigint "user_id", null: false + t.bigint "gekanator_game_id" + t.string "answer", null: false + t.string "source", default: "post_game_extra", null: false + t.float "weight", default: 1.0, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["gekanator_game_id"], name: "index_gekanator_question_examples_on_gekanator_game_id" + t.index ["gekanator_question_id", "post_id", "user_id"], name: "idx_gekanator_question_examples_on_question_post_user", unique: true + t.index ["gekanator_question_id"], name: "index_gekanator_question_examples_on_gekanator_question_id" + t.index ["post_id"], name: "index_gekanator_question_examples_on_post_id" + t.index ["user_id"], name: "index_gekanator_question_examples_on_user_id" + end + create_table "gekanator_question_suggestions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "gekanator_game_id", null: false t.bigint "user_id", null: false @@ -553,6 +570,10 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_09_001000) do add_foreign_key "gekanator_games", "posts", column: "correct_post_id" add_foreign_key "gekanator_games", "posts", column: "guessed_post_id" add_foreign_key "gekanator_games", "users" + add_foreign_key "gekanator_question_examples", "gekanator_games" + add_foreign_key "gekanator_question_examples", "gekanator_questions" + add_foreign_key "gekanator_question_examples", "posts" + add_foreign_key "gekanator_question_examples", "users" add_foreign_key "gekanator_question_suggestions", "gekanator_games", on_delete: :cascade add_foreign_key "gekanator_question_suggestions", "users" add_foreign_key "gekanator_questions", "gekanator_question_suggestions" diff --git a/frontend/src/lib/gekanator.ts b/frontend/src/lib/gekanator.ts index dcd4dff..252e79d 100644 --- a/frontend/src/lib/gekanator.ts +++ b/frontend/src/lib/gekanator.ts @@ -21,6 +21,7 @@ export type GekanatorQuestionKind = | 'source' | 'title' | 'original_date' + | 'post_similarity' export type GekanatorQuestionSource = | 'default' @@ -36,6 +37,18 @@ export type GekanatorQuestionCondition = | { type: 'original-month-day'; monthDay: string } | { type: 'title-length-greater-than'; length: number } | { type: 'title-has-ascii' } + | { + type: 'post-similarity' + postId: number + answer: GekanatorAnswerValue + threshold: number + } + +export type GekanatorExtraQuestion = { + id: number + text: string + source: GekanatorQuestionSource + priorityWeight: number } export type StoredGekanatorQuestion = { id: string @@ -43,7 +56,8 @@ export type StoredGekanatorQuestion = { kind: GekanatorQuestionKind condition: GekanatorQuestionCondition source?: GekanatorQuestionSource - priorityWeight?: number } + priorityWeight?: number + exampleAnswers?: Record<`${ number }`, GekanatorAnswerValue> } export type GekanatorQuestion = { id: string @@ -52,8 +66,24 @@ export type GekanatorQuestion = { condition: GekanatorQuestionCondition source: GekanatorQuestionSource priorityWeight: number + exampleAnswers?: Record<`${ number }`, GekanatorAnswerValue> test: (post: Post) => boolean } + +const directExampleAnswerFor = ( + question: StoredGekanatorQuestion, + post: Post, +): GekanatorAnswerValue | null => { + const direct = question.exampleAnswers?.[String (post.id) as `${ number }`] + if (direct) + return direct + + if (question.condition.type === 'post-similarity' && question.condition.postId === post.id) + return question.condition.answer + + return null +} + const countBy = (values: T[]): Map => { const counts = new Map () values.forEach (value => counts.set (value, (counts.get (value) ?? 0) + 1)) @@ -172,24 +202,59 @@ const questionableTag = (post: Post, key: string): boolean => { const questionMatches = ( post: Post, - condition: GekanatorQuestionCondition, + question: StoredGekanatorQuestion, ): boolean => { - switch (condition.type) + const directAnswer = directExampleAnswerFor (question, post) + if (directAnswer) + return question.condition.type === 'post-similarity' + ? directAnswer === question.condition.answer + : directAnswer === 'yes' + + switch (question.condition.type) { case 'tag': - return questionableTag (post, condition.key) + return questionableTag (post, question.condition.key) case 'source': - return hostOf (post) === condition.host + return hostOf (post) === question.condition.host case 'original-year': - return originalYearOf (post) === condition.year + return originalYearOf (post) === question.condition.year case 'original-month': - return originalMonthOf (post) === condition.month + return originalMonthOf (post) === question.condition.month case 'original-month-day': - return originalMonthDayOf (post) === condition.monthDay + return originalMonthDayOf (post) === question.condition.monthDay case 'title-length-greater-than': - return (post.title?.length ?? 0) > condition.length + return (post.title?.length ?? 0) > question.condition.length case 'title-has-ascii': return /[A-Za-z0-9]/.test (post.title ?? '') + case 'post-similarity': + return false + } +} + + +export const expectedAnswerForQuestion = ( + question: StoredGekanatorQuestion | GekanatorQuestion | undefined, + post: Post | null, +): GekanatorAnswerValue | null => { + if (!(question) || !(post)) + return null + + const directAnswer = directExampleAnswerFor (question, post) + if (directAnswer) + return directAnswer + + switch (question.condition.type) + { + case 'tag': + case 'source': + case 'original-year': + case 'original-month': + case 'original-month-day': + case 'title-length-greater-than': + case 'title-has-ascii': + return questionMatches (post, question) ? 'yes' : 'no' + case 'post-similarity': + return null } } @@ -200,7 +265,7 @@ export const restoreGekanatorQuestion = ( ...question, source: question.source ?? 'default', priorityWeight: question.priorityWeight ?? 1, - test: (post: Post) => questionMatches (post, question.condition) }) + test: (post: Post) => questionMatches (post, question) }) export const storeGekanatorQuestion = ( @@ -211,7 +276,8 @@ export const storeGekanatorQuestion = ( kind: question.kind, condition: question.condition, source: question.source, - priorityWeight: question.priorityWeight }) + priorityWeight: question.priorityWeight, + exampleAnswers: question.exampleAnswers }) export const fetchGekanatorPosts = async (): Promise => { @@ -226,6 +292,15 @@ export const fetchGekanatorQuestions = async (): Promise => { + const data = await apiGet<{ questions: GekanatorExtraQuestion[] }> ( + `/gekanator/games/${ gameId }/extra_questions`) + return data.questions +} + + export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => { const tagCounts = countBy (posts.flatMap (post => post.tags @@ -393,3 +468,16 @@ export const saveGekanatorQuestionSuggestion = async ({ gekanator_game_id: gekanatorGameId, question_text: questionText, answer }) + + +export const saveGekanatorExtraQuestionAnswers = async ({ + gameId, + answers, +}: { + gameId: number + answers: { questionId: number; answer: GekanatorAnswerValue }[] +}) => + await apiPost (`/gekanator/games/${ gameId }/extra_question_answers`, { + answers: answers.map (item => ({ + question_id: item.questionId, + answer: item.answer })) }) diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index 864d0bb..5e8b837 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -11,7 +11,9 @@ export const postsKeys = { export const gekanatorKeys = { root: ['gekanator'] as const, posts: () => ['gekanator', 'posts'] as const, - questions: () => ['gekanator', 'questions'] as const } + questions: () => ['gekanator', 'questions'] as const, + extraQuestions: (gameId: number) => + ['gekanator', 'games', gameId, 'extra-questions'] as const } export const tagsKeys = { root: ['tags'] as const, diff --git a/frontend/src/pages/GekanatorPage.tsx b/frontend/src/pages/GekanatorPage.tsx index 2eef5b6..62c3963 100644 --- a/frontend/src/pages/GekanatorPage.tsx +++ b/frontend/src/pages/GekanatorPage.tsx @@ -1,4 +1,4 @@ -import { useMutation, useQuery } from '@tanstack/react-query' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useEffect, useMemo, useState } from 'react' import { Helmet } from 'react-helmet-async' @@ -6,9 +6,12 @@ import PrefetchLink from '@/components/PrefetchLink' import MainArea from '@/components/layout/MainArea' import { SITE_TITLE } from '@/config' import { buildGekanatorQuestions, + expectedAnswerForQuestion, + fetchGekanatorExtraQuestions, fetchGekanatorQuestions, fetchGekanatorPosts, restoreGekanatorQuestion, + saveGekanatorExtraQuestionAnswers, saveGekanatorGame, saveGekanatorQuestionSuggestion, storeGekanatorQuestion } from '@/lib/gekanator' @@ -19,6 +22,7 @@ import type { FC } from 'react' import type { GekanatorAnswerLog, GekanatorAnswerValue, + GekanatorExtraQuestion, GekanatorQuestion, StoredGekanatorQuestion } from '@/lib/gekanator' import type { Post } from '@/types' @@ -31,6 +35,7 @@ type Phase = | 'end' | 'review' | 'question_suggestion' + | 'extra_questions' | 'learned' type AnswerOption = { @@ -87,7 +92,10 @@ type StoredGekanatorGame = { gameSeed?: string questionSuggestion: string questionSuggestionAnswer: GekanatorAnswerValue - questionSuggestionCount?: number } + questionSuggestionCount?: number + extraQuestions?: GekanatorExtraQuestion[] + extraQuestionAnswers?: Record + extraQuestionState?: 'idle' | 'loading' | 'ready' | 'empty' | 'saved' } const answerOptions: AnswerOption[] = [ { label: 'はい', value: 'yes' }, @@ -219,6 +227,16 @@ const loadStoredGame = (): StoredGekanatorGame | null => { const isStoredPhase = (phase: Phase): boolean => phase !== 'intro' +const resettableExtraQuestionState = (): { + extraQuestions: GekanatorExtraQuestion[] + extraQuestionAnswers: Record + extraQuestionState: 'idle' +} => ({ + extraQuestions: [], + extraQuestionAnswers: { }, + extraQuestionState: 'idle' }) + + const deltaFor = (matched: boolean, answer: GekanatorAnswerValue): number => { switch (answer) { @@ -236,6 +254,55 @@ const deltaFor = (matched: boolean, answer: GekanatorAnswerValue): number => { } +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) + return 2 + + return 4 +} + + const answerWeightFor = ( questionId: string, softenedQuestionIds: Set, @@ -277,10 +344,11 @@ const recalculateScores = ({ const weight = answerWeightFor (answer.questionId, softenedQuestionIds) posts.forEach (post => { + const expected = expectedAnswerForQuestion (question, post) nextScores.set ( post.id, (nextScores.get (post.id) ?? 0) - + deltaFor (question.test (post), answer.answer) * weight) + + deltaForExpectedAnswer (expected, answer.answer) * weight) }) }) @@ -318,9 +386,10 @@ const candidatePostsFor = ({ switch (answer.answer) { case 'yes': - return question.test (post) - case 'no': - return !(question.test (post)) + case 'no': { + const expected = expectedAnswerForQuestion (question, post) + return expected === null || expected === 'unknown' || expected === answer.answer + } default: return true } @@ -378,20 +447,19 @@ const previewAnswer = ({ answer: GekanatorAnswerValue }): AnswerPreview => { const hardFilteredPosts = - answer === 'yes' - ? posts.filter (post => question.test (post)) - : answer === 'no' - ? posts.filter (post => !(question.test (post))) - : posts + answer === 'unknown' + ? posts + : posts.filter (post => expectedAnswerForQuestion (question, post) === answer) const nextPosts = - (answer === 'yes' || answer === 'no') && hardFilteredPosts.length > 0 + answer !== 'unknown' && hardFilteredPosts.length > 0 ? hardFilteredPosts : posts const nextScores = new Map (scores) nextPosts.forEach (post => { + const expected = expectedAnswerForQuestion (question, post) nextScores.set ( post.id, - (nextScores.get (post.id) ?? 0) + deltaFor (question.test (post), answer)) + (nextScores.get (post.id) ?? 0) + deltaForExpectedAnswer (expected, answer)) }) const confidences = confidencesFor (nextPosts, nextScores) @@ -497,6 +565,8 @@ const sameConditionValue = ( return String (condition.length) case 'title-has-ascii': return '' + case 'post-similarity': + return `${ condition.postId }:${ condition.answer }:${ condition.threshold }` } } @@ -757,16 +827,13 @@ const PostMiniCard: FC<{ post: Post }> = ({ post }) => ( const expectedAnswerFor = ( question: GekanatorQuestion | undefined, correctPost: Post | null, -): GekanatorAnswerValue | null => { - if (!(question) || !(correctPost)) - return null - - return question.test (correctPost) ? 'yes' : 'no' -} +): GekanatorAnswerValue | null => + expectedAnswerForQuestion (question, correctPost) const GekanatorPage: FC = () => { const storedGame = useMemo (loadStoredGame, []) + const queryClient = useQueryClient () const [gameSeed, setGameSeed] = useState ( storedGame?.gameSeed ?? createGameSeed ()) const [phase, setPhase] = useState (storedGame?.phase ?? 'intro') @@ -810,6 +877,14 @@ const GekanatorPage: FC = () => { useState (storedGame?.questionSuggestionAnswer ?? 'yes') const [questionSuggestionCount, setQuestionSuggestionCount] = useState ( storedGame?.questionSuggestionCount ?? 0) + const [extraQuestions, setExtraQuestions] = useState ( + storedGame?.extraQuestions ?? []) + const [extraQuestionAnswers, setExtraQuestionAnswers] = + useState> ( + storedGame?.extraQuestionAnswers ?? { }) + const [extraQuestionState, setExtraQuestionState] = useState< + 'idle' | 'loading' | 'ready' | 'empty' | 'saved' + > (storedGame?.extraQuestionState ?? 'idle') const [history, setHistory] = useState ([]) const { data: posts = [], isLoading, error } = useQuery ({ @@ -876,7 +951,10 @@ const GekanatorPage: FC = () => { gameSeed, questionSuggestion, questionSuggestionAnswer, - questionSuggestionCount } + questionSuggestionCount, + extraQuestions, + extraQuestionAnswers, + extraQuestionState } try { @@ -908,7 +986,10 @@ const GekanatorPage: FC = () => { gameSeed, questionSuggestion, questionSuggestionAnswer, - questionSuggestionCount]) + questionSuggestionCount, + extraQuestions, + extraQuestionAnswers, + extraQuestionState]) const eligiblePosts = useMemo ( () => candidatePostsFor ({ @@ -985,10 +1066,25 @@ const GekanatorPage: FC = () => { setQuestionSuggestion ('') setQuestionSuggestionAnswer ('yes') }}) + const extraQuestionAnswersMutation = useMutation ({ + mutationFn: saveGekanatorExtraQuestionAnswers, + onSuccess: () => { + setExtraQuestionState ('saved') + setPhase ('learned') + }}) + + const resetExtraQuestionState = () => { + const next = resettableExtraQuestionState () + setExtraQuestions (next.extraQuestions) + setExtraQuestionAnswers (next.extraQuestionAnswers) + setExtraQuestionState (next.extraQuestionState) + extraQuestionAnswersMutation.reset () + } const reset = () => { clearStoredGame () saveMutation.reset () + questionSuggestionMutation.reset () setPhase ('intro') setScores (new Map ()) setAnswers ([]) @@ -1010,6 +1106,7 @@ const GekanatorPage: FC = () => { setQuestionSuggestion ('') setQuestionSuggestionAnswer ('yes') setQuestionSuggestionCount (0) + resetExtraQuestionState () setHistory ([]) } @@ -1188,6 +1285,7 @@ const GekanatorPage: FC = () => { saveMutation.reset () questionSuggestionMutation.reset () + resetExtraQuestionState () setSaved (false) setSavedGameId (null) setReviewGuessedPostId (guessedPostId) @@ -1203,6 +1301,7 @@ const GekanatorPage: FC = () => { saveMutation.reset () questionSuggestionMutation.reset () + resetExtraQuestionState () setSaved (false) setSavedGameId (null) setSelectingCorrectPost (false) @@ -1236,6 +1335,7 @@ const GekanatorPage: FC = () => { } const saveAndLearn = () => { + resetExtraQuestionState () saveReviewedResult (() => setPhase ('learned')) } @@ -1344,6 +1444,7 @@ const GekanatorPage: FC = () => { const correctAnswerAt = (index: number, value: GekanatorAnswerValue) => { setSaved (false) setSavedGameId (null) + resetExtraQuestionState () setAnswers (answers.map ((answer, i) => i === index ? { ...answer, answer: value } : answer)) } @@ -1353,6 +1454,7 @@ const GekanatorPage: FC = () => { { setSaved (false) setSavedGameId (null) + resetExtraQuestionState () setReviewCorrectPostId (post.id) setSelectingCorrectPost (false) setSearch ('') @@ -1383,6 +1485,60 @@ const GekanatorPage: FC = () => { }) .slice (0, 20) + const loadExtraQuestions = async (gameId: number) => { + extraQuestionAnswersMutation.reset () + setExtraQuestionState ('loading') + setExtraQuestions ([]) + setExtraQuestionAnswers ({ }) + setPhase ('extra_questions') + + try + { + const questions = await queryClient.fetchQuery ({ + queryKey: gekanatorKeys.extraQuestions (gameId), + queryFn: () => fetchGekanatorExtraQuestions (gameId) }) + setExtraQuestions (questions) + setExtraQuestionState (questions.length > 0 ? 'ready' : 'empty') + } + catch + { + setExtraQuestionState ('empty') + } + } + + const startExtraQuestions = () => { + if (reviewCorrectPostId === null || saveMutation.isPending) + return + + saveReviewedResult (gameId => { + void loadExtraQuestions (gameId) + }) + } + + const answerExtraQuestion = ( + questionId: number, + value: GekanatorAnswerValue, + ) => { + setExtraQuestionAnswers ({ + ...extraQuestionAnswers, + [String (questionId)]: value }) + } + + const saveExtraQuestions = () => { + if ( + savedGameId === null + || extraQuestionAnswersMutation.isPending + || extraQuestions.some (question => !(extraQuestionAnswers[String (question.id)])) + ) + return + + extraQuestionAnswersMutation.mutate ({ + gameId: savedGameId, + answers: extraQuestions.map (question => ({ + questionId: question.id, + answer: extraQuestionAnswers[String (question.id)] })) }) + } + const dialogue = phase === 'learned' && resultWon ? <>グカカカカwwwww 洗澡鹿シーザオグカは何でもお見通し! @@ -1650,6 +1806,18 @@ const GekanatorPage: FC = () => { onClick={() => setPhase ('question_suggestion')}> 質問を追加 + )} @@ -1843,9 +2011,81 @@ const GekanatorPage: FC = () => {

)} )} + {phase === 'extra_questions' && ( +
+
+

追加学習

+

追加で 2 問まで答へてください。

+
+ + {extraQuestionState === 'loading' && ( +

追加質問を読み込んでゐます...

)} + + {extraQuestionState === 'empty' && ( +

追加で学習できる質問はありませんでした。

)} + + {extraQuestionState === 'ready' && ( +
+ {extraQuestions.map ((question, index) => ( +
+
+ 追加質問 {index + 1} +
+
{question.text}
+
+ {answerOptions.map (option => ( + ))} +
+
))} +
)} + + {extraQuestionAnswersMutation.isError && ( +

+ 学習内容を保存できませんでした。通信状態を確認してもう一度試して。 +

)} + +
+ + +
+
)} + {phase === 'learned' && (
-

覚えたよ.次はもっと見通す.

+

{extraQuestionState === 'saved' ? '学習しました。' : '覚えたよ.次はもっと見通す.'}