#!/usr/bin/env python3 """seisaku.py — 別々の AI が話し合って、価値のある動画の企画を作る「制作チーム」. 合議(みんなで1つの結論に寄せる)はしない。批判は案を消すためではなく、強くするために使う。 流れ(動画/_制作/{名前}/): 1. 3社が別々に企画書を1本ずつ作る(互いに見ない) Claude … 案_Claude_1.md(チャットの中で Claude が書く) Gemini … 案_Gemini_1.md(このスクリプト。Google 検索つき) codex … 案_codex_1.md (このスクリプト。ウェブ検索つき) 2. 攻めと直し:各社が、ほかの2本を「誰が書いたか伏せて」読み、直し方つきで攻める 批評_{回}_{批評した人}.md。「弱い」だけの指摘は禁止。言い過ぎは、事実の範囲で一番強い言い方に直す。案を捨てる権限は誰にもない 3. 書き直し:作者が、自分の案への批評を読み、取り込むかを自分で決めて書き直す 案_{作者}_{回+1}.md 2と3を 回数.txt の回数(無ければ2回)くり返す 4. 並べる.md … 最後の3本を、作者を伏せて並べる(YouTube で調べた数字を横に添える) 比べる.md … 作者ごとに「最初の版」と「最後の版」を、どちらがどちらか伏せて並べる(話し合いで良くなったかをエイイチが判定する) 答え_見ないで.json … 伏せた名前の対応 Claude の番になると、ここで止まって 進み具合.md に「Claude の番」と書き、Mac に通知を出す。 Claude がファイルを置けば、次の見回りで続きから進む。 """ import json, os, random, re, time from datetime import datetime import debate_runner as dr cr = dr.cr ROOT = "_制作" MARK, DONE, STOP, RUNNING = "かける.txt", "かけた.txt", "止める.txt", "実行中.txt" AUTHORS = ["Claude", "Gemini", "codex"] AUTO = {"Gemini", "codex"} FORMAT = """\ # 企画書の形(この見出しで、この順に書く) ## 題名 ## サムネイルの文字(大きく2〜3行) ## 冒頭15秒のセリフ(ハコブくんが話す) ## 見せ場(見た人が新しく知ること・誰かに話したくなること) ## ロボットに詳しい人の目(この動画にしかない見方) ## 構成(5〜7節。1節1〜2行) ## 事実の主張と出典(動画で言う主張を1行ずつ。出典の URL・発表日・発表元。確かめられないものは「未確認」と書く) ## 撮るもの・作るもの(撮影は2〜3時間以内) ## YouTubeで調べる言葉(入口になる言葉を2つ。1行に1つ) """ GROUND = """\ # 制作チームの決まり(全員に共通) あなたは YouTube チャンネル「フィジカルAI解説|Pixels to Motors」の動画の企画を作る制作チームの一員です。 チームの目的は、全員が反対しない案を作ることではなく、見た人にとって価値があり、たくさんの人が押して最後まで見る企画を作ることです。 - 尖った所・話題性のある所は、チームの財産です。消さずに、事実の範囲で一番強く伝わる形にする - 事実と違うこと・確かめられないことを、確かなこととして言わない。言い過ぎは、事実に合わせて言い方を直す(題材は捨てない) - 時事のニュースは、出典の URL と発表日を必ず添える。検索で見つけた要約ではなく、元の記事・発表で確かめる - 釣り(中身と合わない題名やサムネイル)・煽り・人や会社を悪く言う演出はしない - 日本語で書く。英語の文を混ぜない。自分の名前や役の名前を名乗らない """ DRAFT = """\ # あなたの仕事:企画書を1本作る 依頼を読んで、あなたが一番価値があると思う動画の企画を1本だけ作ってください。題材は自由です。 ほかのメンバーも別々に企画を作っています。似た案にしないために、あなたにしか出せない切り口を選んでください。 無難な案は求めていません。話題性があって、事実で裏付けられる、尖った案を作ってください。 """ + FORMAT CRITIQUE = """\ # あなたの仕事:ほかのメンバーの企画書を、強くするために攻める 下に、ほかのメンバーが作った企画書が2本あります(誰が書いたかは伏せてあります)。1本ずつ、次の形で書いてください。 ## 案A ### この案のいちばん尖っている所(守る・もっと強めるべき所) ### 弱い所と、その直し方(弱い所1つにつき、必ず具体的な直し方を書く。「弱い」だけの指摘は書かない) ### 事実の直し(間違い・言い過ぎ・出典がない所を、事実の範囲で一番強い言い方に書き直す。出典の URL と日付を添える。確かめられなければ「未確認」) ### もっと見られるための一手(題名・サムネイル・冒頭・見せ場のどれか1つを、具体的な文で) ## 案B (同じ形) 決まり:案を「やめるべき」「別の題材にすべき」と言わない。題材を活かしたまま強くする方法だけを書く。 """ REWRITE = """\ # あなたの仕事:自分の企画書を書き直す 下に、あなたが書いた企画書と、それへのほかのメンバーからの批評があります。 この企画の作者はあなたです。批評を取り込むかどうかは、あなたが決めてください。 - 取り込むと良くなる直しは取り込む。事実の直しは必ず取り込む - 案が平凡になる直し・尖りを消す直しは、理由を書いて断ってよい - 題材は変えない(磨く) はじめに「## 受け取った直し」として、直しごとに「取り込んだ/断った」と理由を1行ずつ書き、 そのあとに書き直した企画書の全文を、下の形で書いてください。 """ + FORMAT def log(*a): dr.log(*a) def path(d, name): return os.path.join(d, name) def exists(d, name): return os.path.isfile(path(d, name)) def read(d, name): return dr.read(path(d, name)) def strip_head(text): """書き出しのヘッダ(日時など)を除いた本文""" return text.split("\n---\n", 1)[1].strip() if "\n---\n" in text[:600] else text.strip() def write_doc(d, name, title, meta, body): dr.write(path(d, name), f"# {title}\n\n- 日時: {datetime.now(dr.JST).strftime('%Y-%m-%d %H:%M')}(JST)\n- {meta}\n\n---\n\n{body}") def load(d): p = path(d, "状態.json") if os.path.isfile(p): return json.load(open(p, encoding="utf-8")) rnd = random.Random() labels = {} for c in AUTHORS: # 批評する人ごとに、ほかの2本を 案A・案B に(順は毎回ばらばら) others = [a for a in AUTHORS if a != c] rnd.shuffle(others) labels[c] = {"案A": others[0], "案B": others[1]} return {"labels": labels, "cost_in": 0, "cost_out": 0, "codex": 0, "start": datetime.now(dr.JST).isoformat(timespec="minutes")} def save(d, st): tmp = path(d, "状態.json.tmp") with open(tmp, "w", encoding="utf-8") as f: json.dump(st, f, ensure_ascii=False, indent=1) os.replace(tmp, path(d, "状態.json")) def progress(d, st, rounds, now, note=""): usd = st["cost_in"] / 1e6 * 2.0 + st["cost_out"] / 1e6 * 12.0 rows = [] for a in AUTHORS: vs = [v for v in range(1, rounds + 2) if exists(d, f"案_{a}_{v}.md")] cs = [k for k in range(1, rounds + 1) if exists(d, f"批評_{k}_{a}.md")] rows.append(f"| {a} | {('第' + str(max(vs)) + '版') if vs else 'まだ'} | {('・'.join(f'{k}回目' for k in cs)) or 'まだ'} |") L = [f"# 進み具合(制作チーム:{os.path.basename(d)})", "", f"- いま:**{now}**", f"- 往復の回数:{rounds} 回(攻めと書き直しを1往復と数える)", f"- 始めた時刻 {st['start'].replace('T', ' ')}・最後に書いた時刻 {datetime.now(dr.JST).strftime('%Y-%m-%d %H:%M')}", f"- Gemini の費用の目安 約{usd * 155:,.0f}円・codex {st['codex']} 回(ChatGPT Plus の枠)"] if note: L.append(f"- {note}") L += ["", "| 作者 | 企画書 | 攻めた回 |", "|---|---|---|"] + rows + [ "", "ファイル:案_{作者}_{版}.md(企画書)・批評_{回}_{批評した人}.md(攻め)。最後に 並べる.md と 比べる.md ができる"] dr.write(path(d, "進み具合.md"), "\n".join(L)) def run_gemini(st, key, model, task, body): ans, meta = dr.gemini(key, model, GROUND + "\n" + task, body, search=True) st["cost_in"] += dr.LAST_USAGE["inp"] st["cost_out"] += dr.LAST_USAGE["out"] return ans, meta def run_codex(st, d, task, body): ans, meta = dr.codex(d, GROUND + "\n" + task + "\n\n" + body, web=True) st["codex"] += 1 return ans, meta def critique_input(d, st, critic, version): lab = st["labels"][critic] parts = [] for L in ("案A", "案B"): parts.append(f"==================== {L} ====================\n\n" + strip_head(read(d, f"案_{lab[L]}_{version}.md"))) return "# 依頼\n\n" + read(d, "依頼.md") + "\n\n# ほかのメンバーの企画書\n\n" + "\n\n".join(parts) def section_for(text, label): """批評の中から、案A・案B のどちらか1本ぶんを抜き出す""" text = strip_head(text) m = re.search(rf"^#+\s*{label}\s*$(.*?)(?=^#+\s*案[AB]\s*$|\Z)", text, re.M | re.S) return m.group(1).strip() if m else "" def rewrite_input(d, st, author, k): mine = strip_head(read(d, f"案_{author}_{k}.md")) notes = [] for critic in AUTHORS: if critic == author or not exists(d, f"批評_{k}_{critic}.md"): continue lab = {v: L for L, v in st["labels"][critic].items()}[author] sec = section_for(read(d, f"批評_{k}_{critic}.md"), lab) if sec: notes.append(f"==================== メンバーからの批評 ====================\n\n{sec}") return ("# 依頼\n\n" + read(d, "依頼.md") + "\n\n# あなたの企画書(いまの版)\n\n" + mine + "\n\n# あなたの企画書への批評\n\n" + ("\n\n".join(notes) or "(批評が見つかりませんでした)")) def claude_turn(d, st, rounds, what, name): progress(d, st, rounds, f"Claude の番:{what}({name} を待っています)") flag = path(d, "Claudeの番.txt") if not os.path.exists(flag): dr.write(flag, f"{datetime.now(dr.JST).strftime('%Y-%m-%d %H:%M')}\n{what}\n{name} を置けば続きます\n") dr.notify(f"制作チーム {os.path.basename(d)}:Claude の番({what})") log(f" Claude の番:{what}({name})") def finish(d, st, rounds): rnd = random.Random() key = {"並べる": {}, "比べる": {}} order = AUTHORS[:] rnd.shuffle(order) L = [f"# 並べる:最後の3本({os.path.basename(d)}・作者は伏せてあります)", ""] words = [] for i, a in enumerate(order, 1): key["並べる"][f"案{i}"] = a body = strip_head(read(d, f"案_{a}_{rounds + 1}.md")) body = re.sub(r"^##\s*受け取った直し.*?(?=^##\s*題名)", "", body, flags=re.M | re.S).strip() L += [f"## 案{i}", "", body, ""] m = re.search(r"^##\s*YouTubeで調べる言葉[^\n]*$(.*?)(?=^##|\Z)", body, re.M | re.S) if m: words += [w.strip(" -・*") for w in m.group(1).strip().splitlines() if w.strip(" -・*")][:2] # YouTube で入口の言葉を調べる(小さなチャンネルの実績を横に添える) if words: sub = path(d, "調べ") os.makedirs(sub, exist_ok=True) with open(path(sub, "キーワード.txt"), "w", encoding="utf-8") as f: f.write("\n".join(dict.fromkeys(words)) + "\n") try: import youtube_market youtube_market.run(sub) L += ["---", "", "## YouTube で入口の言葉を調べた数字", "", "(調べ/結果.md の要点。小さなチャンネル=登録者1万人未満の本編を見る)", ""] res = open(path(sub, "結果.md"), encoding="utf-8").read() L += [x for x in res.splitlines() if x.startswith("## ") or x.startswith("- 本編") or x.startswith("- ")][:80] except Exception as e: L += ["", f"(YouTube の調べはできませんでした: {e})"] dr.write(path(d, "並べる.md"), "\n".join(L)) C = [f"# 比べる:話し合いで良くなったか({os.path.basename(d)})", "", "作者ごとに、最初の版と最後の版を、どちらがどちらか伏せて並べました。", "それぞれ「X と Y のどちらが良いか」と一言の理由を書いてください。答えは 答え_見ないで.json にあります。", ""] for i, a in enumerate(order, 1): pair = [("最初", strip_head(read(d, f"案_{a}_1.md"))), ("最後", strip_head(read(d, f"案_{a}_{rounds + 1}.md")))] rnd.shuffle(pair) key["比べる"][f"組{i}"] = {"X": pair[0][0], "Y": pair[1][0], "作者": a} for tag, (_, body) in zip(("X", "Y"), pair): body = re.sub(r"^##\s*受け取った直し.*?(?=^##\s*題名)", "", body, flags=re.M | re.S).strip() C += [f"## 組{i}・{tag}", "", body, ""] C += [f"**組{i}の判定**:(X か Y) 理由:", "", "---", ""] dr.write(path(d, "比べる.md"), "\n".join(C)) with open(path(d, "答え_見ないで.json"), "w", encoding="utf-8") as f: json.dump(key, f, ensure_ascii=False, indent=1) def run(name, d, dry): files = cr.listdir_nfc(d) if "依頼.md" not in files: log(f"× {name}: 依頼.md がありません") return rounds = 2 if "回数.txt" in files: try: rounds = max(1, int(read(d, "回数.txt").strip())) except ValueError: pass st = load(d) save(d, st) if dry: log(f"[試し]制作チーム {name}") return key = dr.gemini_key() model, _ = dr.pick_model(key) dr.write(path(d, RUNNING), f"開始 {datetime.now(dr.JST).strftime('%Y-%m-%d %H:%M:%S')}\nPID {os.getpid()}\n") flag = path(d, "Claudeの番.txt") if os.path.exists(flag): # 待っていた Claude のファイルが置かれたら、印を外して進む want = open(flag, encoding="utf-8").read().splitlines()[-1].split(" ")[0] if exists(d, want): os.remove(flag) else: os.remove(path(d, RUNNING)) return log(f" まだ Claude の番です({want})") try: req = "# 依頼\n\n" + read(d, "依頼.md") posts = {} # 持ち場.json … 作者ごとに、企画のもとにする材料を分ける(同じ材料だと題材が似るため) if exists(d, "持ち場.json"): posts = json.load(open(path(d, "持ち場.json"), encoding="utf-8")) # 1. 別々に企画書 for a in ("Gemini", "codex"): if not exists(d, f"案_{a}_1.md"): progress(d, st, rounds, f"{a} が企画書の第1版を作っています") log(f" {a} 第1版") mine = req + (f"\n\n# あなたの持ち場(この材料から企画を作る。ほかのメンバーは別の持ち場から作っている)\n\n{posts[a]}" if a in posts else "") ans, meta = run_gemini(st, key, model, DRAFT, mine) if a == "Gemini" else run_codex(st, d, DRAFT, mine) write_doc(d, f"案_{a}_1.md", f"企画書 第1版({a})", meta, ans) save(d, st) if not exists(d, "案_Claude_1.md"): return claude_turn(d, st, rounds, "企画書の第1版", "案_Claude_1.md") # 2・3. 攻めと書き直しを往復 for k in range(1, rounds + 1): for c in ("Gemini", "codex"): if not exists(d, f"批評_{k}_{c}.md"): progress(d, st, rounds, f"{k}往復目:{c} がほかの2本を攻めています") log(f" {k}往復目 {c} の批評") body = critique_input(d, st, c, k) ans, meta = run_gemini(st, key, model, CRITIQUE, body) if c == "Gemini" else run_codex(st, d, CRITIQUE, body) write_doc(d, f"批評_{k}_{c}.md", f"{k}往復目の批評({c})", meta, ans) save(d, st) if not exists(d, f"批評_{k}_Claude.md"): dr.write(path(d, f"Claudeへ_批評_{k}.md"), CRITIQUE + "\n\n" + critique_input(d, st, "Claude", k)) return claude_turn(d, st, rounds, f"{k}往復目の批評(Claudeへ_批評_{k}.md を読む)", f"批評_{k}_Claude.md") for a in ("Gemini", "codex"): if not exists(d, f"案_{a}_{k + 1}.md"): progress(d, st, rounds, f"{k}往復目:{a} が自分の企画書を書き直しています") log(f" {k}往復目 {a} の書き直し") body = rewrite_input(d, st, a, k) ans, meta = run_gemini(st, key, model, REWRITE, body) if a == "Gemini" else run_codex(st, d, REWRITE, body) write_doc(d, f"案_{a}_{k + 1}.md", f"企画書 第{k + 1}版({a})", meta, ans) save(d, st) if not exists(d, f"案_Claude_{k + 1}.md"): dr.write(path(d, f"Claudeへ_書き直し_{k}.md"), REWRITE + "\n\n" + rewrite_input(d, st, "Claude", k)) return claude_turn(d, st, rounds, f"{k}往復目の書き直し(Claudeへ_書き直し_{k}.md を読む)", f"案_Claude_{k + 1}.md") # 4. 並べる・比べる progress(d, st, rounds, "最後の3本を並べて、YouTube の数字を調べています") finish(d, st, rounds) if os.path.exists(path(d, MARK)): os.rename(path(d, MARK), path(d, DONE)) progress(d, st, rounds, "終わり。並べる.md(最後の3本)と 比べる.md(良くなったかの判定)を見てください") dr.notify(f"制作チームが終わりました:{os.path.basename(d)}") log(f"✓ 制作チーム {name} が終わりました") except Exception as e: dr.write(path(d, "エラー.txt"), f"{datetime.now(dr.JST).strftime('%Y-%m-%d %H:%M:%S')}\n\n{e}\n") progress(d, st, rounds, "エラーで止まりました(エラー.txt)。次の見回りで続きから試します") log(f"× 制作チーム {name}: {e}") finally: save(d, st) if os.path.exists(path(d, RUNNING)): os.remove(path(d, RUNNING)) def sweep(dry): real = cr.listdir_nfc(dr.VIDEOS).get(dr.nfc(ROOT)) if not real: return False root = os.path.join(dr.VIDEOS, real) for name, r in sorted(cr.listdir_nfc(root).items()): d = os.path.join(root, r) if not os.path.isdir(d): continue f = cr.listdir_nfc(d) if RUNNING in f: if time.time() - os.path.getmtime(path(d, f[RUNNING])) < 3 * 3600: log(f"制作チーム {name} が動いています") return True os.remove(path(d, f[RUNNING])) if MARK in f and STOP not in f: # Claude の番で待っているときは、Claude のファイルが置かれるまで何もしない(Gemini を無駄に呼ばない) run(f"{ROOT}/{name}", d, dry) return True return False