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