Cursorで「Plan→Imp→Debug」を全自動化してほぼ無介入でAPIを完成させたった

結論から言うと、LLMの特性を理解しながら、ルールファイルと要件定義のプロンプトを工夫したところ、ほとんど人が介入せずに機能開発できた。

[LLMのプロンプトエンジニアリング](https://amzn.to/4j6TPWn)がとても役に立った。

# 抱えていた問題意識

バリバリ運用されている巨大webシステムのバックエンドの改修はAIにはまだ難しい感覚がある。Vibe Codingではエンジニアが逐一介入する必要があり、結局めんどくさい。自分でやった方が速いし正確。暴走してクッチャクチャにされるし。

# 目指すゴール像

- 要件をAIに投げたら、自律してAI単体で進め、**どうしても聞かなければいけないことは人間に聞いてくるようにする**
- 暴走を検知し、人間の介入予知を残す

# 環境

エディタ: Cursor
LLMモデル: gemini-2.5-pro-preview-05-06
いじったシステム: Django+Django Rest Framework on Docker

# 基本的な考え方

- LLMは学習データをもとに、前の単語から次の単語に続きやすいものを確率で選んで出力しているに過ぎない
- したがって、MUSTやMUST NOTな事項は、プロンプトの前の方に入れておかないと、無視されたりする。前に出力したものには戻れないため。プロンプトの指示順がとても大事
	- > LLMはテキストを一度しか読めず、後戻りできません。ですから、ミニブレインたちが段落を処理している段階では、その後に「文字数を数えろ」というリクエストが出てくることを知らないのです。
		- 引用元: [LLMのプロンプトエンジニアリング](https://amzn.to/4j6TPWn) 2.5 トランスフォーマーアーキテクチャ
- 確率なので、間違える。間違った際に、間違ったことがわかる仕組み、軌道修正できる仕組みを入れる必要がある
	- > ハルシネーションはモデルの視点では他の補完と区別がつかないため、「勝手に作り話をしないで」 といったプロンプト指示はほとんど効果がありません。その代わり、モデルに検証可能な背景情報を 提供させるアプローチが一般的です。
		- 引用元: [LLMのプロンプトエンジニアリング](https://amzn.to/4j6TPWn) 2.1.3 ハルシネーション
- 確率なので、タスクの終了や一旦停止して人間に聞いた方が良い、みたいな判断ができないことがあり、暴走する
	- > LLMはパターン認識が得意なので、たまたま何らかのパターンが生じると、 どこで打ち切るべきか見つけられなくなることがあります。
		- 引用元: [LLMのプロンプトエンジニアリング](https://amzn.to/4j6TPWn) 2.3.2 パターンと繰り返し

無論、CursorなどのLLMを操るアプリケーション側がいい感じに調整はするはずなのだが、基本的な構造は上記であることを理解していると、プロンプトが組みやすくなる。

# 実施したこと

基本的な考え方をもとにすると、下記がポイントとなる

- 「必ずやってほしいこと」は前の方で指示する
- テストコードなどで、間違いを検知し、軌道修正できるようにする
- 停止条件を使って暴走を止める

では実際に環境を作ってみる。

## 基本ルールをルールファイルで定義

- `.cursor/rules/01_governance.mdc`に下記の指示を書く

```
---
description: 自律実行ガイドライン
globs: 
alwaysApply: true
---
# ✅ 基本方針
- **Autonomy: Medium**  # Plan と Debug フェーズ後に確認、Imp は自動
- PRD の各項目を「Plan → Imp → Debug → Doc」サイクルで処理する  
- irreversible / high-risk 操作（削除・本番 DB 変更・外部 API 決定）は必ず停止する

# 制約
## 技術的制約
- Django 4.2
- Django Rest FrameworkのベストプラクティスにもとづきAPIを作る
- テストフレームワーク: pytest
- テストを実行するときは全テストの実行は禁止

## フォーマッターの実行方法

    ```
    docker compose run app poetry run task フォーマットのコマンド
    ```

## テストの実行方法

    ```
    docker compose run app pytest -s -p no:warnings <テストファイル名>
    ```

# 🚦 停止条件
- Planが終わりPlan全文をmdファイルに書き込んだとき
- PRD 行に `@ASK` が含まれる  
- 1ファイル当たり変更行数 > 300 行  
- 連続3回テスト失敗したとき
- 「複数案が等価」「重大な設計選択」など Fork in the road が発生
- 下記のgit操作をしたいとき
  - rebase
  - revert
  - その他破壊的な操作

# 📝 人間への質問フォーマット

❓ QUESTION
<要約>

OPTIONS:
1. <案 A>
2. <案 B>

/answer <番号> で回答してください。

# commit前のルール

commit 前に フォーマッターをかけ、関連するテストを実行して成功させること
```

- ポイント
	- alwaysApply: trueにすることで、絶対に読み込まれる
	- 技術的制約でフレームワーク等書く。ここで書いておくと、そのフレームワークのベストプラクティスに従う(ような気がする)
	- プロセスを規定する
		- Plan → Imp → Debug → Docのサイクルで実施することを明示
			- 特に停止条件に、`Planが終わりPlan全文をmdファイルに書き込んだとき`を書くのが重要。これをするとPlanが終わった時に、ユーザーにPlanの確認を求めるようになる。ここで致命的な認識違いを防ぐ
	- 必ずやってほしいことをどうやらせるか
		- フォーマッタのコマンドや、テストの実行方法を「前の方」に書く
		- commit前のルールでフォーマッタとテスト実行を命令する
	- 間違いを検知し、軌道修正させるには
		- commit前のテスト実行で間違いに気づくことができる。間違いに気づいたAIは勝手にエラー内容を読み込んでImpフェーズに戻り修正、その後再テストをする流れができる
	- 暴走を防ぐ
		- 停止条件に`連続x回テスト失敗したとき`を入れる。回数は各々で調整して良いと思う。これで意味不明な繰り返しルートに進むことを防げる

## 各フェーズで出力してほしい成果物を定義

- `.cursor/rules/02_plan-export.mdc`に下記を書く

```
---
description: Plan を Markdown として保存
globs: 
alwaysApply: true
---
# 📝 Plan Exporter
- **Trigger**: Plan フェーズが完了した直後
- **Action**:
  1. 生成した Plan 全文を `.docs/todo/${タスクの概要}.md` に **上書き**  
     - 存在する場合は更新動作

- **Confirmation**:  
  - ファイル作成に失敗したら停止
```

- ポイント
	- alwaysApply: trueにすることで、絶対に読み込まれる
	- Triggerを定義することで、出力契機を指定

Planしかやらなかったけど、今思うと、Docサイクルで、設計書とかを逆出力して貰ってもよかった...リファクタタスクとかを別セッションでAIにやらせる場合に使えそう

## テストコードを作っておく

下記のような正常系のテストコードを1つだけ作っておく。

- /path/to/test_code.py

```
class TestHoveViewSet(TransactionTestCase):
    ...
    fixtures = [
        ...
    ]

    def test_list_success_with_product_code(self):
        """商品コードで検索して商品マスタ一覧が取得できること"""
        """
        TODO:
        hogehoge.pyのFactoryBoyのテストデータを真似して、
        テストデータを作成する
        """
        # create_test_data()

        response = Client().get(
            "テストしたいAPIのURL" + "?search_word=1000",
            content_type="application/json",
        )
        self.assertEqual(response.status_code, 200)
        self.assertEqual(
            {
                "count": 2,
                "next": None,
                "previous": None,
                "results": [
                    {
                        "id": 1,
                        "name": "商品1000",
                        "code": "1000",
                    },
                    {
                        "id": 2,
                        "name": "商品1000",
                        "code": "1000",
                    },
                ],
            },
            response.json(),
        )
```

- ポイント
	- テストコードさえあればエントリーポイントが分かるので、AIが迷わなくて済む
	- assertがそのまま仕様となるためAIにとって最高の情報となる
	- テストデータ作成など、面倒なところはTODOコメントを書いておき、既存実装のどこかを真似して作らせる。期待値が分かっている&後述のPRDで関連情報を出すため、AI自身でテストデータ作成が可能(な場合がある)

## PRD(要件定義)ファイルを作る

下記のような要件定義を作る。要件と言いつつ、ほぼ外部設計に近い気もする。

- `.docs/prd/create_hogehoge_api.md`

```
# 商品検索API
## 目的

検索キーワードをもとに、該当の商品マスタ`HogeModel`の一覧を取得するAPIを作りたい

## 要件

- 検索キーワードを入れてAPIを叩いたら、該当の商品マスタ一覧が得られる
- 検索キーワードの、検索項目は商品マスタ.商品コードと商品マスタ.商品名の両方をor検索
- 検索キーワードは必須項目。指定がなければ400エラーにする

## 完了条件

下記のテスト実行が成功

    ```
    /path/to/test_code.py::TestHogeViewSet::test_list_success_with_product_code
    ```

## 制約
- エンドポイントは`GET /hoge/product_masters?search_word=検索キーワード`とする
- urlは`urls.py`に書く

# 実装ガイダンス (参考情報)
- Viewでは`何とかViewSet`を継承したViewSetクラスを作ること
- ViewSetではfilterset_classを使って検索機能を実現すること
```

- ポイント
	- 前述した通り、LLMはタスクの終了を検知できない時がある
		- 完了条件で、テスト成功を明示し、終了を検知させる
		- テストコマンドもルールファイルで展開済みなので、実施してくれる
	- 制約の章でこの辺いじろうな？ みたいなのを明記
	- 実装ガイダンスで参考として技術的な情報を渡す。だいたいこの辺りをこういうふうに作るよね〜みたいな

# いざプロンプト実行

ここまで準備した後、指示したプロンプトは下記

```
@create_hogehoge_api.md を実施して
```

# 結果

- 1回介入しただけでAPIができた。その介入もimportのエラーでドツボにハマっていただけで、ロジック自体はほぼ意図通りだった
- テストエラーになったらその原因を解析し、AIが自律して完成まで持っていった
- なんか勝手に準正常系のテストまで書き出し、ちゃんと通してたw

# 感想と今後

個人的には、介入がきちんとできたことが感動で、検証と停止条件がフローに組み込まれていれば、ここまでまともになることに感動した。

> 基本的に自律してAI単体で進め、どうしても聞かなければいけないことは人間に聞いてくる

人間エンジニアだと当然のように思える↑の行動を制御するのが難しい。LLMの仕組みをわかっていないと辿り着けない領域で、LLMの基礎を学ぶことは大切なんだなと思った。[LLMのプロンプトエンジニアリング](https://amzn.to/4j6TPWn)は一回読んでおいた方が良い。

次はチーム個別のアーキテクチャスタイル(レイヤードとかサービスベースとか)に合わせた書き方ができるようにAIを制御していこうと思う。