グカネータ作成 / テスト型バグ修正 (#41) #363

マージ済み
みてるぞ が 15 個のコミットを feature/041 から main へマージ 2026-06-10 23:43:51 +09:00
14個のファイルの変更606行の追加41行の削除
コミット 7fe7dbd909 の変更だけを表示してゐます - すべてのコミットを表示
+87
ファイルの表示
@@ -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
+8 -1
ファイルの表示
@@ -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
+28 -2
ファイルの表示
@@ -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
+3
ファイルの表示
@@ -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 }
+3 -2
ファイルの表示
@@ -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 }
+17
ファイルの表示
@@ -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
+1
ファイルの表示
@@ -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',
+48
ファイルの表示
@@ -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
+6 -1
ファイルの表示
@@ -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,
+19
ファイルの表示
@@ -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 })) })
+3 -1
ファイルの表示
@@ -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,
+262 -22
ファイルの表示
@@ -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