#!/usr/bin/env python3 """kikaku.py — 動画の企画を、AI に100回以上議論させる「企画会議」. debate_runner.py の見回り(launchd・5分ごと)から呼ばれる。手で動かすときも debate_runner.py --once でよい。 置き場所(動画/_企画/{会議の名前}/): 依頼.md Claude が書く。何の企画を決めたいか・前提・Claude の最初の案 回数.txt 何回議論するか(無ければ100)。数字だけを書く かける.txt 印。Claude が置く。終わると かけた.txt に名前を変える 止める.txt エイイチが置くと、その回が終わったところで止まる(あとで消せば続きから) 進み具合.md 毎回書き換える。いま何回目か・どの段階か・いまの企画ボード・かかった費用の目安 ログ.md 1回ごとの発言を、上に足していく(新しいものが上) 状態.json 続きから再開するための記録 最終案.md 100回終わったときの企画ボード 段階(回数を100としたとき。回数を変えると比で伸び縮みする): 1〜20 出す 企画の案を広く出す 21〜60 揉む 案の穴を突き、直し、合わせ、捨てる 61〜90 絞る 4つの軸と見込みの再生数で点を付け、上位に絞る 91〜100 仕上げる 最終の3本を、題名・サムネイルの文字・冒頭5秒・構成・見込みまで書く 話す役: 10回に1回は codex(数字と釣りの番)。残りは Gemini が、次の役を順に回す 経営者の視聴者・PM の視聴者・開発者の視聴者・YouTube の企画の編集者・穴を突く役・ロボットに詳しくない一般の視聴者・エイイチの目的を守る役 Claude は最初の案(依頼.md の中)と、終わったあとのまとめで加わる """ import json, os, re, signal, subprocess, sys, time from datetime import datetime import debate_runner as dr cr = dr.cr ROOT = "_企画" MARK, DONE, STOP, RUNNING = "かける.txt", "かけた.txt", "止める.txt", "実行中.txt" PRICE = {"pro": (2.0, 12.0), "flash": (0.75, 3.75)} # 1百万トークンあたりのドル(入力・出力の目安。Pro は 3.1 Pro preview、Flash は 3.x Flash) YEN = 155 # 円の換算の目安 MAX_PARALLEL = 3 # 同時に回す会議の数の上限(会議は API を待つだけで Mac は軽い) PRO_ROLES = ("穴を突く",) # この役と「仕上げる」段階だけ Pro。ほかは安い Flash PERSONAS = [ ("経営者の視聴者", "飲食・小売・病院などでロボット導入を判断する立場。忙しく、数字とお金で考える。押したくなる題名か、最後まで見たいかを正直に言う"), ("PM の視聴者", "ロボット導入のプロジェクトを任された人。現場とメーカーの間に立つ。自分の仕事に使えるかで見る"), ("開発者の視聴者", "ロボットや AI のソフトを書く人。前提の甘さ・ありきたりな話にすぐ気づく。新しく分かることがあるかで見る"), ("YouTube の企画の編集者", "日本の YouTube でどんな題材・題名・サムネイルが表示され、押され、見続けられるかに詳しい。釣りはしないが、入口の作りには厳しい"), ("穴を突く役", "今の案のいちばん弱い所を探して突く。みんなが賛成している案ほど疑う"), ("ロボットに詳しくない一般の視聴者", "ロボットにも AI にも詳しくない、普段は身近なニュースや雑学の動画を見る人。おすすめに出てきたとき、題名とサムネイルだけで押すかどうかを決める。知らない言葉が1つでもあれば押さない"), ("エイイチの目的を守る役", "チャンネルの目的(仕事につながる人に見てもらう)と、守ること(釣りをしない・顔は出さない・声は AI)からずれていないかを見る"), ] PHASES = [(0.20, "出す", "企画の案を広く出す段階。いまのボードにない新しい案を1〜3本足す。似た案を足さない"), (0.60, "揉む", "案を揉む段階。ボードの案の穴を突き、直し、合わせ、見込みのない案は理由を書いて捨てる"), (0.90, "絞る", "絞る段階。4つの軸(ターゲットの役に立つ・最後まで分かる・エイイチの見立てがある・事実が正しい)と、公開から3か月の見込みの再生数で点を付け、上位5本に絞る"), (1.01, "仕上げる", "仕上げる段階。上位3本について、題名・サムネイルの文字・冒頭5秒のセリフ・構成(4〜6つの節)・見込みの再生数と根拠・撮影や試算で要るものを書く。前の回の書き方をよくする")] WAY_HEAD = """ # この会議のやり方 - これは、動画の企画を決めるための長い会議です。あなたは今回、1回分だけ発言します - 自分の名前(Gemini・codex・Claude など)や役の名前を名乗らない。発言の中身だけを書く - 前の人の発言に、必ず1つは具体的に反応する(賛成でも反論でもよい)。同じことを言い直さない - 「企画ボード」は、会議の今の結論をまとめた板です。あなたの発言を受けて、ボードを書き直してください """ WAY_BOARD_VIDEO = """- ボードは2,500字以内。案ごとに、題名の案・中身・どの柱か・見込みの再生数(根拠つき)・いまの評価・残っている疑問 を書く - 見込みの再生数は、依頼に書いたチャンネルの実際の数字を土台にする。根拠のない楽観はしない """ WAY_TAIL = """ # 出す形(この形を必ず守る) 【発言】 (あなたの発言。400字くらいまで) 【ボード】 (書き直した企画ボードの全文) """ GROUND = dr.THEME_GROUND + WAY_HEAD + WAY_BOARD_VIDEO + WAY_TAIL # 動画の会議(前と同じ) CODEX_ROLE_VIDEO = ("# あなたの役:数字と釣りの番(codex)\nボードの見込みの再生数の根拠と計算を確かめ、甘い所は直す。" "題名やサムネイルの案が中身より言い過ぎていないか(釣りになっていないか)も止める。必要ならウェブで確かめる。\n") # 会議ごとの差し替え(フォルダに置けば、その会議だけ動画用の決まりの代わりに使う) # 前提.md … 議論の前提とボードの書き方(THEME_GROUND と、ボードの2行の代わり) # 段階.json … [[比, "名前", "やること"], ...](PHASES の代わり) # 番.md … 10回に1回の codex の役(CODEX_ROLE_VIDEO の代わり) # 役.json … Gemini が回す役(PERSONAS の代わり。前からある) def pick_flash(key): """使える Flash の中で、版の数字がいちばん大きい正式版(無ければ preview)""" d = dr.http(f"{dr.GEMINI_BASE}/models?pageSize=1000", key) c = [] for m in d.get("models", []): mm = dr.MODEL_RE.match(m.get("name", "")) if mm and mm.group(3) == "flash" and "generateContent" in m.get("supportedGenerationMethods", []): c.append(((float(mm.group(2)), not mm.group(4)), mm.group(1))) return max(c)[1] if c else None def phase_of(t, total, phases=None): phases = phases or PHASES for edge, name, how in phases: if t <= max(1, round(total * edge)): return name, how return phases[-1][1], phases[-1][2] def split(ans): m = re.search(r"【発言】\s*(.*?)\s*【ボード】\s*(.*)", ans, re.S) if not m: return ans.strip(), None board = m.group(2).strip() return m.group(1).strip(), (board if len(board) > 50 else None) def load(d): p = os.path.join(d, "状態.json") if os.path.isfile(p): return json.load(open(p, encoding="utf-8")) return {"turn": 0, "board": "(まだ案はありません)", "recent": [], "inp": 0, "out": 0, "codex": 0, "start": datetime.now(dr.JST).isoformat(timespec="minutes")} def save(d, st): tmp = os.path.join(d, "状態.json.tmp") with open(tmp, "w", encoding="utf-8") as f: json.dump(st, f, ensure_ascii=False, indent=1) os.replace(tmp, os.path.join(d, "状態.json")) def progress(d, st, total, now_phase, note=""): usd = sum(st.get(f"inp_{k}", 0) / 1e6 * PRICE[k][0] + st.get(f"out_{k}", 0) / 1e6 * PRICE[k][1] for k in PRICE) \ + st["inp"] / 1e6 * PRICE["pro"][0] + st["out"] / 1e6 * PRICE["pro"][1] # inp/out は Pro だけだった前の版の分 bar = "■" * (st["turn"] * 20 // total) + "□" * (20 - st["turn"] * 20 // total) L = [f"# 進み具合({os.path.basename(d)})", "", f"- {bar} **{st['turn']} / {total} 回**(段階:{now_phase})", f"- 始めた時刻 {st['start'].replace('T', ' ')}・最後に書いた時刻 {datetime.now(dr.JST).strftime('%Y-%m-%d %H:%M')}", f"- Gemini の費用の目安 約{usd * YEN:,.0f}円(Pro {st.get('n_pro', 0)} 回・Flash {st.get('n_flash', 0)} 回)・codex {st['codex']} 回(ChatGPT Plus の枠)", "- 止めたいときは、このフォルダに 止める.txt を置く(いまの回が終わったら止まる。消せば続きから)"] if note: L.append(f"- {note}") L += ["", "## 直近の発言", ""] for r in st["recent"][-3:][::-1]: L += [f"**{r['n']}回目|{r['who']}**", "", r["say"][:400], ""] L += ["## いまの企画ボード", "", st["board"], ""] dr.write(os.path.join(d, "進み具合.md"), "\n".join(L)) def append_log(d, n, total, phase, who, say): p = os.path.join(d, "ログ.md") old = open(p, encoding="utf-8").read() if os.path.isfile(p) else f"# 会議のログ({os.path.basename(d)}・新しい発言が上)\n\n" head, _, rest = old.partition("\n\n") entry = f"## {n} / {total}|{phase}|{who}({datetime.now(dr.JST).strftime('%H:%M')})\n\n{say}\n\n" dr.write(p, head + "\n\n" + entry + rest) def run(name, d, dry): files = cr.listdir_nfc(d) if "依頼.md" not in files: dr.log(f"× {name}: 依頼.md がありません") return total = 100 if "回数.txt" in files: try: total = max(10, int(open(os.path.join(d, files["回数.txt"]), encoding="utf-8").read().strip())) except ValueError: pass personas = PERSONAS if "役.json" in files: # 会議ごとに役を差し替える([["名前","説明"], ...]) personas = [tuple(x) for x in json.load(open(os.path.join(d, files["役.json"]), encoding="utf-8"))] ground, phases, codex_role = GROUND, PHASES, CODEX_ROLE_VIDEO if "前提.md" in files: ground = dr.read(os.path.join(d, files["前提.md"])) + WAY_HEAD + WAY_TAIL if "段階.json" in files: phases = [tuple(x) for x in json.load(open(os.path.join(d, files["段階.json"]), encoding="utf-8"))] if "番.md" in files: codex_role = dr.read(os.path.join(d, files["番.md"])) + "\n" st = load(d) if dry: dr.log(f"[試し]{name}: {st['turn']} / {total} 回まで済み") return req = dr.read(os.path.join(d, files["依頼.md"])) key = dr.gemini_key() model, _ = dr.pick_model(key) flash = pick_flash(key) or model dr.write(os.path.join(d, RUNNING), f"開始 {datetime.now(dr.JST).strftime('%Y-%m-%d %H:%M:%S')}\nPID {os.getpid()}\n") dr.log(f"▶ 企画会議 {name}: {st['turn'] + 1} 回目から {total} 回目まで(Pro {model}・Flash {flash})") try: while st["turn"] < total: if os.path.exists(os.path.join(d, STOP)): progress(d, st, total, phase_of(st["turn"] + 1, total, phases)[0], "止める.txt があるので止まっています") dr.log(f" 止める.txt があるので {st['turn']} 回目で止めました") return n = st["turn"] + 1 phase, how = phase_of(n, total, phases) recent = "\n\n".join(f"[{r['n']}回目|{r['who']}]\n{r['say']}" for r in st["recent"][-6:]) or "(まだ発言はありません)" body = (f"# 依頼\n\n{req}\n\n# いまの企画ボード\n\n{st['board']}\n\n# 直近の発言\n\n{recent}\n\n" f"# 今回\n\n{n} / {total} 回目。段階は「{phase}」。{how}") for attempt in range(3): try: if n % 10 == 0: who = "codex(数字と釣りの番)" first = codex_role.split("\n")[0] who = "codex(" + (first.split(":", 1)[-1].split("(")[0].strip() if ":" in first else "番") + ")" ans, _ = dr.codex(d, ground + "\n" + codex_role + "\n" + body, web=True) st["codex"] += 1 else: pname, pdesc = personas[(n - 1) % len(personas)] who = f"Gemini({pname})" search = phase in ("出す", "揉む") and ("編集者" in pname or "一般" in pname or "ニュース" in pname) tier = "pro" if (phase == "仕上げる" or any(r in pname for r in PRO_ROLES) or flash == model) else "flash" ans, _ = dr.gemini(key, model if tier == "pro" else flash, ground + f"\n# あなたの役:{pname}\n{pdesc}\n", body, search=search) st[f"inp_{tier}"] = st.get(f"inp_{tier}", 0) + dr.LAST_USAGE["inp"] st[f"out_{tier}"] = st.get(f"out_{tier}", 0) + dr.LAST_USAGE["out"] st[f"n_{tier}"] = st.get(f"n_{tier}", 0) + 1 who += "・Pro" if tier == "pro" else "" break except Exception as e: dr.log(f" {n}回目 失敗({attempt + 1}回目): {str(e)[:200]}") if attempt == 2: raise time.sleep(30) say, board = split(ans) if board: st["board"] = board st["turn"] = n st["recent"] = (st["recent"] + [{"n": n, "who": who, "say": say}])[-12:] save(d, st) try: os.utime(os.path.join(d, RUNNING)) except OSError: pass append_log(d, n, total, phase, who, say) progress(d, st, total, phase) dr.log(f" {n}/{total} {phase} {who}") if n in (total // 4, total // 2, total * 3 // 4): dr.notify(f"企画会議 {os.path.basename(d)}:{n} / {total} 回") dr.write(os.path.join(d, "最終案.md"), f"# 最終案({os.path.basename(d)}・{total}回の会議のあと)\n\n" + st["board"]) mk = os.path.join(d, MARK) if os.path.exists(mk): os.rename(mk, os.path.join(d, DONE)) progress(d, st, total, "終わり", "会議は終わりました。最終案.md を見てください") dr.log(f"✓ 企画会議 {name}: {total} 回終わりました") dr.notify(f"企画会議が終わりました:{os.path.basename(d)}") except Exception as e: dr.write(os.path.join(d, "エラー.txt"), f"{datetime.now(dr.JST).strftime('%Y-%m-%d %H:%M:%S')}\n\n{e}\n") progress(d, st, total, phase_of(st["turn"] + 1, total, phases)[0], f"エラーで止まりました(エラー.txt)。次の見回りで続きから試します") dr.log(f"× 企画会議 {name}: {e}") dr.notify(f"企画会議が止まりました:{os.path.basename(d)}") finally: p = os.path.join(d, RUNNING) if os.path.exists(p): os.remove(p) SITE_ROOT = os.path.join(os.path.dirname(os.path.dirname(dr.VIDEOS)), "サイト改善", "_企画") # PixelstoMotors/サイト改善/_企画/ def roots(): out = [] real = cr.listdir_nfc(dr.VIDEOS).get(dr.nfc(ROOT)) if real: out.append((ROOT, os.path.join(dr.VIDEOS, real))) if os.path.isdir(SITE_ROOT): out.append(("サイト改善/_企画", SITE_ROOT)) return out def meetings(): for label, root in roots(): for name, r in sorted(cr.listdir_nfc(root).items()): d = os.path.join(root, r) if os.path.isdir(d): yield f"{label}/{name}", d, cr.listdir_nfc(d) def is_running(d, f): """実行中.txt があり、その PID が生きているか、起こしてから20分以内なら「動いている」""" if RUNNING not in f: return False p = os.path.join(d, f[RUNNING]) try: txt = open(p, encoding="utf-8").read() age = time.time() - os.path.getmtime(p) except OSError: return False m = re.search(r"PID (\d+)", txt) if m: try: os.kill(int(m.group(1)), 0) return True except ProcessLookupError: return False except PermissionError: return True return age < 20 * 60 def spawn(label, d): """会議を別の作業として起こし、見張り係はすぐ見回りに戻る(会議が終わるまで待たない)""" dr.write(os.path.join(d, RUNNING), f"起動 {datetime.now(dr.JST).strftime('%Y-%m-%d %H:%M:%S')}(見張り係が起こしたところ)\n") os.makedirs(cr.CONF, exist_ok=True) logf = open(os.path.join(cr.CONF, "kikaku.log"), "a") subprocess.Popen([sys.executable, os.path.abspath(__file__), "--run", d, label], cwd=dr.HERE, stdin=subprocess.DEVNULL, stdout=logf, stderr=subprocess.STDOUT, start_new_session=True, env=os.environ.copy()) dr.log(f"▶ 企画会議 {label} を別の作業として起こしました") def sweep(dry): """印のついた会議を、同時に MAX_PARALLEL 本まで起こす。会議の終わりは待たない。 ほかの議論(台本の議論・テーマの議論)の見回りも続けられるよう、いつも False を返す""" ms = list(meetings()) running = [] for label, d, f in ms: if is_running(d, f): running.append(label) elif RUNNING in f and not dry: os.remove(os.path.join(d, f[RUNNING])) # PID が死んでいる・古い印は、止まった跡 f.pop(RUNNING, None) if running: dr.log(f"企画会議 動いている: {'・'.join(running)}") for label, d, f in ms: if len(running) >= MAX_PARALLEL: break if label in running or MARK not in f or STOP in f: continue if dry: dr.log(f"[試し]企画会議 {label} を起こします") else: spawn(label, d) running.append(label) return False if __name__ == "__main__": if len(sys.argv) >= 4 and sys.argv[1] == "--run": d, label = sys.argv[2], sys.argv[3] try: run(label, d, False) except Exception as e: dr.log(f"× 企画会議 {label}: {e}") p = os.path.join(d, RUNNING) if os.path.exists(p) and "PID" not in open(p, encoding="utf-8").read(): os.remove(p) sys.exit(1)