グカネータ / 質問パターン見直し (#41) #365
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
dependent: :delete_all,
|
||||
inverse_of: :correct_post
|
||||
has_many :gekanator_question_examples, dependent: :delete_all
|
||||
|
||||
has_many :parent_post_implications,
|
||||
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
|
||||
|
||||
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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
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"
|
||||
|
||||
+99
-11
@@ -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 = <T extends string | number> (values: T[]): Map<T, number> => {
|
||||
const counts = new Map<T, number> ()
|
||||
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<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[] => {
|
||||
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 })) })
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, GekanatorAnswerValue>
|
||||
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<string, GekanatorAnswerValue>
|
||||
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<string>,
|
||||
@@ -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<Phase> (storedGame?.phase ?? 'intro')
|
||||
@@ -810,6 +877,14 @@ const GekanatorPage: FC = () => {
|
||||
useState<GekanatorAnswerValue> (storedGame?.questionSuggestionAnswer ?? 'yes')
|
||||
const [questionSuggestionCount, setQuestionSuggestionCount] = useState (
|
||||
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 { 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 <ruby>洗澡鹿<rt>シーザオグカ</rt></ruby>は何でもお見通し!</>
|
||||
@@ -1650,6 +1806,18 @@ const GekanatorPage: FC = () => {
|
||||
onClick={() => setPhase ('question_suggestion')}>
|
||||
質問を追加
|
||||
</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>)}
|
||||
|
||||
@@ -1843,9 +2011,81 @@ const GekanatorPage: FC = () => {
|
||||
</p>)}
|
||||
</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' && (
|
||||
<div className="space-y-3">
|
||||
<p>覚えたよ.次はもっと見通す.</p>
|
||||
<p>{extraQuestionState === 'saved' ? '学習しました。' : '覚えたよ.次はもっと見通す.'}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded bg-pink-600 px-4 py-2 font-bold text-white
|
||||
|
||||
新しい課題から参照
ユーザをブロックする