#!/usr/bin/env python3 """debate_runner.py — 動画の台本を、ほかの AI に議論させる「議論係」. Claude だけで決めると、同じ書き手の盲点が残る。そこで役を分けた3社に台本を読ませ、 1回目は別々に指摘、2回目はお互いの指摘に賛成か反論かを1回だけ言わせる。 採るかどうかは、最後にエイイチが決める(AI の多数決では決めない)。 役: Claude … 台本の作り手。構成と流れの指摘、最後の論点表(まとめ.md)。チャットの中で書く Gemini … 視聴者役。経営者・PM・開発者になりきって、飽きる所・止まる言葉を言う(API) codex … 事実の番。1回目は事実確認。2回目は「直す案が事実をゆがめないか」だけを見る (ChatGPT のログインで動く。codex_runner.py と同じ本体を使う) 置き場所(動画/{動画名}/議論/): 依頼.md Claude が書く。台本と、チャンネルの前提 1_Claude.md Claude が書く(1回目) 1_codex.md 無ければ codex にかける。前の codex_検証結果 を写して置けば、それを使う 1_Gemini.md このスクリプトが書く 2_Gemini.md このスクリプトが書く(2回目) 2_codex.md このスクリプトが書く(2回目) かける.txt 印。Claude が置く。終わると かけた.txt に名前を変える キー: Gemini の API キーは Mac のキーチェーン(名前 gemini_api_key)から読む。ファイルには書かない。 入れ方: security add-generic-password -a "$USER" -s gemini_api_key -w 使い方(Mac のターミナルで): python3 debate_runner.py --check キーとモデルを確かめるだけ(議論はしない) python3 debate_runner.py --once 印のついた動画を1本、議論にかける python3 debate_runner.py --dry-run 何をするかを出すだけ python3 debate_runner.py --install 5分ごとに見回るように登録する(一度だけ。以後は印を置けば自動で回る) python3 debate_runner.py --uninstall 自動の見回りを外す Python の標準の道具だけで動く。台本(narration.py など)には触らない。ファイルは消さない。 """ import argparse, json, os, re, subprocess, sys, time, unicodedata, urllib.error, urllib.request from datetime import datetime, timezone, timedelta HERE = os.path.dirname(os.path.abspath(__file__)) VIDEOS = os.path.dirname(HERE) # 動画/ sys.path.insert(0, HERE) import codex_runner as cr # codex の本体の場所と環境を使い回す JST = timezone(timedelta(hours=9)) SUB = "議論" MARK = "かける.txt" DONE = "かけた.txt" RUNNING = "実行中.txt" REQ = "依頼.md" THEMES = "_議論" # 動画/_議論/{テーマ}/ … 台本ではなく、企画や方針を議論するとき(印と結果はフォルダの中に直接置く) ROLE_GEMINI = "役_Gemini.md" # テーマの議論で、Gemini の1回目の役(あれば視聴者役の代わりに使い、Google 検索も使わせる) ROLE_CODEX = "役_codex.md" # テーマの議論で、codex の1回目の役 KEYCHAIN_SERVICE = "gemini_api_key" GEMINI_BASE = "https://generativelanguage.googleapis.com/v1beta" GEMINI_MODEL = os.environ.get("GEMINI_MODEL", "") # 空なら、使える中でいちばん上の Pro を選ぶ GEMINI_TIMEOUT = 10 * 60 CODEX_EFFORT = "medium" # 2回目は検索なしで、直す案の事実だけを見る CODEX_TIMEOUT = 30 * 60 # ───────── 共通の前提(3社に同じものを渡す) ───────── GROUND = """\ # 議論の前提(全員に共通) あなたは、YouTube の解説動画の台本を良くするための議論に参加しています。 参加者は Claude(台本の作り手)・Gemini(視聴者役)・codex(事実の番)の3つの AI です。 採るかどうかは、最後に人間(エイイチ)が決めます。あなたは材料を出す役です。 ## 判定の軸(この4つだけで評価する) 1. ターゲットの役に立つか(ロボティクスに興味がある経営者・PM・マネージャー・開発者) 2. 最後まで分かるか(耳で聞いて分かる平易な言葉か・冗長でないか・飽きないか) 3. エイイチ(チャンネルの人)の見立て・実感が入っているか 4. 事実は正しいか(言い過ぎていないか) ## 出してはいけない提案 - 再生数を稼ぐための誇張・釣り・煽り。中身と合わないタイトルやサムネイル - 事実を曲げる・不安をあおる・他社や人を悪く言う演出 - 「ユーモアや実体験を AI が作って足す」案(実体験は本人に聞いて入れる決まり) 再生数が伸びそうでも、上の4つの軸に反する案は出さないこと。 ## 書き方 - 日本語で書く。英語の文を混ぜない - 台本の行は「文番号(S001 など)」で指す - 指摘には重さを付ける:【高】直さないと伝わらない・誤り /【中】直すと良くなる /【低】好み - 直す案は、元の文を置き換える短い案文で出す(長い書き直しはしない) """ GEMINI_R1 = """\ # あなたの役:視聴者(Gemini) 次の3人になりきって、この台本の動画を最初から最後まで見たつもりで読んでください。 - 経営者:飲食チェーンの役員。ロボット導入の判断をする立場。数字とお金に敏感で、忙しい - PM:サービスロボットの導入プロジェクトを任された人。現場とメーカーの間に立つ - 開発者:ロボットのソフトを書く人。試算の前提の甘さに気づく ## 出してほしいもの(この順で) 1. 離脱しそうな所:3人それぞれ、どの文番号のあたりで「もういいか」と思うか。理由を1行で 2. 止まる言葉:耳で聞いて意味が取れない言葉・数字の詰め込み。文番号と言い換え案 3. 物足りない所:その人が知りたいのに出てこないこと 4. 良かった所:残すべき所(2〜3個) 5. いちばん直すべき3つ:重さ付きで ほかの AI の意見はまだ見せていません。自分の目で見て書いてください。 """ ROUND2 = """\ # 2回目:ほかの AI の指摘への返事({who}) 下に、3つの AI の1回目の指摘があります(あなた自身のものも含みます)。 ほかの AI の指摘1つずつに、次のどれかで答えてください。新しい指摘は足さないこと。 - 賛成:理由を1行 - 反論:理由を1行(直すと何が悪くなるか) - 保留:判断に人間の情報が要るもの(何を聞けばよいか) {extra} 最後に「人間が先に決めるべき3つ」を、重さ付きで挙げてください。 話を丸くまとめようとせず、意見が割れる所は割れたまま書いてください。 """ CODEX_EXTRA = """\ あなたは事実の番です。賛成・反論の判断は「その直す案を採ったとき、事実が正しいままか・ 言い過ぎにならないか」だけで決めてください。構成・面白さ・言葉の好みには意見しないこと (その指摘は「事実の面では問題なし」とだけ書く)。話の筋や構成を組み替える提案もしないこと。 作業フォルダの narration.py の冒頭のコメントに、数字の出どころが書いてあります。 """ GEMINI_EXTRA = """\ あなたは視聴者役のままです。経営者・PM・開発者の目で、その直し方で見やすくなるかを判断してください。 codex(事実の番)の1回目の指摘には返事をしないこと(事実の判断は codex に任せる)。返事をするのは Claude の指摘だけ。 賛成ばかりにしないこと。視聴者として本当に見やすくなるかを考え、反論すべき所は反論してください。 """ THEME_EXTRA = """\ 賛成ばかりにしないこと。ほかの AI の数字や見立てに、根拠が弱い所・甘い所があれば反論してください。 """ THEME_CODEX_EXTRA = """\ あなたは数字の番です。ほかの AI が出した数字(再生数の見込み・ほかのチャンネルの数字など)について、 根拠が確かか・言い過ぎていないかを中心に返事をしてください。 """ def log(*a): print(datetime.now(JST).strftime("%Y-%m-%d %H:%M:%S"), *a, flush=True) def nfc(s): return unicodedata.normalize("NFC", s) def notify(msg): try: t = msg.replace("\\", "\\\\").replace('"', '\\"') subprocess.run(["osascript", "-e", f'display notification "{t}" with title "議論係"'], capture_output=True, timeout=20) except Exception: pass def read(path): b, _ = cr.read_stable(path) return b.decode("utf-8", errors="replace") def write(path, text): with open(path, "w", encoding="utf-8") as f: f.write(text.rstrip() + "\n") # ───────── Gemini ───────── def gemini_key(): r = subprocess.run(["/usr/bin/security", "find-generic-password", "-s", KEYCHAIN_SERVICE, "-w"], capture_output=True, text=True) key = r.stdout.strip() if r.returncode != 0 or not key: raise RuntimeError("キーチェーンに gemini_api_key が見つかりません。" " security add-generic-password -a \"$USER\" -s gemini_api_key -w で入れてください") return key def http(url, key, body=None, timeout=60): data = json.dumps(body).encode("utf-8") if body is not None else None req = urllib.request.Request(url, data=data, method="POST" if data else "GET", headers={"x-goog-api-key": key, "Content-Type": "application/json"}) try: with urllib.request.urlopen(req, timeout=timeout) as r: return json.loads(r.read().decode("utf-8")) except urllib.error.HTTPError as e: detail = e.read().decode("utf-8", "replace")[:800] raise RuntimeError(f"Gemini の API がエラーを返しました({e.code}): {detail}") MODEL_RE = re.compile(r"^models/(gemini-(\d+(?:\.\d+)?)-(pro|flash)(-preview(?:-\d{2}-\d{4})?)?)$") # customtools・tts などの変わり種は外す def pick_model(key): """使えるモデルの一覧から選ぶ。Pro を優先し、版の数字が大きいもの、正式版を優先""" if GEMINI_MODEL: return GEMINI_MODEL, [] out, url = [], f"{GEMINI_BASE}/models?pageSize=1000" d = http(url, key) cands = [] for m in d.get("models", []): name = m.get("name", "") mm = MODEL_RE.match(name) if not mm or "generateContent" not in m.get("supportedGenerationMethods", []): continue model, ver, kind, pre = mm.groups() cands.append(((kind == "pro", float(ver), not pre), model)) cands.sort(reverse=True) if not cands: raise RuntimeError("使える Gemini のモデルが見つかりません") return cands[0][1], [c[1] for c in cands[:6]] THEME_GROUND = """\ # 議論の前提(全員に共通) あなたは、YouTube チャンネル「フィジカルAI解説|Pixels to Motors」の企画や方針を決めるための議論に参加しています。 参加者は Claude・Gemini・codex の3つの AI です。採るかどうかは、最後に人間(エイイチ)が決めます。あなたは材料を出す役です。 - 再生数を稼ぐための誇張・釣り・煽り、中身と合わないタイトルやサムネイルは、伸びそうでも提案しない - 日本語で書く。英語の文を混ぜない - 数字を言うときは、根拠(実際のデータ・調べた出典・推測)のどれかを必ず添える。推測は推測と書く - 話を丸くまとめず、ほかの AI と意見が割れる所は割れたまま書く """ LAST_USAGE = {"inp": 0, "out": 0} def gemini(key, model, system, user, search=False): body = { "systemInstruction": {"parts": [{"text": system}]}, "contents": [{"role": "user", "parts": [{"text": user}]}], "generationConfig": {"temperature": 0.7}, } if search: body["tools"] = [{"google_search": {}}] t0 = time.time() d = http(f"{GEMINI_BASE}/models/{model}:generateContent", key, body, timeout=GEMINI_TIMEOUT) parts = (d.get("candidates") or [{}])[0].get("content", {}).get("parts", []) text = "\n".join(p.get("text", "") for p in parts if not p.get("thought")).strip() if not text: raise RuntimeError(f"Gemini の答えが空でした: {json.dumps(d, ensure_ascii=False)[:600]}") gm = (d.get("candidates") or [{}])[0].get("groundingMetadata", {}) srcs = [c.get("web", {}) for c in gm.get("groundingChunks", []) if c.get("web")] if srcs: text += "\n\n## Gemini が検索で見た出典\n\n" + "\n".join(f"- {w.get('title', '')}: {w.get('uri', '')}" for w in srcs) u = d.get("usageMetadata", {}) LAST_USAGE.update(inp=u.get("promptTokenCount", 0) or 0, out=(u.get("candidatesTokenCount", 0) or 0) + (u.get("thoughtsTokenCount", 0) or 0)) meta = f"{'Google 検索あり・' if search else ''}モデル {model}・{int(time.time() - t0)}秒・入力 {u.get('promptTokenCount', '?')}・出力 {u.get('candidatesTokenCount', '?')} トークン" return text, meta # ───────── codex ───────── def codex(wdir, prompt, web=False): tmp = os.path.join(os.environ.get("TMPDIR", "/tmp"), f"debate_codex_{os.getpid()}.md") cmd = [cr.CODEX, "exec"] + (["-c", "tools.web_search=true"] if web else []) + ["-c", f'model_reasoning_effort="{CODEX_EFFORT}"', "-m", cr.MODEL, "-s", "read-only", "--skip-git-repo-check", "-C", wdir, "-o", tmp, "-"] t0 = time.time() r = subprocess.run(cmd, input=prompt, text=True, encoding="utf-8", capture_output=True, timeout=CODEX_TIMEOUT, env=cr.env_for_codex(), cwd=wdir) ans = "" if os.path.isfile(tmp): with open(tmp, encoding="utf-8", errors="replace") as f: ans = f.read().strip() os.remove(tmp) if r.returncode != 0 or not ans: raise RuntimeError(f"codex が失敗しました(終了コード {r.returncode}):\n" + "\n".join((r.stderr or "").splitlines()[-30:])) tk = re.findall(r"tokens used\s*\n?\s*([\d,]+)", r.stderr or "") return ans, f"モデル {cr.MODEL}・考える深さ {CODEX_EFFORT}・{int(time.time() - t0)}秒・{tk[-1] if tk else '?'} トークン" # ───────── 1本分 ───────── def head(title, name, meta): return f"# {title}({name})\n\n- 日時: {datetime.now(JST).strftime('%Y-%m-%d %H:%M')}(JST)\n- {meta}\n\n---\n\n" def run_one(name, vdir, dry, ddir=None): theme = ddir is not None ddir = ddir or os.path.join(vdir, SUB) vdir = vdir or ddir files = cr.listdir_nfc(ddir) p = lambda n: os.path.join(ddir, files.get(n, n)) if REQ not in files: log(f"× {name}: {SUB}/{REQ} がありません") return req = read(p(REQ)) todo = [f for f in ("1_Gemini.md", "1_codex.md", "2_Gemini.md", "2_codex.md") if f not in files] log(f"▶ {name}: これから作るもの → {'・'.join(todo) or 'なし'}") if "1_Claude.md" not in files: log(" 注意: 1_Claude.md がありません(Claude 抜きで進めます)") if dry: return write(p(RUNNING), f"開始 {datetime.now(JST).strftime('%Y-%m-%d %H:%M:%S')}\nPID {os.getpid()}\n") try: key = gemini_key() model, _ = pick_model(key) # 1回目 ground = THEME_GROUND if theme else GROUND if "1_Gemini.md" not in files: log(f" 1回目 Gemini({model})…") if theme and ROLE_GEMINI in files: ans, meta = gemini(key, model, ground + "\n" + read(p(ROLE_GEMINI)), req, search=True) else: ans, meta = gemini(key, model, GROUND + "\n" + GEMINI_R1, req) write(p("1_Gemini.md"), head("1回目 Gemini(視聴者役)", name, meta) + ans) files["1_Gemini.md"] = "1_Gemini.md" if "1_codex.md" not in files: log(" 1回目 codex(事実確認)…") if theme and ROLE_CODEX in files: ans, meta = codex(vdir, ground + "\n" + read(p(ROLE_CODEX)) + "\n\n" + req, web=True) else: ans, meta = codex(vdir, GROUND + "\n# あなたの役:事実の番(codex)\n台本の事実確認をしてください。" "観点は事実と言い過ぎだけ。筋・構成・見立ては変えない。\n\n" + req, web=True) write(p("1_codex.md"), head("1回目 codex(事実の番)", name, meta) + ans) files["1_codex.md"] = "1_codex.md" # 2回目(3つの1回目をまとめて見せる) r1 = "" labels = (("Claude", "1_Claude.md"), ("Gemini", "1_Gemini.md"), ("codex", "1_codex.md")) if theme else \ (("Claude(台本の作り手)", "1_Claude.md"), ("Gemini(視聴者役)", "1_Gemini.md"), ("codex(事実の番)", "1_codex.md")) for who, f in labels: if f in files: r1 += f"\n\n==================== {who} の1回目 ====================\n\n" + read(p(f)) body2 = ("# 依頼\n\n" if theme else "# 台本と前提\n\n") + req + "\n\n# 1回目の意見" + r1 if "2_Gemini.md" not in files: log(" 2回目 Gemini…") if theme: ans, meta = gemini(key, model, ground + "\n" + ROUND2.format(who="Gemini", extra=THEME_EXTRA), body2) else: ans, meta = gemini(key, model, GROUND + "\n" + ROUND2.format(who="Gemini・視聴者役", extra=GEMINI_EXTRA), body2) write(p("2_Gemini.md"), head("2回目 Gemini", name, meta) + ans) if "2_codex.md" not in files: log(" 2回目 codex…") if theme: ans, meta = codex(vdir, ground + "\n" + ROUND2.format(who="codex", extra=THEME_CODEX_EXTRA) + "\n\n" + body2) else: ans, meta = codex(vdir, GROUND + "\n" + ROUND2.format(who="codex・事実の番", extra=CODEX_EXTRA) + "\n\n" + body2) write(p("2_codex.md"), head("2回目 codex", name, meta) + ans) except Exception as e: log(f"× {name}: {e}") write(p("エラー.txt"), f"{datetime.now(JST).strftime('%Y-%m-%d %H:%M:%S')}\n\n{e}\n") notify(f"議論が止まりました:{name}") return finally: if os.path.exists(p(RUNNING)): os.remove(p(RUNNING)) if os.path.exists(p(MARK)): os.rename(p(MARK), p(DONE)) log(f"✓ {name}: 議論が終わりました → 1_・2_ のファイル。次は Claude がまとめる番") notify(f"議論が終わりました:{name}") def research(dry): """動画/_調べ/{名前}/ に かける.txt があれば、YouTube を検索して数字を取る(youtube_market.py)""" real = cr.listdir_nfc(VIDEOS).get(nfc("_調べ")) if not real: return False root = os.path.join(VIDEOS, real) for name, r in sorted(cr.listdir_nfc(root).items()): d = os.path.join(root, r) f = cr.listdir_nfc(d) if os.path.isdir(d) else {} if MARK in f and "キーワード.txt" in f: log(f"▶ YouTube の調べ _調べ/{name}") if dry: return True try: import youtube_market n = youtube_market.run(d) os.rename(os.path.join(d, f[MARK]), os.path.join(d, DONE)) log(f"✓ _調べ/{name}: {n} 語を調べました → 結果.md") notify(f"YouTube の調べが終わりました:{name}") except Exception as e: write(os.path.join(d, "エラー.txt"), f"{datetime.now(JST).strftime('%Y-%m-%d %H:%M:%S')}\n\n{e}\n") os.rename(os.path.join(d, f[MARK]), os.path.join(d, "失敗.txt")) log(f"× _調べ/{name}: {e}") return True return False def sweep(dry): if research(dry): return import neta # 動画/_ネタ/ … ネタ出し(neta.py) if neta.sweep(dry): return import seisaku # 動画/_制作/ … 制作チーム(seisaku.py) if seisaku.sweep(dry): return import kikaku # 動画/_企画/ … 100回の企画会議(kikaku.py) if kikaku.sweep(dry): return troot = cr.listdir_nfc(VIDEOS).get(nfc(THEMES)) if troot: tdir = os.path.join(VIDEOS, troot) for name, real in sorted(cr.listdir_nfc(tdir).items()): d = os.path.join(tdir, real) if not os.path.isdir(d): continue f = cr.listdir_nfc(d) if RUNNING in f: log(f"いま動いています: {THEMES}/{name}(今回は何もしません)") return if MARK in f: run_one(f"{THEMES}/{name}", None, dry, ddir=d) return for name, real in sorted(cr.listdir_nfc(VIDEOS).items()): if name.startswith("_"): continue vdir = os.path.join(VIDEOS, real) sub = cr.listdir_nfc(vdir).get(nfc(SUB)) if not sub: continue f = cr.listdir_nfc(os.path.join(vdir, sub)) if RUNNING in f: log(f"いま動いています: {name}(今回は何もしません)") return if MARK in f: run_one(name, vdir, dry) return # 一度に1本だけ log("印(議論/かける.txt・_議論/{テーマ}/かける.txt)のついたものはありません") LABEL = "jp.adaptdesign.pixelstomotors.debate-runner" PLIST = os.path.expanduser(f"~/Library/LaunchAgents/{LABEL}.plist") LOGFILE = os.path.join(cr.CONF, "debate_runner.log") def install(): """5分ごとに見回るように登録する。印(かける.txt)を置けば、ターミナルを開かなくても議論が回る""" os.makedirs(os.path.dirname(PLIST), exist_ok=True) os.makedirs(cr.CONF, exist_ok=True) cal = "".join(f"Minute{m}" for m in range(2, 60, 5)) plist = f""" Label{LABEL} ProgramArguments{sys.executable}{os.path.abspath(__file__)}--once StartCalendarInterval{cal} RunAtLoad WorkingDirectory{HERE} EnvironmentVariables PATH/usr/bin:/bin:/usr/sbin:/sbin CODEX_HOME{cr.CODEX_HOME} LANGja_JP.UTF-8 StandardOutPath{LOGFILE} StandardErrorPath{LOGFILE} """ with open(PLIST, "w") as f: f.write(plist) subprocess.run(["launchctl", "unload", PLIST], capture_output=True) r = subprocess.run(["launchctl", "load", PLIST], capture_output=True, text=True) if r.returncode != 0: raise RuntimeError(f"登録できませんでした: {r.stderr.strip()}") print(f"登録しました。5分ごとに見回ります(いまも1回動きます)。記録: {LOGFILE}") def uninstall(): subprocess.run(["launchctl", "unload", PLIST], capture_output=True) if os.path.exists(PLIST): os.remove(PLIST) print("自動の見回りを外しました") def check(): key = gemini_key() log(f"キー: 見つかりました(頭 {key[:4]}…・{len(key)} 文字)") model, top = pick_model(key) log(f"選んだモデル: {model}") if top: log("候補の上位:", "・".join(top)) ans, meta = gemini(key, model, "日本語で、ひとことで答えてください。", "動作確認です。「準備できました」とだけ返してください。") log(f"Gemini の返事: {ans}({meta})") log(f"codex の本体: {'あります' if os.path.isfile(cr.CODEX) else '見つかりません'}({cr.CODEX})") if __name__ == "__main__": ap = argparse.ArgumentParser(description="動画の台本を、Gemini と codex に議論させる") ap.add_argument("--check", action="store_true", help="キーとモデルを確かめるだけ") ap.add_argument("--once", action="store_true", help="印のついた動画を1本、議論にかける") ap.add_argument("--dry-run", action="store_true", help="何をするかを出すだけ") ap.add_argument("--install", action="store_true", help="5分ごとに見回るように登録する") ap.add_argument("--uninstall", action="store_true", help="自動の見回りを外す") a = ap.parse_args() try: if a.install: install() elif a.uninstall: uninstall() elif a.check: check() else: sweep(a.dry_run) except Exception as e: log("×", e) sys.exit(1)