1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AIエージェントと24時間ライブ配信の自動管理を実装してわかった「陽性対照」設計の重要性

1
Last updated at Posted at 2026-09-16

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 -c0件のとき 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つです。

  1. EXPECT_FP — 指紋を事前に渡し、食い違いがあれば送出しない
  2. wordcheck.py — 陽性対照14本・日英を言語で分けたパターン
  3. occurrences()grep -c ではなく出現回数を返す
  4. run-checked.sh — パイプを跨がないコマンド実行、罠を自己証明
  5. commit_checked — メッセージをファイル経由で渡す
  6. relay-new.sh — タイムスタンプを機械的に埋め込む
  7. _shape() — 対照の値を実行時に組み立てる(族②対策)

「名付けただけで手は止まらない。器に落ちた型は踏まなくなる」——これがセッション全体を通じた最大の教訓です。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?