776dea87d9
開発環境では、**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>
662 行
20 KiB
TypeScript
662 行
20 KiB
TypeScript
import { apiGet, apiPost } from '@/lib/api'
|
|
|
|
import type { Post } from '@/types'
|
|
|
|
export type GekanatorAnswerValue =
|
|
| 'yes'
|
|
| 'no'
|
|
| 'partial'
|
|
| 'probably_no'
|
|
| 'unknown'
|
|
|
|
export type LearnedSemanticSide =
|
|
| 'positive'
|
|
| 'negative'
|
|
| 'unknown'
|
|
|
|
export type GekanatorQuestionPurpose =
|
|
| 'effective_user_suggested'
|
|
| 'learning_user_suggested'
|
|
| 'normal'
|
|
|
|
export type GekanatorAnswerLog = {
|
|
questionId: string
|
|
questionText: string
|
|
questionCondition?: GekanatorQuestionCondition
|
|
questionMode?: 'normal' | 'winning_run'
|
|
questionPurpose?: GekanatorQuestionPurpose
|
|
effectiveQuestion?: boolean
|
|
learningQuestion?: boolean
|
|
answer: GekanatorAnswerValue
|
|
originalAnswer: GekanatorAnswerValue }
|
|
|
|
export type GekanatorQuestionKind =
|
|
| 'tag'
|
|
| 'source'
|
|
| 'title'
|
|
| 'original_date'
|
|
| 'post_similarity'
|
|
|
|
export type GekanatorQuestionSource =
|
|
| 'default'
|
|
| 'user_suggested'
|
|
| 'ai_generated'
|
|
| 'admin_curated'
|
|
|
|
export type GekanatorPerformanceMode = 'normal'
|
|
|
|
export type GekanatorQuestionCondition =
|
|
| { type: 'tag'; key: string }
|
|
| { type: 'source'; host: string }
|
|
| { type: 'original-year'; year: number }
|
|
| { type: 'original-month'; month: number }
|
|
| { type: 'original-month-day'; monthDay: string }
|
|
| { type: 'title-length-at-least'; length: number }
|
|
| { type: 'title-length-greater-than'; length: number }
|
|
| { type: 'title-has-ascii' }
|
|
| { type: 'title-contains'; text: string }
|
|
| {
|
|
type: 'post-similarity'
|
|
postId: number
|
|
answer: GekanatorAnswerValue
|
|
threshold: number
|
|
}
|
|
|
|
|
|
type NonPostSimilarityCondition = Exclude<
|
|
GekanatorQuestionCondition,
|
|
{ type: 'post-similarity' }
|
|
>
|
|
|
|
export type GekanatorExtraQuestion = {
|
|
id: number
|
|
text: string
|
|
source: GekanatorQuestionSource
|
|
priorityWeight: number }
|
|
|
|
export type StoredGekanatorQuestion = {
|
|
recordId?: number
|
|
id: string
|
|
text: string
|
|
kind: GekanatorQuestionKind
|
|
condition: GekanatorQuestionCondition
|
|
source?: GekanatorQuestionSource
|
|
priorityWeight?: number
|
|
exampleAnswers?: Record<`${ number }`, GekanatorAnswerValue> }
|
|
|
|
export type GekanatorQuestion = {
|
|
recordId?: number
|
|
id: string
|
|
text: string
|
|
kind: GekanatorQuestionKind
|
|
condition: GekanatorQuestionCondition
|
|
source: GekanatorQuestionSource
|
|
priorityWeight: number
|
|
exampleAnswers?: Record<`${ number }`, GekanatorAnswerValue>
|
|
test: (post: Post) => boolean }
|
|
|
|
export type BuildGekanatorQuestionsOptions = {
|
|
includeTitleContains?: boolean
|
|
tagQuestionCap?: number
|
|
titleContainsCap?: number
|
|
totalQuestionCap?: number
|
|
}
|
|
|
|
|
|
export const normalizeTitleLengthCondition = (
|
|
condition: GekanatorQuestionCondition): GekanatorQuestionCondition => {
|
|
switch (condition.type)
|
|
{
|
|
case 'title-length-greater-than':
|
|
return {
|
|
type: 'title-length-at-least',
|
|
length: condition.length + 1 }
|
|
default:
|
|
return condition
|
|
}
|
|
}
|
|
|
|
|
|
export const titleLengthMinimumForCondition = (
|
|
condition: GekanatorQuestionCondition): number | null => {
|
|
switch (condition.type)
|
|
{
|
|
case 'title-length-at-least':
|
|
return condition.length
|
|
case 'title-length-greater-than':
|
|
return condition.length + 1
|
|
default:
|
|
return null
|
|
}
|
|
}
|
|
|
|
|
|
export const questionIdForCondition = (
|
|
condition: NonPostSimilarityCondition): string => {
|
|
switch (condition.type)
|
|
{
|
|
case 'tag':
|
|
return `tag:${ condition.key }`
|
|
case 'source':
|
|
return `source:${ condition.host }`
|
|
case 'original-year':
|
|
return `original-year:${ condition.year }`
|
|
case 'original-month':
|
|
return `original-month:${ condition.month }`
|
|
case 'original-month-day':
|
|
return `original-month-day:${ condition.monthDay }`
|
|
case 'title-length-at-least':
|
|
case 'title-length-greater-than':
|
|
return `title:length-at-least:${ titleLengthMinimumForCondition (condition) }`
|
|
case 'title-has-ascii':
|
|
return 'title:ascii'
|
|
case 'title-contains':
|
|
return `title:contains:${ condition.text }`
|
|
}
|
|
}
|
|
|
|
|
|
const directExampleAnswerFor = (
|
|
question: StoredGekanatorQuestion,
|
|
post: Post): GekanatorAnswerValue | null => {
|
|
if (question.kind !== 'post_similarity' && question.kind !== 'tag')
|
|
return null
|
|
|
|
const direct = question.exampleAnswers?.[String (post.id) as `${ number }`]
|
|
if (direct)
|
|
return direct
|
|
|
|
if (question.condition.type === 'post-similarity' && question.condition.postId === post.id)
|
|
return question.condition.answer
|
|
|
|
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 counts = new Map<T, number> ()
|
|
values.forEach (value => counts.set (value, (counts.get (value) ?? 0) + 1))
|
|
return counts
|
|
}
|
|
|
|
|
|
const median = (values: number[]): number => {
|
|
const sorted = [...values].sort ((a, b) => a - b)
|
|
return sorted[Math.floor (sorted.length / 2)] ?? 0
|
|
}
|
|
|
|
|
|
const hostOf = (post: Post): string | null => {
|
|
try
|
|
{
|
|
return new URL (post.url).hostname.replace (/^www\./, '')
|
|
}
|
|
catch
|
|
{
|
|
return null
|
|
}
|
|
}
|
|
|
|
|
|
const originalYearOf = (post: Post): number | null => {
|
|
const value = post.originalCreatedFrom || post.originalCreatedBefore
|
|
if (!(value))
|
|
return null
|
|
|
|
const date = new Date (value)
|
|
if (Number.isNaN (date.getTime ()))
|
|
return null
|
|
|
|
return date.getFullYear ()
|
|
}
|
|
|
|
|
|
const originalDateOf = (post: Post): Date | null => {
|
|
const value = post.originalCreatedFrom || post.originalCreatedBefore
|
|
if (!(value))
|
|
return null
|
|
|
|
const date = new Date (value)
|
|
if (Number.isNaN (date.getTime ()))
|
|
return null
|
|
|
|
return date
|
|
}
|
|
|
|
|
|
const originalMonthOf = (post: Post): number | null => {
|
|
const date = originalDateOf (post)
|
|
if (!(date))
|
|
return null
|
|
|
|
return date.getMonth () + 1
|
|
}
|
|
|
|
|
|
const originalMonthDayOf = (post: Post): string | null => {
|
|
const date = originalDateOf (post)
|
|
if (!(date))
|
|
return null
|
|
|
|
return `${ date.getMonth () + 1 }-${ date.getDate () }`
|
|
}
|
|
|
|
|
|
const tagQuestionKey = ({ category, name }: { category: string; name: string }): string =>
|
|
`${ category }:${ name }`
|
|
|
|
|
|
const tagFromQuestionKey = (key: string): { category: string; name: string } => {
|
|
const [category, ...rest] = key.split (':')
|
|
return { category: category ?? '', name: rest.join (':') }
|
|
}
|
|
|
|
|
|
const nicoTagLabel = (name: string): string => name.replace (/^nico:/, '')
|
|
|
|
|
|
const tagQuestionText = (category: string, label: string): string => {
|
|
switch (category)
|
|
{
|
|
case 'deerjikist':
|
|
return `作者・ニジラーとして「${ label }」に関係してゐる?`
|
|
case 'meme':
|
|
return `元ネタ・ミームとして「${ label }」に関係しさう?`
|
|
case 'character':
|
|
return `「${ label }」といふキャラクターが関係してゐる?`
|
|
case 'material':
|
|
return `素材として「${ label }」に関係してゐる?`
|
|
case 'nico':
|
|
return `ニコニコに「${ label }」といふタグが付いてゐる?`
|
|
case 'general':
|
|
case 'meta':
|
|
default:
|
|
return `内容として「${ label }」に関係しさう?`
|
|
}
|
|
}
|
|
|
|
|
|
const questionableTag = (post: Post, key: string): boolean => {
|
|
const { category, name } = tagFromQuestionKey (key)
|
|
|
|
return (
|
|
post.tags.some (tag =>
|
|
tag.name === name
|
|
&& tag.category === category
|
|
&& !(tag.category === 'meta')
|
|
&& !(tag.name.includes ('タグ希望'))
|
|
&& !(tag.name.includes ('bot操作'))))
|
|
}
|
|
|
|
|
|
const questionMatches = (
|
|
post: Post,
|
|
question: StoredGekanatorQuestion): boolean => {
|
|
const directAnswer = directExampleAnswerFor (question, post)
|
|
if (directAnswer)
|
|
return question.kind === 'post_similarity'
|
|
? learnedSemanticSideForAnswer (directAnswer) === 'positive'
|
|
: directAnswer === 'yes'
|
|
|
|
switch (question.condition.type)
|
|
{
|
|
case 'tag':
|
|
return questionableTag (post, question.condition.key)
|
|
case 'source':
|
|
return hostOf (post) === question.condition.host
|
|
case 'original-year':
|
|
return originalYearOf (post) === question.condition.year
|
|
case 'original-month':
|
|
return originalMonthOf (post) === question.condition.month
|
|
case 'original-month-day':
|
|
return originalMonthDayOf (post) === question.condition.monthDay
|
|
case 'title-length-at-least':
|
|
return (post.title?.length ?? 0) >= question.condition.length
|
|
case 'title-length-greater-than':
|
|
return (post.title?.length ?? 0) > question.condition.length
|
|
case 'title-has-ascii':
|
|
return /[A-Za-z0-9]/.test (post.title ?? '')
|
|
case 'title-contains':
|
|
return (post.title ?? '').includes (question.condition.text)
|
|
case 'post-similarity':
|
|
return false
|
|
}
|
|
}
|
|
|
|
|
|
export const expectedAnswerForQuestion = (
|
|
question: StoredGekanatorQuestion | GekanatorQuestion | undefined,
|
|
post: Post | null): GekanatorAnswerValue | null => {
|
|
if (!(question) || !(post))
|
|
return null
|
|
|
|
const directAnswer = directExampleAnswerFor (question, post)
|
|
if (directAnswer)
|
|
return directAnswer
|
|
|
|
switch (question.condition.type)
|
|
{
|
|
case 'post-similarity':
|
|
if (question.condition.postId === post.id)
|
|
return question.condition.answer
|
|
|
|
return null
|
|
case 'tag':
|
|
case 'source':
|
|
case 'original-year':
|
|
case 'original-month':
|
|
case 'original-month-day':
|
|
case 'title-length-at-least':
|
|
case 'title-length-greater-than':
|
|
case 'title-has-ascii':
|
|
case 'title-contains':
|
|
return questionMatches (post, question) ? 'yes' : 'no'
|
|
}
|
|
}
|
|
|
|
|
|
export const learnedSemanticSideForPost = (
|
|
question: StoredGekanatorQuestion | GekanatorQuestion | undefined,
|
|
post: Post | null): LearnedSemanticSide =>
|
|
learnedSemanticSideForAnswer (expectedAnswerForQuestion (question, post))
|
|
|
|
|
|
export const restoreGekanatorQuestion = (
|
|
question: StoredGekanatorQuestion): GekanatorQuestion => {
|
|
const normalizedCondition = normalizeTitleLengthCondition (question.condition)
|
|
const normalizedQuestion = {
|
|
...question,
|
|
recordId: question.recordId,
|
|
id: normalizedCondition.type === 'title-length-at-least'
|
|
? `title:length-at-least:${ normalizedCondition.length }`
|
|
: question.id,
|
|
condition: normalizedCondition,
|
|
source: question.source ?? 'default',
|
|
priorityWeight: question.priorityWeight ?? 1 }
|
|
|
|
return {
|
|
...normalizedQuestion,
|
|
test: (post: Post) => questionMatches (post, normalizedQuestion) }
|
|
}
|
|
|
|
|
|
export const storeGekanatorQuestion = (
|
|
question: GekanatorQuestion): StoredGekanatorQuestion => ({
|
|
id: question.condition.type === 'title-length-greater-than'
|
|
? `title:length-at-least:${ question.condition.length + 1 }`
|
|
: question.id,
|
|
recordId: question.recordId,
|
|
text: question.text,
|
|
kind: question.kind,
|
|
condition: normalizeTitleLengthCondition (question.condition),
|
|
source: question.source,
|
|
priorityWeight: question.priorityWeight,
|
|
exampleAnswers: question.exampleAnswers })
|
|
|
|
|
|
export const fetchGekanatorPosts = async (): Promise<Post[]> => {
|
|
const data = await apiGet<{ posts: Post[] }> ('/gekanator/posts')
|
|
return data.posts
|
|
}
|
|
|
|
|
|
export const fetchGekanatorQuestions = async (): Promise<StoredGekanatorQuestion[]> => {
|
|
const data = await apiGet<{ questions: StoredGekanatorQuestion[] }> ('/gekanator/questions')
|
|
return data.questions
|
|
}
|
|
|
|
|
|
export const fetchGekanatorExtraQuestions = async (
|
|
gameId: number,
|
|
nonce?: string): Promise<GekanatorExtraQuestion[]> => {
|
|
const data = await apiGet<{ questions: GekanatorExtraQuestion[] }> (
|
|
`/gekanator/games/${ gameId }/extra_questions`,
|
|
{ params: nonce ? { nonce } : undefined })
|
|
return data.questions
|
|
}
|
|
|
|
|
|
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 =>
|
|
post.tags
|
|
.filter (tag =>
|
|
!(tag.category === 'meta')
|
|
&& !(tag.name.includes ('タグ希望'))
|
|
&& !(tag.name.includes ('bot操作')))
|
|
.map (tag => tagQuestionKey (tag))))
|
|
const hosts = countBy (posts.map (hostOf).filter ((host): host is string => Boolean (host)))
|
|
const originalYears = countBy (
|
|
posts
|
|
.map (originalYearOf)
|
|
.filter ((year): year is number => year != null))
|
|
const originalMonths = countBy (
|
|
posts
|
|
.map (originalMonthOf)
|
|
.filter ((month): month is number => month != null))
|
|
const originalMonthDays = countBy (
|
|
posts
|
|
.map (originalMonthDayOf)
|
|
.filter ((monthDay): monthDay is string => monthDay != null))
|
|
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>,
|
|
cap: number) =>
|
|
[...counts.entries ()]
|
|
.filter (([, count]) => count > 0 && count < posts.length)
|
|
.sort ((a, b) => Math.abs (posts.length / 2 - a[1])
|
|
- Math.abs (posts.length / 2 - b[1]))
|
|
.slice (0, cap)
|
|
|
|
const tagQuestions = usefulEntries (tagCounts, Math.max (tagQuestionCap, 80))
|
|
.filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7))
|
|
.slice (0, tagQuestionCap)
|
|
.map (([key]) => {
|
|
const { category, name } = tagFromQuestionKey (String (key))
|
|
const label = category === 'nico' ? nicoTagLabel (name) : name
|
|
|
|
return {
|
|
id: `tag:${ key }`,
|
|
text: tagQuestionText (category, label),
|
|
kind: 'tag' as const,
|
|
condition: { type: 'tag' as const, key: String (key) },
|
|
source: 'default' as const,
|
|
priorityWeight: 1,
|
|
test: (post: Post) => questionableTag (post, String (key)) }
|
|
})
|
|
|
|
const sourceQuestions = usefulEntries (hosts, 20)
|
|
.filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7))
|
|
.slice (0, 20)
|
|
.map (([host]) => ({
|
|
id: `source:${ host }`,
|
|
text: `${ host } の投稿を思ひ浮かべてゐる?`,
|
|
kind: 'source' as const,
|
|
condition: { type: 'source' as const, host },
|
|
source: 'default' as const,
|
|
priorityWeight: 1,
|
|
test: (post: Post) => hostOf (post) === host }))
|
|
|
|
const originalYearQuestions = usefulEntries (originalYears, 20)
|
|
.filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7))
|
|
.slice (0, 20)
|
|
.map (([year]) => ({
|
|
id: `original-year:${ year }`,
|
|
text: `オリジナルの投稿年は ${ year } 年?`,
|
|
kind: 'original_date' as const,
|
|
condition: { type: 'original-year' as const, year },
|
|
source: 'default' as const,
|
|
priorityWeight: 1,
|
|
test: (post: Post) => originalYearOf (post) === year }))
|
|
|
|
const originalMonthQuestions = usefulEntries (originalMonths, 20)
|
|
.filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7))
|
|
.slice (0, 20)
|
|
.map (([month]) => ({
|
|
id: `original-month:${ month }`,
|
|
text: `オリジナルの投稿月は ${ month } 月?`,
|
|
kind: 'original_date' as const,
|
|
condition: { type: 'original-month' as const, month },
|
|
source: 'default' as const,
|
|
priorityWeight: 1,
|
|
test: (post: Post) => originalMonthOf (post) === month }))
|
|
|
|
const originalMonthDayQuestions = usefulEntries (originalMonthDays, 20)
|
|
.filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7))
|
|
.slice (0, 20)
|
|
.map (([monthDay]) => {
|
|
const [month, day] = String (monthDay).split ('-')
|
|
|
|
return {
|
|
id: `original-month-day:${ monthDay }`,
|
|
text: `オリジナルの投稿日は ${ month } 月 ${ day } 日?`,
|
|
kind: 'original_date' as const,
|
|
condition: { type: 'original-month-day' as const, monthDay: String (monthDay) },
|
|
source: 'default' as const,
|
|
priorityWeight: 1,
|
|
test: (post: Post) => originalMonthDayOf (post) === monthDay }
|
|
})
|
|
|
|
const titleQuestions = [
|
|
{
|
|
id: `title:length-at-least:${ titleLengthMedian }`,
|
|
text: `タイトルは ${ titleLengthMedian } 文字以上?`,
|
|
kind: 'title' as const,
|
|
condition: {
|
|
type: 'title-length-at-least' as const,
|
|
length: titleLengthMedian },
|
|
source: 'default' as const,
|
|
priorityWeight: 1,
|
|
test: (post: Post) => (post.title?.length ?? 0) >= titleLengthMedian },
|
|
{
|
|
id: 'title:ascii',
|
|
text: '題名に英数字が混じってゐる?',
|
|
kind: 'title' as const,
|
|
condition: { type: 'title-has-ascii' as const },
|
|
source: 'default' as const,
|
|
priorityWeight: 1,
|
|
test: (post: Post) => /[A-Za-z0-9]/.test (post.title ?? '') }]
|
|
.filter (question => {
|
|
const yes = posts.filter (post => question.test (post)).length
|
|
const no = posts.length - yes
|
|
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 [
|
|
...sourceQuestions,
|
|
...originalYearQuestions,
|
|
...originalMonthQuestions,
|
|
...originalMonthDayQuestions,
|
|
...titleQuestions,
|
|
...titleContainsQuestions,
|
|
...tagQuestions].slice (0, totalQuestionCap)
|
|
}
|
|
|
|
|
|
export const saveGekanatorGame = async ({
|
|
guessedPostId,
|
|
correctPostId,
|
|
answers,
|
|
}: {
|
|
guessedPostId: number
|
|
correctPostId: number
|
|
answers: GekanatorAnswerLog[]
|
|
}): Promise<{ id: number; learnedExampleCount: number }> =>
|
|
await apiPost ('/gekanator/games', {
|
|
guessed_post_id: guessedPostId,
|
|
correct_post_id: correctPostId,
|
|
answers: answers.map (answer => ({
|
|
question_id: answer.questionId,
|
|
question_text: answer.questionText,
|
|
question_condition: answer.questionCondition ?? null,
|
|
question_mode: answer.questionMode,
|
|
question_purpose: answer.questionPurpose,
|
|
effective_question: answer.effectiveQuestion,
|
|
learning_question: answer.learningQuestion,
|
|
answer: answer.answer,
|
|
original_answer: answer.originalAnswer })) })
|
|
|
|
|
|
export const saveGekanatorQuestionSuggestion = async ({
|
|
gekanatorGameId,
|
|
existingQuestionId,
|
|
questionText,
|
|
answer,
|
|
}: {
|
|
gekanatorGameId: number
|
|
existingQuestionId?: number
|
|
questionText?: string
|
|
answer: GekanatorAnswerValue
|
|
}): Promise<{ id: number; count: number }> =>
|
|
await apiPost ('/gekanator/question_suggestions', {
|
|
gekanator_game_id: gekanatorGameId,
|
|
existing_question_id: existingQuestionId,
|
|
question_text: questionText,
|
|
answer })
|
|
|
|
|
|
export const saveGekanatorExtraQuestionAnswers = async ({
|
|
gameId,
|
|
answers,
|
|
}: {
|
|
gameId: number
|
|
answers: { questionId: number; answer: GekanatorAnswerValue }[]
|
|
}) =>
|
|
await apiPost (`/gekanator/games/${ gameId }/extra_question_answers`, {
|
|
answers: answers.map (item => ({
|
|
question_id: item.questionId,
|
|
answer: item.answer })) })
|