diff --git a/backend/app/controllers/gekanator_question_suggestions_controller.rb b/backend/app/controllers/gekanator_question_suggestions_controller.rb new file mode 100644 index 0000000..bcd1c6a --- /dev/null +++ b/backend/app/controllers/gekanator_question_suggestions_controller.rb @@ -0,0 +1,19 @@ +class GekanatorQuestionSuggestionsController < ApplicationController + def create + return head :not_found unless current_user&.admin? + + game = GekanatorGame.find_by(id: params.require(:gekanator_game_id)) + return head :not_found unless game + + suggestion = GekanatorQuestionSuggestion.new( + gekanator_game: game, + user: current_user, + question_text: params.require(:question_text)) + + if suggestion.save + render json: { id: suggestion.id }, status: :created + else + render_validation_error suggestion + end + end +end diff --git a/backend/app/models/gekanator_game.rb b/backend/app/models/gekanator_game.rb index 2808be4..ae10c83 100644 --- a/backend/app/models/gekanator_game.rb +++ b/backend/app/models/gekanator_game.rb @@ -2,6 +2,9 @@ class GekanatorGame < ApplicationRecord belongs_to :user belongs_to :guessed_post, class_name: 'Post' belongs_to :correct_post, class_name: 'Post' + has_many :question_suggestions, + class_name: 'GekanatorQuestionSuggestion', + 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_suggestion.rb b/backend/app/models/gekanator_question_suggestion.rb new file mode 100644 index 0000000..9034ee8 --- /dev/null +++ b/backend/app/models/gekanator_question_suggestion.rb @@ -0,0 +1,21 @@ +class GekanatorQuestionSuggestion < ApplicationRecord + MAX_QUESTIONS_PER_GAME = 1 + + belongs_to :gekanator_game + belongs_to :user + + validates :question_text, presence: true, length: { maximum: 1000 } + validates :processed, inclusion: { in: [true, false] } + validate :question_suggestion_limit_per_game, on: :create + + private + + def question_suggestion_limit_per_game + return if gekanator_game_id.blank? + + count = GekanatorQuestionSuggestion.where(gekanator_game_id:).count + if count >= MAX_QUESTIONS_PER_GAME + errors.add(:base, '質問追加数を超えてゐます.') + end + end +end diff --git a/backend/config/routes.rb b/backend/config/routes.rb index 0c76bb1..a0ec2c8 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -65,6 +65,9 @@ Rails.application.routes.draw do namespace :gekanator do resources :games, only: [:create], controller: '/gekanator_games' + resources :question_suggestions, + only: [:create], + controller: '/gekanator_question_suggestions' end resources :users, only: [:create, :update] do diff --git a/backend/db/migrate/20260608000000_create_gekanator_question_suggestions.rb b/backend/db/migrate/20260608000000_create_gekanator_question_suggestions.rb new file mode 100644 index 0000000..57b38bb --- /dev/null +++ b/backend/db/migrate/20260608000000_create_gekanator_question_suggestions.rb @@ -0,0 +1,14 @@ +class CreateGekanatorQuestionSuggestions < ActiveRecord::Migration[8.0] + def change + create_table :gekanator_question_suggestions do |t| + t.references :gekanator_game, + null: false, + foreign_key: { on_delete: :cascade } + t.references :user, null: false, foreign_key: true + t.text :question_text, null: false + t.boolean :processed, null: false, default: false + + t.timestamps + end + end +end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index ee3d86a..986465c 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_07_000000) do +ActiveRecord::Schema[8.0].define(version: 2026_06_08_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 @@ -63,6 +63,17 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_07_000000) do t.check_constraint "`question_count` >= 0", name: "chk_gekanator_games_question_count_nonnegative" 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 + t.text "question_text", null: false + t.boolean "processed", default: false, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["gekanator_game_id"], name: "index_gekanator_question_suggestions_on_gekanator_game_id" + t.index ["user_id"], name: "index_gekanator_question_suggestions_on_user_id" + end + create_table "ip_addresses", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.binary "ip_address", limit: 16, null: false t.datetime "banned_at" @@ -496,6 +507,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_07_000000) 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_suggestions", "gekanator_games", on_delete: :cascade + add_foreign_key "gekanator_question_suggestions", "users" add_foreign_key "material_versions", "materials" add_foreign_key "material_versions", "materials", column: "parent_id" add_foreign_key "material_versions", "tags" diff --git a/frontend/src/lib/gekanator.ts b/frontend/src/lib/gekanator.ts index f53b9a1..16e7940 100644 --- a/frontend/src/lib/gekanator.ts +++ b/frontend/src/lib/gekanator.ts @@ -11,9 +11,10 @@ export type GekanatorAnswerValue = | 'unknown' export type GekanatorAnswerLog = { - questionId: string - questionText: string - answer: GekanatorAnswerValue } + questionId: string + questionText: string + answer: GekanatorAnswerValue + originalAnswer: GekanatorAnswerValue } export type GekanatorQuestionKind = | 'tag' @@ -293,4 +294,17 @@ export const saveGekanatorGame = async ({ answers: answers.map (answer => ({ question_id: answer.questionId, question_text: answer.questionText, - answer: answer.answer })) }) + answer: answer.answer, + original_answer: answer.originalAnswer })) }) + + +export const saveGekanatorQuestionSuggestion = async ({ + gekanatorGameId, + questionText, +}: { + gekanatorGameId: number + questionText: string +}): Promise<{ id: number }> => + await apiPost ('/gekanator/question_suggestions', { + gekanator_game_id: gekanatorGameId, + question_text: questionText }) diff --git a/frontend/src/pages/GekanatorPage.tsx b/frontend/src/pages/GekanatorPage.tsx index 4d7f9d5..c600993 100644 --- a/frontend/src/pages/GekanatorPage.tsx +++ b/frontend/src/pages/GekanatorPage.tsx @@ -7,7 +7,8 @@ import MainArea from '@/components/layout/MainArea' import { SITE_TITLE } from '@/config' import { buildGekanatorQuestions, fetchGekanatorPosts, - saveGekanatorGame } from '@/lib/gekanator' + saveGekanatorGame, + saveGekanatorQuestionSuggestion } from '@/lib/gekanator' import { gekanatorKeys } from '@/lib/queryKeys' import { cn } from '@/lib/utils' @@ -25,6 +26,7 @@ type Phase = | 'continue' | 'end' | 'review' + | 'question_suggestion' | 'learned' type AnswerOption = { @@ -66,6 +68,9 @@ const answerOptions: AnswerOption[] = [ { label: 'たぶんいいえ', value: 'probably_no' }, { label: 'わからない', value: 'unknown' }] +const answerLabelFor = (value: GekanatorAnswerValue): string => + answerOptions.find (option => option.value === value)?.label ?? value + const questionsBetweenGuesses = 25 const minQuestionsBeforeCertainGuess = 5 const certainGuessPercent = 99.5 @@ -313,10 +318,14 @@ const chooseQuestion = ({ const scoredPosts = posts .map (post => ({ post, score: scores.get (post.id) ?? 0 })) .sort ((a, b) => b.score - a.score) - const topScore = scoredPosts[0]?.score ?? 0 - const nearTopCandidates = scoredPosts.filter (item => topScore - item.score <= 2) - const focusCandidates = - nearTopCandidates.length >= 2 && nearTopCandidates.length <= 8 ? nearTopCandidates : [] + const maxScore = scoredPosts[0]?.score ?? 0 + const weightedPosts = scoredPosts.map (item => ({ + ...item, + weight: Math.exp ((item.score - maxScore) / confidenceTemperature) })) + const totalWeight = + weightedPosts.reduce ((sum, item) => sum + item.weight, 0) || 1 + const normalisedWeightedPosts = + weightedPosts.map (item => ({ ...item, weight: item.weight / totalWeight })) const signatureFor = ( question: GekanatorQuestion, @@ -342,9 +351,9 @@ const chooseQuestion = ({ } const rank = ( - questionsToRank: GekanatorQuestion[], - candidates: { post: Post; score: number }[], - focusCandidates: { post: Post; score: number }[], + questionsToRank: GekanatorQuestion[], + candidates: { post: Post; score: number }[], + weightedCandidates: { post: Post; score: number; weight: number }[], ) => { const redundant = redundantSignatures (candidates) const nonTagCount = @@ -361,49 +370,36 @@ const chooseQuestion = ({ if (yes === 0 || no === 0) return null - const focusSignature = signatureFor (question, focusCandidates) - const focusYes = focusSignature.split ('').filter (value => value === '1').length - const focusNo = focusCandidates.length - focusYes - const separatesFocus = focusCandidates.length >= 2 && focusYes > 0 && focusNo > 0 + const yesWeight = weightedCandidates.reduce ( + (sum, item) => sum + (question.test (item.post) ? item.weight : 0), + 0) + const noWeight = 1 - yesWeight + if (yesWeight <= 0 || noWeight <= 0) + return null - const splitScore = Math.abs (candidates.length / 2 - yes) - const focusSplitScore = focusCandidates.length >= 2 - ? Math.abs (focusCandidates.length / 2 - focusYes) - : 0 - const tagPenalty = question.kind === 'tag' && nonTagCount < 4 ? 12 : 0 + const weightedSplitScore = Math.abs (.5 - yesWeight) + const unweightedSplitScore = Math.abs (candidates.length / 2 - yes) / candidates.length + const tagPenalty = question.kind === 'tag' && nonTagCount < 4 ? .12 : 0 const minSide = candidates.length < 10 ? 1 : Math.max (3, candidates.length * .08) - const narrowPenalty = yes < minSide || no < minSide ? candidates.length : 0 - const focusPenalty = - focusCandidates.length >= 2 && !(separatesFocus) ? candidates.length * 10 : 0 + const narrowPenalty = yes < minSide || no < minSide ? .15 : 0 return { question, - score: focusPenalty - + focusSplitScore * 20 - + splitScore + score: weightedSplitScore * 100 + + unweightedSplitScore * 8 + tagPenalty + narrowPenalty, - narrow: narrowPenalty > 0, - separatesFocus } + narrow: narrowPenalty > 0 } }) .filter ((item): item is { - question: GekanatorQuestion - score: number - narrow: boolean - separatesFocus: boolean } => item !== null && Number.isFinite (item.score)) + question: GekanatorQuestion + score: number + narrow: boolean } => item !== null && Number.isFinite (item.score)) .sort ((a, b) => a.score - b.score) } const unansweredQuestions = questions.filter (question => !(askedIds.has (question.id))) - const ranked = rank (unansweredQuestions, scoredPosts, focusCandidates) - - if (focusCandidates.length >= 2) - return ( - ranked.find (item => item.separatesFocus && !(item.narrow)) - ?? ranked.find (item => item.separatesFocus) - ?? ranked.find (item => !(item.narrow)) - ?? ranked[0] - )?.question ?? null + const ranked = rank (unansweredQuestions, scoredPosts, normalisedWeightedPosts) return (ranked.find (item => !(item.narrow)) ?? ranked[0])?.question ?? null } @@ -434,6 +430,17 @@ 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' +} + + const GekanatorPage: FC = () => { const [phase, setPhase] = useState ('intro') const [scores, setScores] = useState> (new Map ()) @@ -451,6 +458,8 @@ const GekanatorPage: FC = () => { const [activeGuessId, setActiveGuessId] = useState (null) const [reviewGuessedPostId, setReviewGuessedPostId] = useState (null) const [reviewCorrectPostId, setReviewCorrectPostId] = useState (null) + const [savedGameId, setSavedGameId] = useState (null) + const [questionSuggestion, setQuestionSuggestion] = useState ('') const [history, setHistory] = useState ([]) const { data: posts = [], isLoading, error } = useQuery ({ @@ -471,6 +480,9 @@ const GekanatorPage: FC = () => { const scoringQuestions = useMemo (() => { return mergeQuestions ([...questions, ...askedQuestionBank]) }, [questions, askedQuestionBank]) + const scoringQuestionById = useMemo ( + () => new Map (scoringQuestions.map (question => [question.id, question])), + [scoringQuestions]) const questionsSinceLastGuess = answers.length - lastGuessQuestionCount const nonRejectedPosts = useMemo ( () => posts.filter (post => !(rejectedPostIds.has (post.id))), @@ -510,10 +522,16 @@ const GekanatorPage: FC = () => { posts.find (post => post.id === reviewCorrectPostId) ?? null const saveMutation = useMutation ({ mutationFn: saveGekanatorGame, - onSuccess: () => { + onSuccess: (data, variables) => { setSaved (true) - setResultWon (reviewGuessedPostId === reviewCorrectPostId) - setPhase ('learned') + setSavedGameId (data.id) + setResultWon (variables.guessedPostId === variables.correctPostId) + }}) + const questionSuggestionMutation = useMutation ({ + mutationFn: saveGekanatorQuestionSuggestion, + onSuccess: () => { + setQuestionSuggestion ('') + reset () }}) const reset = () => { @@ -534,6 +552,8 @@ const GekanatorPage: FC = () => { setActiveGuessId (null) setReviewGuessedPostId (null) setReviewCorrectPostId (null) + setSavedGameId (null) + setQuestionSuggestion ('') setHistory ([]) } @@ -637,9 +657,10 @@ const GekanatorPage: FC = () => { reviewGuessedPostId, reviewCorrectPostId }]) const nextAnswers = [...answers, { - questionId: currentQuestion.id, - questionText: currentQuestion.text, - answer: value }] + questionId: currentQuestion.id, + questionText: currentQuestion.text, + answer: value, + originalAnswer: value }] const nextAskedIds = new Set ([...askedIds, currentQuestion.id]) const nextAskedQuestionBank = [ ...askedQuestionBank.filter (question => question.id !== currentQuestion.id), @@ -705,6 +726,9 @@ const GekanatorPage: FC = () => { return saveMutation.reset () + questionSuggestionMutation.reset () + setSaved (false) + setSavedGameId (null) setReviewGuessedPostId (guessedPostId) setReviewCorrectPostId (correctPostId) setSearch ('') @@ -717,24 +741,51 @@ const GekanatorPage: FC = () => { return saveMutation.reset () + questionSuggestionMutation.reset () + setSaved (false) + setSavedGameId (null) setSelectingCorrectPost (false) setSearch ('') setPhase ('review') } - const saveReviewedResult = () => { + const saveReviewedResult = (onSuccess: (gameId: number) => void) => { if ( reviewGuessedPostId === null || reviewCorrectPostId === null || saveMutation.isPending - || saved ) return + if (savedGameId !== null) + { + onSuccess (savedGameId) + return + } + saveMutation.mutate ({ guessedPostId: reviewGuessedPostId, correctPostId: reviewCorrectPostId, - answers }) + answers }, + { onSuccess: data => onSuccess (data.id) }) + } + + const saveAndReset = () => { + saveReviewedResult (reset) + } + + const saveAndLearn = () => { + saveReviewedResult (() => setPhase ('learned')) + } + + const submitQuestionSuggestion = () => { + const questionText = questionSuggestion.trim () + if (!(questionText) || questionSuggestionMutation.isPending) + return + + saveReviewedResult (gekanatorGameId => { + questionSuggestionMutation.mutate ({ gekanatorGameId, questionText }) + }) } const rejectGuess = () => { @@ -811,6 +862,8 @@ const GekanatorPage: FC = () => { } const correctAnswerAt = (index: number, value: GekanatorAnswerValue) => { + setSaved (false) + setSavedGameId (null) setAnswers (answers.map ((answer, i) => i === index ? { ...answer, answer: value } : answer)) } @@ -818,6 +871,8 @@ const GekanatorPage: FC = () => { const selectCorrectPost = (post: Post) => { if (phase === 'review') { + setSaved (false) + setSavedGameId (null) setReviewCorrectPostId (post.id) setSelectingCorrectPost (false) setSearch ('') @@ -1006,7 +1061,7 @@ const GekanatorPage: FC = () => { {saveMutation.isError && (

- 学習ログの保存に失敗しました。もう一度試せます。 + 記録できませんでした。通信状態を確認してもう一度試して。

)} )} @@ -1045,7 +1100,7 @@ const GekanatorPage: FC = () => {

ゲーム終了

-

今回の結果を保存できます。

+

グカカカカwwwww

{reviewGuessedPost && ( @@ -1076,7 +1131,7 @@ const GekanatorPage: FC = () => { {saveMutation.isError && (

- 学習ログの保存に失敗しました。もう一度試せます。 + 記録できませんでした。通信状態を確認してもう一度試して。

)}
@@ -1084,20 +1139,27 @@ const GekanatorPage: FC = () => { type="button" className="rounded bg-pink-600 px-4 py-2 font-bold text-white hover:bg-pink-500 disabled:opacity-50" - disabled={ - reviewCorrectPostId === null || saveMutation.isPending || saved - } - onClick={saveReviewedResult}> - 保存 + disabled={reviewCorrectPostId === null || saveMutation.isPending} + onClick={saveAndReset}> + もう一度 +
)} @@ -1133,29 +1195,49 @@ const GekanatorPage: FC = () => {
質問と回答
- {answers.map ((answer, index) => ( -
-
- 質問 {index + 1} -
-
{answer.questionText}
- -
))} + {answers.map ((answer, index) => { + const expectedAnswer = expectedAnswerFor ( + scoringQuestionById.get (answer.questionId), + reviewCorrectPost) + + return ( +
+
+ 質問 {index + 1} +
+
{answer.questionText}
+
+
+ グカネータ判定: + {expectedAnswer ? answerLabelFor (expectedAnswer) : '不明'} +
+
+ 実際の回答: + {answerLabelFor (answer.originalAnswer)} +
+ +
+
) + })}
@@ -1166,7 +1248,7 @@ const GekanatorPage: FC = () => { {saveMutation.isError && (

- 学習ログの保存に失敗しました。もう一度試せます。 + 記録できませんでした。通信状態を確認してもう一度試して。

)}
@@ -1175,10 +1257,12 @@ const GekanatorPage: FC = () => { className="rounded bg-pink-600 px-4 py-2 font-bold text-white hover:bg-pink-500 disabled:opacity-50" disabled={ - reviewCorrectPostId === null || saveMutation.isPending || saved + reviewCorrectPostId === null + || saveMutation.isPending + || questionSuggestionMutation.isPending } - onClick={saveReviewedResult}> - 保存 + onClick={saveAndLearn}> + 完了
)} + {phase === 'question_suggestion' && ( +
+
+

質問追加

+

どんな質問なら見分けられさう?

+
+