コミットを比較

..

3 コミット

作成者 SHA1 メッセージ 日付
みてるぞ 01b063f473 #361 2026-06-14 02:35:54 +09:00
みてるぞ c06d73fc6c #361 2026-06-14 00:53:11 +09:00
みてるぞ 3b5ad3b805 #361 2026-06-14 00:01:03 +09:00
11個のファイルの変更3155行の追加422行の削除
+10
ファイルの表示
@@ -158,6 +158,13 @@ npm run preview
- 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
@@ -179,6 +186,9 @@ npm run preview
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:
+17 -9
ファイルの表示
@@ -1,6 +1,6 @@
class GekanatorGamesController < ApplicationController class GekanatorGamesController < ApplicationController
def create def create
return head :not_found unless current_user&.admin? return head :unauthorized unless current_user
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
@@ -22,10 +22,8 @@ class GekanatorGamesController < ApplicationController
end end
def extra_questions def extra_questions
return head :not_found unless current_user&.admin? game = find_owned_game
return if performed?
game = GekanatorGame.find_by(id: params[:id])
return head :not_found unless game
questions = questions =
GekanatorQuestion GekanatorQuestion
@@ -45,10 +43,8 @@ class GekanatorGamesController < ApplicationController
end end
def extra_question_answers def extra_question_answers
return head :not_found unless current_user&.admin? game = find_owned_game
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)
@@ -137,4 +133,16 @@ 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
end end
-2
ファイルの表示
@@ -1,7 +1,5 @@
class GekanatorPostsController < ApplicationController class GekanatorPostsController < ApplicationController
def index def index
return head :not_found unless current_user&.admin?
posts = posts =
Post Post
.preload(tags: :tag_name) .preload(tags: :tag_name)
+4 -1
ファイルの表示
@@ -1,9 +1,12 @@
class GekanatorQuestionSuggestionsController < ApplicationController class GekanatorQuestionSuggestionsController < ApplicationController
def create def create
return head :not_found unless current_user&.admin? return head :unauthorized unless current_user
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
suggestion = GekanatorQuestionSuggestion.new( suggestion = GekanatorQuestionSuggestion.new(
gekanator_game: game, gekanator_game: game,
+4 -2
ファイルの表示
@@ -1,7 +1,5 @@
class GekanatorQuestionsController < ApplicationController class GekanatorQuestionsController < ApplicationController
def index def index
return head :not_found unless current_user&.admin?
questions = questions =
GekanatorQuestion GekanatorQuestion
.accepted .accepted
@@ -49,6 +47,8 @@ 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
@@ -77,6 +77,8 @@ 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
+151 -1
ファイルの表示
@@ -1,5 +1,15 @@
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:
@@ -8,11 +18,151 @@ module Gekanator
end end
def call def call
raise NotImplementedError, 'AI question conversion is not implemented yet.' suggestion.with_lock do
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
+2 -15
ファイルの表示
@@ -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, ReactNode, SetStateAction } from 'react' import type { Dispatch, FC, SetStateAction } from 'react'
import type { User } from '@/types' import type { User } from '@/types'
@@ -81,10 +81,7 @@ 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={ <Route path="/gekanator" element={<GekanatorPage user={user}/>}/>
<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>
@@ -92,16 +89,6 @@ 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
ファイルの表示
@@ -66,6 +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: '/gekanator' },
{ name: '上映会 (β)', to: '/theatres/1' }] }, { name: '上映会 (β)', to: '/theatres/1' }] },
{ name: 'ユーザ', to: '/users/settings', visible: false, subMenu: [ { name: 'ユーザ', to: '/users/settings', visible: false, subMenu: [
{ name: '一覧', to: '/users', visible: false }, { name: '一覧', to: '/users', visible: false },
+67 -10
ファイルの表示
@@ -13,6 +13,7 @@ export type GekanatorAnswerLog = {
questionId: string questionId: string
questionText: string questionText: string
questionCondition?: GekanatorQuestionCondition questionCondition?: GekanatorQuestionCondition
questionMode?: 'normal' | 'winning_run'
answer: GekanatorAnswerValue answer: GekanatorAnswerValue
originalAnswer: GekanatorAnswerValue } originalAnswer: GekanatorAnswerValue }
@@ -29,6 +30,8 @@ export type GekanatorQuestionSource =
| 'ai_generated' | 'ai_generated'
| 'admin_curated' | 'admin_curated'
export type GekanatorPerformanceMode = 'lite' | 'normal'
export type GekanatorQuestionCondition = export type GekanatorQuestionCondition =
| { type: 'tag'; key: string } | { type: 'tag'; key: string }
| { type: 'source'; host: string } | { type: 'source'; host: string }
@@ -38,6 +41,7 @@ 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
@@ -76,6 +80,13 @@ 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,
@@ -127,6 +138,8 @@ 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 }`
} }
} }
@@ -292,6 +305,8 @@ 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
} }
@@ -319,6 +334,7 @@ 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': case 'post-similarity':
return null return null
@@ -382,7 +398,16 @@ export const fetchGekanatorExtraQuestions = async (
} }
export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => { export const buildGekanatorQuestions = (
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 =>
@@ -404,17 +429,31 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
.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> (counts: Map<T, number>) => const usefulEntries = <T extends string | 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, 80) .slice (0, cap)
const tagQuestions = usefulEntries (tagCounts) const tagQuestions = usefulEntries (tagCounts, Math.max (tagQuestionCap, 80))
.filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7)) .filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7))
.slice (0, 80) .slice (0, tagQuestionCap)
.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
@@ -429,7 +468,7 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
test: (post: Post) => questionableTag (post, String (key)) } test: (post: Post) => questionableTag (post, String (key)) }
}) })
const sourceQuestions = usefulEntries (hosts) const sourceQuestions = usefulEntries (hosts, 20)
.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]) => ({
@@ -441,7 +480,7 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
priorityWeight: 1, priorityWeight: 1,
test: (post: Post) => hostOf (post) === host })) test: (post: Post) => hostOf (post) === host }))
const originalYearQuestions = usefulEntries (originalYears) const originalYearQuestions = usefulEntries (originalYears, 20)
.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]) => ({
@@ -453,7 +492,7 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
priorityWeight: 1, priorityWeight: 1,
test: (post: Post) => originalYearOf (post) === year })) test: (post: Post) => originalYearOf (post) === year }))
const originalMonthQuestions = usefulEntries (originalMonths) const originalMonthQuestions = usefulEntries (originalMonths, 20)
.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]) => ({
@@ -465,7 +504,7 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
priorityWeight: 1, priorityWeight: 1,
test: (post: Post) => originalMonthOf (post) === month })) test: (post: Post) => originalMonthOf (post) === month }))
const originalMonthDayQuestions = usefulEntries (originalMonthDays) const originalMonthDayQuestions = usefulEntries (originalMonthDays, 20)
.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]) => {
@@ -505,6 +544,23 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
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,
@@ -512,7 +568,8 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
...originalMonthQuestions, ...originalMonthQuestions,
...originalMonthDayQuestions, ...originalMonthDayQuestions,
...titleQuestions, ...titleQuestions,
...tagQuestions] ...titleContainsQuestions,
...tagQuestions].slice (0, totalQuestionCap)
} }
+12 -2
ファイルの表示
@@ -100,7 +100,7 @@ export const allConcreteAnswerOptionsExhausted = (
} }
const nextRecoveryBatchSize = (recoveryStepCount: number): number => const nextRecoveryTargetSize = (recoveryStepCount: number): number =>
6 * (2 ** recoveryStepCount) 6 * (2 ** recoveryStepCount)
@@ -125,6 +125,16 @@ export const recoverCandidatePosts = ({
recoveryStepCount: number recoveryStepCount: number
} | null => { } | null => {
const recovered = new Map (recoveredCandidatePosts) const recovered = new Map (recoveredCandidatePosts)
const targetSize = nextRecoveryTargetSize (recoveryStepCount)
const countedPostIds = new Set ([
...eligiblePostIds,
...recovered.keys ()])
const addCount = targetSize - countedPostIds.size
if (addCount <= 0)
return {
recoveredCandidatePosts: recovered,
recoveryStepCount: recoveryStepCount + 1 }
const candidates = posts const candidates = posts
.filter (post => .filter (post =>
!(rejectedPostIds.has (post.id)) !(rejectedPostIds.has (post.id))
@@ -133,7 +143,7 @@ export const recoverCandidatePosts = ({
.sort ((a, b) => .sort ((a, b) =>
(scores.get (b.id) ?? Number.NEGATIVE_INFINITY) (scores.get (b.id) ?? Number.NEGATIVE_INFINITY)
- (scores.get (a.id) ?? Number.NEGATIVE_INFINITY)) - (scores.get (a.id) ?? Number.NEGATIVE_INFINITY))
.slice (0, nextRecoveryBatchSize (recoveryStepCount)) .slice (0, addCount)
if (candidates.length === 0) if (candidates.length === 0)
return null return null
ファイル差分が大きすぎるため省略します 差分を読込み