自動でコンテンツを作る仕組みを回していると、必ず「この題材を使ってよいか」を機械が判断する場面が来る。 実在の事件や災害を素材にしてしまう事故は、生成そのものより手前の題材選別 で起きる。
フリートで運用している短編ホラーのサイト こわいはなし は、実在のトレンド語を毎回1つ選んで作品の素材にする。 つまり外から来た文字列をそのまま創作の入力にする 構造で、ここが素通りすると被害者の実名や災害名が作中に入りうる。
この記事では、そこで使っている題材フィルタの作り方を一般化して書く。要点は2つで、方針をコードではなくJSONに置くこと と、判定不能を必ず reject にすること である。
なぜ「迷ったら通す」が事故になるのか 素朴に書くと、こういうフィルタになりがちだ。
1 2 3 4 5 function isSafe (word ) { if (BLOCKED.includes(word)) return false ; return true ; }
このコードの既定値は true である。つまりブロックリストに載っていない語はすべて採用 される。 語彙は無限にあるので、ブロックリストは常に後追いになる。新しい事件が起きた翌日、その事件名はまだリストに無い。
fail-closed にするというのは、この既定値をひっくり返すことだ。
1 2 3 4 5 6 7 function decide (word ) { let decision = "reject" ; ... return decision; }
「安全なものを列挙する」ほうが「危険なものを列挙する」より圧倒的に難しく感じるが、 題材選別では採用する形(shape)を決めておける ので成立する。記念日、暦、天気、食べ物、一般名詞。これらは形で書ける。
方針をJSONに外出しする 判定の中身をコードに書くと、方針を変えるたびにデプロイが必要になり、 「いま何を弾いているのか」がコードを読める人にしか分からなくなる。
そこで、判定の手順と語彙をJSONに置く 。実際のファイルはこういう形をしている。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 { "default_decision" : "reject" , "fail_closed" : true , "normalization" : { "steps" : ["trim" , "NFKC" , "lowercase_latin" , "collapse_internal_whitespace" , "strip_leading_hash" ] }, "pipeline" : [ { "stage" : 1 , "name" : "blocklist_exact" , "action" : "reject" }, { "stage" : 2 , "name" : "regex_patterns" , "action" : "reject" }, { "stage" : 3 , "name" : "category_classifier" , "action" : "reject_if_any" }, { "stage" : 4 , "name" : "allowlist_shape" , "action" : "accept_only_if_match" } ] }
読み方はそのままで、1〜3段は落とすためだけの段 、4段目だけが通す段 である。 1〜3を全部すり抜けても、4段目の許可形に合致しなければ reject で終わる。
正規化を先にやる理由 判定の前に文字列をそろえておかないと、同じ語が別物として扱われる。 上のJSONに書いた正規化は、それぞれ具体的な回避手口に対応している。
NFKC … 全角英数字・半角カタカナ・互換文字を正規形にそろえる。ABC と ABC を同じにする
lowercase_latin … 英字の大小をそろえる
collapse_internal_whitespace … 語中の連続空白を1つにする
strip_leading_hash … ハッシュタグの # を落とす。#語 と 語 を同じ判定にかける
Nodeで書くとこうなる。そのまま実行できる。
1 2 3 4 5 6 7 8 9 10 function normalize (raw ) { let s = String (raw).trim(); s = s.normalize("NFKC" ); s = s.replace(/[A-Z]/g , c => c.toLowerCase()); s = s.replace(/\s+/g , " " ); s = s.replace(/^#+/ , "" ); return s; }console .log(normalize(" #Lorem Ipsum " ));
順序には意味がある。strip_leading_hash を NFKC より後 に置くのは、 全角の # が NFKC で半角 # に変換されるからだ。逆順にすると全角ハッシュタグが落ちずに残る。
4段パイプラインを実装する 判定器の全体はこれだけで書ける。ポイントは decision の初期値が "reject" で、stage 4 でしか "accept" にならない ことである。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 const POLICY = { blocklist_exact : ["地震" , "訃報" , "逮捕" ], regex_patterns : [ "\\d+人(死亡|負傷|重体)" , "(容疑|逮捕|起訴|判決)" , "^[a-z]+[-_][a-z]+$" ], reject_categories : ["real_person" , "real_group" , "disaster" ], allowed_shapes : [ { id : "anniversary" , match : "(の日|デー|記念日|ウィーク|月間)$" }, { id : "calendar" , match : "(連休|祝日|週末|月末|年末|年始)" }, { id : "general_noun" , match : "^[\\u3040-\\u309f\\u30a0-\\u30ff\\u4e00-\\u9fff\\u30fc]{2,12}$" } ] };function decide (raw, classify ) { const word = normalize(raw); const trace = []; if (POLICY.blocklist_exact.includes(word)) { return { decision : "reject" , stage : 1 , reason : "blocklist_exact" , word, trace }; } trace.push("stage1:pass" ); for (const p of POLICY.regex_patterns) { if (new RegExp (p).test(word)) { return { decision : "reject" , stage : 2 , reason : `regex:${p} ` , word, trace }; } } trace.push("stage2:pass" ); const c = classify(word); if (!c.confident) { return { decision : "reject" , stage : 3 , reason : "low_confidence" , word, trace }; } const hit = c.labels.find(l => POLICY.reject_categories.includes(l)); if (hit) { return { decision : "reject" , stage : 3 , reason : `category:${hit} ` , word, trace }; } trace.push("stage3:pass" ); for (const shape of POLICY.allowed_shapes) { if (new RegExp (shape.match).test(word)) { return { decision : "accept" , stage : 4 , reason : `shape:${shape.id} ` , word, trace }; } } return { decision : "reject" , stage : 4 , reason : "no_allowed_shape" , word, trace }; }
分類器は差し替え可能にしておく。手元で試すだけなら、常に「分からない」を返すものを渡せばよい。
1 2 3 4 5 6 7 8 9 10 11 12 13 const alwaysUnknown = () => ({ labels : [], confident : false });const naive = (w ) => ({ labels : [], confident : true });console .log(decide("ハグの日" , naive));console .log(decide("#仮面ライダーゼッツ" , naive));console .log(decide("ハグの日" , alwaysUnknown));
3つ目の出力が、この設計のいちばん大事なところだ。分類器が壊れている・応答しない・自信が無い、のいずれでも結果は reject になる。 外部サービスに分類を任せている場合、障害時に「全部通る」のではなく「全部止まる」ほうへ倒れる。
落とした語をどう扱うか fail-closed の副作用として、採用率は下がる 。運用でこれを吸収する仕組みが要る。
候補を複数持つ 。1語だけ取ってきて落ちたら、その回は何も作れない。上位10語を取り、accept が出るまで順に試す。
落ちた理由を記録する 。上の decide() は stage と reason を返す。これを残しておくと、 「許可形が狭すぎて accept が出ない」のか「本当に危ない語ばかりの日だった」のかを後から切り分けられる。
許可形は足すが、既定は変えない 。採用率が低いときに default_decision を accept に変えたくなるが、それをやると全部が無意味になる。足すのは allowed_shapes のほうである。
外部ファイルが読めなかったときの挙動を決めておく 方針をJSONに外出しすると、そのファイルが読めない という新しい失敗経路が増える。 ここも既定を決めておかないと、「ファイルが無いので何もフィルタせず通す」という最悪の挙動になる。
1 2 3 4 5 6 7 8 9 10 const fs = require ("node:fs" );function loadLexicon (path ) { try { return fs.readFileSync(path, "utf8" ).split("\n" ).filter(Boolean ); } catch (e) { throw new Error (`lexicon unreadable: ${path} ` ); } }
実際の設定ファイルにも、この方針を on_missing: "reject_all" として明示的に書いてある。読めなかったことと、該当が無かったことを、同じ「0件」として扱わない。 これはフィルタに限らず、 取得系の処理すべてに当てはまる原則である。
まとめ
既定値を reject にする。accept は最後の1段でしか出さない
判定の手順と語彙はJSONに外出しし、コード変更なしで方針を動かせるようにする
正規化は判定より先に、順序も含めて明示する
分類器の信頼度不足・外部ファイルの読み取り失敗は、どちらも reject に倒す
採用率が落ちたら、既定ではなく許可形を足して調整する
実際にこのフィルタを通した題材で作っている短編は こわいはなし で読める。 文字列の正規化そのものを手元で確かめたいときは、文字数カウント や全角・半角変換 が使える。