グカネータ / 質問パターン見直し (#41) #365
@@ -20,4 +20,91 @@ class GekanatorGamesController < ApplicationController
|
|||||||
render json: { errors: game.errors.full_messages }, status: :unprocessable_entity
|
render json: { errors: game.errors.full_messages }, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|||||||
@@ -11,7 +11,14 @@ class GekanatorQuestionSuggestionsController < ApplicationController
|
|||||||
question_text: params.require(:question_text),
|
question_text: params.require(:question_text),
|
||||||
answer: params.require(:answer))
|
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: {
|
render json: {
|
||||||
id: suggestion.id,
|
id: suggestion.id,
|
||||||
count: game.question_suggestions.count
|
count: game.question_suggestions.count
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ class GekanatorQuestionsController < ApplicationController
|
|||||||
def index
|
def index
|
||||||
return head :not_found unless current_user&.admin?
|
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: {
|
render json: {
|
||||||
questions: questions.map { |question| question_json(question) }
|
questions: questions.map { |question| question_json(question) }
|
||||||
@@ -12,7 +16,7 @@ class GekanatorQuestionsController < ApplicationController
|
|||||||
private
|
private
|
||||||
|
|
||||||
def question_json question
|
def question_json question
|
||||||
{
|
json = {
|
||||||
id: question_id_for(question),
|
id: question_id_for(question),
|
||||||
text: question.text,
|
text: question.text,
|
||||||
kind: question.kind,
|
kind: question.kind,
|
||||||
@@ -20,6 +24,10 @@ class GekanatorQuestionsController < ApplicationController
|
|||||||
source: question.source,
|
source: question.source,
|
||||||
priority_weight: question.priority_weight
|
priority_weight: question.priority_weight
|
||||||
}
|
}
|
||||||
|
if question.kind == 'post_similarity'
|
||||||
|
json[:example_answers] = example_answers_json(question)
|
||||||
|
end
|
||||||
|
json
|
||||||
end
|
end
|
||||||
|
|
||||||
def question_id_for question
|
def question_id_for question
|
||||||
@@ -40,6 +48,8 @@ class GekanatorQuestionsController < ApplicationController
|
|||||||
"title:length-greater-than:#{ condition[:length] }"
|
"title:length-greater-than:#{ condition[:length] }"
|
||||||
when 'title-has-ascii'
|
when 'title-has-ascii'
|
||||||
'title:ascii'
|
'title:ascii'
|
||||||
|
when 'post-similarity'
|
||||||
|
"post-similarity:#{ question.id }"
|
||||||
else
|
else
|
||||||
"catalog:#{ question.id }"
|
"catalog:#{ question.id }"
|
||||||
end
|
end
|
||||||
@@ -54,4 +64,20 @@ class GekanatorQuestionsController < ApplicationController
|
|||||||
|
|
||||||
json
|
json
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ class GekanatorGame < ApplicationRecord
|
|||||||
has_many :question_suggestions,
|
has_many :question_suggestions,
|
||||||
class_name: 'GekanatorQuestionSuggestion',
|
class_name: 'GekanatorQuestionSuggestion',
|
||||||
dependent: :delete_all
|
dependent: :delete_all
|
||||||
|
has_many :question_examples,
|
||||||
|
class_name: 'GekanatorQuestionExample',
|
||||||
|
dependent: :delete_all
|
||||||
|
|
||||||
validates :answers, presence: true
|
validates :answers, presence: true
|
||||||
validates :question_count, numericality: { greater_than_or_equal_to: 0 }
|
validates :question_count, numericality: { greater_than_or_equal_to: 0 }
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
class GekanatorQuestion < ApplicationRecord
|
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
|
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 :gekanator_question_suggestion, optional: true
|
||||||
belongs_to :created_by, class_name: 'User', 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 :kind, presence: true, inclusion: { in: KINDS }
|
||||||
validates :source, presence: true, inclusion: { in: SOURCES }
|
validates :source, presence: true, inclusion: { in: SOURCES }
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -21,6 +21,7 @@ class Post < ApplicationRecord
|
|||||||
foreign_key: :correct_post_id,
|
foreign_key: :correct_post_id,
|
||||||
dependent: :delete_all,
|
dependent: :delete_all,
|
||||||
inverse_of: :correct_post
|
inverse_of: :correct_post
|
||||||
|
has_many :gekanator_question_examples, dependent: :delete_all
|
||||||
|
|
||||||
has_many :parent_post_implications,
|
has_many :parent_post_implications,
|
||||||
class_name: 'PostImplication',
|
class_name: 'PostImplication',
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -64,7 +64,12 @@ Rails.application.routes.draw do
|
|||||||
end
|
end
|
||||||
|
|
||||||
namespace :gekanator do
|
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 :posts, only: [:index], controller: '/gekanator_posts'
|
||||||
resources :questions, only: [:index], controller: '/gekanator_questions'
|
resources :questions, only: [:index], controller: '/gekanator_questions'
|
||||||
resources :question_suggestions,
|
resources :question_suggestions,
|
||||||
|
|||||||
@@ -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
|
||||||
生成ファイル
+22
-1
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# 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|
|
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
t.string "name", null: false
|
t.string "name", null: false
|
||||||
t.string "record_type", 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"
|
t.check_constraint "`question_count` >= 0", name: "chk_gekanator_games_question_count_nonnegative"
|
||||||
end
|
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|
|
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 "gekanator_game_id", null: false
|
||||||
t.bigint "user_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: "correct_post_id"
|
||||||
add_foreign_key "gekanator_games", "posts", column: "guessed_post_id"
|
add_foreign_key "gekanator_games", "posts", column: "guessed_post_id"
|
||||||
add_foreign_key "gekanator_games", "users"
|
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", "gekanator_games", on_delete: :cascade
|
||||||
add_foreign_key "gekanator_question_suggestions", "users"
|
add_foreign_key "gekanator_question_suggestions", "users"
|
||||||
add_foreign_key "gekanator_questions", "gekanator_question_suggestions"
|
add_foreign_key "gekanator_questions", "gekanator_question_suggestions"
|
||||||
|
|||||||
+99
-11
@@ -21,6 +21,7 @@ export type GekanatorQuestionKind =
|
|||||||
| 'source'
|
| 'source'
|
||||||
| 'title'
|
| 'title'
|
||||||
| 'original_date'
|
| 'original_date'
|
||||||
|
| 'post_similarity'
|
||||||
|
|
||||||
export type GekanatorQuestionSource =
|
export type GekanatorQuestionSource =
|
||||||
| 'default'
|
| 'default'
|
||||||
@@ -36,6 +37,18 @@ export type GekanatorQuestionCondition =
|
|||||||
| { type: 'original-month-day'; monthDay: string }
|
| { type: 'original-month-day'; monthDay: string }
|
||||||
| { type: 'title-length-greater-than'; length: number }
|
| { type: 'title-length-greater-than'; length: number }
|
||||||
| { type: 'title-has-ascii' }
|
| { 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 = {
|
export type StoredGekanatorQuestion = {
|
||||||
id: string
|
id: string
|
||||||
@@ -43,7 +56,8 @@ export type StoredGekanatorQuestion = {
|
|||||||
kind: GekanatorQuestionKind
|
kind: GekanatorQuestionKind
|
||||||
condition: GekanatorQuestionCondition
|
condition: GekanatorQuestionCondition
|
||||||
source?: GekanatorQuestionSource
|
source?: GekanatorQuestionSource
|
||||||
priorityWeight?: number }
|
priorityWeight?: number
|
||||||
|
exampleAnswers?: Record<`${ number }`, GekanatorAnswerValue> }
|
||||||
|
|
||||||
export type GekanatorQuestion = {
|
export type GekanatorQuestion = {
|
||||||
id: string
|
id: string
|
||||||
@@ -52,8 +66,24 @@ export type GekanatorQuestion = {
|
|||||||
condition: GekanatorQuestionCondition
|
condition: GekanatorQuestionCondition
|
||||||
source: GekanatorQuestionSource
|
source: GekanatorQuestionSource
|
||||||
priorityWeight: number
|
priorityWeight: number
|
||||||
|
exampleAnswers?: Record<`${ number }`, GekanatorAnswerValue>
|
||||||
test: (post: Post) => boolean }
|
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 = <T extends string | number> (values: T[]): Map<T, number> => {
|
const countBy = <T extends string | number> (values: T[]): Map<T, number> => {
|
||||||
const counts = new Map<T, number> ()
|
const counts = new Map<T, number> ()
|
||||||
values.forEach (value => counts.set (value, (counts.get (value) ?? 0) + 1))
|
values.forEach (value => counts.set (value, (counts.get (value) ?? 0) + 1))
|
||||||
@@ -172,24 +202,59 @@ const questionableTag = (post: Post, key: string): boolean => {
|
|||||||
|
|
||||||
const questionMatches = (
|
const questionMatches = (
|
||||||
post: Post,
|
post: Post,
|
||||||
condition: GekanatorQuestionCondition,
|
question: StoredGekanatorQuestion,
|
||||||
): boolean => {
|
): 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':
|
case 'tag':
|
||||||
return questionableTag (post, condition.key)
|
return questionableTag (post, question.condition.key)
|
||||||
case 'source':
|
case 'source':
|
||||||
return hostOf (post) === condition.host
|
return hostOf (post) === question.condition.host
|
||||||
case 'original-year':
|
case 'original-year':
|
||||||
return originalYearOf (post) === condition.year
|
return originalYearOf (post) === question.condition.year
|
||||||
case 'original-month':
|
case 'original-month':
|
||||||
return originalMonthOf (post) === condition.month
|
return originalMonthOf (post) === question.condition.month
|
||||||
case 'original-month-day':
|
case 'original-month-day':
|
||||||
return originalMonthDayOf (post) === condition.monthDay
|
return originalMonthDayOf (post) === question.condition.monthDay
|
||||||
case 'title-length-greater-than':
|
case 'title-length-greater-than':
|
||||||
return (post.title?.length ?? 0) > condition.length
|
return (post.title?.length ?? 0) > question.condition.length
|
||||||
case 'title-has-ascii':
|
case 'title-has-ascii':
|
||||||
return /[A-Za-z0-9]/.test (post.title ?? '')
|
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,
|
...question,
|
||||||
source: question.source ?? 'default',
|
source: question.source ?? 'default',
|
||||||
priorityWeight: question.priorityWeight ?? 1,
|
priorityWeight: question.priorityWeight ?? 1,
|
||||||
test: (post: Post) => questionMatches (post, question.condition) })
|
test: (post: Post) => questionMatches (post, question) })
|
||||||
|
|
||||||
|
|
||||||
export const storeGekanatorQuestion = (
|
export const storeGekanatorQuestion = (
|
||||||
@@ -211,7 +276,8 @@ export const storeGekanatorQuestion = (
|
|||||||
kind: question.kind,
|
kind: question.kind,
|
||||||
condition: question.condition,
|
condition: question.condition,
|
||||||
source: question.source,
|
source: question.source,
|
||||||
priorityWeight: question.priorityWeight })
|
priorityWeight: question.priorityWeight,
|
||||||
|
exampleAnswers: question.exampleAnswers })
|
||||||
|
|
||||||
|
|
||||||
export const fetchGekanatorPosts = async (): Promise<Post[]> => {
|
export const fetchGekanatorPosts = async (): Promise<Post[]> => {
|
||||||
@@ -226,6 +292,15 @@ export const fetchGekanatorQuestions = async (): Promise<StoredGekanatorQuestion
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const fetchGekanatorExtraQuestions = async (
|
||||||
|
gameId: number,
|
||||||
|
): Promise<GekanatorExtraQuestion[]> => {
|
||||||
|
const data = await apiGet<{ questions: GekanatorExtraQuestion[] }> (
|
||||||
|
`/gekanator/games/${ gameId }/extra_questions`)
|
||||||
|
return data.questions
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
|
export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
|
||||||
const tagCounts = countBy (posts.flatMap (post =>
|
const tagCounts = countBy (posts.flatMap (post =>
|
||||||
post.tags
|
post.tags
|
||||||
@@ -393,3 +468,16 @@ export const saveGekanatorQuestionSuggestion = async ({
|
|||||||
gekanator_game_id: gekanatorGameId,
|
gekanator_game_id: gekanatorGameId,
|
||||||
question_text: questionText,
|
question_text: questionText,
|
||||||
answer })
|
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 })) })
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ export const postsKeys = {
|
|||||||
export const gekanatorKeys = {
|
export const gekanatorKeys = {
|
||||||
root: ['gekanator'] as const,
|
root: ['gekanator'] as const,
|
||||||
posts: () => ['gekanator', 'posts'] 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 = {
|
export const tagsKeys = {
|
||||||
root: ['tags'] as const,
|
root: ['tags'] as const,
|
||||||
|
|||||||
@@ -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 { useEffect, useMemo, useState } from 'react'
|
||||||
import { Helmet } from 'react-helmet-async'
|
import { Helmet } from 'react-helmet-async'
|
||||||
|
|
||||||
@@ -6,9 +6,12 @@ import PrefetchLink from '@/components/PrefetchLink'
|
|||||||
import MainArea from '@/components/layout/MainArea'
|
import MainArea from '@/components/layout/MainArea'
|
||||||
import { SITE_TITLE } from '@/config'
|
import { SITE_TITLE } from '@/config'
|
||||||
import { buildGekanatorQuestions,
|
import { buildGekanatorQuestions,
|
||||||
|
expectedAnswerForQuestion,
|
||||||
|
fetchGekanatorExtraQuestions,
|
||||||
fetchGekanatorQuestions,
|
fetchGekanatorQuestions,
|
||||||
fetchGekanatorPosts,
|
fetchGekanatorPosts,
|
||||||
restoreGekanatorQuestion,
|
restoreGekanatorQuestion,
|
||||||
|
saveGekanatorExtraQuestionAnswers,
|
||||||
saveGekanatorGame,
|
saveGekanatorGame,
|
||||||
saveGekanatorQuestionSuggestion,
|
saveGekanatorQuestionSuggestion,
|
||||||
storeGekanatorQuestion } from '@/lib/gekanator'
|
storeGekanatorQuestion } from '@/lib/gekanator'
|
||||||
@@ -19,6 +22,7 @@ import type { FC } from 'react'
|
|||||||
|
|
||||||
import type { GekanatorAnswerLog,
|
import type { GekanatorAnswerLog,
|
||||||
GekanatorAnswerValue,
|
GekanatorAnswerValue,
|
||||||
|
GekanatorExtraQuestion,
|
||||||
GekanatorQuestion,
|
GekanatorQuestion,
|
||||||
StoredGekanatorQuestion } from '@/lib/gekanator'
|
StoredGekanatorQuestion } from '@/lib/gekanator'
|
||||||
import type { Post } from '@/types'
|
import type { Post } from '@/types'
|
||||||
@@ -31,6 +35,7 @@ type Phase =
|
|||||||
| 'end'
|
| 'end'
|
||||||
| 'review'
|
| 'review'
|
||||||
| 'question_suggestion'
|
| 'question_suggestion'
|
||||||
|
| 'extra_questions'
|
||||||
| 'learned'
|
| 'learned'
|
||||||
|
|
||||||
type AnswerOption = {
|
type AnswerOption = {
|
||||||
@@ -87,7 +92,10 @@ type StoredGekanatorGame = {
|
|||||||
gameSeed?: string
|
gameSeed?: string
|
||||||
questionSuggestion: string
|
questionSuggestion: string
|
||||||
questionSuggestionAnswer: GekanatorAnswerValue
|
questionSuggestionAnswer: GekanatorAnswerValue
|
||||||
questionSuggestionCount?: number }
|
questionSuggestionCount?: number
|
||||||
|
extraQuestions?: GekanatorExtraQuestion[]
|
||||||
|
extraQuestionAnswers?: Record<string, GekanatorAnswerValue>
|
||||||
|
extraQuestionState?: 'idle' | 'loading' | 'ready' | 'empty' | 'saved' }
|
||||||
|
|
||||||
const answerOptions: AnswerOption[] = [
|
const answerOptions: AnswerOption[] = [
|
||||||
{ label: 'はい', value: 'yes' },
|
{ label: 'はい', value: 'yes' },
|
||||||
@@ -219,6 +227,16 @@ const loadStoredGame = (): StoredGekanatorGame | null => {
|
|||||||
const isStoredPhase = (phase: Phase): boolean => phase !== 'intro'
|
const isStoredPhase = (phase: Phase): boolean => phase !== 'intro'
|
||||||
|
|
||||||
|
|
||||||
|
const resettableExtraQuestionState = (): {
|
||||||
|
extraQuestions: GekanatorExtraQuestion[]
|
||||||
|
extraQuestionAnswers: Record<string, GekanatorAnswerValue>
|
||||||
|
extraQuestionState: 'idle'
|
||||||
|
} => ({
|
||||||
|
extraQuestions: [],
|
||||||
|
extraQuestionAnswers: { },
|
||||||
|
extraQuestionState: 'idle' })
|
||||||
|
|
||||||
|
|
||||||
const deltaFor = (matched: boolean, answer: GekanatorAnswerValue): number => {
|
const deltaFor = (matched: boolean, answer: GekanatorAnswerValue): number => {
|
||||||
switch (answer)
|
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 = (
|
const answerWeightFor = (
|
||||||
questionId: string,
|
questionId: string,
|
||||||
softenedQuestionIds: Set<string>,
|
softenedQuestionIds: Set<string>,
|
||||||
@@ -277,10 +344,11 @@ const recalculateScores = ({
|
|||||||
|
|
||||||
const weight = answerWeightFor (answer.questionId, softenedQuestionIds)
|
const weight = answerWeightFor (answer.questionId, softenedQuestionIds)
|
||||||
posts.forEach (post => {
|
posts.forEach (post => {
|
||||||
|
const expected = expectedAnswerForQuestion (question, post)
|
||||||
nextScores.set (
|
nextScores.set (
|
||||||
post.id,
|
post.id,
|
||||||
(nextScores.get (post.id) ?? 0)
|
(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)
|
switch (answer.answer)
|
||||||
{
|
{
|
||||||
case 'yes':
|
case 'yes':
|
||||||
return question.test (post)
|
case 'no': {
|
||||||
case 'no':
|
const expected = expectedAnswerForQuestion (question, post)
|
||||||
return !(question.test (post))
|
return expected === null || expected === 'unknown' || expected === answer.answer
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -378,20 +447,19 @@ const previewAnswer = ({
|
|||||||
answer: GekanatorAnswerValue
|
answer: GekanatorAnswerValue
|
||||||
}): AnswerPreview => {
|
}): AnswerPreview => {
|
||||||
const hardFilteredPosts =
|
const hardFilteredPosts =
|
||||||
answer === 'yes'
|
answer === 'unknown'
|
||||||
? posts.filter (post => question.test (post))
|
? posts
|
||||||
: answer === 'no'
|
: posts.filter (post => expectedAnswerForQuestion (question, post) === answer)
|
||||||
? posts.filter (post => !(question.test (post)))
|
|
||||||
: posts
|
|
||||||
const nextPosts =
|
const nextPosts =
|
||||||
(answer === 'yes' || answer === 'no') && hardFilteredPosts.length > 0
|
answer !== 'unknown' && hardFilteredPosts.length > 0
|
||||||
? hardFilteredPosts
|
? hardFilteredPosts
|
||||||
: posts
|
: posts
|
||||||
const nextScores = new Map (scores)
|
const nextScores = new Map (scores)
|
||||||
nextPosts.forEach (post => {
|
nextPosts.forEach (post => {
|
||||||
|
const expected = expectedAnswerForQuestion (question, post)
|
||||||
nextScores.set (
|
nextScores.set (
|
||||||
post.id,
|
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)
|
const confidences = confidencesFor (nextPosts, nextScores)
|
||||||
@@ -497,6 +565,8 @@ const sameConditionValue = (
|
|||||||
return String (condition.length)
|
return String (condition.length)
|
||||||
case 'title-has-ascii':
|
case 'title-has-ascii':
|
||||||
return ''
|
return ''
|
||||||
|
case 'post-similarity':
|
||||||
|
return `${ condition.postId }:${ condition.answer }:${ condition.threshold }`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -757,16 +827,13 @@ const PostMiniCard: FC<{ post: Post }> = ({ post }) => (
|
|||||||
const expectedAnswerFor = (
|
const expectedAnswerFor = (
|
||||||
question: GekanatorQuestion | undefined,
|
question: GekanatorQuestion | undefined,
|
||||||
correctPost: Post | null,
|
correctPost: Post | null,
|
||||||
): GekanatorAnswerValue | null => {
|
): GekanatorAnswerValue | null =>
|
||||||
if (!(question) || !(correctPost))
|
expectedAnswerForQuestion (question, correctPost)
|
||||||
return null
|
|
||||||
|
|
||||||
return question.test (correctPost) ? 'yes' : 'no'
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const GekanatorPage: FC = () => {
|
const GekanatorPage: FC = () => {
|
||||||
const storedGame = useMemo (loadStoredGame, [])
|
const storedGame = useMemo (loadStoredGame, [])
|
||||||
|
const queryClient = useQueryClient ()
|
||||||
const [gameSeed, setGameSeed] = useState (
|
const [gameSeed, setGameSeed] = useState (
|
||||||
storedGame?.gameSeed ?? createGameSeed ())
|
storedGame?.gameSeed ?? createGameSeed ())
|
||||||
const [phase, setPhase] = useState<Phase> (storedGame?.phase ?? 'intro')
|
const [phase, setPhase] = useState<Phase> (storedGame?.phase ?? 'intro')
|
||||||
@@ -810,6 +877,14 @@ const GekanatorPage: FC = () => {
|
|||||||
useState<GekanatorAnswerValue> (storedGame?.questionSuggestionAnswer ?? 'yes')
|
useState<GekanatorAnswerValue> (storedGame?.questionSuggestionAnswer ?? 'yes')
|
||||||
const [questionSuggestionCount, setQuestionSuggestionCount] = useState (
|
const [questionSuggestionCount, setQuestionSuggestionCount] = useState (
|
||||||
storedGame?.questionSuggestionCount ?? 0)
|
storedGame?.questionSuggestionCount ?? 0)
|
||||||
|
const [extraQuestions, setExtraQuestions] = useState<GekanatorExtraQuestion[]> (
|
||||||
|
storedGame?.extraQuestions ?? [])
|
||||||
|
const [extraQuestionAnswers, setExtraQuestionAnswers] =
|
||||||
|
useState<Record<string, GekanatorAnswerValue>> (
|
||||||
|
storedGame?.extraQuestionAnswers ?? { })
|
||||||
|
const [extraQuestionState, setExtraQuestionState] = useState<
|
||||||
|
'idle' | 'loading' | 'ready' | 'empty' | 'saved'
|
||||||
|
> (storedGame?.extraQuestionState ?? 'idle')
|
||||||
const [history, setHistory] = useState<GameSnapshot[]> ([])
|
const [history, setHistory] = useState<GameSnapshot[]> ([])
|
||||||
|
|
||||||
const { data: posts = [], isLoading, error } = useQuery ({
|
const { data: posts = [], isLoading, error } = useQuery ({
|
||||||
@@ -876,7 +951,10 @@ const GekanatorPage: FC = () => {
|
|||||||
gameSeed,
|
gameSeed,
|
||||||
questionSuggestion,
|
questionSuggestion,
|
||||||
questionSuggestionAnswer,
|
questionSuggestionAnswer,
|
||||||
questionSuggestionCount }
|
questionSuggestionCount,
|
||||||
|
extraQuestions,
|
||||||
|
extraQuestionAnswers,
|
||||||
|
extraQuestionState }
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -908,7 +986,10 @@ const GekanatorPage: FC = () => {
|
|||||||
gameSeed,
|
gameSeed,
|
||||||
questionSuggestion,
|
questionSuggestion,
|
||||||
questionSuggestionAnswer,
|
questionSuggestionAnswer,
|
||||||
questionSuggestionCount])
|
questionSuggestionCount,
|
||||||
|
extraQuestions,
|
||||||
|
extraQuestionAnswers,
|
||||||
|
extraQuestionState])
|
||||||
|
|
||||||
const eligiblePosts = useMemo (
|
const eligiblePosts = useMemo (
|
||||||
() => candidatePostsFor ({
|
() => candidatePostsFor ({
|
||||||
@@ -985,10 +1066,25 @@ const GekanatorPage: FC = () => {
|
|||||||
setQuestionSuggestion ('')
|
setQuestionSuggestion ('')
|
||||||
setQuestionSuggestionAnswer ('yes')
|
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 = () => {
|
const reset = () => {
|
||||||
clearStoredGame ()
|
clearStoredGame ()
|
||||||
saveMutation.reset ()
|
saveMutation.reset ()
|
||||||
|
questionSuggestionMutation.reset ()
|
||||||
setPhase ('intro')
|
setPhase ('intro')
|
||||||
setScores (new Map ())
|
setScores (new Map ())
|
||||||
setAnswers ([])
|
setAnswers ([])
|
||||||
@@ -1010,6 +1106,7 @@ const GekanatorPage: FC = () => {
|
|||||||
setQuestionSuggestion ('')
|
setQuestionSuggestion ('')
|
||||||
setQuestionSuggestionAnswer ('yes')
|
setQuestionSuggestionAnswer ('yes')
|
||||||
setQuestionSuggestionCount (0)
|
setQuestionSuggestionCount (0)
|
||||||
|
resetExtraQuestionState ()
|
||||||
setHistory ([])
|
setHistory ([])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1188,6 +1285,7 @@ const GekanatorPage: FC = () => {
|
|||||||
|
|
||||||
saveMutation.reset ()
|
saveMutation.reset ()
|
||||||
questionSuggestionMutation.reset ()
|
questionSuggestionMutation.reset ()
|
||||||
|
resetExtraQuestionState ()
|
||||||
setSaved (false)
|
setSaved (false)
|
||||||
setSavedGameId (null)
|
setSavedGameId (null)
|
||||||
setReviewGuessedPostId (guessedPostId)
|
setReviewGuessedPostId (guessedPostId)
|
||||||
@@ -1203,6 +1301,7 @@ const GekanatorPage: FC = () => {
|
|||||||
|
|
||||||
saveMutation.reset ()
|
saveMutation.reset ()
|
||||||
questionSuggestionMutation.reset ()
|
questionSuggestionMutation.reset ()
|
||||||
|
resetExtraQuestionState ()
|
||||||
setSaved (false)
|
setSaved (false)
|
||||||
setSavedGameId (null)
|
setSavedGameId (null)
|
||||||
setSelectingCorrectPost (false)
|
setSelectingCorrectPost (false)
|
||||||
@@ -1236,6 +1335,7 @@ const GekanatorPage: FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const saveAndLearn = () => {
|
const saveAndLearn = () => {
|
||||||
|
resetExtraQuestionState ()
|
||||||
saveReviewedResult (() => setPhase ('learned'))
|
saveReviewedResult (() => setPhase ('learned'))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1344,6 +1444,7 @@ const GekanatorPage: FC = () => {
|
|||||||
const correctAnswerAt = (index: number, value: GekanatorAnswerValue) => {
|
const correctAnswerAt = (index: number, value: GekanatorAnswerValue) => {
|
||||||
setSaved (false)
|
setSaved (false)
|
||||||
setSavedGameId (null)
|
setSavedGameId (null)
|
||||||
|
resetExtraQuestionState ()
|
||||||
setAnswers (answers.map ((answer, i) =>
|
setAnswers (answers.map ((answer, i) =>
|
||||||
i === index ? { ...answer, answer: value } : answer))
|
i === index ? { ...answer, answer: value } : answer))
|
||||||
}
|
}
|
||||||
@@ -1353,6 +1454,7 @@ const GekanatorPage: FC = () => {
|
|||||||
{
|
{
|
||||||
setSaved (false)
|
setSaved (false)
|
||||||
setSavedGameId (null)
|
setSavedGameId (null)
|
||||||
|
resetExtraQuestionState ()
|
||||||
setReviewCorrectPostId (post.id)
|
setReviewCorrectPostId (post.id)
|
||||||
setSelectingCorrectPost (false)
|
setSelectingCorrectPost (false)
|
||||||
setSearch ('')
|
setSearch ('')
|
||||||
@@ -1383,6 +1485,60 @@ const GekanatorPage: FC = () => {
|
|||||||
})
|
})
|
||||||
.slice (0, 20)
|
.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 =
|
const dialogue =
|
||||||
phase === 'learned' && resultWon
|
phase === 'learned' && resultWon
|
||||||
? <>グカカカカwwwww <ruby>洗澡鹿<rt>シーザオグカ</rt></ruby>は何でもお見通し!</>
|
? <>グカカカカwwwww <ruby>洗澡鹿<rt>シーザオグカ</rt></ruby>は何でもお見通し!</>
|
||||||
@@ -1650,6 +1806,18 @@ const GekanatorPage: FC = () => {
|
|||||||
onClick={() => setPhase ('question_suggestion')}>
|
onClick={() => setPhase ('question_suggestion')}>
|
||||||
質問を追加
|
質問を追加
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded border border-yellow-300 px-4 py-2
|
||||||
|
hover:bg-yellow-100 dark:border-red-700
|
||||||
|
dark:hover:bg-red-900 disabled:opacity-50"
|
||||||
|
disabled={reviewCorrectPostId === null
|
||||||
|
|| saveMutation.isPending
|
||||||
|
|| extraQuestionState === 'loading'
|
||||||
|
|| extraQuestionAnswersMutation.isPending}
|
||||||
|
onClick={startExtraQuestions}>
|
||||||
|
追加で質問に答へる
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>)}
|
</div>)}
|
||||||
|
|
||||||
@@ -1843,9 +2011,81 @@ const GekanatorPage: FC = () => {
|
|||||||
</p>)}
|
</p>)}
|
||||||
</div>)}
|
</div>)}
|
||||||
|
|
||||||
|
{phase === 'extra_questions' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-neutral-500">追加学習</p>
|
||||||
|
<p className="text-xl font-bold">追加で 2 問まで答へてください。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{extraQuestionState === 'loading' && (
|
||||||
|
<p>追加質問を読み込んでゐます...</p>)}
|
||||||
|
|
||||||
|
{extraQuestionState === 'empty' && (
|
||||||
|
<p>追加で学習できる質問はありませんでした。</p>)}
|
||||||
|
|
||||||
|
{extraQuestionState === 'ready' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{extraQuestions.map ((question, index) => (
|
||||||
|
<div
|
||||||
|
key={question.id}
|
||||||
|
className="rounded border border-yellow-100 p-3
|
||||||
|
dark:border-red-900">
|
||||||
|
<div className="text-sm text-neutral-600 dark:text-neutral-300">
|
||||||
|
追加質問 {index + 1}
|
||||||
|
</div>
|
||||||
|
<div className="font-bold">{question.text}</div>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
{answerOptions.map (option => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
className={cn (
|
||||||
|
'rounded border px-3 py-2',
|
||||||
|
extraQuestionAnswers[String (question.id)] === option.value
|
||||||
|
? 'border-pink-600 bg-pink-600 text-white'
|
||||||
|
: 'border-yellow-300 hover:bg-yellow-100 dark:border-red-700 dark:hover:bg-red-900')}
|
||||||
|
onClick={() => answerExtraQuestion (question.id, option.value)}>
|
||||||
|
{option.label}
|
||||||
|
</button>))}
|
||||||
|
</div>
|
||||||
|
</div>))}
|
||||||
|
</div>)}
|
||||||
|
|
||||||
|
{extraQuestionAnswersMutation.isError && (
|
||||||
|
<p className="text-sm text-red-600">
|
||||||
|
学習内容を保存できませんでした。通信状態を確認してもう一度試して。
|
||||||
|
</p>)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded border border-neutral-300 px-4 py-2
|
||||||
|
hover:bg-neutral-100 dark:border-neutral-700
|
||||||
|
dark:hover:bg-red-900"
|
||||||
|
disabled={extraQuestionAnswersMutation.isPending}
|
||||||
|
onClick={() => setPhase ('end')}>
|
||||||
|
戻る
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded bg-pink-600 px-4 py-2 font-bold text-white
|
||||||
|
hover:bg-pink-500 disabled:opacity-50"
|
||||||
|
disabled={
|
||||||
|
extraQuestionState !== 'ready'
|
||||||
|
|| extraQuestionAnswersMutation.isPending
|
||||||
|
|| extraQuestions.some (
|
||||||
|
question => !(extraQuestionAnswers[String (question.id)]))
|
||||||
|
}
|
||||||
|
onClick={saveExtraQuestions}>
|
||||||
|
学習する
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>)}
|
||||||
|
|
||||||
{phase === 'learned' && (
|
{phase === 'learned' && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p>覚えたよ.次はもっと見通す.</p>
|
<p>{extraQuestionState === 'saved' ? '学習しました。' : '覚えたよ.次はもっと見通す.'}</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="rounded bg-pink-600 px-4 py-2 font-bold text-white
|
className="rounded bg-pink-600 px-4 py-2 font-bold text-white
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする