素材管理 (#306) (#381)

開発環境では、**DB を壊さない前提で、migration → API → 画面 → 同期 → ZIP → 履歴**の順に見るのがよいです。今回の差分は素材管理全体に触っているので、単体でチョンチョン見るより、素材の一生を通すのが早いです。

## 0. 先に方針

**やらないこと:**

```sh id="snb3i6"
rails db:drop
rails db:reset
rails db:setup
DISABLE_DATABASE_ENVIRONMENT_CHECK=1 ...
```

これは禁止。
開発 DB に本番データを入れているなら、床板を剥がして耐震確認するようなものです。

---

## 1. migration 確認

まず現在の状態を見る。

```sh id="qsdfpt"
cd backend
RAILS_ENV=development bundle exec rails db:migrate:status
```

その後、通常 migration。

```sh id="a89fir"
RAILS_ENV=development bundle exec rails db:migrate
```

見るポイント:

```txt id="c1u57a"
materials に source_* / normalized_source_key / version_no がある
material_versions に event_type / file snapshot / source snapshot がある
material_export_items がある
material_sync_suppressions がある
material_sync_sources がある
既存 materials に material_versions version_no=1 create が backfill されている
```

確認用:

```sh id="g4vd4m"
RAILS_ENV=development bundle exec rails runner '
puts "materials=#{Material.count}"
puts "versions=#{MaterialVersion.count}"
puts "materials without versions=#{Material.left_joins(:material_versions).where(material_versions: { id: nil }).count}"
puts "sync suppressions table=#{ActiveRecord::Base.connection.table_exists?(:material_sync_suppressions)}"
'
```

ここで `materials without versions=0` になれば、backfill は通っています。

---

## 2. 既存素材一覧の画面確認

フロントを起動して `/materials` を見る。

```sh id="vl28jd"
cd frontend
npm run dev
```

見る観点:

```txt id="i2ovxw"
初期表示で素材が出る
初期表示ではグルーピングがオフ
タグなし素材も出る
カード表示でサムネまたは代替テキストが出る
一覧表示に切り替えられる
q / tag_state / media_kind / sort / direction が効く
```

ここでまず、普通の素材一覧が壊れていないことを確認します。

---

## 3. 左タグバーの確認

`/materials` を開いて左タグバーからタグを選ぶ。

見る観点:

```txt id="n6udul"
URL が tag_id=...&include_descendants=1&group_by=parent_tag になる
選択中タグが左バーで強調される
一覧上部に「選択中」の表示が出る
「タグ選択を解除」で通常表示に戻れる
解除後、tag_id / include_descendants / group_by / page が消える
子タグ・孫タグの素材も一覧に出る
親タググルーピングされる
```

特に重要なのはこれ。

```txt id="d33os0"
親タグ A を選択
  A に直接紐づく素材
  A > B に紐づく素材
  A > B > C に紐づく素材
が同じ一覧に出ること
```

---

## 4. 選択タグから素材追加

左タグからタグを選択した状態で、一覧上部の **このタグに素材を追加** を押す。

見る観点:

```txt id="qdd5uw"
素材追加画面の tag 欄に選択中タグ名が初期入力されている
file または url を指定して保存できる
保存後 return_to で元のタグ選択済み一覧に戻る
戻った一覧に追加した素材が出る
material_versions に create が 1 件できる
```

Rails console でも確認できます。

```sh id="i07rrb"
RAILS_ENV=development bundle exec rails runner '
m = Material.order(id: :desc).first
puts({ id: m.id, tag: m.tag&.name, versions: m.material_versions.count, version_no: m.version_no }.inspect)
'
```

---

## 5. グループ見出しから素材追加

親タググルーピング表示中に、各グループ見出しの **このタグに素材を追加** を押す。

見る観点:

```txt id="fpx8w4"
グループタグ名が tag 欄に初期入力される
保存後、元の一覧に戻る
追加素材がそのグループ内に出る
```

ここは今回の導線の肝です。棚の見出しから直接その棚へ素材を置けるかを見る。

---

## 6. 素材更新と履歴

既存素材の詳細または編集導線から、タグ・URL・ファイル・export path を更新する。

見る観点:

```txt id="w36i61"
更新前 snapshot が無ければ create が補われる
更新後 update version ができる
file_blob_id / file_filename / file_sha256 が material_versions に入る
export_paths_json が履歴に残る
/materials/changes または /materials/versions で履歴が見える
```

console:

```sh id="a3okhv"
RAILS_ENV=development bundle exec rails runner '
m = Material.order(updated_at: :desc).first
puts m.material_versions.order(:version_no).map { |v|
  [v.version_no, v.event_type, v.tag_name, v.file_filename, v.file_sha256, v.export_paths_hash]
}.inspect
'
```

---

## 7. サムネイル

画像素材を追加して、一覧にサムネイルが出るか確認。

動画素材があるなら、`ffmpeg` が入っている環境で backfill。

```sh id="qxm8fj"
cd backend
RAILS_ENV=development bundle exec rails materials:thumbnails:backfill
```

見る観点:

```txt id="s9a1vf"
画像は 180x180 のサムネが付く
動画はフレームからサムネが作られる
非対応ファイルは代替テキスト表示になる
ログに result が出る
```

---

## 8. ZIP export

export path がある素材を用意して、ブラウザで確認。

```txt id="aq7f7o"
/materials/download.zip?profile=legacy_drive
```

見る観点:

```txt id="h4rve5"
ZIP が落ちる
entry path が material_export_items.export_path になる
disabled な export item は入らない
ファイル実体が欠けている場合は 422 と missing_files が返る
```

---

## 9. 抑止

`/materials/suppressions` で path prefix 抑止を追加する。

見る観点:

```txt id="w80jbu"
member で作成できる
guest は forbidden / unauthorized
google_drive_path_prefix で既存素材が discard される
discard 履歴が material_versions に残る
同期時に同じ source_path 配下が再作成されない
```

---

## 10. Google Drive 同期

開発環境では、まず小さいフォルダでやるのがよいです。
いきなり本番素材集フォルダを食わせると、ログが藪になります 🌿

必要な ENV:

```sh id="kzbb7f"
GOOGLE_DRIVE_SERVICE_ACCOUNT_EMAIL=...
GOOGLE_DRIVE_PRIVATE_KEY_PATH=...
MATERIAL_SYNC_SOURCE_KIND=google_drive_path
MATERIAL_SYNC_SOURCE_FILE_ID=<folder_id>
MATERIAL_SYNC_SOURCE_NAME=dev-small-folder
MATERIAL_SYNC_SOURCE_PROFILE=legacy_drive
```

seed で source 作成、または console で作成。

```sh id="sec40c"
RAILS_ENV=development bundle exec rails db:seed
RAILS_ENV=development bundle exec rails materials:sync
```

見る観点:

```txt id="a5ltqu"
imported / updated / unchanged / suppressed / failed がログに出る
2 回目実行で unchanged が増える
tag は nil のままでも保存できる
人手で tag / url を付けた既存同期素材が、再同期で消えない
Google native file は skip される
download 後 sha256 block が効く
```

---

## 11. schema.rb は別途確認

これはテストというより merge gate です。
今回まだ怪しいので、差分に以下が混ざっていないことを確認します。

```txt id="s3k6da"
wiki_assets 削除
wiki_pages.next_asset_no 削除
素材管理と無関係な CHECK constraint 削除
素材管理と無関係な index order 消失
```

ここが残るなら、機能テストが通っても merge は止めた方がいいです。

---

## 最小テスト順

時間がないなら、この順で十分です。

```txt id="c1k2pw"
1. db:migrate
2. materials without versions = 0 を確認
3. /materials 初期表示
4. 左タグ選択 → 子孫込み表示 → グルーピング
5. タグ選択解除
6. 選択タグから素材追加 → return_to で戻る
7. グループ見出しから素材追加
8. 更新して material_versions を確認
9. ZIP export
10. 小さい Drive folder で materials:sync を 2 回
```

これで、今回の差分の主要な導線はほぼ踏めます。

Reviewed-on: #381
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
このコミットはPull リクエスト #381 でマージされました.
このコミットが含まれているのは:
2026-06-28 06:35:53 +09:00
committed by みてるぞ
コミット 776dea87d9
88個のファイルの変更6330行の追加1143行の削除
+73 -146
ファイルの表示
@@ -238,8 +238,7 @@ const createGameSeed = (): string => {
const normalizeStoredQuestionId = (
questionId: string,
condition?: GekanatorQuestionCondition,
): string => {
condition?: GekanatorQuestionCondition): string => {
if (condition?.type === 'title-length-greater-than')
return `title:length-at-least:${ condition.length + 1 }`
@@ -312,8 +311,7 @@ const sourcePriorityForMerge = (question: GekanatorQuestion): number => {
const shouldReplaceMergedQuestion = (
current: GekanatorQuestion | undefined,
candidate: GekanatorQuestion,
): boolean => {
candidate: GekanatorQuestion): boolean => {
if (!(current))
return true
@@ -408,8 +406,7 @@ const loadRecentGames = (): RecentGameSummary[] => {
const storeRecentGameSummary = (
summary: RecentGameSummary,
): RecentGameSummary[] => {
summary: RecentGameSummary): RecentGameSummary[] => {
const next =
[summary,
...loadRecentGames ().filter (item => (item.savedAt !== summary.savedAt
@@ -459,8 +456,7 @@ const resettableExtraQuestionState = (): {
const recoveredCandidateMapFromStored = (
items: RecoveredCandidatePost[],
scores: [number, number][],
): Map<number, RecoveredCandidateState> => {
scores: [number, number][]): Map<number, RecoveredCandidateState> => {
const storedScores = new Map (scores)
return new Map (items.map (item => [item.postId, {
@@ -470,8 +466,7 @@ const recoveredCandidateMapFromStored = (
const storedRecoveredCandidatesFromMap = (
recoveredCandidatePosts: Map<number, RecoveredCandidateState>,
): RecoveredCandidatePost[] =>
recoveredCandidatePosts: Map<number, RecoveredCandidateState>): RecoveredCandidatePost[] =>
[...recoveredCandidatePosts.entries ()]
.map (([postId, recoveredCandidate]) => ({
postId,
@@ -513,8 +508,7 @@ const distributionEntropy = (weights: number[]): number =>
const questionCategoryPenalty = (
question: GekanatorQuestion,
answerCount: number,
repeatPenalty: number,
): number => {
repeatPenalty: number): number => {
const earlyFactor = Math.max (0, (3 - answerCount) / 3)
const titleLengthPenalty = (() => {
if (titleLengthMinimumForCondition (question.condition) == null)
@@ -553,8 +547,7 @@ const relatedPostIdsOf = (post: Post): number[] => {
const userPriorWeightsFor = (
posts: Post[],
recentGames: RecentGameSummary[],
): Map<number, number> => {
recentGames: RecentGameSummary[]): Map<number, number> => {
const postById = new Map (posts.map (post => [post.id, post]))
const weights = new Map<number, number> ()
const addWeight = (postId: number, weight: number) => {
@@ -581,14 +574,12 @@ const userPriorWeightsFor = (
const answerWeightFor = (
questionId: string,
softenedQuestionIds: Set<string>,
): number => softenedQuestionIds.has (questionId) ? softenedAnswerWeight : 1
softenedQuestionIds: Set<string>): number => softenedQuestionIds.has (questionId) ? softenedAnswerWeight : 1
const scoreWeightForAnswer = (
answer: GekanatorAnswerLog,
softenedQuestionIds: Set<string>,
): number =>
softenedQuestionIds: Set<string>): number =>
answerWeightFor (answer.questionId, softenedQuestionIds)
* (
answer.questionPurpose === 'learning_user_suggested'
@@ -638,8 +629,7 @@ const titleTermPattern =
const addPostIdToIndex = <K extends string | number> (
index: Map<K, Set<number>>,
key: K,
postId: number,
) => {
postId: number) => {
const current = index.get (key)
if (current)
{
@@ -652,8 +642,7 @@ const addPostIdToIndex = <K extends string | number> (
const buildMaterialIndex = (
posts: Post[],
): GekanatorQuestionMaterialIndex => {
posts: Post[]): GekanatorQuestionMaterialIndex => {
const postById = new Map<number, Post> ()
const tagKeysByPostId = new Map<number, string[]> ()
const postIdsByTagKey = new Map<string, Set<number>> ()
@@ -784,8 +773,7 @@ const originalDateQuestionTextFor = (
condition: Extract<
GekanatorQuestionCondition,
{ type: 'original-year' | 'original-month' | 'original-month-day' }
>,
): string => {
>): string => {
switch (condition.type)
{
case 'original-year':
@@ -852,8 +840,7 @@ const isLearnableTagKey = (key: string): boolean => !(key.startsWith ('nico:'))
const isUserSuggestedLearnedSemanticQuestion = (
question: GekanatorQuestion,
): boolean => isLearnedSemanticQuestion (question)
question: GekanatorQuestion): boolean => isLearnedSemanticQuestion (question)
type LearnedSemanticCandidateStats = {
@@ -872,8 +859,7 @@ const learnedSemanticStatsForCandidateIds = (
question }: {
candidateIds: number[]
posts: Post[]
question: GekanatorQuestion },
): LearnedSemanticCandidateStats => {
question: GekanatorQuestion }): LearnedSemanticCandidateStats => {
const candidateIdSet = new Set (candidateIds)
const positiveIds = new Set<number> ()
const negativeIds = new Set<number> ()
@@ -915,8 +901,7 @@ const learnedSemanticQuestionIsEffectiveForCandidateIds = (
question }: {
candidateIds: number[]
posts: Post[]
question: GekanatorQuestion },
): boolean => {
question: GekanatorQuestion }): boolean => {
if (!(isUserSuggestedLearnedSemanticQuestion (question)))
return false
@@ -936,8 +921,7 @@ const learnedSemanticQuestionIsEffectiveForCandidateIds = (
const directSemanticAnswerForPost = (
question: GekanatorQuestion,
post: Post,
): GekanatorAnswerValue | null => {
post: Post): GekanatorAnswerValue | null => {
const direct = question.exampleAnswers?.[String (post.id) as `${ number }`]
if (direct)
return direct
@@ -956,8 +940,7 @@ const learnedSemanticLearningValueForTopPosts = (
question: GekanatorQuestion
learningTargetPosts: Post[]
candidateIds: number[]
posts: Post[] },
): { missingTopCount: number
posts: Post[] }): { missingTopCount: number
knownCount: number
hasLearningValue: boolean } => {
const missingTopCount =
@@ -1000,8 +983,7 @@ const learningTargetPostsForCandidates = ({
const questionPurposeCountsFor = (
answers: GekanatorAnswerLog[],
): {
answers: GekanatorAnswerLog[]): {
effectiveUserSuggestedCount: number
learningUserSuggestedCount: number
normalQuestionCount: number
@@ -1041,8 +1023,7 @@ const questionPurposeCountsFor = (
const learnedSemanticNarrowPenaltyForStats = (
candidateCount: number,
stats: LearnedSemanticCandidateStats,
): number => {
stats: LearnedSemanticCandidateStats): number => {
const minSide = candidateCount < 10 ? 1 : Math.max (3, candidateCount * .08)
return stats.positiveCount < minSide || stats.negativeCount < minSide ? .15 : 0
}
@@ -1050,8 +1031,7 @@ const learnedSemanticNarrowPenaltyForStats = (
const learnedSemanticScoreDeltaForExpectedAnswer = (
userAnswer: GekanatorAnswerValue,
expectedAnswer: GekanatorAnswerValue | null,
): number => {
expectedAnswer: GekanatorAnswerValue | null): number => {
switch (userAnswer)
{
case 'yes':
@@ -1089,8 +1069,7 @@ const learnedSemanticScoreDeltaForExpectedAnswer = (
const scoreDropDeltaForRecoveredPost = (
postId: number,
totalScore: number,
recoveredCandidatePosts: Map<number, RecoveredCandidateState>,
): number => {
recoveredCandidatePosts: Map<number, RecoveredCandidateState>): number => {
const recoveredCandidate = recoveredCandidatePosts.get (postId)
if (recoveredCandidate == null)
return totalScore
@@ -1114,8 +1093,7 @@ const postPassesScoreDrop = (
recoveredCandidatePosts }: {
postId: number
scores: Map<number, number>
recoveredCandidatePosts: Map<number, RecoveredCandidateState> },
): boolean => {
recoveredCandidatePosts: Map<number, RecoveredCandidateState> }): boolean => {
if (!(activeCandidateScoreDropEnabled (scores)))
return true
@@ -1129,8 +1107,7 @@ const postPassesScoreDrop = (
// `post_similarities` is the score-propagation graph, not the question kind.
const questionUsesPostSimilarityPropagationGraphForScoring = (
question: GekanatorQuestion,
): boolean =>
question: GekanatorQuestion): boolean =>
(question.kind === 'post_similarity'
&& !(isUserSuggestedLearnedSemanticQuestion (question)))
|| (question.kind === 'tag'
@@ -1139,8 +1116,7 @@ const questionUsesPostSimilarityPropagationGraphForScoring = (
const questionSupportsAnswerBasedHardFiltering = (
question: GekanatorQuestion,
): boolean => !(questionUsesPostSimilarityPropagationGraphForScoring (question))
question: GekanatorQuestion): boolean => !(questionUsesPostSimilarityPropagationGraphForScoring (question))
&& !(isUserSuggestedLearnedSemanticQuestion (question))
@@ -1160,8 +1136,7 @@ const usesLearnedTagExamples = (question: GekanatorQuestion): boolean =>
const searchedQuestionsFor = (
questions: GekanatorQuestion[],
search: string,
): GekanatorQuestion[] => {
search: string): GekanatorQuestion[] => {
const needle = search.trim ()
if (!(needle))
return []
@@ -1235,8 +1210,7 @@ type QuestionMatchResolver = {
const buildGekanatorMatchIndex = (
posts: Post[],
questions: GekanatorQuestion[],
): GekanatorMatchIndex => new Map (
questions: GekanatorQuestion[]): GekanatorMatchIndex => new Map (
questions.map (question => [
question.id,
new Set (
@@ -1285,8 +1259,7 @@ const matchingPostIdsForQuestion = ({
const positiveMatchingPostIdsForQuestion = (
resolver: QuestionMatchResolver,
): Set<number> => {
resolver: QuestionMatchResolver): Set<number> => {
if (isUserSuggestedLearnedSemanticQuestion (resolver.question))
{
const cached = resolver.dynamicMatchIndex?.get (resolver.question.id)
@@ -1356,8 +1329,7 @@ const matchingWeightInCandidates = (
materialIndex: GekanatorQuestionMaterialIndex
matchIndex: GekanatorMatchIndex
question: GekanatorQuestion
dynamicMatchIndex?: GekanatorMatchIndex },
): number => {
dynamicMatchIndex?: GekanatorMatchIndex }): number => {
const matched = positiveMatchingPostIdsForQuestion ({
posts,
materialIndex,
@@ -1380,8 +1352,7 @@ const signatureForCandidateIds = (
materialIndex: GekanatorQuestionMaterialIndex
matchIndex: GekanatorMatchIndex
question: GekanatorQuestion
dynamicMatchIndex?: GekanatorMatchIndex },
): string => {
dynamicMatchIndex?: GekanatorMatchIndex }): string => {
if (isUserSuggestedLearnedSemanticQuestion (question))
{
const postById = new Map (posts.map (post => [post.id, post]))
@@ -1421,8 +1392,7 @@ const postIdsForHardAnswer = (
posts: Post[]
materialIndex: GekanatorQuestionMaterialIndex
matchIndex: GekanatorMatchIndex
dynamicMatchIndex?: GekanatorMatchIndex },
): number[] => {
dynamicMatchIndex?: GekanatorMatchIndex }): number[] => {
if (!(questionSupportsAnswerBasedHardFiltering (question)))
return candidateIds
@@ -1562,8 +1532,7 @@ const buildIndexedQuestion = (
text: string
kind: GekanatorQuestionKind
priorityWeight: number
materialIndex: GekanatorQuestionMaterialIndex },
): GekanatorQuestion => ({
materialIndex: GekanatorQuestionMaterialIndex }): GekanatorQuestion => ({
id: questionIdForCondition (condition),
text,
kind,
@@ -1578,8 +1547,7 @@ const buildIndexedQuestion = (
const rankedEntriesForCounts = <T extends string | number> (
{ counts, total, cap }: { counts: Map<T, number>
total: number
cap: number },
): [T, number][] =>
cap: number }): [T, number][] =>
([...counts.entries ()]
.filter (([, count]) => count > 0 && count < total)
.sort ((a, b) => Math.abs (total / 2 - a[1]) - Math.abs (total / 2 - b[1]))
@@ -1594,8 +1562,7 @@ const buildQuestionsForCandidateIds = (
materialIndex: GekanatorQuestionMaterialIndex
acceptedQuestions: GekanatorQuestion[]
mode?: QuestionBuildMode
confirmationPostId?: number | null },
): GekanatorQuestion[] => {
confirmationPostId?: number | null }): GekanatorQuestion[] => {
const total = candidateIds.length
const confirmationPost = (() => {
if (confirmationPostId == null)
@@ -1652,8 +1619,7 @@ const buildQuestionsForCandidateIds = (
condition: Extract<
GekanatorQuestionCondition,
{ type: 'original-year' | 'original-month' | 'original-month-day' }
>,
): GekanatorQuestion => {
>): GekanatorQuestion => {
const priorityWeight = (() => {
if (condition.type === 'original-year')
return 1.04
@@ -1673,8 +1639,7 @@ const buildQuestionsForCandidateIds = (
const specialMonthDays = rankedEntriesForCounts ({
counts: monthDayCounts,
total,
cap: factCap
}).filter (([monthDay]) => specialOriginalMonthDayLabelFor (String (monthDay)) != null)
cap: factCap}).filter (([monthDay]) => specialOriginalMonthDayLabelFor (String (monthDay)) != null)
if (mode === 'split')
{
@@ -2124,8 +2089,7 @@ type ExclusiveConditionGroup =
const exclusiveConditionGroupFor = (
condition: GekanatorQuestion['condition'],
): ExclusiveConditionGroup | null => {
condition: GekanatorQuestion['condition']): ExclusiveConditionGroup | null => {
switch (condition.type)
{
case 'original-month':
@@ -2144,8 +2108,7 @@ const exclusiveConditionGroupFor = (
const sameConditionValue = (
left: GekanatorQuestion['condition'],
right: GekanatorQuestion['condition'],
): boolean => {
right: GekanatorQuestion['condition']): boolean => {
const leftTitleLength = titleLengthMinimumForCondition (left)
const rightTitleLength = titleLengthMinimumForCondition (right)
if (leftTitleLength != null || rightTitleLength != null)
@@ -2187,8 +2150,7 @@ const sameConditionValue = (
const isMonthCrossMatch = (
candidate: GekanatorQuestion['condition'],
previous: GekanatorQuestion['condition'],
): boolean => {
previous: GekanatorQuestion['condition']): boolean => {
const candidateMonth = monthForCondition (candidate)
const previousMonth = monthForCondition (previous)
if (candidateMonth == null || previousMonth == null)
@@ -2204,8 +2166,7 @@ const isMonthCrossMatch = (
const isExclusiveContradiction = (
candidate: GekanatorQuestion['condition'],
previous: GekanatorQuestion['condition'],
): boolean => {
previous: GekanatorQuestion['condition']): boolean => {
const candidateGroup = exclusiveConditionGroupFor (candidate)
const previousGroup = exclusiveConditionGroupFor (previous)
@@ -2242,16 +2203,14 @@ const contradictionPenaltyFor = ({
case 'no':
if (
sameConditionValue (question.condition, previous)
|| isMonthCrossMatch (question.condition, previous)
)
|| isMonthCrossMatch (question.condition, previous))
return sum + 40
return sum
case 'probably_no':
if (
sameConditionValue (question.condition, previous)
|| isMonthCrossMatch (question.condition, previous)
)
|| isMonthCrossMatch (question.condition, previous))
return sum + 20
return sum
@@ -2281,8 +2240,7 @@ const chooseQuestion = (
recentFirstQuestionPenaltyById: Map<string, number>
userPriorWeights: Map<number, number>
materialIndex: GekanatorQuestionMaterialIndex
matchIndex: GekanatorMatchIndex },
): QuestionSelection | null => {
matchIndex: GekanatorMatchIndex }): QuestionSelection | null => {
const dynamicMatchIndex = new Map<string, Set<number>> ()
const invertedSignature = (signature: string): string =>
@@ -2327,8 +2285,7 @@ const chooseQuestion = (
const rank = (
questionsToRank: GekanatorQuestion[],
candidates: { post: Post; score: number }[],
weightedCandidates: { post: Post; score: number; weight: number }[],
) => {
weightedCandidates: { post: Post; score: number; weight: number }[]) => {
const redundant = redundantSignatures (candidates.map (item => item.post))
const candidateById = new Map (candidates.map (item => [item.post.id, item.post]))
const candidateIds = candidates.map (item => item.post.id)
@@ -2613,8 +2570,7 @@ const chooseQuestion = (
if (
effectiveRatio < targetEffectiveUserSuggestedQuestionRatio
&& totalUserSuggestedRatio < targetTotalUserSuggestedQuestionRatio
&& effectiveUserSuggestedPool.length > 0
)
&& effectiveUserSuggestedPool.length > 0)
{
selectedPool = effectiveUserSuggestedPool
selectedPurpose = 'effective_user_suggested'
@@ -2622,8 +2578,7 @@ const chooseQuestion = (
else if (
learningRatio < targetLearningUserSuggestedQuestionRatio
&& totalUserSuggestedRatio < targetTotalUserSuggestedQuestionRatio
&& learningUserSuggestedPool.length > 0
)
&& learningUserSuggestedPool.length > 0)
{
selectedPool = learningUserSuggestedPool
selectedPurpose = 'learning_user_suggested'
@@ -2683,8 +2638,7 @@ const chooseQuestion = (
const winningRunPriorityFor = (
expected: GekanatorAnswerValue,
): number | null => {
expected: GekanatorAnswerValue): number | null => {
if (expected === 'yes')
return 0
if (expected === 'partial')
@@ -2719,8 +2673,7 @@ const chooseWinningRunQuestion = ({
materialIndex,
acceptedQuestions,
mode: 'confirmation',
confirmationPostId: targetPost.id
})
confirmationPostId: targetPost.id})
.filter (question => {
if (askedIds.has (question.id))
return false
@@ -2829,8 +2782,7 @@ const chooseFallbackQuestion = ({
materialIndex,
acceptedQuestions: [],
mode: 'confirmation',
confirmationPostId: post.id
})))
confirmationPostId: post.id})))
.slice (0, 32)
const dynamicMatchIndex = new Map<string, Set<number>> ()
const ranked = mergeQuestions ([
@@ -2905,8 +2857,7 @@ const chooseFallbackQuestion = ({
const shouldEnterGuessPhase = (
reason: GuessReason | null,
): reason is 'hard_max_questions' | 'winning_run_finished' | 'question_count_checkpoint' =>
reason: GuessReason | null): reason is 'hard_max_questions' | 'winning_run_finished' | 'question_count_checkpoint' =>
(reason === 'hard_max_questions'
|| reason === 'winning_run_finished'
|| reason === 'question_count_checkpoint')
@@ -2914,15 +2865,13 @@ const shouldEnterGuessPhase = (
const isWinningRunActive = (
winningRunTargetId: number | null,
winningRunStartAnswerCount: number | null,
): boolean =>
winningRunStartAnswerCount: number | null): boolean =>
winningRunTargetId != null && winningRunStartAnswerCount != null
const winningRunQuestionCount = (
answers: GekanatorAnswerLog[],
winningRunStartAnswerCount: number | null,
): number => {
winningRunStartAnswerCount: number | null): number => {
if (winningRunStartAnswerCount == null)
return 0
@@ -2962,8 +2911,7 @@ const nextQuestionPlanFor = (
matchIndex: GekanatorMatchIndex
lastGuessQuestionCount: number
winningRunTargetId: number | null
winningRunStartAnswerCount: number | null },
): { question: GekanatorQuestion | null
winningRunStartAnswerCount: number | null }): { question: GekanatorQuestion | null
guess: Post | null
guessReason: GuessReason | null
questionMode: QuestionMode
@@ -3013,8 +2961,7 @@ const nextQuestionPlanFor = (
if (
isWinningRunActive (winningRunTargetId, winningRunStartAnswerCount)
&& winningRunTargetId === nextWinningRunTargetId
&& winningRunStartAnswerCount != null
)
&& winningRunStartAnswerCount != null)
return winningRunStartAnswerCount
return answers.length
@@ -3188,8 +3135,7 @@ const mascotStateFor = (
resultWon: boolean | null,
eligiblePostCount: number,
bestConfidencePercent: number,
winningRunActive: boolean,
): MascotState => {
winningRunActive: boolean): MascotState => {
const resultPhase =
phase === 'end'
|| phase === 'review'
@@ -3210,13 +3156,11 @@ const mascotStateFor = (
if (
winningRunActive
|| eligiblePostCount <= 2
|| bestConfidencePercent >= 70
)
|| bestConfidencePercent >= 70)
return 'thinking_near'
if (
eligiblePostCount >= 15
&& bestConfidencePercent < 45
)
&& bestConfidencePercent < 45)
return 'thinking_far'
return 'thinking_mid'
case 'guess':
@@ -3327,8 +3271,7 @@ const GekanatorBackdrop: FC<{
const settingsForMode = useCallback (
(
mode: 'normal' | 'winning_run' | 'guess',
): { columns: number; rows: number; opacity: number } => {
mode: 'normal' | 'winning_run' | 'guess'): { columns: number; rows: number; opacity: number } => {
if (mode === 'winning_run' || mode === 'guess')
return { columns: 8, rows: 8, opacity: motionMode === 'calm' ? .18 : .24 }
@@ -3342,8 +3285,7 @@ const GekanatorBackdrop: FC<{
const scaleForMode = useCallback (
(
mode: 'normal' | 'winning_run' | 'guess',
displayedWinningCount: number,
): number => {
displayedWinningCount: number): number => {
if (mode === 'guess')
return 8
@@ -3355,8 +3297,7 @@ const GekanatorBackdrop: FC<{
[])
const postsForMode = useCallback ((
mode: 'normal' | 'winning_run' | 'guess',
): Post[] => {
mode: 'normal' | 'winning_run' | 'guess'): Post[] => {
if (mode === 'guess' && displayedGuess)
return [displayedGuess]
if (mode === 'winning_run' && winningRunTargetPost)
@@ -3366,8 +3307,7 @@ const GekanatorBackdrop: FC<{
const thumbnailsForMode = useCallback ((
mode: 'normal' | 'winning_run' | 'guess',
count: number,
): string[] => {
count: number): string[] => {
const modePosts = postsForMode (mode)
if (modePosts.length === 0)
return []
@@ -3734,8 +3674,7 @@ const GekanatorBackdrop: FC<{
const expectedAnswerFor = (
question: GekanatorQuestion | undefined,
correctPost: Post | null,
): GekanatorAnswerValue | null =>
correctPost: Post | null): GekanatorAnswerValue | null =>
expectedAnswerForQuestion (question, correctPost)
@@ -4169,8 +4108,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
setSaved (true)
setSavedGameId (data.id)
setLearnedExampleCount (data.learnedExampleCount)
setResultWon (variables.guessedPostId === variables.correctPostId)
}})
setResultWon (variables.guessedPostId === variables.correctPostId)}})
const questionSuggestionMutation = useMutation ({
mutationFn: saveGekanatorQuestionSuggestion,
onSuccess: async data => {
@@ -4180,15 +4118,13 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
setQuestionSuggestionSearch ('')
setQuestionSuggestionSelectedId (null)
setQuestionSuggestion ('')
setQuestionSuggestionAnswer ('yes')
}})
setQuestionSuggestionAnswer ('yes')}})
const extraQuestionAnswersMutation = useMutation ({
mutationFn: saveGekanatorExtraQuestionAnswers,
onSuccess: async () => {
await queryClient.refetchQueries ({ queryKey: gekanatorKeys.questions () })
setExtraQuestionState ('saved')
setPhase ('end')
}})
setPhase ('end')}})
const resetExtraQuestionState = () => {
const next = resettableExtraQuestionState ()
@@ -4330,8 +4266,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
if (
!(allowPreQuestionRecovery)
|| recoveredEligiblePosts.length === 0
|| recoveredEligiblePosts.length === 1
)
|| recoveredEligiblePosts.length === 1)
return false
const nextQuestion = chooseQuestion ({
@@ -4493,8 +4428,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
if (
!(nextPlan.question)
&& !(shouldEnterGuessPhase (nextPlan.guessReason))
&& recovered.eligiblePosts.length !== 1
)
&& recovered.eligiblePosts.length !== 1)
{
const recoveredForQuestion = recoverQuestionState ({
nextAnswers,
@@ -4602,8 +4536,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
!(canPersistGame)
|| reviewGuessedPostId == null
|| reviewCorrectPostId == null
|| saveMutation.isPending
)
|| saveMutation.isPending)
return
if (savedGameId != null)
@@ -4666,8 +4599,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
!(canPersistGame)
|| savedGameId == null
|| extraQuestionAnswersMutation.isPending
|| extraQuestions.some (question => !(extraQuestionAnswers[String (question.id)]))
)
|| extraQuestions.some (question => !(extraQuestionAnswers[String (question.id)])))
return
extraQuestionAnswersMutation.mutate ({
@@ -4870,8 +4802,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
const answerExtraQuestion = (
questionId: number,
value: GekanatorAnswerValue,
) => {
value: GekanatorAnswerValue) => {
setExtraQuestionAnswers ({
...extraQuestionAnswers,
[String (questionId)]: value })
@@ -4909,8 +4840,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
|| isLoading
|| acceptedQuestionsLoading
|| shouldEnterGuessPhase (questionPlan.guessReason)
|| eligiblePosts.length === 1
)
|| eligiblePosts.length === 1)
return
const recovered = recoverQuestionState ({
@@ -4926,8 +4856,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
if (
recovered.recoveryStepCount === recoveryStepCount
&& recovered.recoveredCandidatePosts.size === recoveredCandidatePosts.size
&& recovered.softenedQuestionIds.size === softenedQuestionIds.size
)
&& recovered.softenedQuestionIds.size === softenedQuestionIds.size)
return
setSoftenedQuestionIds (recovered.softenedQuestionIds)
@@ -4954,15 +4883,13 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
if (
phase !== 'question'
|| isLoading
|| acceptedQuestionsLoading
)
|| acceptedQuestionsLoading)
return
if (
currentQuestion
|| !(questionPlan.guess)
|| !(shouldEnterGuessPhase (questionPlan.guessReason))
)
|| !(shouldEnterGuessPhase (questionPlan.guessReason)))
return
setWinningRunTargetId (questionPlan.winningRunTargetId)