コミットを比較

..

22 コミット

作成者 SHA1 メッセージ 日付
みてるぞ 53446807c2 #41 ウィニング・ラン修正 2026-06-12 02:08:03 +09:00
みてるぞ 5fbb737c70 Merge remote-tracking branch 'origin/main' into feature/041 2026-06-12 01:35:42 +09:00
みてるぞ a5d08c99cf #41 2026-06-12 01:33:40 +09:00
みてるぞ 2522485f6a Merge remote-tracking branch 'origin/main' into feature/041 2026-06-11 23:58:00 +09:00
みてるぞ de5db81e16 #41 2026-06-11 23:54:55 +09:00
みてるぞ fd90ef3b15 Merge remote-tracking branch 'origin/main' into feature/041 2026-06-11 23:18:57 +09:00
みてるぞ 4caea6213a #41 少々しやぅ修正 2026-06-11 23:18:37 +09:00
みてるぞ 884a7bc3da Merge remote-tracking branch 'origin/main' into feature/041 2026-06-10 23:43:24 +09:00
みてるぞ f936c1e5ce #41 テスト型バグ修正 2026-06-10 23:41:45 +09:00
みてるぞ 8bf51bbb4a #41 2026-06-10 23:17:12 +09:00
みてるぞ 480a06caaf #41 2026-06-10 22:24:01 +09:00
みてるぞ 7fe7dbd909 #41 2026-06-10 20:02:08 +09:00
みてるぞ 159ad5ed5a #41 2026-06-09 23:36:24 +09:00
みてるぞ ae1deaac8c #41 2026-06-09 23:05:37 +09:00
みてるぞ be5359eb84 #41 2026-06-09 08:17:16 +09:00
みてるぞ a1ea35a7ec #41 2026-06-09 01:29:43 +09:00
みてるぞ 49d42d576a #41 2026-06-09 00:35:25 +09:00
みてるぞ 77b5c8f262 #41 2026-06-08 17:47:19 +09:00
みてるぞ 543f051f8f #41 2026-06-08 12:44:45 +09:00
みてるぞ fb2b2a632c #41 2026-06-08 08:45:52 +09:00
みてるぞ de21141f5a #41 2026-06-08 08:41:52 +09:00
みてるぞ 96df2a4eaa #41 2026-06-08 00:30:20 +09:00
28個のファイルの変更852行の追加5472行の削除
-68
ファイルの表示
@@ -125,64 +125,6 @@ npm run preview
- TypeScript and TSX use 4-space logical indentation. - TypeScript and TSX use 4-space logical indentation.
- In TypeScript and TSX only, replace every leading run of 8 spaces with a tab. - In TypeScript and TSX only, replace every leading run of 8 spaces with a tab.
- Tabs are only for leading indentation, never for spaces after non-space text. - Tabs are only for leading indentation, never for spaces after non-space text.
- TypeScript and TSX imports may stay on one line if they remain within the
line limit; do not expand short type-only imports mechanically.
- In TypeScript and TSX, when a function takes one destructured object
argument plus an inline type, prefer this shape when it fits locally:
```ts
const helper = (
{ value, flag }: { value: string
flag: boolean },
): Result => {
// ...
}
```
- In TypeScript and TSX, put `switch` case block braces on their own lines
when a case needs a lexical block:
```ts
case 'yes':
case 'no':
{
const expected = valueFor (item)
return expected == null || expected === answer
}
```
- In TypeScript and TSX, use `value == null` and `value != null` as the
default nullish checks. Do not use `=== null`, `=== undefined`,
`!== null`, or `!== undefined`.
- If code appears to need a distinction between `null` and `undefined`, treat
that as a design smell and revise the logic to avoid the distinction.
External library APIs that explicitly require distinguishing the two are the
only exception.
- In TypeScript and TSX, keep short arrays on one line when they fit under the
line limit; break arrays only when readability or line length requires it.
- In TypeScript and TSX, when a ternary expression is split across multiple
lines, align `?` and `:` with the condition expression. Do not indent `?` and
`:` one extra level under the condition.
```ts
const value =
condition
? consequent
: alternate
```
- In TypeScript and TSX, keep short ternary expressions on one line when they
fit cleanly under the line limit.
- In TypeScript and TSX, prefer ternary expressions for simple conditional
value selection. Do not replace a clear ternary with `if` statements, and do
not introduce immediately invoked functions just to avoid or reformat a
ternary expression.
- In TypeScript and TSX, do not write `let` followed by later `if` assignments
when the value can be expressed as a single `const` initializer. Prefer
`const` because it prevents accidental later reassignment.
- When fixing formatting, change formatting only. Do not change expression
structure, control flow, or variable mutability unless the requested style
explicitly requires it.
- Do not add production dependencies without explicit approval. - Do not add production dependencies without explicit approval.
- Do not create, modify, or run tests unless the user explicitly asks for - Do not create, modify, or run tests unless the user explicitly asks for
test work. When the user asks for tests, keep working and rerun them until test work. When the user asks for tests, keep working and rerun them until
@@ -216,13 +158,6 @@ const value =
- Keep page-level code under `frontend/src/pages` and shared UI/feature code - Keep page-level code under `frontend/src/pages` and shared UI/feature code
under `frontend/src/components` unless existing patterns point elsewhere. under `frontend/src/components` unless existing patterns point elsewhere.
- Match existing Tailwind, component, and import alias conventions. - Match existing Tailwind, component, and import alias conventions.
- In TypeScript and TSX, prefer direct comparison operators such as `===` and
`!==` over negating a comparison like `!(a === b)`.
- In TypeScript and TSX, prefer `++i` or `--i` over `i += 1` or `i -= 1` for
simple unit-step counter updates.
- For user-facing Japanese text, prefer modern kana usage and natural current
phrasing over historical spellings or awkward literal wording.
- For user-facing Japanese ellipses, prefer `……` over ASCII `...`.
### Frontend TSX style ### Frontend TSX style
@@ -244,9 +179,6 @@ const value =
single physical line. single physical line.
- Always add braces around `if`, `else`, or `for` bodies when the body spans - Always add braces around `if`, `else`, or `for` bodies when the body spans
two or more physical lines, even if it is one statement. two or more physical lines, even if it is one statement.
- Do not use a leading semicolon for expression statements such as
`;([...]).forEach(...)`; rewrite the expression to avoid ASI hazards
explicitly, for example with `void`.
Preferred: Preferred:
+14 -146
ファイルの表示
@@ -1,6 +1,6 @@
class GekanatorGamesController < ApplicationController class GekanatorGamesController < ApplicationController
def create def create
return head :unauthorized unless current_user return head :not_found unless current_user&.admin?
guessed_post_id = params.require(:guessed_post_id) guessed_post_id = params.require(:guessed_post_id)
correct_post_id = params[:correct_post_id].presence correct_post_id = params[:correct_post_id].presence
@@ -14,29 +14,18 @@ class GekanatorGamesController < ApplicationController
question_count: answers.length, question_count: answers.length,
answers:) answers:)
if game.invalid? if game.save
render json: { id: game.id }, status: :created
else
render json: { errors: game.errors.full_messages }, status: :unprocessable_entity render json: { errors: game.errors.full_messages }, status: :unprocessable_entity
return
end end
learned_example_count = 0
ActiveRecord::Base.transaction do
game.save!
learned_example_count = learn_answers_from_game!(game)
end
render json: {
id: game.id,
learned_example_count:
}, status: :created
rescue ActiveRecord::RecordInvalid => e
render json: { errors: e.record.errors.full_messages }, status: :unprocessable_entity
end end
def extra_questions def extra_questions
game = find_owned_game return head :not_found unless current_user&.admin?
return if performed?
game = GekanatorGame.find_by(id: params[:id])
return head :not_found unless game
questions = questions =
GekanatorQuestion GekanatorQuestion
@@ -45,12 +34,10 @@ class GekanatorGamesController < ApplicationController
.where(kind: 'post_similarity', source: 'user_suggested') .where(kind: 'post_similarity', source: 'user_suggested')
.to_a .to_a
selected = selected = weighted_sample_questions(
prioritized_extra_questions(
questions, questions,
post_id: game.correct_post_id, post_id: game.correct_post_id,
user: current_user, limit: 2)
limit: 6)
render json: { render json: {
questions: selected.map { |question| extra_question_json(question) } questions: selected.map { |question| extra_question_json(question) }
@@ -58,8 +45,10 @@ class GekanatorGamesController < ApplicationController
end end
def extra_question_answers def extra_question_answers
game = find_owned_game return head :not_found unless current_user&.admin?
return if performed?
game = GekanatorGame.find_by(id: params[:id])
return head :not_found unless game
answer_params = params.require(:answers) answer_params = params.require(:answers)
if !answer_params.is_a?(Array) if !answer_params.is_a?(Array)
@@ -111,23 +100,6 @@ class GekanatorGamesController < ApplicationController
} }
end end
def prioritized_extra_questions questions, post_id:, user:, limit:
answered_question_ids =
GekanatorQuestionExample
.where(user:, gekanator_question_id: questions.map(&:id))
.distinct
.pluck(:gekanator_question_id)
unanswered, answered =
questions.partition { |question| !answered_question_ids.include?(question.id) }
selected = weighted_sample_questions(unanswered, post_id:, limit:)
return selected if selected.length >= limit
selected + weighted_sample_questions(
answered.reject { |question| selected.any? { _1.id == question.id } },
post_id:,
limit: limit - selected.length)
end
def weighted_sample_questions questions, post_id:, limit: def weighted_sample_questions questions, post_id:, limit:
remaining = questions.uniq(&:id) remaining = questions.uniq(&:id)
selected = [] selected = []
@@ -165,108 +137,4 @@ class GekanatorGamesController < ApplicationController
question.priority_weight.to_f / (1.0 + sample_count * 0.15) question.priority_weight.to_f / (1.0 + sample_count * 0.15)
end end
def find_owned_game
return head :unauthorized unless current_user
game = GekanatorGame.find_by(id: params[:id])
return head :not_found unless game
if !current_user.admin? && game.user_id != current_user.id
return head :not_found
end
game
end
def learn_answers_from_game! game
correct_post = game.correct_post
return 0 if correct_post.blank?
accepted_questions =
GekanatorQuestion
.accepted
.index_by { |question| public_question_id_for(question) }
learned_count = 0
Array(game.answers).each do |answer|
answer_value = answer['answer'].to_s
next if answer_value.blank? || answer_value == 'unknown'
question_id = game_answer_question_id(answer)
next if question_id.blank?
question = accepted_questions[question_id.to_s]
next unless learnable_game_answer_question?(question)
example =
GekanatorQuestionExample.find_or_initialize_by(
gekanator_question: question,
post: correct_post,
user: current_user)
example.record_answer!(
answer: answer_value,
source: 'post_game_answer',
gekanator_game: game)
example.save!
learned_count += 1
end
learned_count
end
def public_question_id_for question
condition = normalize_condition(question.condition)
case condition[:type]
when 'tag'
"tag:#{condition[:key]}"
when 'source'
"source:#{condition[:host]}"
when 'original-year'
"original-year:#{condition[:year]}"
when 'original-month'
"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-at-least:#{condition[:length].to_i + 1}"
when 'title-has-ascii'
'title:ascii'
when 'title-contains'
"title:contains:#{condition[:text]}"
when 'post-similarity'
"post-similarity:#{question.id}"
else
"catalog:#{question.id}"
end
end
def normalize_condition condition
json = condition.deep_dup.as_json
if json['type'] == 'original-month-day' && json['monthDay'].blank?
json['monthDay'] = json.delete('month_day')
end
json.deep_symbolize_keys
end
def learnable_game_answer_question? question
return false if question.nil?
return true if question.kind == 'post_similarity'
return false unless question.kind == 'tag'
condition = normalize_condition(question.condition)
key = condition[:key].to_s
!key.start_with?('nico:')
end
def game_answer_question_id answer
answer['question_id'] ||
answer[:question_id] ||
answer['questionId'] ||
answer[:questionId]
end
end end
+3 -7
ファイルの表示
@@ -1,8 +1,10 @@
class GekanatorPostsController < ApplicationController class GekanatorPostsController < ApplicationController
def index def index
return head :not_found unless current_user&.admin?
posts = posts =
Post Post
.preload(:post_similarities, tags: :tag_name) .preload(tags: :tag_name)
.with_attached_thumbnail .with_attached_thumbnail
.order(Arel.sql( .order(Arel.sql(
'COALESCE(posts.original_created_before - INTERVAL 1 MINUTE, ' \ 'COALESCE(posts.original_created_before - INTERVAL 1 MINUTE, ' \
@@ -22,12 +24,6 @@ class GekanatorPostsController < ApplicationController
thumbnail_base: post.thumbnail_base, thumbnail_base: post.thumbnail_base,
original_created_from: post.original_created_from, original_created_from: post.original_created_from,
original_created_before: post.original_created_before, original_created_before: post.original_created_before,
post_similarity_edges: post.post_similarities.map { |similarity|
{
target_post_id: similarity.target_post_id,
cos: similarity.cos.to_f
}
},
tags: post.tags.map { |tag| tag_json(tag) } tags: post.tags.map { |tag| tag_json(tag) }
} }
end end
+1 -43
ファイルの表示
@@ -1,41 +1,9 @@
class GekanatorQuestionSuggestionsController < ApplicationController class GekanatorQuestionSuggestionsController < ApplicationController
def create def create
return head :unauthorized unless current_user return head :not_found unless current_user&.admin?
game = GekanatorGame.find_by(id: params.require(:gekanator_game_id)) game = GekanatorGame.find_by(id: params.require(:gekanator_game_id))
return head :not_found unless game return head :not_found unless game
if !current_user.admin? && game.user_id != current_user.id
return head :not_found
end
existing_question_id = params[:existing_question_id].presence
if existing_question_id
question = GekanatorQuestion.accepted.find_by(id: existing_question_id)
return head :not_found unless question
unless learnable_existing_question?(question)
return render_validation_error fields: { existing_question_id: ['質問が不正です.'] }
end
example =
GekanatorQuestionExample.find_or_initialize_by(
gekanator_question: question,
post: game.correct_post,
user: current_user)
example.record_answer!(
answer: params.require(:answer),
source: 'post_game_extra',
gekanator_game: game)
if example.save
render json: {
id: question.id,
count: game.question_suggestions.count
}, status: :created
else
render_validation_error example
end
return
end
suggestion = GekanatorQuestionSuggestion.new( suggestion = GekanatorQuestionSuggestion.new(
gekanator_game: game, gekanator_game: game,
@@ -82,14 +50,4 @@ class GekanatorQuestionSuggestionsController < ApplicationController
rescue NotImplementedError rescue NotImplementedError
head :not_implemented head :not_implemented
end end
private
def learnable_existing_question? question
return true if question.kind == 'post_similarity'
return false unless question.kind == 'tag'
key = question.condition.as_json['key'].to_s
!key.start_with?('nico:')
end
end end
+3 -6
ファイルの表示
@@ -1,5 +1,7 @@
class GekanatorQuestionsController < ApplicationController class GekanatorQuestionsController < ApplicationController
def index def index
return head :not_found unless current_user&.admin?
questions = questions =
GekanatorQuestion GekanatorQuestion
.accepted .accepted
@@ -16,7 +18,6 @@ class GekanatorQuestionsController < ApplicationController
def question_json question def question_json question
condition = condition_json(question.condition).deep_symbolize_keys condition = condition_json(question.condition).deep_symbolize_keys
json = { json = {
record_id: question.id,
id: question_id_for(question, condition), id: question_id_for(question, condition),
text: question_text_for(question, condition), text: question_text_for(question, condition),
kind: question.kind, kind: question.kind,
@@ -24,7 +25,7 @@ class GekanatorQuestionsController < ApplicationController
source: question.source, source: question.source,
priority_weight: question.priority_weight priority_weight: question.priority_weight
} }
if question.kind == 'post_similarity' || question.kind == 'tag' if question.kind == 'post_similarity'
json[:example_answers] = example_answers_json(question) json[:example_answers] = example_answers_json(question)
end end
json json
@@ -48,8 +49,6 @@ class GekanatorQuestionsController < ApplicationController
"title:length-at-least:#{ condition[:length].to_i + 1 }" "title:length-at-least:#{ condition[:length].to_i + 1 }"
when 'title-has-ascii' when 'title-has-ascii'
'title:ascii' 'title:ascii'
when 'title-contains'
"title:contains:#{ condition[:text] }"
when 'post-similarity' when 'post-similarity'
"post-similarity:#{ question.id }" "post-similarity:#{ question.id }"
else else
@@ -78,8 +77,6 @@ class GekanatorQuestionsController < ApplicationController
case condition[:type] case condition[:type]
when 'title-length-at-least' when 'title-length-at-least'
"タイトルは #{ condition[:length] } 文字以上?" "タイトルは #{ condition[:length] } 文字以上?"
when 'title-contains'
"題名に「#{ condition[:text] }」が含まれる?"
else else
question.text question.text
end end
+2 -2
ファイルの表示
@@ -1,7 +1,7 @@
class GekanatorQuestionExample < ApplicationRecord class GekanatorQuestionExample < ApplicationRecord
ANSWERS = GekanatorQuestionSuggestion::ANSWERS ANSWERS = GekanatorQuestionSuggestion::ANSWERS
NON_UNKNOWN_ANSWERS = ANSWERS - ['unknown'] NON_UNKNOWN_ANSWERS = ANSWERS - ['unknown']
SOURCES = ['initial_suggestion', 'post_game_answer', 'post_game_extra'].freeze SOURCES = ['initial_suggestion', 'post_game_extra'].freeze
belongs_to :gekanator_question belongs_to :gekanator_question
belongs_to :post belongs_to :post
@@ -35,7 +35,7 @@ class GekanatorQuestionExample < ApplicationRecord
self.answer_counts = counts self.answer_counts = counts
self.sample_count = counts.values.sum self.sample_count = counts.values.sum
self.gekanator_game = gekanator_game if gekanator_game.present? self.gekanator_game = gekanator_game if gekanator_game.present?
self.source = source self.source = source if new_record?
apply_aggregated_answer!(preferred_answer: answer) apply_aggregated_answer!(preferred_answer: answer)
self self
+13
ファイルの表示
@@ -1,4 +1,5 @@
class GekanatorQuestionSuggestion < ApplicationRecord class GekanatorQuestionSuggestion < ApplicationRecord
MAX_QUESTIONS_PER_GAME = 3
ANSWERS = ['yes', 'no', 'partial', 'probably_no', 'unknown'].freeze ANSWERS = ['yes', 'no', 'partial', 'probably_no', 'unknown'].freeze
belongs_to :gekanator_game belongs_to :gekanator_game
@@ -9,4 +10,16 @@ class GekanatorQuestionSuggestion < ApplicationRecord
validates :question_text, presence: true, length: { maximum: 1000 } validates :question_text, presence: true, length: { maximum: 1000 }
validates :answer, presence: true, inclusion: { in: ANSWERS } validates :answer, presence: true, inclusion: { in: ANSWERS }
validates :processed, inclusion: { in: [true, false] } 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 end
+1 -151
ファイルの表示
@@ -1,15 +1,5 @@
module Gekanator module Gekanator
class QuestionSuggestionAiConverter class QuestionSuggestionAiConverter
# Temporary heuristic converter for #361.
# This creates pending ai_generated questions without external LLM calls;
# accepted questions are still distributed only after admin approval.
TITLE_LENGTH_RE = /\Aタイトルは\s*(\d+)\s*文字以上[??]\z/
ORIGINAL_YEAR_RE = /\Aオリジナルの投稿年は\s*(\d{4})\s*年[??]\z/
ORIGINAL_MONTH_RE = /\Aオリジナルの投稿月は\s*(\d{1,2})\s*月[??]\z/
ORIGINAL_MONTH_DAY_RE = /\Aオリジナルの投稿日は\s*(\d{1,2})\s*月\s*(\d{1,2})\s*日[??]\z/
TITLE_CONTAINS_RE = /\A題名に「(.+?)」が含まれる[??]\z/
SOURCE_RE = /\A(.+?)\s+の投稿を思[ひい]浮かべて[ゐい]る[??]\z/
def self.call(...) = new(...).call def self.call(...) = new(...).call
def initialize suggestion:, user: def initialize suggestion:, user:
@@ -18,151 +8,11 @@ module Gekanator
end end
def call def call
suggestion.with_lock do raise NotImplementedError, 'AI question conversion is not implemented yet.'
existing = existing_generated_question
return existing if existing
run = suggestion.gekanator_ai_runs.create!(
model: 'heuristic_converter_v1',
status: 'running',
input_tokens: 0,
output_tokens: 0,
estimated_cost_jpy: 0)
question_attributes = build_question
question =
question_attributes &&
GekanatorQuestion.create!(
**question_attributes,
source: 'ai_generated',
status: 'pending',
gekanator_question_suggestion: suggestion,
created_by: user)
run.update!(status: question ? 'succeeded' : 'failed')
question
end
rescue => error
run&.update!(status: 'failed') if run&.persisted? && run.status != 'failed'
raise error
end end
private private
attr_reader :suggestion, :user attr_reader :suggestion, :user
def existing_generated_question
suggestion
.gekanator_questions
.where(source: 'ai_generated')
.order(id: :desc)
.first
end
def build_question
text = normalized_text
return nil if text.blank?
structured_question_for(text) || post_similarity_question_for(text)
end
def normalized_text
suggestion.question_text.to_s.gsub(/[[:space:]]+/, ' ').strip
end
def structured_question_for text
case text
when TITLE_LENGTH_RE
length = Regexp.last_match(1).to_i
return nil if length <= 0
{
text:,
kind: 'title',
condition: {
type: 'title-length-at-least',
length:
},
priority_weight: 0.95
}
when /\A題名に英数字が混じって[ゐい]る[??]\z/
{
text: '題名に英数字が混じってゐる?',
kind: 'title',
condition: { type: 'title-has-ascii' },
priority_weight: 0.95
}
when ORIGINAL_YEAR_RE
year = Regexp.last_match(1).to_i
{
text:,
kind: 'original_date',
condition: { type: 'original-year', year: },
priority_weight: 0.95
}
when ORIGINAL_MONTH_RE
month = Regexp.last_match(1).to_i
return nil unless month.between?(1, 12)
{
text:,
kind: 'original_date',
condition: { type: 'original-month', month: },
priority_weight: 0.95
}
when ORIGINAL_MONTH_DAY_RE
month = Regexp.last_match(1).to_i
day = Regexp.last_match(2).to_i
return nil unless month.between?(1, 12) && day.between?(1, 31)
{
text:,
kind: 'original_date',
condition: {
type: 'original-month-day',
monthDay: "#{ month }-#{ day }"
},
priority_weight: 0.95
}
when TITLE_CONTAINS_RE
title_text = Regexp.last_match(1).to_s.strip
return nil if title_text.blank?
{
text: "題名に「#{ title_text }」が含まれる?",
kind: 'title',
condition: { type: 'title-contains', text: title_text },
priority_weight: 0.95
}
when SOURCE_RE
host = Regexp.last_match(1).to_s.strip
return nil if host.blank?
{
text:,
kind: 'source',
condition: { type: 'source', host: },
priority_weight: 0.95
}
else
nil
end
end
def post_similarity_question_for text
return nil if suggestion.answer == 'unknown'
{
text:,
kind: 'post_similarity',
condition: {
type: 'post-similarity',
postId: suggestion.gekanator_game.correct_post_id,
answer: suggestion.answer,
threshold: 0.65
},
priority_weight: 1.0
}
end
end end
end end
-4
ファイルの表示
@@ -1,10 +1,6 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe TagNameSanitisationRule, type: :model do RSpec.describe TagNameSanitisationRule, type: :model do
before do
described_class.unscoped.delete_all
end
describe '.sanitise' do describe '.sanitise' do
before do before do
described_class.create!(priority: 10, source_pattern: '_', replacement: '') described_class.create!(priority: 10, source_pattern: '_', replacement: '')
+4 -5
ファイルの表示
@@ -52,16 +52,16 @@ RSpec.describe 'Gekanator games API', type: :request do
expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:unprocessable_entity)
end end
it 'returns unauthorized without a user' do it 'returns not found without an admin user' do
post '/gekanator/games', params: { post '/gekanator/games', params: {
guessed_post_id: guessed_post.id, guessed_post_id: guessed_post.id,
correct_post_id: guessed_post.id, correct_post_id: guessed_post.id,
answers: [] } answers: [] }
expect(response).to have_http_status(:unauthorized) expect(response).to have_http_status(:not_found)
end end
it 'stores a game for a non-admin user' do it 'returns not found for a non-admin user' do
sign_in_as user sign_in_as user
post '/gekanator/games', params: { post '/gekanator/games', params: {
@@ -69,8 +69,7 @@ RSpec.describe 'Gekanator games API', type: :request do
correct_post_id: guessed_post.id, correct_post_id: guessed_post.id,
answers: [{ question_id: 'tag:1', answer: 'yes' }] } answers: [{ question_id: 'tag:1', answer: 'yes' }] }
expect(response).to have_http_status(:created) expect(response).to have_http_status(:not_found)
expect(GekanatorGame.find(json['id']).user).to eq(user)
end end
end end
end end
+13 -432
ファイルの表示
@@ -129,7 +129,7 @@ RSpec.describe 'Gekanator learning API', type: :request do
expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:unprocessable_entity)
end end
it 'stores a game result for a non-admin user' do it 'returns not found for a non-admin user' do
sign_in_as member sign_in_as member
post '/gekanator/games', params: { post '/gekanator/games', params: {
@@ -138,200 +138,7 @@ RSpec.describe 'Gekanator learning API', type: :request do
answers: [] answers: []
} }
expect(response).to have_http_status(:created) expect(response).to have_http_status(:not_found)
expect(GekanatorGame.find(json['id']).user).to eq(member)
end
it 'returns unauthorized without a user' do
post '/gekanator/games', params: {
guessed_post_id: guessed_post.id,
correct_post_id: correct_post.id,
answers: []
}
expect(response).to have_http_status(:unauthorized)
end
it 'learns accepted non-nico tag answers from camelCase main game logs' do
sign_in_as admin
tag_question = GekanatorQuestion.create!(
text: 'MAD 要素がある?',
kind: 'tag',
source: 'admin_curated',
status: 'accepted',
priority_weight: 1.0,
condition: { type: 'tag', key: 'meme:MAD' },
created_by: admin
)
expect {
post '/gekanator/games', params: {
guessed_post_id: guessed_post.id,
correct_post_id: correct_post.id,
answers: [
{
questionId: 'tag:meme:MAD',
question_text: 'MAD 要素がある?',
answer: 'yes',
original_answer: 'yes'
},
{
questionId: 'tag:meme:missing',
question_text: '存在しない質問?',
answer: 'yes',
original_answer: 'yes'
},
{
questionId: 'tag:meme:MAD',
question_text: 'MAD 要素がある?',
answer: 'unknown',
original_answer: 'unknown'
}
]
}
}.to change { GekanatorQuestionExample.count }.by(1)
expect(response).to have_http_status(:created)
expect(json['learned_example_count']).to eq(1)
example = GekanatorQuestionExample.last
expect(example).to have_attributes(
gekanator_question_id: tag_question.id,
post_id: correct_post.id,
user_id: admin.id,
answer: 'yes',
source: 'post_game_answer'
)
expect(example.gekanator_game_id).to eq(json['id'])
end
it 'learns accepted post_similarity answers from main game logs' do
sign_in_as admin
question = create_post_similarity_question!(text: '泣いてる?')
expect {
post '/gekanator/games', params: {
guessed_post_id: guessed_post.id,
correct_post_id: correct_post.id,
answers: [
{
question_id: "post-similarity:#{question.id}",
question_text: '泣いてる?',
answer: 'partial',
original_answer: 'partial'
}
]
}
}.to change { GekanatorQuestionExample.count }.by(1)
expect(response).to have_http_status(:created)
expect(json['learned_example_count']).to eq(1)
example = GekanatorQuestionExample.last
expect(example).to have_attributes(
gekanator_question_id: question.id,
post_id: correct_post.id,
user_id: admin.id,
answer: 'partial',
source: 'post_game_answer'
)
expect(example.gekanator_game_id).to eq(json['id'])
end
it 'does not learn fact questions or nico tag questions from main game logs' do
sign_in_as admin
[
{
text: 'example.com 由来?',
kind: 'source',
condition: { type: 'source', host: 'example.com' }
},
{
text: '題名に結束バンドを含む?',
kind: 'title',
condition: { type: 'title-contains', text: '結束バンド' }
},
{
text: '2024 年投稿?',
kind: 'original_date',
condition: { type: 'original-year', year: 2024 }
},
{
text: 'ニコニコにぼっちタグ?',
kind: 'tag',
condition: { type: 'tag', key: 'nico:ぼっち' }
}
].each do |attributes|
GekanatorQuestion.create!(
text: attributes[:text],
kind: attributes[:kind],
source: 'admin_curated',
status: 'accepted',
priority_weight: 1.0,
condition: attributes[:condition],
created_by: admin
)
end
expect {
post '/gekanator/games', params: {
guessed_post_id: guessed_post.id,
correct_post_id: correct_post.id,
answers: [
{ question_id: 'source:example.com', answer: 'yes' },
{ question_id: 'title:contains:結束バンド', answer: 'yes' },
{ question_id: 'original-year:2024', answer: 'yes' },
{ question_id: 'tag:nico:ぼっち', answer: 'yes' }
]
}
}.not_to change { GekanatorQuestionExample.count }
expect(response).to have_http_status(:created)
expect(json['learned_example_count']).to eq(0)
end
it 'updates an existing main game example instead of duplicating it' do
sign_in_as admin
tag_question = GekanatorQuestion.create!(
text: '喜多ちゃんが関係してる?',
kind: 'tag',
source: 'admin_curated',
status: 'accepted',
priority_weight: 1.0,
condition: { type: 'tag', key: 'character:喜多郁代' },
created_by: admin
)
existing = GekanatorQuestionExample.create!(
gekanator_question: tag_question,
post: correct_post,
user: admin,
answer: 'no',
source: 'post_game_answer',
weight: 1.0
)
expect {
post '/gekanator/games', params: {
guessed_post_id: guessed_post.id,
correct_post_id: correct_post.id,
answers: [
{
question_id: 'tag:character:喜多郁代',
answer: 'yes',
original_answer: 'yes'
}
]
}
}.not_to change { GekanatorQuestionExample.count }
expect(response).to have_http_status(:created)
expect(json['learned_example_count']).to eq(1)
expect(existing.reload.answer).to eq('yes')
expect(existing.sample_count).to eq(2)
end end
end end
@@ -431,7 +238,7 @@ RSpec.describe 'Gekanator learning API', type: :request do
expect(GekanatorQuestionSuggestion.last.processed).to eq(false) expect(GekanatorQuestionSuggestion.last.processed).to eq(false)
end end
it 'allows more than three suggestions per game' do it 'limits suggestions to three per game' do
sign_in_as admin sign_in_as admin
3.times do |i| 3.times do |i|
@@ -449,119 +256,47 @@ RSpec.describe 'Gekanator learning API', type: :request do
question_text: 'fourth question?', question_text: 'fourth question?',
answer: 'yes' answer: 'yes'
} }
}.to change { GekanatorQuestionSuggestion.count }.by(1) }.not_to change { GekanatorQuestionSuggestion.count }
expect(response).to have_http_status(:created) expect(response).to have_http_status(:unprocessable_entity)
expect(json['count']).to eq(4)
end end
it 'allows a non-admin user to suggest a question for their own game' do it 'returns not found for a non-admin user' do
member_game = GekanatorGame.create!(
user: member,
guessed_post: guessed_post,
correct_post: correct_post,
won: false,
question_count: 1,
answers: [{ 'question_id' => 'tag:1', 'answer' => 'yes' }]
)
sign_in_as member sign_in_as member
expect {
post '/gekanator/question_suggestions', params: {
gekanator_game_id: member_game.id,
question_text: 'member question?',
answer: 'yes'
}
}.to change { GekanatorQuestionSuggestion.count }.by(1)
expect(response).to have_http_status(:created)
expect(GekanatorQuestionSuggestion.last).to have_attributes(
gekanator_game_id: member_game.id,
user_id: member.id
)
end
it 'returns not found for another user game' do
sign_in_as member
expect {
post '/gekanator/question_suggestions', params: { post '/gekanator/question_suggestions', params: {
gekanator_game_id: game.id, gekanator_game_id: game.id,
question_text: 'member question?', question_text: 'member question?',
answer: 'yes' answer: 'yes'
} }
}.not_to change { GekanatorQuestionSuggestion.count }
expect(response).to have_http_status(:not_found) expect(response).to have_http_status(:not_found)
end end
it 'returns unauthorized without a user' do
expect {
post '/gekanator/question_suggestions', params: {
gekanator_game_id: game.id,
question_text: 'member question?',
answer: 'yes'
}
}.not_to change { GekanatorQuestionSuggestion.count }
expect(response).to have_http_status(:unauthorized)
end
end end
describe 'GET /gekanator/games/:id/extra_questions' do describe 'GET /gekanator/games/:id/extra_questions' do
it 'returns at most six accepted user_suggested post_similarity questions without duplicates' do it 'returns at most two accepted user_suggested post_similarity questions without duplicates' do
sign_in_as admin sign_in_as admin
lowest = create_post_similarity_question!(
text: 'lowest?',
priority_weight: 0.5
)
low = create_post_similarity_question!( low = create_post_similarity_question!(
text: 'low?', text: 'low?',
priority_weight: 1.0 priority_weight: 1.0
) )
middle = create_post_similarity_question!(
text: 'middle?',
priority_weight: 1.5
)
medium_high = create_post_similarity_question!(
text: 'medium high?',
priority_weight: 2.0
)
high = create_post_similarity_question!( high = create_post_similarity_question!(
text: 'high?', text: 'high?',
priority_weight: 2.5
)
higher = create_post_similarity_question!(
text: 'higher?',
priority_weight: 2.8
)
highest = create_post_similarity_question!(
text: 'highest?',
priority_weight: 3.0 priority_weight: 3.0
) )
overflow = create_post_similarity_question!( middle = create_post_similarity_question!(
text: 'overflow?', text: 'middle?',
priority_weight: 2.2 priority_weight: 2.0
) )
get "/gekanator/games/#{game.id}/extra_questions" get "/gekanator/games/#{game.id}/extra_questions"
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json['questions'].length).to eq(6) expect(json['questions'].length).to eq(2)
expect(json['questions'].map { _1['id'] }.uniq.length).to eq(6) expect(json['questions'].map { _1['id'] }.uniq.length).to eq(2)
expect(json['questions'].map { _1['id'] }).to all( expect(json['questions'].map { _1['id'] }).to all(be_in([low.id, high.id, middle.id]))
be_in([
lowest.id,
low.id,
middle.id,
medium_high.id,
high.id,
higher.id,
highest.id,
overflow.id,
])
)
end end
it 'can return questions that already have an example for the correct post' do it 'can return questions that already have an example for the correct post' do
@@ -584,37 +319,6 @@ RSpec.describe 'Gekanator learning API', type: :request do
expect(json['questions'].map { _1['id'] }).to include(existing.id) expect(json['questions'].map { _1['id'] }).to include(existing.id)
end end
it 'prioritizes questions the current user has not answered' do
sign_in_as admin
answered = create_post_similarity_question!(
text: 'already answered?',
priority_weight: 3.0
)
GekanatorQuestionExample.create!(
gekanator_question: answered,
post: other_post,
user: admin,
answer: 'yes',
source: 'post_game_extra'
)
unanswered =
6.times.map { |index|
create_post_similarity_question!(
text: "unanswered #{index}?",
priority_weight: 0.5
)
}
get "/gekanator/games/#{game.id}/extra_questions"
expect(response).to have_http_status(:ok)
expect(json['questions'].map { _1['id'] }).to match_array(
unanswered.map(&:id)
)
end
it 'can return questions already asked in the game using snake_case question_id' do it 'can return questions already asked in the game using snake_case question_id' do
sign_in_as admin sign_in_as admin
@@ -673,38 +377,6 @@ RSpec.describe 'Gekanator learning API', type: :request do
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json['questions'].map { _1['id'] }).to contain_exactly(accepted.id) expect(json['questions'].map { _1['id'] }).to contain_exactly(accepted.id)
end end
it 'allows a non-admin user to fetch extra questions for their own game' do
member_game = GekanatorGame.create!(
user: member,
guessed_post: guessed_post,
correct_post: correct_post,
won: false,
question_count: 1,
answers: [{ 'question_id' => 'tag:1', 'answer' => 'yes' }]
)
accepted = create_post_similarity_question!(text: 'accepted?')
sign_in_as member
get "/gekanator/games/#{member_game.id}/extra_questions"
expect(response).to have_http_status(:ok)
expect(json['questions'].map { _1['id'] }).to include(accepted.id)
end
it 'returns not found for another user game' do
sign_in_as member
get "/gekanator/games/#{game.id}/extra_questions"
expect(response).to have_http_status(:not_found)
end
it 'returns unauthorized without a user' do
get "/gekanator/games/#{game.id}/extra_questions"
expect(response).to have_http_status(:unauthorized)
end
end end
describe 'POST /gekanator/games/:id/extra_question_answers' do describe 'POST /gekanator/games/:id/extra_question_answers' do
@@ -831,69 +503,6 @@ RSpec.describe 'Gekanator learning API', type: :request do
expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:unprocessable_entity)
end end
it 'allows a non-admin user to answer extra questions for their own game' do
member_game = GekanatorGame.create!(
user: member,
guessed_post: guessed_post,
correct_post: correct_post,
won: false,
question_count: 1,
answers: [{ 'question_id' => 'tag:1', 'answer' => 'yes' }]
)
question = create_post_similarity_question!(text: 'extra?')
sign_in_as member
expect {
post "/gekanator/games/#{member_game.id}/extra_question_answers", params: {
answers: [
{
question_id: question.id,
answer: 'yes'
}
]
}
}.to change { GekanatorQuestionExample.count }.by(1)
expect(response).to have_http_status(:created)
expect(GekanatorQuestionExample.last).to have_attributes(
user_id: member.id,
gekanator_game_id: member_game.id
)
end
it 'returns not found for another user game' do
question = create_post_similarity_question!(text: 'extra?')
sign_in_as member
expect {
post "/gekanator/games/#{game.id}/extra_question_answers", params: {
answers: [
{
question_id: question.id,
answer: 'yes'
}
]
}
}.not_to change { GekanatorQuestionExample.count }
expect(response).to have_http_status(:not_found)
end
it 'returns unauthorized without a user' do
question = create_post_similarity_question!(text: 'extra?')
post "/gekanator/games/#{game.id}/extra_question_answers", params: {
answers: [
{
question_id: question.id,
answer: 'yes'
}
]
}
expect(response).to have_http_status(:unauthorized)
end
end end
describe 'GET /gekanator/questions' do describe 'GET /gekanator/questions' do
@@ -999,33 +608,5 @@ RSpec.describe 'Gekanator learning API', type: :request do
'length' => 21 'length' => 21
) )
end end
it 'returns title-contains questions without authentication' do
GekanatorQuestion.create!(
text: '題名に「結束バンド」が含まれる?',
kind: 'title',
source: 'ai_generated',
status: 'accepted',
priority_weight: 0.95,
condition: {
type: 'title-contains',
text: '結束バンド'
},
created_by: admin
)
get '/gekanator/questions'
expect(response).to have_http_status(:ok)
question_json = json['questions'].find { _1['id'] == 'title:contains:結束バンド' }
expect(question_json).to include(
'text' => '題名に「結束バンド」が含まれる?',
'kind' => 'title'
)
expect(question_json['condition']).to include(
'type' => 'title-contains',
'text' => '結束バンド'
)
end
end end
end end
-112
ファイルの表示
@@ -1,112 +0,0 @@
require 'rails_helper'
RSpec.describe Gekanator::QuestionSuggestionAiConverter do
let(:user) { create(:user, :member) }
let(:guessed_post) { Post.create!(title: 'guess', url: 'https://example.com/guess') }
let(:correct_post) { Post.create!(title: 'correct', url: 'https://example.com/correct') }
let(:game) do
GekanatorGame.create!(
user: user,
guessed_post: guessed_post,
correct_post: correct_post,
won: false,
question_count: 1,
answers: [{ 'question_id' => 'tag:1', 'answer' => 'yes' }]
)
end
def create_suggestion!(question_text:, answer: 'yes')
GekanatorQuestionSuggestion.create!(
gekanator_game: game,
user: user,
question_text: question_text,
answer: answer
)
end
it 'converts title-contains suggestions to pending ai-generated questions' do
suggestion = create_suggestion!(question_text: '題名に「結束バンド」が含まれる?')
expect {
described_class.call(suggestion: suggestion, user: user)
}.to change { GekanatorQuestion.count }.by(1)
.and change { GekanatorAiRun.count }.by(1)
question = GekanatorQuestion.last
expect(question).to have_attributes(
text: '題名に「結束バンド」が含まれる?',
kind: 'title',
source: 'ai_generated',
status: 'pending',
priority_weight: 0.95,
gekanator_question_suggestion_id: suggestion.id,
created_by_id: user.id
)
expect(question.condition).to include(
'type' => 'title-contains',
'text' => '結束バンド'
)
expect(GekanatorAiRun.last).to have_attributes(
gekanator_question_suggestion_id: suggestion.id,
model: 'heuristic_converter_v1',
status: 'succeeded'
)
end
it 'converts concrete non-unknown suggestions to post-similarity questions' do
suggestion = create_suggestion!(
question_text: '喜多ちゃんが泣いてる?',
answer: 'partial'
)
question = described_class.call(suggestion: suggestion, user: user)
expect(question).to have_attributes(
text: '喜多ちゃんが泣いてる?',
kind: 'post_similarity',
source: 'ai_generated',
status: 'pending',
priority_weight: 1.0
)
expect(question.condition).to include(
'type' => 'post-similarity',
'postId' => correct_post.id,
'answer' => 'partial',
'threshold' => 0.65
)
end
it 'records a failed run when the suggestion cannot be converted' do
suggestion = create_suggestion!(
question_text: 'よく分からない質問?',
answer: 'unknown'
)
expect {
expect(described_class.call(suggestion: suggestion, user: user)).to be_nil
}.not_to change { GekanatorQuestion.count }
expect(GekanatorAiRun.last).to have_attributes(
gekanator_question_suggestion_id: suggestion.id,
status: 'failed'
)
end
it 'returns an existing generated question without creating a duplicate run' do
suggestion = create_suggestion!(question_text: 'タイトルは 10 文字以上?')
existing = GekanatorQuestion.create!(
text: 'タイトルは 10 文字以上?',
kind: 'title',
source: 'ai_generated',
status: 'pending',
priority_weight: 0.95,
condition: { type: 'title-length-at-least', length: 10 },
gekanator_question_suggestion: suggestion,
created_by: user
)
expect {
expect(described_class.call(suggestion: suggestion, user: user)).to eq(existing)
}.not_to change { GekanatorAiRun.count }
end
end
バイナリファイルは表示されません.

変更前

幅:  |  高さ:  |  サイズ: 559 KiB

バイナリファイルは表示されません.

変更前

幅:  |  高さ:  |  サイズ: 146 KiB

バイナリファイルは表示されません.

変更前

幅:  |  高さ:  |  サイズ: 1.2 MiB

バイナリファイルは表示されません.

変更前

幅:  |  高さ:  |  サイズ: 188 KiB

バイナリファイルは表示されません.

変更前

幅:  |  高さ:  |  サイズ: 201 KiB

バイナリファイルは表示されません.

変更前

幅:  |  高さ:  |  サイズ: 196 KiB

バイナリファイルは表示されません.

変更前

幅:  |  高さ:  |  サイズ: 179 KiB

+15 -2
ファイルの表示
@@ -40,7 +40,7 @@ import WikiHistoryPage from '@/pages/wiki/WikiHistoryPage'
import WikiNewPage from '@/pages/wiki/WikiNewPage' import WikiNewPage from '@/pages/wiki/WikiNewPage'
import WikiSearchPage from '@/pages/wiki/WikiSearchPage' import WikiSearchPage from '@/pages/wiki/WikiSearchPage'
import type { Dispatch, FC, SetStateAction } from 'react' import type { Dispatch, FC, ReactNode, SetStateAction } from 'react'
import type { User } from '@/types' import type { User } from '@/types'
@@ -81,7 +81,10 @@ const RouteTransitionWrapper = ({ user, setUser }: {
<Route path="/users/settings" element={<SettingPage user={user} setUser={setUser}/>}/> <Route path="/users/settings" element={<SettingPage user={user} setUser={setUser}/>}/>
<Route path="/settings" element={<Navigate to="/users/settings" replace/>}/> <Route path="/settings" element={<Navigate to="/users/settings" replace/>}/>
<Route path="/tos" element={<TOSPage/>}/> <Route path="/tos" element={<TOSPage/>}/>
<Route path="/gekanator" element={<GekanatorPage user={user}/>}/> <Route path="/gekanator" element={
<AdminOnly user={user}>
<GekanatorPage/>
</AdminOnly>}/>
<Route path="/more" element={<MorePage/>}/> <Route path="/more" element={<MorePage/>}/>
<Route path="*" element={<NotFound/>}/> <Route path="*" element={<NotFound/>}/>
</Routes> </Routes>
@@ -89,6 +92,16 @@ const RouteTransitionWrapper = ({ user, setUser }: {
} }
const AdminOnly = ({ user, children }: {
user: User | null
children: ReactNode }) => {
if (user?.role !== 'admin')
return <NotFound/>
return <>{children}</>
}
const PostDetailRoute = ({ user }: { user: User | null }) => { const PostDetailRoute = ({ user }: { user: User | null }) => {
const location = useLocation () const location = useLocation ()
const key = location.pathname const key = location.pathname
+1 -2
ファイルの表示
@@ -66,8 +66,7 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: {
{ name: '履歴', to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg }, { name: '履歴', to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg },
{ name: '編輯', to: `/wiki/${ wikiId || wikiTitle }/edit`, visible: wikiPageFlg }] }, { name: '編輯', to: `/wiki/${ wikiId || wikiTitle }/edit`, visible: wikiPageFlg }] },
{ name: 'おたのしみ', visible: false, subMenu: [ { name: 'おたのしみ', visible: false, subMenu: [
{ name: '上映会 (β)', to: '/theatres/1' }, { name: '上映会 (β)', to: '/theatres/1' }] },
{ name: 'グカネータ (β)', to: '/gekanator' }] },
{ name: 'ユーザ', to: '/users/settings', visible: false, subMenu: [ { name: 'ユーザ', to: '/users/settings', visible: false, subMenu: [
{ name: '一覧', to: '/users', visible: false }, { name: '一覧', to: '/users', visible: false },
{ name: 'お前', to: `/users/${ user?.id }`, visible: false }, { name: 'お前', to: `/users/${ user?.id }`, visible: false },
+1 -127
ファイルの表示
@@ -4,8 +4,6 @@ import { apiPost } from '@/lib/api'
import { import {
buildGekanatorQuestions, buildGekanatorQuestions,
expectedAnswerForQuestion, expectedAnswerForQuestion,
learnedSemanticSideForPost,
questionIdForCondition,
restoreGekanatorQuestion, restoreGekanatorQuestion,
saveGekanatorExtraQuestionAnswers, saveGekanatorExtraQuestionAnswers,
saveGekanatorGame, saveGekanatorGame,
@@ -166,54 +164,6 @@ describe('expectedAnswerForQuestion', () => {
expect(expectedAnswerForQuestion(question, post({ id: 1, title: 'short' }))).toBe('no') expect(expectedAnswerForQuestion(question, post({ id: 1, title: 'short' }))).toBe('no')
}) })
it('returns yes for matching title-contains questions', () => {
const question: StoredGekanatorQuestion = {
id: 'title:contains:結束バンド',
text: '題名に「結束バンド」が含まれる?',
kind: 'title',
condition: {
type: 'title-contains',
text: '結束バンド',
},
}
expect(expectedAnswerForQuestion(
question,
post({ title: '結束バンドのライブ' }),
)).toBe('yes')
expect(expectedAnswerForQuestion(
question,
post({ title: '後藤ひとりの休日' }),
)).toBe('no')
})
})
describe('learnedSemanticSideForPost', () => {
it('classifies post_similarity examples as positive, negative, or unknown', () => {
const question: StoredGekanatorQuestion = {
id: 'post-similarity:10',
text: '喜多ちゃんが泣いてる?',
kind: 'post_similarity',
source: 'user_suggested',
priorityWeight: 1.2,
condition: {
type: 'post-similarity',
postId: 123,
answer: 'partial',
threshold: 0.65,
},
exampleAnswers: {
1: 'yes',
2: 'probably_no',
},
}
expect(learnedSemanticSideForPost(question, post({ id: 1 }))).toBe('positive')
expect(learnedSemanticSideForPost(question, post({ id: 2 }))).toBe('negative')
expect(learnedSemanticSideForPost(question, post({ id: 3 }))).toBe('unknown')
expect(learnedSemanticSideForPost(question, post({ id: 123 }))).toBe('positive')
})
}) })
describe('restoreGekanatorQuestion', () => { describe('restoreGekanatorQuestion', () => {
@@ -276,7 +226,7 @@ describe('restoreGekanatorQuestion', () => {
}) })
expect(question.test(post({ id: 1 }))).toBe(true) expect(question.test(post({ id: 1 }))).toBe(true)
expect(question.test(post({ id: 2 }))).toBe(true) expect(question.test(post({ id: 2 }))).toBe(false)
}) })
it('normalizes legacy title-length-greater-than questions', () => { it('normalizes legacy title-length-greater-than questions', () => {
@@ -298,21 +248,6 @@ describe('restoreGekanatorQuestion', () => {
expect(question.test(post({ title: 'x'.repeat(20) }))).toBe(false) expect(question.test(post({ title: 'x'.repeat(20) }))).toBe(false)
expect(question.test(post({ title: 'x'.repeat(21) }))).toBe(true) expect(question.test(post({ title: 'x'.repeat(21) }))).toBe(true)
}) })
it('restores title-contains questions with a title matcher', () => {
const question = restoreGekanatorQuestion({
id: 'title:contains:結束バンド',
text: '題名に「結束バンド」が含まれる?',
kind: 'title',
condition: {
type: 'title-contains',
text: '結束バンド',
},
})
expect(question.test(post({ title: '結束バンドのライブ' }))).toBe(true)
expect(question.test(post({ title: '後藤ひとりの休日' }))).toBe(false)
})
}) })
describe('buildGekanatorQuestions', () => { describe('buildGekanatorQuestions', () => {
@@ -329,59 +264,6 @@ describe('buildGekanatorQuestions', () => {
expect(titleQuestion?.text).toMatch(/^タイトルは \d+ 文字以上\?$/) expect(titleQuestion?.text).toMatch(/^タイトルは \d+ 文字以上\?$/)
expect(titleQuestion?.id).toMatch(/^title:length-at-least:\d+$/) expect(titleQuestion?.id).toMatch(/^title:length-at-least:\d+$/)
}) })
it('builds title-contains questions from repeated title words', () => {
const questions = buildGekanatorQuestions([
post({ id: 1, title: '結束バンド ライブ' }),
post({ id: 2, title: '結束バンド 新曲' }),
post({ id: 3, title: '後藤ひとり 練習' }),
post({ id: 4, title: '伊地知虹夏 練習' }),
])
const titleContainsQuestion = questions.find(question =>
question.condition.type === 'title-contains'
&& question.condition.text === '結束バンド')
expect(titleContainsQuestion).toMatchObject({
id: 'title:contains:結束バンド',
text: '題名に「結束バンド」が含まれる?',
kind: 'title',
source: 'default',
priorityWeight: .96,
})
expect(titleContainsQuestion?.test(post({ title: '結束バンドのライブ' }))).toBe(true)
expect(titleContainsQuestion?.test(post({ title: '廣井きくりのライブ' }))).toBe(false)
})
it('honors question caps and title-contains toggles', () => {
const posts = [
post({ id: 1, title: '結束バンド ライブ' }),
post({ id: 2, title: '結束バンド 新曲' }),
post({ id: 3, title: '後藤ひとり 練習' }),
post({ id: 4, title: '伊地知虹夏 練習' }),
]
const capped = buildGekanatorQuestions(posts, {
titleContainsCap: 1,
totalQuestionCap: 1,
})
const withoutTitleContains = buildGekanatorQuestions(posts, {
includeTitleContains: false,
})
expect(capped).toHaveLength(1)
expect(withoutTitleContains.some(question =>
question.condition.type === 'title-contains')).toBe(false)
})
})
describe('questionIdForCondition', () => {
it('builds stable ids for title-contains questions', () => {
expect(questionIdForCondition({
type: 'title-contains',
text: '結束バンド',
})).toBe('title:contains:結束バンド')
})
}) })
describe('Gekanator API writers', () => { describe('Gekanator API writers', () => {
@@ -400,10 +282,6 @@ describe('Gekanator API writers', () => {
type: 'tag', type: 'tag',
key: 'character:喜多郁代', key: 'character:喜多郁代',
}, },
questionMode: 'normal',
questionPurpose: 'effective_user_suggested',
effectiveQuestion: true,
learningQuestion: false,
answer: 'yes', answer: 'yes',
originalAnswer: 'partial', originalAnswer: 'partial',
}, },
@@ -428,10 +306,6 @@ describe('Gekanator API writers', () => {
type: 'tag', type: 'tag',
key: 'character:喜多郁代', key: 'character:喜多郁代',
}, },
question_mode: 'normal',
question_purpose: 'effective_user_suggested',
effective_question: true,
learning_question: false,
answer: 'yes', answer: 'yes',
original_answer: 'partial', original_answer: 'partial',
}, },
+20 -131
ファイルの表示
@@ -9,24 +9,10 @@ export type GekanatorAnswerValue =
| 'probably_no' | 'probably_no'
| 'unknown' | 'unknown'
export type LearnedSemanticSide =
| 'positive'
| 'negative'
| 'unknown'
export type GekanatorQuestionPurpose =
| 'effective_user_suggested'
| 'learning_user_suggested'
| 'normal'
export type GekanatorAnswerLog = { export type GekanatorAnswerLog = {
questionId: string questionId: string
questionText: string questionText: string
questionCondition?: GekanatorQuestionCondition questionCondition?: GekanatorQuestionCondition
questionMode?: 'normal' | 'winning_run'
questionPurpose?: GekanatorQuestionPurpose
effectiveQuestion?: boolean
learningQuestion?: boolean
answer: GekanatorAnswerValue answer: GekanatorAnswerValue
originalAnswer: GekanatorAnswerValue } originalAnswer: GekanatorAnswerValue }
@@ -43,8 +29,6 @@ export type GekanatorQuestionSource =
| 'ai_generated' | 'ai_generated'
| 'admin_curated' | 'admin_curated'
export type GekanatorPerformanceMode = 'normal'
export type GekanatorQuestionCondition = export type GekanatorQuestionCondition =
| { type: 'tag'; key: string } | { type: 'tag'; key: string }
| { type: 'source'; host: string } | { type: 'source'; host: string }
@@ -54,7 +38,6 @@ export type GekanatorQuestionCondition =
| { type: 'title-length-at-least'; length: number } | { 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' }
| { type: 'title-contains'; text: string }
| { | {
type: 'post-similarity' type: 'post-similarity'
postId: number postId: number
@@ -75,7 +58,6 @@ export type GekanatorExtraQuestion = {
priorityWeight: number } priorityWeight: number }
export type StoredGekanatorQuestion = { export type StoredGekanatorQuestion = {
recordId?: number
id: string id: string
text: string text: string
kind: GekanatorQuestionKind kind: GekanatorQuestionKind
@@ -85,7 +67,6 @@ export type StoredGekanatorQuestion = {
exampleAnswers?: Record<`${ number }`, GekanatorAnswerValue> } exampleAnswers?: Record<`${ number }`, GekanatorAnswerValue> }
export type GekanatorQuestion = { export type GekanatorQuestion = {
recordId?: number
id: string id: string
text: string text: string
kind: GekanatorQuestionKind kind: GekanatorQuestionKind
@@ -95,13 +76,6 @@ export type GekanatorQuestion = {
exampleAnswers?: Record<`${ number }`, GekanatorAnswerValue> exampleAnswers?: Record<`${ number }`, GekanatorAnswerValue>
test: (post: Post) => boolean } test: (post: Post) => boolean }
export type BuildGekanatorQuestionsOptions = {
includeTitleContains?: boolean
tagQuestionCap?: number
titleContainsCap?: number
totalQuestionCap?: number
}
export const normalizeTitleLengthCondition = ( export const normalizeTitleLengthCondition = (
condition: GekanatorQuestionCondition, condition: GekanatorQuestionCondition,
@@ -153,8 +127,6 @@ export const questionIdForCondition = (
return `title:length-at-least:${ titleLengthMinimumForCondition (condition) }` return `title:length-at-least:${ titleLengthMinimumForCondition (condition) }`
case 'title-has-ascii': case 'title-has-ascii':
return 'title:ascii' return 'title:ascii'
case 'title-contains':
return `title:contains:${ condition.text }`
} }
} }
@@ -163,7 +135,7 @@ const directExampleAnswerFor = (
question: StoredGekanatorQuestion, question: StoredGekanatorQuestion,
post: Post, post: Post,
): GekanatorAnswerValue | null => { ): GekanatorAnswerValue | null => {
if (question.kind !== 'post_similarity' && question.kind !== 'tag') if (question.kind !== 'post_similarity')
return null return null
const direct = question.exampleAnswers?.[String (post.id) as `${ number }`] const direct = question.exampleAnswers?.[String (post.id) as `${ number }`]
@@ -176,26 +148,6 @@ const directExampleAnswerFor = (
return null return null
} }
export const isLearnedSemanticQuestion = (
question: StoredGekanatorQuestion | GekanatorQuestion,
): boolean =>
question.kind === 'post_similarity'
&& question.source === 'user_suggested'
export const learnedSemanticSideForAnswer = (
answer: GekanatorAnswerValue | null,
): LearnedSemanticSide => {
if (answer === 'yes' || answer === 'partial')
return 'positive'
if (answer === 'no' || answer === 'probably_no')
return 'negative'
return 'unknown'
}
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))
@@ -318,8 +270,8 @@ const questionMatches = (
): boolean => { ): boolean => {
const directAnswer = directExampleAnswerFor (question, post) const directAnswer = directExampleAnswerFor (question, post)
if (directAnswer) if (directAnswer)
return question.kind === 'post_similarity' return question.condition.type === 'post-similarity'
? learnedSemanticSideForAnswer (directAnswer) === 'positive' ? directAnswer === question.condition.answer
: directAnswer === 'yes' : directAnswer === 'yes'
switch (question.condition.type) switch (question.condition.type)
@@ -340,8 +292,6 @@ const questionMatches = (
return (post.title?.length ?? 0) > question.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 'title-contains':
return (post.title ?? '').includes (question.condition.text)
case 'post-similarity': case 'post-similarity':
return false return false
} }
@@ -361,11 +311,6 @@ export const expectedAnswerForQuestion = (
switch (question.condition.type) switch (question.condition.type)
{ {
case 'post-similarity':
if (question.condition.postId === post.id)
return question.condition.answer
return null
case 'tag': case 'tag':
case 'source': case 'source':
case 'original-year': case 'original-year':
@@ -374,26 +319,19 @@ export const expectedAnswerForQuestion = (
case 'title-length-at-least': case 'title-length-at-least':
case 'title-length-greater-than': case 'title-length-greater-than':
case 'title-has-ascii': case 'title-has-ascii':
case 'title-contains':
return questionMatches (post, question) ? 'yes' : 'no' return questionMatches (post, question) ? 'yes' : 'no'
case 'post-similarity':
return null
} }
} }
export const learnedSemanticSideForPost = (
question: StoredGekanatorQuestion | GekanatorQuestion | undefined,
post: Post | null,
): LearnedSemanticSide =>
learnedSemanticSideForAnswer (expectedAnswerForQuestion (question, post))
export const restoreGekanatorQuestion = ( export const restoreGekanatorQuestion = (
question: StoredGekanatorQuestion, question: StoredGekanatorQuestion,
): GekanatorQuestion => { ): GekanatorQuestion => {
const normalizedCondition = normalizeTitleLengthCondition (question.condition) const normalizedCondition = normalizeTitleLengthCondition (question.condition)
const normalizedQuestion = { const normalizedQuestion = {
...question, ...question,
recordId: question.recordId,
id: normalizedCondition.type === 'title-length-at-least' id: normalizedCondition.type === 'title-length-at-least'
? `title:length-at-least:${ normalizedCondition.length }` ? `title:length-at-least:${ normalizedCondition.length }`
: question.id, : question.id,
@@ -413,7 +351,6 @@ export const storeGekanatorQuestion = (
id: question.condition.type === 'title-length-greater-than' id: question.condition.type === 'title-length-greater-than'
? `title:length-at-least:${ question.condition.length + 1 }` ? `title:length-at-least:${ question.condition.length + 1 }`
: question.id, : question.id,
recordId: question.recordId,
text: question.text, text: question.text,
kind: question.kind, kind: question.kind,
condition: normalizeTitleLengthCondition (question.condition), condition: normalizeTitleLengthCondition (question.condition),
@@ -445,16 +382,7 @@ export const fetchGekanatorExtraQuestions = async (
} }
export const buildGekanatorQuestions = ( export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
posts: Post[],
options: BuildGekanatorQuestionsOptions = { },
): GekanatorQuestion[] => {
const {
includeTitleContains = true,
tagQuestionCap = 192,
titleContainsCap = 24,
totalQuestionCap = Number.POSITIVE_INFINITY,
} = options
const tagCounts = countBy (posts.flatMap (post => const tagCounts = countBy (posts.flatMap (post =>
post.tags post.tags
.filter (tag => .filter (tag =>
@@ -466,41 +394,27 @@ export const buildGekanatorQuestions = (
const originalYears = countBy ( const originalYears = countBy (
posts posts
.map (originalYearOf) .map (originalYearOf)
.filter ((year): year is number => year != null)) .filter ((year): year is number => year !== null))
const originalMonths = countBy ( const originalMonths = countBy (
posts posts
.map (originalMonthOf) .map (originalMonthOf)
.filter ((month): month is number => month != null)) .filter ((month): month is number => month !== null))
const originalMonthDays = countBy ( const originalMonthDays = countBy (
posts posts
.map (originalMonthDayOf) .map (originalMonthDayOf)
.filter ((monthDay): monthDay is string => monthDay != null)) .filter ((monthDay): monthDay is string => monthDay !== null))
const titleLengthMedian = median (posts.map (post => post.title?.length ?? 0)) const titleLengthMedian = median (posts.map (post => post.title?.length ?? 0))
const titleWordCounts =
includeTitleContains
? countBy (
posts.flatMap (post =>
Array.from (
new Set (
(post.title ?? '')
.match (
/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}A-Za-z0-9]{2,}/gu)
?? []))))
: new Map<string, number> ()
const usefulEntries = <T extends string | number> ( const usefulEntries = <T extends string | number> (counts: Map<T, number>) =>
counts: Map<T, number>,
cap: number,
) =>
[...counts.entries ()] [...counts.entries ()]
.filter (([, count]) => count > 0 && count < posts.length) .filter (([, count]) => count > 0 && count < posts.length)
.sort ((a, b) => Math.abs (posts.length / 2 - a[1]) .sort ((a, b) => Math.abs (posts.length / 2 - a[1])
- Math.abs (posts.length / 2 - b[1])) - Math.abs (posts.length / 2 - b[1]))
.slice (0, cap) .slice (0, 80)
const tagQuestions = usefulEntries (tagCounts, Math.max (tagQuestionCap, 80)) const tagQuestions = usefulEntries (tagCounts)
.filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7)) .filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7))
.slice (0, tagQuestionCap) .slice (0, 80)
.map (([key]) => { .map (([key]) => {
const { category, name } = tagFromQuestionKey (String (key)) const { category, name } = tagFromQuestionKey (String (key))
const label = category === 'nico' ? nicoTagLabel (name) : name const label = category === 'nico' ? nicoTagLabel (name) : name
@@ -515,7 +429,7 @@ export const buildGekanatorQuestions = (
test: (post: Post) => questionableTag (post, String (key)) } test: (post: Post) => questionableTag (post, String (key)) }
}) })
const sourceQuestions = usefulEntries (hosts, 20) const sourceQuestions = usefulEntries (hosts)
.filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7)) .filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7))
.slice (0, 20) .slice (0, 20)
.map (([host]) => ({ .map (([host]) => ({
@@ -527,7 +441,7 @@ export const buildGekanatorQuestions = (
priorityWeight: 1, priorityWeight: 1,
test: (post: Post) => hostOf (post) === host })) test: (post: Post) => hostOf (post) === host }))
const originalYearQuestions = usefulEntries (originalYears, 20) const originalYearQuestions = usefulEntries (originalYears)
.filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7)) .filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7))
.slice (0, 20) .slice (0, 20)
.map (([year]) => ({ .map (([year]) => ({
@@ -539,7 +453,7 @@ export const buildGekanatorQuestions = (
priorityWeight: 1, priorityWeight: 1,
test: (post: Post) => originalYearOf (post) === year })) test: (post: Post) => originalYearOf (post) === year }))
const originalMonthQuestions = usefulEntries (originalMonths, 20) const originalMonthQuestions = usefulEntries (originalMonths)
.filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7)) .filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7))
.slice (0, 20) .slice (0, 20)
.map (([month]) => ({ .map (([month]) => ({
@@ -551,7 +465,7 @@ export const buildGekanatorQuestions = (
priorityWeight: 1, priorityWeight: 1,
test: (post: Post) => originalMonthOf (post) === month })) test: (post: Post) => originalMonthOf (post) === month }))
const originalMonthDayQuestions = usefulEntries (originalMonthDays, 20) const originalMonthDayQuestions = usefulEntries (originalMonthDays)
.filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7)) .filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7))
.slice (0, 20) .slice (0, 20)
.map (([monthDay]) => { .map (([monthDay]) => {
@@ -591,23 +505,6 @@ export const buildGekanatorQuestions = (
const no = posts.length - yes const no = posts.length - yes
return yes >= 2 && no >= 2 && yes <= posts.length * .7 && no <= posts.length * .7 return yes >= 2 && no >= 2 && yes <= posts.length * .7 && no <= posts.length * .7
}) })
const titleContainsQuestions =
includeTitleContains
? usefulEntries (titleWordCounts, titleContainsCap)
.filter (([word, count]) =>
String (word).length <= 24
&& count >= 2
&& count <= Math.max (2, posts.length * .7))
.slice (0, titleContainsCap)
.map (([word]) => ({
id: `title:contains:${ word }`,
text: `題名に「${ word }」が含まれる?`,
kind: 'title' as const,
condition: { type: 'title-contains' as const, text: String (word) },
source: 'default' as const,
priorityWeight: .96,
test: (post: Post) => (post.title ?? '').includes (String (word)) }))
: []
return [ return [
...sourceQuestions, ...sourceQuestions,
@@ -615,8 +512,7 @@ export const buildGekanatorQuestions = (
...originalMonthQuestions, ...originalMonthQuestions,
...originalMonthDayQuestions, ...originalMonthDayQuestions,
...titleQuestions, ...titleQuestions,
...titleContainsQuestions, ...tagQuestions]
...tagQuestions].slice (0, totalQuestionCap)
} }
@@ -628,7 +524,7 @@ export const saveGekanatorGame = async ({
guessedPostId: number guessedPostId: number
correctPostId: number correctPostId: number
answers: GekanatorAnswerLog[] answers: GekanatorAnswerLog[]
}): Promise<{ id: number; learnedExampleCount: number }> => }): Promise<{ id: number }> =>
await apiPost ('/gekanator/games', { await apiPost ('/gekanator/games', {
guessed_post_id: guessedPostId, guessed_post_id: guessedPostId,
correct_post_id: correctPostId, correct_post_id: correctPostId,
@@ -636,28 +532,21 @@ export const saveGekanatorGame = async ({
question_id: answer.questionId, question_id: answer.questionId,
question_text: answer.questionText, question_text: answer.questionText,
question_condition: answer.questionCondition ?? null, question_condition: answer.questionCondition ?? null,
question_mode: answer.questionMode,
question_purpose: answer.questionPurpose,
effective_question: answer.effectiveQuestion,
learning_question: answer.learningQuestion,
answer: answer.answer, answer: answer.answer,
original_answer: answer.originalAnswer })) }) original_answer: answer.originalAnswer })) })
export const saveGekanatorQuestionSuggestion = async ({ export const saveGekanatorQuestionSuggestion = async ({
gekanatorGameId, gekanatorGameId,
existingQuestionId,
questionText, questionText,
answer, answer,
}: { }: {
gekanatorGameId: number gekanatorGameId: number
existingQuestionId?: number questionText: string
questionText?: string
answer: GekanatorAnswerValue answer: GekanatorAnswerValue
}): Promise<{ id: number; count: number }> => }): Promise<{ id: number; count: number }> =>
await apiPost ('/gekanator/question_suggestions', { await apiPost ('/gekanator/question_suggestions', {
gekanator_game_id: gekanatorGameId, gekanator_game_id: gekanatorGameId,
existing_question_id: existingQuestionId,
question_text: questionText, question_text: questionText,
answer }) answer })
+9 -117
ファイルの表示
@@ -11,7 +11,6 @@ import type {
GekanatorAnswerValue, GekanatorAnswerValue,
GekanatorQuestion, GekanatorQuestion,
} from '@/lib/gekanator' } from '@/lib/gekanator'
import type { RecoveredCandidateState } from '@/lib/gekanatorCandidateRecovery'
import type { Post } from '@/types' import type { Post } from '@/types'
@@ -52,21 +51,6 @@ const postSimilarityQuestion = (
}) })
const sourceQuestion = (
host: string,
): GekanatorQuestion => ({
id: `source:${ host }`,
text: `${ host }?`,
kind: 'source',
condition: {
type: 'source',
host },
source: 'default',
priorityWeight: 1,
test: candidate => new URL (candidate.url).hostname === host,
})
const answer = ( const answer = (
question: GekanatorQuestion, question: GekanatorQuestion,
value: GekanatorAnswerValue, value: GekanatorAnswerValue,
@@ -79,17 +63,8 @@ const answer = (
}) })
const recoveredState = (
answerCountAtRecovery: number,
scoreAtRecovery = 0,
): RecoveredCandidateState => ({
answerCountAtRecovery,
scoreAtRecovery,
})
describe('candidatePostsFor', () => { describe('candidatePostsFor', () => {
it('does not hard-filter semantic post_similarity answers', () => { it('lets recovered candidates ignore old answers but not later answers', () => {
const posts = [post (1), post (2), post (3)] const posts = [post (1), post (2), post (3)]
const oldQuestion = postSimilarityQuestion ('old', { const oldQuestion = postSimilarityQuestion ('old', {
1: 'no', 1: 'no',
@@ -109,31 +84,8 @@ describe('candidatePostsFor', () => {
softenedQuestionIds: new Set (), softenedQuestionIds: new Set (),
rejectedPostIds: new Set (), rejectedPostIds: new Set (),
recoveredCandidatePosts: new Map ([ recoveredCandidatePosts: new Map ([
[1, recoveredState (1)], [1, 1],
[3, recoveredState (1)], [3, 1],
]) })
expect(candidates.map (candidate => candidate.id)).toEqual ([1, 2, 3])
})
it('lets recovered candidates ignore old fact answers but not later fact answers', () => {
const posts = [
{ ...post (1), url: 'https://other.example/posts/1' },
post (2),
{ ...post (3), url: 'https://example.com/posts/3' },
]
const oldQuestion = sourceQuestion ('old.example.com')
const laterQuestion = sourceQuestion ('example.com')
const candidates = candidatePostsFor ({
posts,
questions: [oldQuestion, laterQuestion],
answers: [answer (oldQuestion, 'yes'), answer (laterQuestion, 'yes')],
softenedQuestionIds: new Set (),
rejectedPostIds: new Set (),
recoveredCandidatePosts: new Map ([
[1, recoveredState (1)],
[3, recoveredState (1)],
]) }) ]) })
expect(candidates.map (candidate => candidate.id)).toEqual ([3]) expect(candidates.map (candidate => candidate.id)).toEqual ([3])
@@ -152,7 +104,7 @@ describe('candidatePostsFor', () => {
answers: [answer (question, 'yes')], answers: [answer (question, 'yes')],
softenedQuestionIds: new Set (), softenedQuestionIds: new Set (),
rejectedPostIds: new Set ([1]), rejectedPostIds: new Set ([1]),
recoveredCandidatePosts: new Map ([[1, recoveredState (1)]]) }) recoveredCandidatePosts: new Map ([[1, 1]]) })
expect(candidates.map (candidate => candidate.id)).toEqual ([2]) expect(candidates.map (candidate => candidate.id)).toEqual ([2])
}) })
@@ -160,7 +112,7 @@ describe('candidatePostsFor', () => {
describe('hardFilteredPostsForAnswer', () => { describe('hardFilteredPostsForAnswer', () => {
it('keeps the original pool for semantic post_similarity answers', () => { it('returns zero candidates without falling back to the original pool', () => {
const posts = [post (1), post (2)] const posts = [post (1), post (2)]
const question = postSimilarityQuestion ('question', { const question = postSimilarityQuestion ('question', {
1: 'yes', 1: 'yes',
@@ -171,41 +123,7 @@ describe('hardFilteredPostsForAnswer', () => {
posts, posts,
question, question,
answer: 'no', answer: 'no',
})).toEqual (posts) })).toEqual ([])
})
it('hard-filters fact answers only for yes and no', () => {
const posts = [
{ ...post (1), url: 'https://example.com/posts/1' },
{ ...post (2), url: 'https://other.example/posts/2' },
]
const question = sourceQuestion ('example.com')
expect(hardFilteredPostsForAnswer ({
posts,
question,
answer: 'yes',
}).map (candidate => candidate.id)).toEqual ([1])
expect(hardFilteredPostsForAnswer ({
posts,
question,
answer: 'no',
}).map (candidate => candidate.id)).toEqual ([2])
expect(hardFilteredPostsForAnswer ({
posts,
question,
answer: 'partial',
})).toEqual (posts)
expect(hardFilteredPostsForAnswer ({
posts,
question,
answer: 'probably_no',
})).toEqual (posts)
expect(hardFilteredPostsForAnswer ({
posts,
question,
answer: 'unknown',
})).toEqual (posts)
}) })
}) })
@@ -219,7 +137,7 @@ describe('recoverCandidatePosts', () => {
posts, posts,
scores, scores,
rejectedPostIds: new Set ([10]), rejectedPostIds: new Set ([10]),
recoveredCandidatePosts: new Map ([[8, recoveredState (1, 8)]]), recoveredCandidatePosts: new Map ([[8, 1]]),
eligiblePostIds: new Set ([9]), eligiblePostIds: new Set ([9]),
answerCountAtRecovery: 2, answerCountAtRecovery: 2,
recoveryStepCount: 0, recoveryStepCount: 0,
@@ -227,33 +145,7 @@ describe('recoverCandidatePosts', () => {
expect(recovered?.recoveryStepCount).toBe (1) expect(recovered?.recoveryStepCount).toBe (1)
expect([...(recovered?.recoveredCandidatePosts.keys () ?? [])]) expect([...(recovered?.recoveredCandidatePosts.keys () ?? [])])
.toEqual ([8, 7, 6, 5, 4]) .toEqual ([8, 7, 6, 5, 4, 3, 2])
expect(recovered?.recoveredCandidatePosts.get (7)).toEqual ({ expect(recovered?.recoveredCandidatePosts.get (7)).toBe (2)
answerCountAtRecovery: 2,
scoreAtRecovery: 7,
})
})
it('does not add posts when recovered and eligible candidates already hit the target', () => {
const posts = Array.from ({ length: 10 }, (_value, index) => post (index + 1))
const scores = new Map (posts.map (candidate => [candidate.id, candidate.id]))
const recovered = recoverCandidatePosts ({
posts,
scores,
rejectedPostIds: new Set (),
recoveredCandidatePosts: new Map ([
[1, recoveredState (1, 1)],
[2, recoveredState (1, 2)],
[3, recoveredState (1, 3)],
]),
eligiblePostIds: new Set ([4, 5, 6]),
answerCountAtRecovery: 2,
recoveryStepCount: 0,
})
expect(recovered?.recoveryStepCount).toBe (1)
expect([...(recovered?.recoveredCandidatePosts.keys () ?? [])])
.toEqual ([1, 2, 3])
}) })
}) })
+59 -72
ファイルの表示
@@ -1,49 +1,43 @@
import { isLearnedSemanticQuestion, import { expectedAnswerForQuestion } from '@/lib/gekanator'
learnedSemanticSideForPost } from '@/lib/gekanator'
import type { GekanatorAnswerLog, GekanatorAnswerValue, GekanatorQuestion } from '@/lib/gekanator' import type {
GekanatorAnswerLog,
GekanatorAnswerValue,
GekanatorQuestion,
} from '@/lib/gekanator'
import type { Post } from '@/types' import type { Post } from '@/types'
export type RecoveredCandidatePost = { export type RecoveredCandidatePost = {
postId: number postId: number
answerCountAtRecovery: number answerCountAtRecovery: number }
scoreAtRecovery: number }
export type RecoveredCandidateState = {
answerCountAtRecovery: number
scoreAtRecovery: number }
const questionSupportsAnswerBasedHardFiltering = (question: GekanatorQuestion): boolean => export const candidatePostsFor = ({
!(isLearnedSemanticQuestion (question) posts,
|| (question.kind === 'tag'
&& question.condition.type === 'tag'
&& !(question.condition.key.startsWith ('nico:'))))
export const candidatePostsFor = (
{ posts,
questions, questions,
answers, answers,
softenedQuestionIds, softenedQuestionIds,
rejectedPostIds, rejectedPostIds,
recoveredCandidatePosts }: { posts: Post[] recoveredCandidatePosts,
}: {
posts: Post[]
questions: GekanatorQuestion[] questions: GekanatorQuestion[]
answers: GekanatorAnswerLog[] answers: GekanatorAnswerLog[]
softenedQuestionIds: Set<string> softenedQuestionIds: Set<string>
rejectedPostIds: Set<number> rejectedPostIds: Set<number>
recoveredCandidatePosts: Map<number, RecoveredCandidateState> }, recoveredCandidatePosts: Map<number, number>
): Post[] => { }): Post[] => {
const questionById = new Map (questions.map (question => [question.id, question])) const questionById = new Map (questions.map (question => [question.id, question]))
return posts.filter (post => { return posts.filter (post => {
if (rejectedPostIds.has (post.id)) if (rejectedPostIds.has (post.id))
return false return false
const recoveredCandidate = recoveredCandidatePosts.get (post.id) const answerCountAtRecovery = recoveredCandidatePosts.get (post.id)
return answers.every ((answer, index) => { return answers.every ((answer, index) => {
if (recoveredCandidate != null && index < recoveredCandidate.answerCountAtRecovery) if (answerCountAtRecovery !== undefined && index < answerCountAtRecovery)
return true return true
if (softenedQuestionIds.has (answer.questionId)) if (softenedQuestionIds.has (answer.questionId))
@@ -52,18 +46,13 @@ export const candidatePostsFor = (
const question = questionById.get (answer.questionId) const question = questionById.get (answer.questionId)
if (!(question)) if (!(question))
return true return true
if (!(questionSupportsAnswerBasedHardFiltering (question)))
return true
switch (answer.answer) switch (answer.answer)
{ {
case 'yes': case 'yes':
case 'no': case 'no': {
{ const expected = expectedAnswerForQuestion (question, post)
const expected = learnedSemanticSideForPost (question, post) return expected === null || expected === 'unknown' || expected === answer.answer
return expected === 'unknown'
|| (answer.answer === 'yes' && expected === 'positive')
|| (answer.answer === 'no' && expected === 'negative')
} }
default: default:
return true return true
@@ -73,27 +62,30 @@ export const candidatePostsFor = (
} }
export const hardFilteredPostsForAnswer = ( export const hardFilteredPostsForAnswer = ({
{ posts, question, answer }: { posts: Post[] posts,
question,
answer,
}: {
posts: Post[]
question: GekanatorQuestion question: GekanatorQuestion
answer: GekanatorAnswerValue }, answer: GekanatorAnswerValue
): Post[] => { }): Post[] => {
if (!(questionSupportsAnswerBasedHardFiltering (question))) if (answer === 'unknown')
return posts
if (!(answer === 'yes' || answer === 'no'))
return posts return posts
return posts.filter (post => { return posts.filter (post => {
const side = learnedSemanticSideForPost (question, post) const expected = expectedAnswerForQuestion (question, post)
return side === 'unknown' return expected === null || expected === 'unknown' || expected === answer
|| (answer === 'yes' && side === 'positive')
|| (answer === 'no' && side === 'negative')
}) })
} }
const concreteAnswerOptions: GekanatorAnswerValue[] = ['yes', 'no', 'partial', 'probably_no'] const concreteAnswerOptions: GekanatorAnswerValue[] = [
'yes',
'no',
'partial',
'probably_no']
export const allConcreteAnswerOptionsExhausted = ( export const allConcreteAnswerOptionsExhausted = (
@@ -108,52 +100,47 @@ export const allConcreteAnswerOptionsExhausted = (
} }
const nextRecoveryTargetSize = (recoveryStepCount: number): number => const nextRecoveryBatchSize = (recoveryStepCount: number): number =>
6 * (2 ** recoveryStepCount) 6 * (2 ** recoveryStepCount)
export const recoverCandidatePosts = ( export const recoverCandidatePosts = ({
{ posts, posts,
scores, scores,
rejectedPostIds, rejectedPostIds,
recoveredCandidatePosts, recoveredCandidatePosts,
eligiblePostIds, eligiblePostIds,
answerCountAtRecovery, answerCountAtRecovery,
recoveryStepCount }: { posts: Post[] recoveryStepCount,
}: {
posts: Post[]
scores: Map<number, number> scores: Map<number, number>
rejectedPostIds: Set<number> rejectedPostIds: Set<number>
recoveredCandidatePosts: Map<number, RecoveredCandidateState> recoveredCandidatePosts: Map<number, number>
eligiblePostIds: Set<number> eligiblePostIds: Set<number>
answerCountAtRecovery: number answerCountAtRecovery: number
recoveryStepCount: number }, recoveryStepCount: number
): { recoveredCandidatePosts: Map<number, RecoveredCandidateState> }): {
recoveryStepCount: number } | null => { recoveredCandidatePosts: Map<number, number>
recoveryStepCount: number
} | null => {
const recovered = new Map (recoveredCandidatePosts) const recovered = new Map (recoveredCandidatePosts)
const targetSize = nextRecoveryTargetSize (recoveryStepCount) const candidates = posts
const countedPostIds = new Set ([...eligiblePostIds, ...recovered.keys ()]) .filter (post =>
const addCount = targetSize - countedPostIds.size !(rejectedPostIds.has (post.id))
if (addCount <= 0)
{
return { recoveredCandidatePosts: recovered,
recoveryStepCount: recoveryStepCount + 1 }
}
const candidates =
posts
.filter (post => (!(rejectedPostIds.has (post.id))
&& !(eligiblePostIds.has (post.id)) && !(eligiblePostIds.has (post.id))
&& !(recovered.has (post.id)))) && !(recovered.has (post.id)))
.sort ((a, b) => ((scores.get (b.id) ?? Number.NEGATIVE_INFINITY) .sort ((a, b) =>
- (scores.get (a.id) ?? Number.NEGATIVE_INFINITY))) (scores.get (b.id) ?? Number.NEGATIVE_INFINITY)
.slice (0, addCount) - (scores.get (a.id) ?? Number.NEGATIVE_INFINITY))
.slice (0, nextRecoveryBatchSize (recoveryStepCount))
if (candidates.length === 0) if (candidates.length === 0)
return null return null
candidates.forEach (post => recovered.set (post.id, { candidates.forEach (post => recovered.set (post.id, answerCountAtRecovery))
answerCountAtRecovery,
scoreAtRecovery: scores.get (post.id) ?? 0 }))
return { recoveredCandidatePosts: recovered, return {
recoveredCandidatePosts: recovered,
recoveryStepCount: recoveryStepCount + 1 } recoveryStepCount: recoveryStepCount + 1 }
} }
-86
ファイルの表示
@@ -1,6 +1,3 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { isQuestionHardFilteredAfterAnswers } from '@/lib/gekanatorQuestionFilters' import { isQuestionHardFilteredAfterAnswers } from '@/lib/gekanatorQuestionFilters'
@@ -52,89 +49,6 @@ const blocked = (
isQuestionHardFilteredAfterAnswers (question (candidate), [answer (previous, value)]) isQuestionHardFilteredAfterAnswers (question (candidate), [answer (previous, value)])
const gekanatorPageSource = readFileSync (
resolve (process.cwd (), 'src/pages/GekanatorPage.tsx'),
'utf8')
const gekanatorBackdropSource = gekanatorPageSource.slice (
gekanatorPageSource.indexOf ('const GekanatorBackdrop'),
gekanatorPageSource.indexOf ('const expectedAnswerFor'))
const gekanatorChooseQuestionSource = gekanatorPageSource.slice (
gekanatorPageSource.indexOf ('const chooseQuestion'),
gekanatorPageSource.indexOf ('const winningRunPriorityFor'))
const gekanatorFallbackQuestionSource = gekanatorPageSource.slice (
gekanatorPageSource.indexOf ('const chooseFallbackQuestion'),
gekanatorPageSource.indexOf ('const shouldEnterGuessPhase'))
describe('GekanatorBackdrop regression structure', () => {
it('keeps displayedBackdropMode as the render-time source of truth', () => {
expect(gekanatorBackdropSource).not.toContain ('isLeavingGuessBackdrop')
expect(gekanatorBackdropSource).not.toContain ('renderBackdropMode')
expect(gekanatorBackdropSource).not.toContain ('renderWinningRunCount')
expect(gekanatorBackdropSource).not.toContain ('renderThumbnails')
expect(gekanatorBackdropSource).not.toContain ('renderIsCrossfading')
expect(gekanatorBackdropSource).toContain (
"const renderedSettings = settingsForMode (displayedBackdropMode)")
expect(gekanatorBackdropSource).toContain (
'scaleForMode (displayedBackdropMode, displayedWinningRunCount)')
expect(gekanatorBackdropSource).toContain (
"backdropMode === 'guess' || displayedBackdropMode === 'guess'")
})
it('does not split guess into a separate renderer or force a remount', () => {
expect(gekanatorBackdropSource).not.toContain ('renderStaticGuessBackdrop')
expect(gekanatorBackdropSource).not.toContain ('guessZoomAnimationKey')
expect(gekanatorBackdropSource).not.toContain ('shouldAnimateGuessZoomIn')
expect(gekanatorBackdropSource).not.toContain ('previousBackdropModeRef')
expect(gekanatorBackdropSource).not.toContain (
'if (isGuessPresentation && guessThumbnail)')
})
it('keeps tile keys independent from backdrop mode', () => {
expect(gekanatorBackdropSource).toContain ('key={duplicate}')
expect(gekanatorBackdropSource).toContain ('key={`${ duplicate }:${ index }`}')
expect(gekanatorBackdropSource).not.toMatch (/key=\{`\$\{\s*mode/)
expect(gekanatorBackdropSource).not.toMatch (/key=\{`\$\{\s*displayedBackdropMode/)
})
it('keeps guess on the shared scale, x, and y animation path', () => {
expect(gekanatorBackdropSource).toContain ('animate={{ scale: renderedScale')
expect(gekanatorBackdropSource).toContain (
"x: displayedBackdropMode === 'guess' ? guessFocusOffset.x : '0%'")
expect(gekanatorBackdropSource).toContain (
"y: displayedBackdropMode === 'guess' ? guessFocusOffset.y : '0%'")
})
})
describe('Gekanator question selection regression structure', () => {
it('prefers normal questions after user_suggested quota has been met', () => {
const normalFallbackIndex = gekanatorChooseQuestionSource.indexOf (
'else if (normalPool.length > 0)')
const effectiveFallbackIndex = gekanatorChooseQuestionSource.indexOf (
'else if (effectiveUserSuggestedPool.length > 0)')
expect(normalFallbackIndex).toBeGreaterThan(0)
expect(effectiveFallbackIndex).toBeGreaterThan(0)
expect(normalFallbackIndex).toBeLessThan(effectiveFallbackIndex)
})
it('does not let fallback questions bypass user_suggested purpose tracking', () => {
expect(gekanatorFallbackQuestionSource).toContain (
"question.source !== 'user_suggested'")
})
it('does not show a fixed extra-question count in the extra learning UI', () => {
expect(gekanatorPageSource).not.toContain ('追加で 2 問まで答えてください。')
expect(gekanatorPageSource).toContain ('追加で質問に答えてください。')
})
})
describe('isQuestionHardFilteredAfterAnswers', () => { describe('isQuestionHardFilteredAfterAnswers', () => {
it('blocks only contradictory or redundant month questions after a yes answer', () => { it('blocks only contradictory or redundant month questions after a yes answer', () => {
const previous: GekanatorQuestionCondition = { type: 'original-month', month: 12 } const previous: GekanatorQuestionCondition = { type: 'original-month', month: 12 }
ファイル差分が大きすぎるため省略します 差分を読込み
-4
ファイルの表示
@@ -139,10 +139,6 @@ export type Post = {
title: string | null title: string | null
thumbnail: string | null thumbnail: string | null
thumbnailBase: string | null thumbnailBase: string | null
postSimilarityEdges?: {
targetPostId: number
cos: number
}[]
tags: Tag[] tags: Tag[]
parentPosts?: Post[] parentPosts?: Post[]
childPosts?: Post[] childPosts?: Post[]