diff --git a/backend/app/controllers/gekanator_questions_controller.rb b/backend/app/controllers/gekanator_questions_controller.rb index 407b0f9..b586aa2 100644 --- a/backend/app/controllers/gekanator_questions_controller.rb +++ b/backend/app/controllers/gekanator_questions_controller.rb @@ -16,11 +16,12 @@ class GekanatorQuestionsController < ApplicationController private def question_json question + condition = condition_json(question.condition).deep_symbolize_keys json = { - id: question_id_for(question), - text: question.text, + id: question_id_for(question, condition), + text: question_text_for(question, condition), kind: question.kind, - condition: condition_json(question.condition), + condition: condition, source: question.source, priority_weight: question.priority_weight } @@ -30,9 +31,7 @@ class GekanatorQuestionsController < ApplicationController json end - def question_id_for question - condition = condition_json(question.condition).deep_symbolize_keys - + def question_id_for question, condition case condition[:type] when 'tag' "tag:#{ condition[:key] }" @@ -44,8 +43,10 @@ class GekanatorQuestionsController < ApplicationController "original-month:#{ condition[:month] }" when 'original-month-day' "original-month-day:#{ condition[:monthDay] || condition[:month_day] }" + when 'title-length-at-least' + "title:length-at-least:#{ condition[:length] }" when 'title-length-greater-than' - "title:length-greater-than:#{ condition[:length] }" + "title:length-at-least:#{ condition[:length].to_i + 1 }" when 'title-has-ascii' 'title:ascii' when 'post-similarity' @@ -62,9 +63,25 @@ class GekanatorQuestionsController < ApplicationController json['monthDay'] = json.delete('month_day') end + if json['type'] == 'title-length-greater-than' + json['type'] = 'title-length-at-least' + json['length'] = json['length'].to_i + 1 + end + json end + def question_text_for question, condition + return question.text unless question.kind == 'title' + + case condition[:type] + when 'title-length-at-least' + "タイトルは #{ condition[:length] } 文字以上?" + else + question.text + end + end + def example_answers_json question question .gekanator_question_examples diff --git a/frontend/src/lib/gekanator.ts b/frontend/src/lib/gekanator.ts index 252e79d..af7d4c6 100644 --- a/frontend/src/lib/gekanator.ts +++ b/frontend/src/lib/gekanator.ts @@ -35,6 +35,7 @@ export type GekanatorQuestionCondition = | { type: 'original-year'; year: number } | { type: 'original-month'; month: number } | { type: 'original-month-day'; monthDay: string } + | { type: 'title-length-at-least'; length: number } | { type: 'title-length-greater-than'; length: number } | { type: 'title-has-ascii' } | { @@ -44,6 +45,12 @@ export type GekanatorQuestionCondition = threshold: number } + +type NonPostSimilarityCondition = Exclude< + GekanatorQuestionCondition, + { type: 'post-similarity' } +> + export type GekanatorExtraQuestion = { id: number text: string @@ -70,10 +77,67 @@ export type GekanatorQuestion = { test: (post: Post) => boolean } +export const normalizeTitleLengthCondition = ( + condition: GekanatorQuestionCondition, +): GekanatorQuestionCondition => { + switch (condition.type) + { + case 'title-length-greater-than': + return { + type: 'title-length-at-least', + length: condition.length + 1 } + default: + return condition + } +} + + +export const titleLengthMinimumForCondition = ( + condition: GekanatorQuestionCondition, +): number | null => { + switch (condition.type) + { + case 'title-length-at-least': + return condition.length + case 'title-length-greater-than': + return condition.length + 1 + default: + return null + } +} + + +export const questionIdForCondition = ( + condition: NonPostSimilarityCondition, +): string => { + switch (condition.type) + { + case 'tag': + return `tag:${ condition.key }` + case 'source': + return `source:${ condition.host }` + case 'original-year': + return `original-year:${ condition.year }` + case 'original-month': + return `original-month:${ condition.month }` + case 'original-month-day': + return `original-month-day:${ condition.monthDay }` + case 'title-length-at-least': + case 'title-length-greater-than': + return `title:length-at-least:${ titleLengthMinimumForCondition (condition) }` + case 'title-has-ascii': + return 'title:ascii' + } +} + + const directExampleAnswerFor = ( question: StoredGekanatorQuestion, post: Post, ): GekanatorAnswerValue | null => { + if (question.kind !== 'post_similarity') + return null + const direct = question.exampleAnswers?.[String (post.id) as `${ number }`] if (direct) return direct @@ -222,6 +286,8 @@ const questionMatches = ( return originalMonthOf (post) === question.condition.month case 'original-month-day': return originalMonthDayOf (post) === question.condition.monthDay + case 'title-length-at-least': + return (post.title?.length ?? 0) >= question.condition.length case 'title-length-greater-than': return (post.title?.length ?? 0) > question.condition.length case 'title-has-ascii': @@ -250,6 +316,7 @@ export const expectedAnswerForQuestion = ( case 'original-year': case 'original-month': case 'original-month-day': + case 'title-length-at-least': case 'title-length-greater-than': case 'title-has-ascii': return questionMatches (post, question) ? 'yes' : 'no' @@ -261,20 +328,32 @@ export const expectedAnswerForQuestion = ( export const restoreGekanatorQuestion = ( question: StoredGekanatorQuestion, -): GekanatorQuestion => ({ - ...question, - source: question.source ?? 'default', - priorityWeight: question.priorityWeight ?? 1, - test: (post: Post) => questionMatches (post, question) }) +): GekanatorQuestion => { + const normalizedCondition = normalizeTitleLengthCondition (question.condition) + const normalizedQuestion = { + ...question, + id: normalizedCondition.type === 'title-length-at-least' + ? `title:length-at-least:${ normalizedCondition.length }` + : question.id, + condition: normalizedCondition, + source: question.source ?? 'default', + priorityWeight: question.priorityWeight ?? 1 } + + return { + ...normalizedQuestion, + test: (post: Post) => questionMatches (post, normalizedQuestion) } +} export const storeGekanatorQuestion = ( question: GekanatorQuestion, ): StoredGekanatorQuestion => ({ - id: question.id, + id: question.condition.type === 'title-length-greater-than' + ? `title:length-at-least:${ question.condition.length + 1 }` + : question.id, text: question.text, kind: question.kind, - condition: question.condition, + condition: normalizeTitleLengthCondition (question.condition), source: question.source, priorityWeight: question.priorityWeight, exampleAnswers: question.exampleAnswers }) @@ -402,15 +481,15 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => { const titleQuestions = [ { - id: 'title:long', - text: '題名が長めの投稿?', + id: `title:length-at-least:${ titleLengthMedian }`, + text: `タイトルは ${ titleLengthMedian } 文字以上?`, kind: 'title' as const, condition: { - type: 'title-length-greater-than' as const, + type: 'title-length-at-least' as const, length: titleLengthMedian }, source: 'default' as const, priorityWeight: 1, - test: (post: Post) => (post.title?.length ?? 0) > titleLengthMedian }, + test: (post: Post) => (post.title?.length ?? 0) >= titleLengthMedian }, { id: 'title:ascii', text: '題名に英数字が混じってゐる?', diff --git a/frontend/src/pages/GekanatorPage.tsx b/frontend/src/pages/GekanatorPage.tsx index 3dcf84f..1d0c87f 100644 --- a/frontend/src/pages/GekanatorPage.tsx +++ b/frontend/src/pages/GekanatorPage.tsx @@ -10,11 +10,13 @@ import { buildGekanatorQuestions, fetchGekanatorExtraQuestions, fetchGekanatorQuestions, fetchGekanatorPosts, + normalizeTitleLengthCondition, restoreGekanatorQuestion, saveGekanatorExtraQuestionAnswers, saveGekanatorGame, saveGekanatorQuestionSuggestion, - storeGekanatorQuestion } from '@/lib/gekanator' + storeGekanatorQuestion, + titleLengthMinimumForCondition } from '@/lib/gekanator' import { gekanatorKeys } from '@/lib/queryKeys' import { cn } from '@/lib/utils' @@ -23,6 +25,7 @@ import type { FC } from 'react' import type { GekanatorAnswerLog, GekanatorAnswerValue, GekanatorExtraQuestion, + GekanatorQuestionCondition, GekanatorQuestion, StoredGekanatorQuestion } from '@/lib/gekanator' import type { Post } from '@/types' @@ -144,6 +147,46 @@ const createGameSeed = (): string => { } +const normalizeStoredQuestionId = ( + questionId: string, + condition?: GekanatorQuestionCondition, +): string => { + if (condition?.type === 'title-length-greater-than') + return `title:length-at-least:${ condition.length + 1 }` + + if (questionId.startsWith ('title:length-greater-than:')) + { + const length = Number (questionId.split (':').pop ()) + if (Number.isInteger (length)) + return `title:length-at-least:${ length + 1 }` + } + + return questionId +} + + +const normalizeStoredGame = (game: StoredGekanatorGame): StoredGekanatorGame => ({ + ...game, + answers: game.answers.map (answer => ({ + ...answer, + questionId: normalizeStoredQuestionId ( + answer.questionId, + answer.questionCondition), + questionCondition: answer.questionCondition + ? normalizeTitleLengthCondition (answer.questionCondition) + : undefined })), + askedIds: game.askedIds.map (questionId => normalizeStoredQuestionId (questionId)), + softenedQuestionIds: game.softenedQuestionIds.map (questionId => + normalizeStoredQuestionId (questionId)), + askedQuestionBank: game.askedQuestionBank?.map (question => + ({ + ...question, + id: normalizeStoredQuestionId (question.id, question.condition), + condition: normalizeTitleLengthCondition (question.condition) })), + askedQuestionBankIds: game.askedQuestionBankIds?.map (questionId => + normalizeStoredQuestionId (questionId)) }) + + const sourcePriorityForMerge = (question: GekanatorQuestion): number => { switch (question.source) { @@ -214,7 +257,7 @@ const loadStoredGame = (): StoredGekanatorGame | null => { if (!(raw)) return null - return JSON.parse (raw) as StoredGekanatorGame + return normalizeStoredGame (JSON.parse (raw) as StoredGekanatorGame) } catch { @@ -548,6 +591,13 @@ const sameConditionValue = ( left: GekanatorQuestion['condition'], right: GekanatorQuestion['condition'], ): boolean => { + const leftTitleLength = titleLengthMinimumForCondition (left) + const rightTitleLength = titleLengthMinimumForCondition (right) + if (leftTitleLength !== null || rightTitleLength !== null) + return leftTitleLength !== null + && rightTitleLength !== null + && leftTitleLength === rightTitleLength + if (left.type !== right.type) return false @@ -564,12 +614,13 @@ const sameConditionValue = ( return String (condition.month) case 'original-month-day': return condition.monthDay - case 'title-length-greater-than': - return String (condition.length) case 'title-has-ascii': return '' case 'post-similarity': return `${ condition.postId }:${ condition.answer }:${ condition.threshold }` + case 'title-length-at-least': + case 'title-length-greater-than': + return String (titleLengthMinimumForCondition (condition) ?? '') } } @@ -591,6 +642,38 @@ const monthForCondition = ( } +const isTitleLengthContradiction = ( + candidate: GekanatorQuestion['condition'], + previous: GekanatorQuestion['condition'], + answer: GekanatorAnswerValue, +): boolean => { + const candidateLength = titleLengthMinimumForCondition (candidate) + const previousLength = titleLengthMinimumForCondition (previous) + if (candidateLength === null || previousLength === null) + return false + + switch (answer) + { + case 'yes': + return candidateLength <= previousLength + case 'no': + return candidateLength >= previousLength + default: + return false + } +} + + +const isQuestionRedundantAfterAnswers = ( + question: GekanatorQuestion, + answers: GekanatorAnswerLog[], +): boolean => answers.some (answer => { + const previous = answer.questionCondition + return previous !== undefined + && isTitleLengthContradiction (question.condition, previous, answer.answer) +}) + + const isMonthCrossMatch = ( candidate: GekanatorQuestion['condition'], previous: GekanatorQuestion['condition'], @@ -725,6 +808,9 @@ const chooseQuestion = ({ return questionsToRank .map (question => { + if (isQuestionRedundantAfterAnswers (question, answers)) + return null + const signature = signatureFor (question, candidates) if (redundant.has (signature)) return null