題材フィルタを fail-closed で作る — JSONに方針を外出しした4段パイプライン

自動でコンテンツを作る仕組みを回していると、必ず「この題材を使ってよいか」を機械が判断する場面が来る。
実在の事件や災害を素材にしてしまう事故は、生成そのものより手前の題材選別で起きる。

フリートで運用している短編ホラーのサイト こわいはなし は、実在のトレンド語を毎回1つ選んで作品の素材にする。
つまり外から来た文字列をそのまま創作の入力にする構造で、ここが素通りすると被害者の実名や災害名が作中に入りうる。

この記事では、そこで使っている題材フィルタの作り方を一般化して書く。要点は2つで、方針をコードではなくJSONに置くことと、判定不能を必ず reject にすることである。

なぜ「迷ったら通す」が事故になるのか

素朴に書くと、こういうフィルタになりがちだ。

1
2
3
4
5
// 素朴な実装(危ない)
function isSafe(word) {
if (BLOCKED.includes(word)) return false;
return true; // ← 知らない語は全部 true
}

このコードの既定値は true である。つまりブロックリストに載っていない語はすべて採用される。
語彙は無限にあるので、ブロックリストは常に後追いになる。新しい事件が起きた翌日、その事件名はまだリストに無い。

fail-closed にするというのは、この既定値をひっくり返すことだ。

1
2
3
4
5
6
7
// fail-closed(既定は不採用)
function decide(word) {
// どこかで明示的に accept されない限り reject のまま
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 … 全角英数字・半角カタカナ・互換文字を正規形にそろえる。ABCABC を同じにする
  • 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 ")); // "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]+$" // 人名らしいローマ字ID
],
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 = [];

// stage 1: 完全一致
if (POLICY.blocklist_exact.includes(word)) {
return { decision: "reject", stage: 1, reason: "blocklist_exact", word, trace };
}
trace.push("stage1:pass");

// stage 2: 正規表現
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");

// stage 3: 分類器(信頼度不足も reject)
const c = classify(word); // { labels: [...], confident: bool }
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");

// stage 4: 許可形(ここだけが accept を出せる)
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));
// { decision: 'accept', stage: 4, reason: 'shape:anniversary', ... }

console.log(decide("#仮面ライダーゼッツ", naive));
// { decision: 'reject', stage: 4, reason: 'no_allowed_shape', ... }
// → ハッシュタグは剥がされたが、許可形のどれにも当たらないので落ちる

console.log(decide("ハグの日", alwaysUnknown));
// { decision: 'reject', stage: 3, reason: 'low_confidence', ... }
// → 分類器が答えられない回は、安全側に倒れて落ちる

3つ目の出力が、この設計のいちばん大事なところだ。
分類器が壊れている・応答しない・自信が無い、のいずれでも結果は reject になる。
外部サービスに分類を任せている場合、障害時に「全部通る」のではなく「全部止まる」ほうへ倒れる。

落とした語をどう扱うか

fail-closed の副作用として、採用率は下がる。運用でこれを吸収する仕組みが要る。

  1. 候補を複数持つ。1語だけ取ってきて落ちたら、その回は何も作れない。上位10語を取り、accept が出るまで順に試す。
  2. 落ちた理由を記録する。上の decide()stagereason を返す。これを残しておくと、
    「許可形が狭すぎて accept が出ない」のか「本当に危ない語ばかりの日だった」のかを後から切り分けられる。
  3. 許可形は足すが、既定は変えない。採用率が低いときに default_decisionaccept に変えたくなるが、それをやると全部が無意味になる。足すのは 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 に倒す
  • 採用率が落ちたら、既定ではなく許可形を足して調整する

実際にこのフィルタを通した題材で作っている短編は こわいはなし で読める。
文字列の正規化そのものを手元で確かめたいときは、文字数カウント
全角・半角変換 が使える。