AIエージェントと長時間ライブ配信管理を実装してわかった「陽性対照」設計の重要性
はじめに
YouTube の24時間ライブ配信を自動管理するシステムを、Claude Code(AIエージェント)と人間が役割を分担しながら開発するセッションを行いました。配信枠の自動ローテーション、OAuth 審査への対応、本番サーバーへのデプロイまで、1日で数十件の実装・修正・検証を繰り返しました。
このセッションで強烈に学んだのは、「テストが通った」と「実際に安全に動く」は別物という事実です。特にシェルスクリプトのパイプ結合、Python の正規表現、Git フックにおいて、出力が「成功」を示していても内部で静かに失敗していたケースが連続して発生しました。
本記事では、そのセッションで生まれた「陽性対照を埋め込む設計パターン」を、実際のコードとともに紹介します。
やったこと
全体像
- YouTube Live API(
liveBroadcasts.insert)を使った配信枠の自動切り替え - OAuth スコープ審査への申請書作成と動画編集
- 本番サーバーへのスクリプト配布と設定ドリフト検査
- CI/CD 的な pre-commit/pre-push フックによる秘密情報の自動検出
作成した主なツール
| スクリプト | 役割 |
|---|---|
scripts/wordcheck.py |
禁止語の出現数を検査(日英混在対応) |
scripts/lib/run-checked.sh |
シェルのパイプ罠を避けた安全なコマンド実行 |
scripts/relay-new.sh |
中継ファイルにタイムスタンプを機械的に埋め込む |
scripts/check-secret.py |
Git の差分に秘密情報が含まれないかを走査 |
ハマったポイント
1. \b は日本語テキストで機能しない
禁止語リストを使って申請文や台本を検査する wordcheck.py を実装しました。最初の実装では Python の正規表現 \b(単語境界)を使っていました。
# 最初の実装(❌)
def occurrences(word: str, text: str) -> int:
pattern = r"\b" + re.escape(word) + r"\b"
return len(re.findall(pattern, text, re.I))
問題: \b は \w(英数字+アンダースコア)と \W の境界です。日本語の漢字・ひらがなは \w に含まれないため、「切らないと」という禁止語を日本語テキストに埋め込んでも 30語中30語が全て検出されませんでした。
しかも陽性対照が英語テキストで作られていたため、対照はずっと ✅ のまま——問題に気づいたのは外部からの指摘でした。
修正:
# 修正後(✅)
_CJK = re.compile(
r"[\u3000-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uff00-\uffef]"
)
def is_cjk(word: str) -> bool:
return _CJK.search(word) is not None
def _pattern(word: str) -> str:
if is_cjk(word):
return re.escape(word) # 日本語:部分一致
return r"\b" + re.escape(word) + r"\b" # 英語:単語境界
def occurrences(word: str, text: str) -> int:
return len(re.findall(_pattern(word), text, re.I))
さらに、陽性対照を走査対象と同じ形式で埋め込むことを追加しました。
def selftest() -> None:
# 日本語の中に日本語の禁止語を埋め込んで検出できるか
jp_sample = "これは切らないとダメなサンプルです"
assert occurrences("切らないと", jp_sample) == 1, "日本語の検出が失敗"
# 日本語の禁止語が存在しないテキストで 0 を返すか
assert occurrences("切らないと", "This is an English text.") == 0, "誤検知"
# 英語の境界は保たれているか(approved の中に prove があっても拾わない)
assert occurrences("prove", "This has been approved.") == 0, "境界が崩れた"
教訓: 対照の入力は、本番の走査対象と同じ形式(言語・文字種)でなければなりません。「対照が通る」と「実務で機能する」は別の主張です。
2. パイプの最後の戻り値を「全体の成功」と読む
シェルで tsc のエラーを見やすくしようとして、こんなコードを書きました。
# ❌ tsc が失敗しても "✅ 通りました" が出る
pnpm exec tsc --noEmit | tail -4 && echo "✅ 通りました"
tail コマンドは(入力がゼロバイトでも)ほぼ常に exit 0 を返します。&& は tail の戻り値で判断するため、tsc が失敗していても ✅ 通りました が出力されます。
同様に grep -c は 0件のとき exit 1 を返します。
# ❌ 0件のときに && チェーンが切れる
grep -c "pattern" file && git add file
これを防ぐために run-checked.sh を作成しました。
#!/usr/bin/env bash
# scripts/lib/run-checked.sh
# 埋め込み陽性対照:パイプと && の罠を自分で実証する
_self_test() {
# ① exit 7 が正しく返るか
( exit 7 ); local r=$?
[[ $r -eq 7 ]] || { echo "❌ 対照①失敗"; exit 2; }
# ② false | true が 0 を返す罠を実証(パイプはダメ)
false | true; r=$?
[[ $r -eq 0 ]] || { echo "❌ 対照②失敗(環境が違う)"; exit 2; }
echo "⚠️ false | true = 0(パイプの罠は存在する)"
# ③ grep -c が 0件で 1 を返す罠を実証
echo "" | grep -c "nothing"; r=$?
[[ $r -eq 1 ]] || { echo "❌ 対照③失敗"; exit 2; }
echo "⚠️ grep -c 0件 = exit 1(-c の罠は存在する)"
# ④ 成功も成功として返る
( exit 0 ); r=$?
[[ $r -eq 0 ]] || { echo "❌ 対照④失敗"; exit 2; }
echo "✅ 対照4本 成立"
}
run_checked() {
local label="$1"; shift
_self_test
"$@" > /tmp/run_checked_out 2>&1
RC_CODE=$?
RC_OUT=$(cat /tmp/run_checked_out)
echo "$RC_OUT"
return $RC_CODE
}
重要な設計思想: ②と③の対照は「直った」ではなく「罠は存在する。だからこの器が必要」を毎回証明します。鳴らない見張りは緑に見える——見張りが自分の存在理由を毎回示す仕組みです。
3. コミットメッセージのバッククォートがシェルで展開される
コミットメッセージに技術的な記述(バッククォートを含む)を書いたところ、シェルがそれを展開しようとして一部の行が消えました。
# ❌ バッククォートが展開される
git commit -m "wordcheck: 対照を `selftest()` に埋め込み"
修正: コミットメッセージをファイル経由で渡す commit_checked 関数を追加しました。
commit_checked() {
_self_test
local msg
msg=$(cat) # stdin からメッセージを受け取る
local tmp
tmp=$(mktemp)
echo "$msg" > "$tmp"
git commit -F "$tmp"
local rc=$?
rm -f "$tmp"
return $rc
}
# 使い方
commit_checked << 'EOF'
wordcheck: 対照を `selftest()` に埋め込み
バッククォートも $変数展開 も安全に含められる
EOF
類似事例: ffmpeg の drawtext フィルターでコロンを含むテキストを渡すとき、text='...' の中のコロンがフィルター区切りとして解釈されます。解決策は同じ——文字列ではなくファイルで渡す(textfile=)。
# ❌ コロンがフィルター区切りになる
-vf "drawtext=text='Scopes requested: youtube'"
# ✅ ファイル経由で渡す
-vf "drawtext=textfile=/tmp/label.txt"
4. 秘密情報の検出器が自分の陽性対照で止まる
秘密情報を検出する pre-commit フックに、テスト用のダミー値を文字列として埋め込んでいました。
# ❌ 検出器が自分の陽性対照を本物として拾う
def self_test():
dummy = "abcd-efgh-ijkl-mnop-qrst" # キーの形を字のまま置いた
assert detector(dummy) == True
これを別のスクリプトから秘密走査したとき、dummy の値が「本物のキーの形をしている」として検出されました。
修正: 実行時に組み立てる。
# ✅ 実行時に組み立てる(族②対策)
def _shape() -> str:
"""キーの形を、文字を並べて組み立てる(字のまま置かない)"""
groups = ['a' * 4, 'b' * 4, 'c' * 4, 'd' * 4, 'e' * 4]
return '-'.join(groups)
def self_test():
dummy = _shape() # 実行時に組み立てる
assert detector(dummy) == True
派生する問題: 陽性対照を複数の値を混ぜた1つの文で実施していた場合、どの値がどの検出パターンを成立させているか分かりません。
# ❌ まとめて植えると責任の所在が不明
sample = f"email: {email}, ip: {ip}, id: {frame_id}, key: {key}"
assert detector(sample) == True # どれが当たっているか不明
# ✅ 形ごとに独立して取る
assert detector(email_only_text) == True # メアドだけ植えた文で検出されるか
assert detector(ip_only_text) == True # IP だけ植えた文で検出されるか
assert detector(frame_id_only_text) == True # 枠IDだけ植えた文で検出されるか
5. タイムスタンプの単調ドリフト
作業ログのファイル名に時刻を含めていましたが、途中から date コマンドを呼ばず、前の値に経過時間を手で足すようになりました。結果として最大 +98分 のずれが発生しました。
判明した手がかりはずれが単調増加していたこと。
15:40 -21分 / 17:08 +27分 / 17:32 +41分 / 17:48 +47分 / 18:08 +61分 / 19:25 +98分
ランダムなずれなら測り間違い。単調に増えるなら、測るのをやめて足している。
修正: タイムスタンプを機械的に埋め込むスクリプトと、既存ファイルを検査する --check モードを実装しました。
#!/usr/bin/env bash
# scripts/relay-new.sh(抜粋)
TOL=10 # 許容するずれ(分)
name_is_now() {
local nm="$1"
local h=${nm:9:2} m=${nm:11:2}
local file_min=$(( 10#$h * 60 + 10#$m ))
local now_min=$(( $(date '+%-H') * 60 + $(date '+%-M') ))
local diff=$(( file_min - now_min ))
[[ $diff -lt 0 ]] && diff=$(( -diff ))
[[ $diff -le $TOL ]]
}
# 陽性対照:60分ずれた名前で鳴るか、3分以内なら許すか
_self_test() {
local now_h now_m off_h
now_h=$(date '+%-H')
now_m=$(date '+%-M')
off_h=$(( (now_h + 1) % 24 ))
local off_name="20260824-$(printf '%02d%02d' $off_h $now_m)_test"
name_is_now "$off_name" && { echo "❌ 60分ずれを検出できなかった"; exit 2; }
local cur_name="20260824-$(printf '%02d%02d' $now_h $now_m)_test"
name_is_now "$cur_name" || { echo "❌ 正しい名前を弾いた"; exit 2; }
echo "✅ 陽性対照 成立"
}
学び
陽性対照を「判定の中に埋め込む」
このセッション全体を通じて最も効果があったのは、陽性対照(positive control)を判定ロジックの中に埋め込み、対照が失敗したら本番の判定を実行しないという設計です。
def scan_files(words: list[str], paths: list[str]) -> dict:
# 先に対照を全部通す
selftest() # ← 失敗したら SystemExit(2) でここで止まる
# 対照が通った場合だけ本番の走査を実行
results = {}
for path in paths:
...
return results
この設計が効いた場面:
-
\bを\\bと書いたバグ(2回)→ 対照が落ちて発見 - 和文で
\bが機能しないバグ → 和文の陽性対照を追加して発見 -
grep -cが0件で exit 1 を返すバグ → 対照がチェーンを止めて発見
(個人の感想)「テストを書く」というより「器が自分の存在理由を毎回証明する」という感覚が近く、これは通常のユニットテストとは異なる哲学だと感じました。
「成功の返事」と「出来たものの確認」を分ける
| 状況 | 誤った判断 |
|---|---|
tsc | tail && echo ✅ |
tsc が失敗しても成功と判断 |
| ffmpeg が rc=0 を返した | 動画の長さを測るまで「完成」と言わない |
| CDN から 403 が返っても "100% Render complete" | 警告の件数を別に数える |
git push | tail でタイムアウト |
macOS に timeout がなく、tail の rc を読んでいた |
一貫したルール:道具の返事は、どちら向きにも当てにならない。確認するのは出力された成果物そのもの。
走査範囲を出力に含める
「0件だった」という報告は、「走査した範囲と方法」とセットでなければ意味がありません。
# ❌
wordcheck: 合計 0 件
# ✅
wordcheck: 対照14本 ✅ / 🔴 走査 847行・数値196個 / 合計 0 件 / force-ssl=0 …
走査範囲が見えることで、「1件見つけて終わりにした」という落とし穴も防げます。
文書の「待ち」の節は、条件が満たされたら消す(または更新する)
probe を走らせるまで待つ という記述が、probe ではなくサポート回答で条件が満たされたときに残り続けると、3人が独立に「まだ待ち状態」と誤読しました。
解決策として、待ちの節に「条件(何が分かれば解けるか)」と「方法(当初の想定)」を分けて書き、実際に解決された経路も記録する形式を採用しました。
| 条件(何が分かれば解けるか) | 想定していた方法 | 解けた日・実際の経路 |
|---|---|---|
| 上限の実数 | probe の /limits | ✅ 08-07 / 🔴 サポート回答(/limits は 404) |
まとめ
このセッションで「器に落ちた」(コードとして実装した)パターンは以下の7つです。
-
EXPECT_FP— 指紋を事前に渡し、食い違いがあれば送出しない -
wordcheck.py— 陽性対照14本・日英を言語で分けたパターン -
occurrences()—grep -cではなく出現回数を返す -
run-checked.sh— パイプを跨がないコマンド実行、罠を自己証明 -
commit_checked— メッセージをファイル経由で渡す -
relay-new.sh— タイムスタンプを機械的に埋め込む -
_shape()系 — 対照の値を実行時に組み立てる(族②対策)
「名付けただけで手は止まらない。器に落ちた型は踏まなくなる」——これがセッション全体を通じた最大の教訓です。