#!/usr/bin/env python3
"""site_team.py — サイトの入口のページを、3社の AI の「制作チーム」で作る(合議はしない).
合議(全員の批判を通る案を残す)にすると、残るのは価値のある案ではなく、誰も反論できない無難な案になる。
そこで、ひとつにまとめず、作者ごとの案を磨いて並べる。
流れ(1つの依頼につき):
1. 3社(Claude・Gemini・codex)が、互いに見せずに企画書を1本ずつ書く(v1)
2. ほかの2社が評者として攻める。指摘は必ず「弱い所」と「こう直せばもっと強くなる」の組。
案を捨てる・題材を変えさせる権限は誰にもない。事実の誤りは、ログにある範囲でいちばん強い言い方に直して示す
3. 作者が書き直す。指摘を取り込むかは作者が決め、尖りは守る。2〜3を 回数.txt の回数(既定2)くり返す
4. 3本を並べたまま終える(並べた.md)。選ぶのはエイイチ
5. 話し合いで良くなったかを確かめる(比べる.md)。各作者の最初の版と最後の版を、
どちらがどちらか伏せて並べる。答えは _答え(見ないで).json
評者にも作者の名前は伏せる(案A・案B・案C と呼ぶ)。どの社がどの案かは _対応(見ないで).json
置き場所: PixelstoMotors/サイト改善/制作/{依頼の名前}/
依頼.md Claude が書く。何を作るか・守ること・材料(数字・ログ一覧)
回数.txt 書き直しの回数(無ければ2)
かける.txt 印。終わると かけた.txt に名前を変える
止める.txt 置くと、いまの段が終わったところで止まる(消せば続きから。できたファイルは作り直さない)
使い方(Mac のターミナルで):
python3 site_team.py --check 3社が答えるかを確かめるだけ
python3 site_team.py --once 印のついた依頼を1つ回す
python3 site_team.py --install 5分ごとに見回るように登録する(YouTube の会議とは別に回る)
"""
import argparse, json, os, random, re, subprocess, sys, tempfile, time
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
HERE = os.path.dirname(os.path.abspath(__file__)) # PixelstoMotors/サイト改善/_仕組み
P2M = os.path.dirname(os.path.dirname(HERE)) # PixelstoMotors
BASE = os.path.join(P2M, "デジタルツイン学習R2R2R", "動画", "_制作ベース")
sys.path.insert(0, BASE)
import debate_runner as dr # Gemini・codex の呼び方とキーを使い回す
cr = dr.cr
JOBS = os.path.join(os.path.dirname(HERE), "制作")
MARK, DONE, STOP, RUNNING = "かける.txt", "かけた.txt", "止める.txt", "実行中.txt"
AUTHORS = ["Claude", "Gemini", "codex"]
CLAUDE = os.path.expanduser("~/.npm-global/bin/claude")
CLAUDE_TIMEOUT = 25 * 60
PATH = ":".join([os.path.expanduser("~/.npm-global/bin"), "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"])
LABEL = "jp.adaptdesign.pixelstomotors.site-team"
PLIST = os.path.expanduser(f"~/Library/LaunchAgents/{LABEL}.plist")
LOGFILE = os.path.join(cr.CONF, "site_team.log")
GROUND = """\
# 前提(全員に共通)
あなたは、Webサイト「Pixels to Motors」(pixelstomotors.com)の、人の入口になるページを作る制作チームの一員です。
チームは3つの AI で、最後に人間(エイイチ)が選びます。
目指すのは、届けたい相手が「これは保存しておきたい」と思う、価値のあるページです。誰にも怒られない無難なページではありません。
## 守ること(エイイチが決めたこと)
- 個々のログ(/record/…)の形式は変えない。入口のページから送るだけ
- 題材を広げない。入口のページは、依頼の「ログ一覧」にある中身だけで作る。ログにない事実・数字を足さない
- トップや SNS に相談の窓口を出さない。新しいキャッチコピー・肩書きを足さない
- 中身より言い過ぎた見出し・煽り・他社を悪く言う演出はしない。ただし、ログにある事実の範囲でいちばん強く言うのはよい
- 日本語で書く。英語の文を混ぜない。数字には根拠(依頼の実際の数字・ログ・推測)を添え、推測は推測と書く
"""
PLAN = """\
## 企画書の形(3,000字以内。この見出しで書く)
### ページの名前と URL の案
### 誰に(1人に絞る。その人がどこから来て、何に困っているか)
### この人がこのページで得るもの(1文)
### 最初の画面(見出しと、その下の3行。実際の文面で書く)
### 見せ場(このページにしかないもの。ログのどの事実か)
### 構成(4〜6節。節ごとに、何を書くかと、使うログ /record/… とその中の事実)
### 束ねるログ(/record/… の一覧)
### 今あるページとのつなぎ方(トップ・シリーズ・今ある LP)
### 公開後に見る数字(30件たまるまでは判断しない)
### ゴールにどう効くか(依頼にゴールの表があれば、その行ごとに。効く理由と、効かないかもしれない理由の両方)
"""
AUTHOR_V1 = """\
# あなたの役:作者
入口のページを1本、作者として企画してください。ほかの作者の案は見ていません。見る必要もありません。
- 依頼に「達成したいこと」と「ゴール」があれば、それに効くことを最優先にする
- 自分がいちばん価値があると思う題材と切り口を、ログ一覧から選ぶ。ほかの人も選びそうな無難な切り口より、あなたにしか出せない切り口を選ぶ
- 届けたい相手を1人に絞り、その人に深く刺さるように尖らせる
- 迷ったら、平均的な案ではなく、強い案を選ぶ。言い過ぎは後で直せるが、弱さは直せない
""" + PLAN
CRITIC = """\
# あなたの役:評者({me})
作者が書いた「{target}」の企画書を、もっと強くするために攻めてください。ほかの案とは比べません。
- 「強い」とは、依頼の「達成したいこと」と「ゴール」に効くこと。ゴールに効かない所をいちばん重く見る
- 指摘は必ず「弱い所」と「こう直せばもっと強くなる(具体の書き換え案・文面)」の組で書く。弱い所だけの指摘は書かない
- 案を捨てる・題材や相手を変えるよう求めない。作者が選んだ題材と相手のまま、強くする
- 事実の誤り(ログ一覧にないログ・ログにない中身・言い過ぎ)は、ログにある範囲でいちばん強い言い方に書き直して示す
- 指摘には重さを付ける:【高】直さないと伝わらない・誤り /【中】直すと強くなる /【低】好み。全部で5〜8個まで
- 最後に「守るべき尖り」として、この案のいちばん良い所を1つ書く(作者が書き直しで消さないように)
"""
REWRITE = """\
# あなたの役:作者(書き直し)
あなたが書いた企画書({target})に、2人の評者から指摘が届きました。
- 取り込むかどうかは、作者のあなたが決める。評者の言いなりにならない。案の尖り(いちばん良い所)は守る
- 事実の誤りの指摘だけは、必ず直す(ログにある範囲でいちばん強い言い方に)
- 出す形:まず【採否】として、指摘ごとに「採る/一部採る/採らない」と理由を1行ずつ。次に【企画書】として、書き直した企画書の全文
""" + PLAN
VALUE_CRITIC = """\
# あなたの役:評者({me})
作者が書いた「{target}」のページを、もっと価値のあるものにするために攻めてください。ほかの案とは比べません。
問うのは1つだけ:**読む人(ロボット導入を判断する経営者・任された PM)が、このページで初めて得るものは何か。**
- ほかのサイトやカタログでも手に入ること、読む人の判断に効かないことは「削る」か「この現場の数字で強くする」かを、書き換えの文面つきで示す
- いちばん刺さる項目と、いちばん弱い項目を1つずつ挙げ、弱い方の直し方を書く
- 案を捨てさせない。作者の切り口のまま強くする。指摘は5〜8個まで
- 事実の確かめは「事実の照合」の材料と照らして行う。合わない所は、ログにある範囲でいちばん強い言い方に直して示す
"""
REWRITE_PAGE = """\
# あなたの役:作者(書き直し)
あなたが書いたページ({target})に、2人の評者から指摘が届きました。
- 取り込むかどうかは、作者のあなたが決める。いちばん刺さる所は守る
- 事実の誤りの指摘だけは、必ず直す
- 出す形:まず【採否】として、指摘ごとに「採る/一部採る/採らない」と理由を1行ずつ。次に【企画書】として、書き直したページの全文
"""
AUTHOR_PAGE = """\
# あなたの役:作者
依頼の「ページの形」に沿って、ページそのものを書いてください。ほかの作者のページは見ていません。
- 依頼の後ろにある「持ち寄った材料」(ログからまとめた項目・外の文脈・事実の照合)を全部使う
- 読む人が「入れる前に知りたかった」と思うことを、いちばん前に出す。無難にまとめない
"""
def now():
return datetime.now(dr.JST).strftime("%Y-%m-%d %H:%M")
def claude(prompt, cwd=None, tools=None):
env = dict(os.environ)
for k in ("ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"):
env.pop(k, None)
env.update({"ENABLE_CLAUDEAI_MCP_SERVERS": "false", "PATH": PATH, "LANG": "ja_JP.UTF-8"})
with tempfile.TemporaryDirectory() as tmp, tempfile.TemporaryFile("w+", encoding="utf-8") as fo, \
tempfile.TemporaryFile("w+", encoding="utf-8") as fe:
cmd = [CLAUDE, "-p", prompt, "--output-format", "text", "--permission-mode", "default"]
if tools:
cmd += ["--allowedTools", ",".join(tools)]
p = subprocess.Popen(cmd, cwd=cwd or tmp, stdout=fo, stderr=fe, stdin=subprocess.DEVNULL, env=env, start_new_session=True)
try:
p.wait(timeout=CLAUDE_TIMEOUT)
except subprocess.TimeoutExpired:
p.kill()
raise RuntimeError("Claude が時間内に答えませんでした")
fo.seek(0); fe.seek(0)
out, err = fo.read().strip(), fe.read().strip()
if p.returncode != 0 or not out:
raise RuntimeError(f"Claude が失敗しました(終了コード {p.returncode}): {err[-600:]}")
return out
class Caller:
def __init__(self):
self.key = dr.gemini_key()
self.model, _ = dr.pick_model(self.key)
def ask(self, who, system, user, wdir, search=False, files=False):
for attempt in range(3):
try:
if who == "Gemini":
ans, _ = dr.gemini(self.key, self.model, system, user, search=search)
elif who == "codex":
ans, _ = dr.codex(wdir, system + "\n\n" + user, web=False)
else:
tools = (["Read", "Glob", "Grep"] if files else []) + (["WebSearch", "WebFetch"] if search else [])
ans = claude(system + "\n\n" + user, cwd=wdir if files else None, tools=tools or None)
return ans.strip()
except Exception as e:
dr.log(f" {who} 失敗({attempt + 1}回目): {str(e)[:200]}")
if attempt == 2:
raise
time.sleep(30)
def rd(p):
return open(p, encoding="utf-8").read()
def wr(p, text):
os.makedirs(os.path.dirname(p), exist_ok=True)
dr.write(p, text)
def plan_part(ans):
m = re.search(r"【企画書】\s*(.*)", ans, re.S)
return (m.group(1) if m else ans).strip()
def progress(d, lines):
wr(os.path.join(d, "進み具合.md"), f"# 進み具合({os.path.basename(d)})\n\n- 最後に書いた時刻 {now()}\n" + "".join(f"- {x}\n" for x in lines))
def run(d, dry=False):
name = os.path.basename(d)
req = rd(os.path.join(d, "依頼.md"))
rounds = 2
if os.path.exists(os.path.join(d, "回数.txt")):
try:
rounds = max(1, min(4, int(rd(os.path.join(d, "回数.txt")).strip())))
except ValueError:
pass
mp = os.path.join(d, "_対応(見ないで).json")
if os.path.exists(mp):
who_of = json.load(open(mp, encoding="utf-8"))
else:
labels = ["案A", "案B", "案C"]
sh = AUTHORS[:]
random.shuffle(sh)
who_of = dict(zip(labels, sh))
if not dry:
wr(mp, json.dumps(who_of, ensure_ascii=False, indent=1))
labels = sorted(who_of)
pack_tasks = None
if os.path.exists(os.path.join(d, "持ち寄り.json")):
pack_tasks = json.load(open(os.path.join(d, "持ち寄り.json"), encoding="utf-8"))
if dry:
dr.log(f"[試し]{name}: 書き直し {rounds} 回・{who_of}")
return
call = Caller()
wr(os.path.join(d, RUNNING), f"開始 {now()}\nPID {os.getpid()}\n")
done = []
pool = ThreadPoolExecutor(max_workers=3)
def stopped():
if os.path.exists(os.path.join(d, STOP)):
progress(d, done + ["止める.txt があるので止まっています(消せば続きから)"])
return True
return False
def step(jobs):
"""jobs: [(出力パス, 社, system, user[, {search, files}])]。まだ無いものだけを同時に"""
todo = [j for j in jobs if not os.path.exists(j[0])]
futs = [(j[0], pool.submit(call.ask, j[1], GROUND + "\n" + j[2], j[3], d, **(j[4] if len(j) > 4 else {}))) for j in todo]
err = None
for path, f in futs: # できた分は残す(続きから再開したときに作り直さない)
try:
wr(path, f.result() + "\n")
except Exception as e:
err = err or e
if err:
raise err
try:
dr.log(f"▶ 制作チーム {name}: 書き直し {rounds} 回")
author_v1, critic, rewrite = AUTHOR_V1, CRITIC, REWRITE
if pack_tasks:
# 0. 持ち寄り:3社がそれぞれにしか取れない材料を作る(Claude と codex は 材料/ のログ全文を読む。Gemini は Web 検索)
opt = {"Claude": {"files": True}, "codex": {}, "Gemini": {"search": True}}
step([(os.path.join(d, "持ち寄り", f"{w}.md"), w, pack_tasks[w]["役"], req, opt[w]) for w in AUTHORS])
packs = "\n\n".join(f"## {pack_tasks[w]['名前']}\n\n" + rd(os.path.join(d, "持ち寄り", f"{w}.md")) for w in AUTHORS)
req_raw, req = req, req + "\n\n# 持ち寄った材料\n\n" + packs
author_v1, critic, rewrite = AUTHOR_PAGE, VALUE_CRITIC, REWRITE_PAGE
done.append("0. 持ち寄りの材料(3社)ができました")
# 比べる相手:Claude 1本だけで、同じ生の材料から同じページを書く(持ち寄りも話し合いもなし)
step([(os.path.join(d, "1本", "v1.md"), "Claude", AUTHOR_PAGE + "\n- 材料/ のログ全文を自分で読み、必要なら Web 検索もしてよい。持ち寄った材料は無い",
req_raw, {"files": True, "search": True})])
done.append("0. 比べる相手(Claude 1本の版)ができました")
progress(d, done)
# 1. 互いに見せずに書く
step([(os.path.join(d, L, "v1.md"), who_of[L], author_v1, req) for L in labels])
done.append("1. 3本の企画書(v1)ができました")
progress(d, done)
for r in range(1, rounds + 1):
if stopped():
return
# 2. ほかの2社が攻める(名前は伏せる)
jobs = []
for L in labels:
cur = rd(os.path.join(d, L, f"v{r}.md"))
for C in labels:
if C == L:
continue
sysm = critic.format(me=f"評者({C}の作者)", target=L)
jobs.append((os.path.join(d, L, f"批評{r}_{C}の作者から.md"), who_of[C], sysm,
f"{req}\n\n# 作者の企画書({L}・第{r}版)\n\n{cur}"))
step(jobs)
done.append(f"2. {r}回目の批評ができました")
progress(d, done)
if stopped():
return
# 3. 作者が書き直す
jobs = []
for L in labels:
cur = rd(os.path.join(d, L, f"v{r}.md"))
crit = "\n\n".join(f"## 評者({C}の作者)から\n\n" + rd(os.path.join(d, L, f"批評{r}_{C}の作者から.md"))
for C in labels if C != L)
jobs.append((os.path.join(d, L, f"書き直し{r}.md"), who_of[L], rewrite.format(target=L),
f"{req}\n\n# あなたの企画書({L}・第{r}版)\n\n{cur}\n\n# 届いた指摘\n\n{crit}"))
step(jobs)
for L in labels:
v = os.path.join(d, L, f"v{r + 1}.md")
if not os.path.exists(v):
wr(v, plan_part(rd(os.path.join(d, L, f"書き直し{r}.md"))) + "\n")
done.append(f"3. {r}回目の書き直し(v{r + 1})ができました")
progress(d, done)
last = rounds + 1
# 4. 3本を並べる
wr(os.path.join(d, "並べた.md"), f"# 並べた({name}・最後の版)\n\nどの社がどの案かは伏せています(_対応(見ないで).json)。選ぶのはエイイチ。\n\n"
+ "\n\n---\n\n".join(f"# {L}\n\n" + rd(os.path.join(d, L, f"v{last}.md")) for L in labels))
# 5. 最初と最後を伏せて比べる
if pack_tasks:
cands = [("1本(Claude だけ)", rd(os.path.join(d, "1本", "v1.md")))] + [(f"{L}(話し合いのあと)", rd(os.path.join(d, L, f"v{last}.md"))) for L in labels]
random.shuffle(cands)
marks = ["①", "②", "③", "④"]
wr(os.path.join(d, "_答え(見ないで).json"), json.dumps({m: c[0] for m, c in zip(marks, cands)}, ensure_ascii=False, indent=1))
wr(os.path.join(d, "比べる.md"), "# 比べる(1本のAIより、話し合いの方が良いか)\n\n4本のうち1本は Claude だけで書いた版、3本は3社の持ち寄りと話し合いのあとの版です。どれがどれかは伏せています。"
"いちばん良いものと、いちばん悪いものを選んでから、_答え(見ないで).json を開いてください。\n\n"
+ "\n\n---\n\n".join(f"# {m}\n\n" + c[1] for m, c in zip(marks, cands)))
ans, body = None, None
else:
ans, body = {}, []
for L in (labels if ans is not None else []):
pair = [("v1", rd(os.path.join(d, L, "v1.md"))), (f"v{last}", rd(os.path.join(d, L, f"v{last}.md")))]
random.shuffle(pair)
ans[L] = {"甲": pair[0][0], "乙": pair[1][0]}
body.append(f"# {L}\n\n## 甲\n\n{pair[0][1]}\n\n## 乙\n\n{pair[1][1]}")
if ans is not None:
wr(os.path.join(d, "_答え(見ないで).json"), json.dumps(ans, ensure_ascii=False, indent=1))
wr(os.path.join(d, "比べる.md"), "# 比べる(話し合いで良くなったか)\n\n各案の最初の版と最後の版を、どちらがどちらか伏せて並べています。"
"案ごとに「甲と乙のどちらが良いか」を決めてから、_答え(見ないで).json を開いてください。\n\n" + "\n\n---\n\n".join(body))
os.rename(os.path.join(d, MARK), os.path.join(d, DONE))
done.append("4. 並べた.md と 比べる.md ができました。終わりです")
progress(d, done)
dr.log(f"✓ 制作チーム {name}: 終わりました")
dr.notify(f"サイトの制作チームが終わりました:{name}")
except Exception as e:
wr(os.path.join(d, "エラー.txt"), f"{now()}\n\n{e}\n")
progress(d, done + [f"エラーで止まりました(エラー.txt)。次の見回りで続きから試します"])
dr.log(f"× 制作チーム {name}: {e}")
dr.notify(f"サイトの制作チームが止まりました:{name}")
finally:
pool.shutdown(wait=False)
p = os.path.join(d, RUNNING)
if os.path.exists(p):
os.remove(p)
def alive(d):
p = os.path.join(d, RUNNING)
if not os.path.exists(p):
return False
m = re.search(r"PID (\d+)", rd(p))
if m:
try:
os.kill(int(m.group(1)), 0)
return True
except ProcessLookupError:
os.remove(p)
return False
except PermissionError:
return True
return False
def once(dry):
if not os.path.isdir(JOBS):
dr.log("制作/ がありません")
return
for name in sorted(os.listdir(JOBS)):
d = os.path.join(JOBS, name)
if not os.path.isdir(d):
continue
f = set(os.listdir(d))
if alive(d):
dr.log(f"制作チーム {name} が動いています")
return
if MARK in f and STOP not in f and "依頼.md" in f:
run(d, dry)
return
dr.log("印(制作/{名前}/かける.txt)のついたものはありません")
def check():
call = Caller()
dr.log(f"Gemini のモデル: {call.model}")
for who in AUTHORS:
t0 = time.time()
a = call.ask(who, "短く答えてください。", "「準備できました」とだけ答えてください。", HERE)
dr.log(f"{who}: {a[:40]}({int(time.time() - t0)}秒)")
def install():
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(4, 60, 5))
plist = f"""
Label{LABEL}
ProgramArguments{sys.executable}{os.path.abspath(__file__)}--once
StartCalendarInterval{cal}
RunAtLoad
AbandonProcessGroup
WorkingDirectory{HERE}
EnvironmentVariables
PATH{PATH}
CODEX_HOME{cr.CODEX_HOME}
LANGja_JP.UTF-8
StandardOutPath{LOGFILE}
StandardErrorPath{LOGFILE}
"""
open(PLIST, "w").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}")
if __name__ == "__main__":
ap = argparse.ArgumentParser(description="サイトの入口のページを、3社の AI の制作チームで作る")
ap.add_argument("--check", action="store_true")
ap.add_argument("--once", action="store_true")
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--install", action="store_true")
a = ap.parse_args()
try:
if a.install:
install()
elif a.check:
check()
else:
once(a.dry_run)
except Exception as e:
dr.log("×", e)
sys.exit(1)