グカネータ / 質問パターン見直し (#41) #365
@@ -16,11 +16,12 @@ class GekanatorQuestionsController < ApplicationController
|
|||||||
private
|
private
|
||||||
|
|
||||||
def question_json question
|
def question_json question
|
||||||
|
condition = condition_json(question.condition).deep_symbolize_keys
|
||||||
json = {
|
json = {
|
||||||
id: question_id_for(question),
|
id: question_id_for(question, condition),
|
||||||
text: question.text,
|
text: question_text_for(question, condition),
|
||||||
kind: question.kind,
|
kind: question.kind,
|
||||||
condition: condition_json(question.condition),
|
condition: condition,
|
||||||
source: question.source,
|
source: question.source,
|
||||||
priority_weight: question.priority_weight
|
priority_weight: question.priority_weight
|
||||||
}
|
}
|
||||||
@@ -30,9 +31,7 @@ class GekanatorQuestionsController < ApplicationController
|
|||||||
json
|
json
|
||||||
end
|
end
|
||||||
|
|
||||||
def question_id_for question
|
def question_id_for question, condition
|
||||||
condition = condition_json(question.condition).deep_symbolize_keys
|
|
||||||
|
|
||||||
case condition[:type]
|
case condition[:type]
|
||||||
when 'tag'
|
when 'tag'
|
||||||
"tag:#{ condition[:key] }"
|
"tag:#{ condition[:key] }"
|
||||||
@@ -44,8 +43,10 @@ class GekanatorQuestionsController < ApplicationController
|
|||||||
"original-month:#{ condition[:month] }"
|
"original-month:#{ condition[:month] }"
|
||||||
when 'original-month-day'
|
when 'original-month-day'
|
||||||
"original-month-day:#{ condition[:monthDay] || condition[: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'
|
when 'title-length-greater-than'
|
||||||
"title:length-greater-than:#{ condition[:length] }"
|
"title:length-at-least:#{ condition[:length].to_i + 1 }"
|
||||||
when 'title-has-ascii'
|
when 'title-has-ascii'
|
||||||
'title:ascii'
|
'title:ascii'
|
||||||
when 'post-similarity'
|
when 'post-similarity'
|
||||||
@@ -62,9 +63,25 @@ class GekanatorQuestionsController < ApplicationController
|
|||||||
json['monthDay'] = json.delete('month_day')
|
json['monthDay'] = json.delete('month_day')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if json['type'] == 'title-length-greater-than'
|
||||||
|
json['type'] = 'title-length-at-least'
|
||||||
|
json['length'] = json['length'].to_i + 1
|
||||||
|
end
|
||||||
|
|
||||||
json
|
json
|
||||||
end
|
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
|
def example_answers_json question
|
||||||
question
|
question
|
||||||
.gekanator_question_examples
|
.gekanator_question_examples
|
||||||
|
|||||||
+90
-11
@@ -35,6 +35,7 @@ export type GekanatorQuestionCondition =
|
|||||||
| { type: 'original-year'; year: number }
|
| { type: 'original-year'; year: number }
|
||||||
| { type: 'original-month'; month: number }
|
| { type: 'original-month'; month: number }
|
||||||
| { type: 'original-month-day'; monthDay: string }
|
| { type: 'original-month-day'; monthDay: string }
|
||||||
|
| { type: 'title-length-at-least'; length: number }
|
||||||
| { type: 'title-length-greater-than'; length: number }
|
| { type: 'title-length-greater-than'; length: number }
|
||||||
| { type: 'title-has-ascii' }
|
| { type: 'title-has-ascii' }
|
||||||
| {
|
| {
|
||||||
@@ -44,6 +45,12 @@ export type GekanatorQuestionCondition =
|
|||||||
threshold: number
|
threshold: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type NonPostSimilarityCondition = Exclude<
|
||||||
|
GekanatorQuestionCondition,
|
||||||
|
{ type: 'post-similarity' }
|
||||||
|
>
|
||||||
|
|
||||||
export type GekanatorExtraQuestion = {
|
export type GekanatorExtraQuestion = {
|
||||||
id: number
|
id: number
|
||||||
text: string
|
text: string
|
||||||
@@ -70,10 +77,67 @@ export type GekanatorQuestion = {
|
|||||||
test: (post: Post) => boolean }
|
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 = (
|
const directExampleAnswerFor = (
|
||||||
question: StoredGekanatorQuestion,
|
question: StoredGekanatorQuestion,
|
||||||
post: Post,
|
post: Post,
|
||||||
): GekanatorAnswerValue | null => {
|
): GekanatorAnswerValue | null => {
|
||||||
|
if (question.kind !== 'post_similarity')
|
||||||
|
return null
|
||||||
|
|
||||||
const direct = question.exampleAnswers?.[String (post.id) as `${ number }`]
|
const direct = question.exampleAnswers?.[String (post.id) as `${ number }`]
|
||||||
if (direct)
|
if (direct)
|
||||||
return direct
|
return direct
|
||||||
@@ -222,6 +286,8 @@ const questionMatches = (
|
|||||||
return originalMonthOf (post) === question.condition.month
|
return originalMonthOf (post) === question.condition.month
|
||||||
case 'original-month-day':
|
case 'original-month-day':
|
||||||
return originalMonthDayOf (post) === question.condition.monthDay
|
return originalMonthDayOf (post) === question.condition.monthDay
|
||||||
|
case 'title-length-at-least':
|
||||||
|
return (post.title?.length ?? 0) >= question.condition.length
|
||||||
case 'title-length-greater-than':
|
case 'title-length-greater-than':
|
||||||
return (post.title?.length ?? 0) > question.condition.length
|
return (post.title?.length ?? 0) > question.condition.length
|
||||||
case 'title-has-ascii':
|
case 'title-has-ascii':
|
||||||
@@ -250,6 +316,7 @@ export const expectedAnswerForQuestion = (
|
|||||||
case 'original-year':
|
case 'original-year':
|
||||||
case 'original-month':
|
case 'original-month':
|
||||||
case 'original-month-day':
|
case 'original-month-day':
|
||||||
|
case 'title-length-at-least':
|
||||||
case 'title-length-greater-than':
|
case 'title-length-greater-than':
|
||||||
case 'title-has-ascii':
|
case 'title-has-ascii':
|
||||||
return questionMatches (post, question) ? 'yes' : 'no'
|
return questionMatches (post, question) ? 'yes' : 'no'
|
||||||
@@ -261,20 +328,32 @@ export const expectedAnswerForQuestion = (
|
|||||||
|
|
||||||
export const restoreGekanatorQuestion = (
|
export const restoreGekanatorQuestion = (
|
||||||
question: StoredGekanatorQuestion,
|
question: StoredGekanatorQuestion,
|
||||||
): GekanatorQuestion => ({
|
): GekanatorQuestion => {
|
||||||
...question,
|
const normalizedCondition = normalizeTitleLengthCondition (question.condition)
|
||||||
source: question.source ?? 'default',
|
const normalizedQuestion = {
|
||||||
priorityWeight: question.priorityWeight ?? 1,
|
...question,
|
||||||
test: (post: Post) => questionMatches (post, 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 = (
|
export const storeGekanatorQuestion = (
|
||||||
question: GekanatorQuestion,
|
question: GekanatorQuestion,
|
||||||
): StoredGekanatorQuestion => ({
|
): 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,
|
text: question.text,
|
||||||
kind: question.kind,
|
kind: question.kind,
|
||||||
condition: question.condition,
|
condition: normalizeTitleLengthCondition (question.condition),
|
||||||
source: question.source,
|
source: question.source,
|
||||||
priorityWeight: question.priorityWeight,
|
priorityWeight: question.priorityWeight,
|
||||||
exampleAnswers: question.exampleAnswers })
|
exampleAnswers: question.exampleAnswers })
|
||||||
@@ -402,15 +481,15 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
|
|||||||
|
|
||||||
const titleQuestions = [
|
const titleQuestions = [
|
||||||
{
|
{
|
||||||
id: 'title:long',
|
id: `title:length-at-least:${ titleLengthMedian }`,
|
||||||
text: '題名が長めの投稿?',
|
text: `タイトルは ${ titleLengthMedian } 文字以上?`,
|
||||||
kind: 'title' as const,
|
kind: 'title' as const,
|
||||||
condition: {
|
condition: {
|
||||||
type: 'title-length-greater-than' as const,
|
type: 'title-length-at-least' as const,
|
||||||
length: titleLengthMedian },
|
length: titleLengthMedian },
|
||||||
source: 'default' as const,
|
source: 'default' as const,
|
||||||
priorityWeight: 1,
|
priorityWeight: 1,
|
||||||
test: (post: Post) => (post.title?.length ?? 0) > titleLengthMedian },
|
test: (post: Post) => (post.title?.length ?? 0) >= titleLengthMedian },
|
||||||
{
|
{
|
||||||
id: 'title:ascii',
|
id: 'title:ascii',
|
||||||
text: '題名に英数字が混じってゐる?',
|
text: '題名に英数字が混じってゐる?',
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ import { buildGekanatorQuestions,
|
|||||||
fetchGekanatorExtraQuestions,
|
fetchGekanatorExtraQuestions,
|
||||||
fetchGekanatorQuestions,
|
fetchGekanatorQuestions,
|
||||||
fetchGekanatorPosts,
|
fetchGekanatorPosts,
|
||||||
|
normalizeTitleLengthCondition,
|
||||||
restoreGekanatorQuestion,
|
restoreGekanatorQuestion,
|
||||||
saveGekanatorExtraQuestionAnswers,
|
saveGekanatorExtraQuestionAnswers,
|
||||||
saveGekanatorGame,
|
saveGekanatorGame,
|
||||||
saveGekanatorQuestionSuggestion,
|
saveGekanatorQuestionSuggestion,
|
||||||
storeGekanatorQuestion } from '@/lib/gekanator'
|
storeGekanatorQuestion,
|
||||||
|
titleLengthMinimumForCondition } from '@/lib/gekanator'
|
||||||
import { gekanatorKeys } from '@/lib/queryKeys'
|
import { gekanatorKeys } from '@/lib/queryKeys'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
@@ -23,6 +25,7 @@ import type { FC } from 'react'
|
|||||||
import type { GekanatorAnswerLog,
|
import type { GekanatorAnswerLog,
|
||||||
GekanatorAnswerValue,
|
GekanatorAnswerValue,
|
||||||
GekanatorExtraQuestion,
|
GekanatorExtraQuestion,
|
||||||
|
GekanatorQuestionCondition,
|
||||||
GekanatorQuestion,
|
GekanatorQuestion,
|
||||||
StoredGekanatorQuestion } from '@/lib/gekanator'
|
StoredGekanatorQuestion } from '@/lib/gekanator'
|
||||||
import type { Post } from '@/types'
|
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 => {
|
const sourcePriorityForMerge = (question: GekanatorQuestion): number => {
|
||||||
switch (question.source)
|
switch (question.source)
|
||||||
{
|
{
|
||||||
@@ -214,7 +257,7 @@ const loadStoredGame = (): StoredGekanatorGame | null => {
|
|||||||
if (!(raw))
|
if (!(raw))
|
||||||
return null
|
return null
|
||||||
|
|
||||||
return JSON.parse (raw) as StoredGekanatorGame
|
return normalizeStoredGame (JSON.parse (raw) as StoredGekanatorGame)
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -548,6 +591,13 @@ const sameConditionValue = (
|
|||||||
left: GekanatorQuestion['condition'],
|
left: GekanatorQuestion['condition'],
|
||||||
right: GekanatorQuestion['condition'],
|
right: GekanatorQuestion['condition'],
|
||||||
): boolean => {
|
): 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)
|
if (left.type !== right.type)
|
||||||
return false
|
return false
|
||||||
|
|
||||||
@@ -564,12 +614,13 @@ const sameConditionValue = (
|
|||||||
return String (condition.month)
|
return String (condition.month)
|
||||||
case 'original-month-day':
|
case 'original-month-day':
|
||||||
return condition.monthDay
|
return condition.monthDay
|
||||||
case 'title-length-greater-than':
|
|
||||||
return String (condition.length)
|
|
||||||
case 'title-has-ascii':
|
case 'title-has-ascii':
|
||||||
return ''
|
return ''
|
||||||
case 'post-similarity':
|
case 'post-similarity':
|
||||||
return `${ condition.postId }:${ condition.answer }:${ condition.threshold }`
|
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 = (
|
const isMonthCrossMatch = (
|
||||||
candidate: GekanatorQuestion['condition'],
|
candidate: GekanatorQuestion['condition'],
|
||||||
previous: GekanatorQuestion['condition'],
|
previous: GekanatorQuestion['condition'],
|
||||||
@@ -725,6 +808,9 @@ const chooseQuestion = ({
|
|||||||
|
|
||||||
return questionsToRank
|
return questionsToRank
|
||||||
.map (question => {
|
.map (question => {
|
||||||
|
if (isQuestionRedundantAfterAnswers (question, answers))
|
||||||
|
return null
|
||||||
|
|
||||||
const signature = signatureFor (question, candidates)
|
const signature = signatureFor (question, candidates)
|
||||||
if (redundant.has (signature))
|
if (redundant.has (signature))
|
||||||
return null
|
return null
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする