木曜の夜、同じ依頼を三度書き直していました。個人開発の作業は、詰まった時間をそのまま自分で払うことになります。記事一覧ページの改修を Antigravity に頼み、返ってきた差分を見て、チェックポイントまで巻き戻す。文言を丁寧にし直して、また巻き戻す。三度目にようやく通ったころには、自分で書いたほうが早かったと分かる時間になっていました。
翌朝、直近の依頼ログを CSV に落として眺めました。すると、巻き戻した依頼にはひとつの共通点がありました。プロンプトの丁寧さとは無関係だったのです。巻き戻した依頼は、例外なく「触るファイル数」か「含まれる意思決定の数」のどちらかが多かった。
これは主観の話ではなく、送信前に数えられる量です。数えられるなら、送信前に判定できます。この記事は、その判定器を作って自分の依頼ログ80件に当ててみた記録です。
差し戻し率という一本の指標
「エージェントの出力品質」は曖昧です。良い・悪いを人が採点すると、その日の疲労で揺れます。そこで指標を一本に絞りました。
差し戻し率(rework rate)= その依頼が一発で採用されず、巻き戻し・大幅な書き直し・追加指示による軌道修正を要した割合。
計測は単純です。依頼ごとに accepted / reworked の二値を記録するだけ。Antigravity のチェックポイントを使っていれば、ロールバックの有無がそのまま reworked の判定になります。私はエディタのセッションログから抽出しましたが、手動でメモしても構いません。重要なのは、主観の五段階評価ではなく二値にすることです。二値なら、あとから相関が取れます。
三週間ぶんの依頼、80件。差し戻し率は全体で 41% でした。半分近くをやり直していたわけです。この数字を初めて見たとき、胸のあたりが少し重くなりました。
送信前に採点する — preflight スコアラー
依頼文からスコアを出します。目的は「大きすぎる依頼を送信前に止める」ことだけなので、構文解析まではしません。以下の6因子を数えます。
| 因子 | 数え方 | 重み | 根拠 |
|---|---|---|---|
| 推定変更ファイル数 | 依頼文中のファイル名・コンポーネント名・「〜も」の列挙数 | ×2.0 | 6ファイル以上で後半の整合性が崩れる |
| 未決定の意思決定点 | 「どうする」「最適な」「いい感じに」等の判断委譲表現 | ×3.0 | 最も差し戻しに効く因子 |
| 実装とテストの同居 | 「テストも」「テストまで」の有無 | ×2.5 | 思考モードが異なり片方が雑になる |
| 接続詞による抱き合わせ | 「ついでに」「あわせて」「ついでに言うと」 | ×2.0 | 抱き合わせは意思決定の追加を隠す |
| 恒久ルールの混入 | 「型は any を使わない」等プロジェクト全体の規約 | ×1.0 | 注意が本題から分散する |
| 自己完結性の欠如 | 「あとで繋ぐ」「一旦」「仮で」 | ×1.5 | 中間成果物は後工程で辻褄合わせが増える |
重みは天下りではありません。最初は全て 1.0 で始め、80件のログでロジスティック回帰を掛けて係数の大小関係だけを見て、扱いやすい数字に丸めました。厳密な回帰係数を使うほどのデータ量ではないので、順序が保たれていれば十分だと判断しています。
# preflight_score.py — 依頼文をエージェントに送る前に採点する
import re
from dataclasses import dataclass
DECISION_MARKERS = [
"どうする", "どうすべき", "最適な", "いい感じ", "よしなに",
"適切に", "任せる", "決めて", "選んで",
]
TEST_MARKERS = ["テストも", "テストまで", "テストも書いて", "テストを含め"]
BUNDLE_MARKERS = ["ついでに", "あわせて", "同時に", "その流れで"]
POLICY_MARKERS = ["any を使わない", "コメントは日本語", "命名規則", "フォーマッタ"]
DEFERRAL_MARKERS = ["あとで繋", "一旦", "仮で", "とりあえず形だけ"]
# ファイル・モジュールらしき語のパターン(拡張子つき / PascalCase コンポーネント)
FILE_PAT = re.compile(r"[\w./-]+\.(tsx?|jsx?|py|go|rs|mdx?|css)\b")
COMPONENT_PAT = re.compile(r"\b[A-Z][a-zA-Z0-9]{2,}\b")
WEIGHTS = {
"files": 2.0, "decisions": 3.0, "tests": 2.5,
"bundles": 2.0, "policies": 1.0, "deferrals": 1.5,
}
@dataclass
class Score:
total: float
factors: dict
@property
def band(self) -> str:
if self.total < 6:
return "GREEN"
if self.total < 12:
return "AMBER"
return "RED"
def _count(text: str, markers: list[str]) -> int:
return sum(text.count(m) for m in markers)
def score_request(text: str) -> Score:
files = len(set(FILE_PAT.findall(text))) + len(set(COMPONENT_PAT.findall(text)))
factors = {
# ファイル数は 3 までは無害。超過分だけを罰する
"files": max(0, files - 3),
"decisions": _count(text, DECISION_MARKERS),
"tests": _count(text, TEST_MARKERS),
"bundles": _count(text, BUNDLE_MARKERS),
"policies": _count(text, POLICY_MARKERS),
"deferrals": _count(text, DEFERRAL_MARKERS),
}
total = sum(WEIGHTS[k] * v for k, v in factors.items())
return Score(total=total, factors=factors)
if __name__ == "__main__":
import sys
body = sys.stdin.read()
s = score_request(body)
print(f"score={s.total:.1f} band={s.band}")
for k, v in s.factors.items():
if v:
print(f" {k}: {v} (+{WEIGHTS[k] * v:.1f})")
sys.exit(0 if s.band != "RED" else 1)ファイル数を「3 を超えた分だけ罰する」形にしたのは、実装上の細かい判断ですが効きます。素直に本数へ比例させると、単一ファイルの小さな依頼まで軽くスコアが乗ってしまい、閾値の意味が薄れました。無害な領域を 0 点に潰しておくと、閾値が「危険の始まる点」を素直に表します。
意思決定マーカーの重みが最も大きいのも、実測から来ています。「認証を追加して」という一文には、セッション管理・トークン保存先・失敗時の UX という三つの判断が畳み込まれています。エージェントはそれを尋ね返さず、最も一般的な選択肢を黙って選びます。その選択が既存コードの流儀と衝突したとき、差分は静かに壊れます。