LLM × BDD でブラックボックス性を軽減した自動開発

# 実現したいこと
LLMを使ってアプリケーションの開発を行う場合、どうしてもプロセスがブラックボックスになってしまい、LLMにプロセス全体を任せるのは難しいのが現状だと思います。

そのブラックボックス性を少しでも軽減できれば、要所で人が関与しやすくなり、品質を担保しながら高速な開発ができるのではないか？と考え、少しお試しです！

※今回は、LangChain/LlamaIndexなどは使わず、OpenAI APIのみを使いました。

# 概念の補足

## 1. LLM
Large Language Model(大規模言語モデル)
大規模なテキスト群を学習しており、人に近い汎用的なタスクを遂行することができる。
ChatGPTの裏で動いているGPTなど。
例：GPT・PaLM・Claude・LLaMA

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2787945/1c21e4cf-a598-e2d4-c097-8cf6aba7fea7.png)

参考：[Beyond Words: Large Language Models Expand AI’s Horizon](https://blogs.nvidia.com/blog/llms-ai-horizon/)


## 2. BDD
Behavior Driven Development(振る舞い駆動開発)
> BDDは、ビジネス担当者と技術担当者の間のギャップを埋める、ソフトウェア・チームの作業方法である：
・解決すべき問題に対する理解を共有するために、役割を超えたコラボレーションを奨励する。
・フィードバックと価値のフローを増やすために、迅速で小さなイテレーションで作業する。
・システムの動作と自動的に照合されるシステム文書を作成する。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2787945/ce3fbbbf-0301-56f2-16b8-c659a7edbd70.png)
参考：[Behaviour-Driven Development](https://cucumber.io/docs/bdd/)

# 試したこと
「実装対象の機能を人が決定すると、後工程の開発はLLMが自動で行う」という仕組みを簡易的に作ってみました。

また、できるだけ自動化しつつ、人が理解しやすい中間成果物を残しておくことによって、人の手を要所で入れやすい状態を目指しました。
```mermaid
graph TB
    X[実装対象の機能を決定する] -.-> A
    subgraph LLMに任せる範囲
        A[1. 仕様を定義する] --> B[2. テストを書く]
        B --> C[3. プロダクトコードを書く]
        C --> D[4. テストを実行する]
        D -- テスト失敗 --> E[5. テストが通るように修正する]
        E -.-> D
        D -- テスト成功 --> F[完了]
    end
```


## 1. 仕様を定義する

まずは、プランナーというロールを与えて、仕様を定義してもらいます。
実装対象の機能は決定されていると仮定して、Gherkin形式で仕様を書いてもらいましょう。

ほしいテキストのみをアウトプットさせるために、JSON Modeを使います。
（システム的に処理するために、余分な前置きなどを排除したい、というのがJSON Modeを使う意図になります。)

※コードが多く見えますが、プロンプトが占める割合がほとんどです！

### Input

```python
target_func = "割り勘機能"

plan_sys_msg = """あなたは網羅的に仕様を定義するのが得意なプランナーです。
ユーザーから「実装機能」が与えられるので、「ルール」に則りつつ、Gherkin形式で機能の仕様を書き出してください。
出力は、「出力形式」に従い、JSON形式でお願いします。

### ルール
- あなたが定義する仕様記述に沿って、プログラマーがユニットテストを書くことができるくらい、明確に仕様を定義してください。

### 出力形式
{
  "lang": "Gherkin",
  "code": "Feature: 計算機能
この計算機能は、基本的な算術演算（加算、減算、乗算、除算）を実行することができる。

Scenario: 二つの数値の加算
  Given ユーザーが計算機アプリを開いている
  When ユーザーが数値「5」と「3」を入力する
  And 加算の操作を選択する
  Then 計算機は結果「8」を表示する

Scenario: 二つの数値の減算
  Given ユーザーが計算機アプリを開いている
  When ユーザーが数値「5」と「3」を入力する
  And 減算の操作を選択する
  Then 計算機は結果「2」を表示する

Scenario: 二つの数値の乗算
  Given ユーザーが計算機アプリを開いている
  When ユーザーが数値「5」と「3」を入力する
  And 乗算の操作を選択する
  Then 計算機は結果「15」を表示する

Scenario: 二つの数値の除算
  Given ユーザーが計算機アプリを開いている
  When ユーザーが数値「15」と「3」を入力する
  And 除算の操作を選択する
  Then 計算機は結果「5」を表示する"
}"""

plan_usr_msg = """### 実装機能
{}"""

response = client.chat.completions.create(
  model="gpt-4-1106-preview",
  messages=[
      {
        "role": "system",
        "content": plan_sys_msg
      },
      {
        "role": "user",
        "content": plan_usr_msg.format(target_func)
      }
  ],
  response_format={ "type": "json_object" },
  temperature=0
)

json_response = json.loads(response.choices[0].message.content)
spec = json_response["code"]
spec
```

### Output
```cucumber
Feature: 割り勘機能
この割り勘機能は、合計金額と人数を入力することで、一人当たりの支払額を計算する。

Scenario: 合計金額と人数を入力して割り勘を計算
  Given ユーザーが割り勘計算アプリを開いている
  When ユーザーが合計金額「1500」円と人数「3」人を入力する
  And 割り勘計算を実行する
  Then アプリは一人当たりの支払額「500」円を表示する

Scenario: 人数にゼロを入力した場合のエラー処理
  Given ユーザーが割り勘計算アプリを開いている
  When ユーザーが合計金額「1500」円と人数「0」人を入力する
  And 割り勘計算を実行する
  Then アプリはエラーメッセージ「人数には1以上の数値を入力してください」を表示する

Scenario: 合計金額に負の数を入力した場合のエラー処理
  Given ユーザーが割り勘計算アプリを開いている
  When ユーザーが合計金額「-1500」円と人数「3」人を入力する
  And 割り勘計算を実行する
  Then アプリはエラーメッセージ「合計金額には0以上の数値を入力してください」を表示する

Scenario: 合計金額を均等に割り切れない場合の処理
  Given ユーザーが割り勘計算アプリを開いている
  When ユーザーが合計金額「1000」円と人数「3」人を入力する
  And 割り勘計算を実行する
  Then アプリは一人当たりの支払額「333」円と余り「1」円を表示する
```

## 2. テストを書く
続いて、プログラマーというロールを与えて、テストを実装してもらいます。
1で定義したGherkinの仕様を満たしているかテストできるように、pytest-bddのテストを書いてもらいましょう。

### Input
```python
test_sys_msg = """あなたは、振る舞い駆動開発の手法に基づいて開発を行うプログラマーです。
まず、プロダクトコードを記述する前に、Gherkin形式の仕様を満たしているか確認するためのテストを実装します。
ユーザーから「実装機能」と「機能仕様」が与えられるので、「ルール」に則りつつ、pytest-bddのテストを記述してください。
出力は、「出力形式」に従い、JSON形式でお願いします。

### ルール
- Gherkinの全シナリオを網羅するようにpytestを記述します。
- Gherkinの仕様は、spec.featureに書かれています。
- 各テストメソッドは、どのシナリオをテストするためのものかわかるように書いてください。
- プロダクトコードは未実装ですが、func.pyに実装される予定なので、そのモジュールをimportしてテストを実行できるようにしてください。

### 出力形式
{
  "lang": "Python",
  "code": "import pytest
from pytest_bdd import scenario, given, when, then
from calculator import Calculator

@pytest.fixture
def calculator():
    return Calculator()

@scenario('calculator.feature', '二つの数値の加算')
def test_add():
    pass

@given('ユーザーが計算機アプリを開いている')
def step_impl(calculator):
    pass

@when('ユーザーが数値「5」と「3」を入力する')
def step_impl(calculator):
    calculator.enter(5)
    calculator.enter(3)

@when('加算の操作を選択する')
def step_impl(calculator):
    calculator.press_add()

@then('計算機は結果「8」を表示する')
def step_impl(calculator):
    assert calculator.result == 8"
}
"""

test_usr_msg = """### 実装機能
{}

### 機能仕様(Gherkin)
{}"""

response = client.chat.completions.create(
  model="gpt-4-1106-preview",
  messages=[
      {
        "role": "system",
        "content": test_sys_msg
      },
      {
        "role": "user",
        "content": test_usr_msg.format(target_func, spec)
      }
  ],
  response_format={ "type": "json_object" },
  temperature=0
)

json_response = json.loads(response.choices[0].message.content)
test = json_response["code"]
test
```

### Output
```python
import pytest
from pytest_bdd import scenarios, given, when, then, parsers
from func import SplitBillCalculator

@pytest.fixture
def calculator():
    return SplitBillCalculator()

scenarios('spec.feature')

@given('ユーザーが割り勘計算アプリを開いている')
def app_open(calculator):
    pass

@when(parsers.parse('ユーザーが合計金額「{total}」円と人数「{people}」人を入力する'))
def enter_amounts(calculator, total, people):
    calculator.set_total(int(total))
    calculator.set_people(int(people))

@when('割り勘計算を実行する')
def calculate_split(calculator):
    calculator.calculate()

@then(parsers.parse('アプリは一人当たりの支払額「{amount}」円を表示する'))
def should_display_amount_per_person(calculator, amount):
    assert calculator.amount_per_person == int(amount)

@then(parsers.parse('アプリはエラーメッセージ「{message}」を表示する'))
def should_display_error_message(calculator, message):
    assert calculator.error_message == message

@then(parsers.parse('アプリは一人当たりの支払額「{amount}」円と余り「{remainder}」円を表示する'))
def should_display_amount_and_remainder(calculator, amount, remainder):
    assert calculator.amount_per_person == int(amount)
    assert calculator.remainder == int(remainder)
```

## 3. プロダクトコードを書く
2で生成したテストコードをPassするようにプロダクトコードを書いてもらいましょう。
前段階での生成物はユーザーメッセージに含めます。

### Input

```python
product_sys_msg = """あなたは、振る舞い駆動開発の手法に基づいて開発を行うプログラマーです。
pytest-bddで記述されたテストがPassするように、対象機能のプロダクトコードを実装します。
ユーザーから「実装機能」と「機能仕様」と「テストコード」が与えられるので、「ルール」に則りつつ、Pythonで実装してください。
出力は、「出力形式」に従い、JSON形式でお願いします。

### ルール
- classの定義の仕方は、テストコード側でimportできるようにします。
- クラスとメソッドには、docstringを付与してください。
- テストコード側で呼び出されているメソッドは確実に実装するようにしてください。

### 出力形式
{
  "lang": "Python",
  "code": "class Calculator:
def __init__(self):
    self._values = []

def enter(self, value):
    self._values.append(value)

def press_add(self):
    if len(self._values) < 2:
        raise ValueError("2つの値の入力が必要です。")
    self._result = sum(self._values)
    self._values.clear()

@property
def result(self):
    return self._result"
}
"""

product_usr_msg = """### 実装機能
{}

### 機能仕様
{}

### テストコード
{}"""

response = client.chat.completions.create(
  model="gpt-4-1106-preview",
  messages=[
      {
        "role": "system",
        "content": product_sys_msg
      },
      {
        "role": "user",
        "content": product_usr_msg.format(target_func, spec, test)
      }
  ],
  response_format={ "type": "json_object" },
  temperature=0
)

json_response = json.loads(response.choices[0].message.content)
func = json_response["code"]
func
```

### Output
```python
class SplitBillCalculator:
    """
    割り勘計算を行うクラス。
    """

    def __init__(self):
        """
        SplitBillCalculatorの初期化。
        """
        self.total_amount = 0
        self.number_of_people = 0

    def enter_amount_and_people(self, amount, people):
        """
        合計金額と人数を入力する。

        :param amount: 合計金額
        :param people: 人数
        """
        if amount < 0:
            raise ValueError('合計金額には0以上の数値を入力してください')
        if people <= 0:
            raise ValueError('人数には1以上の数値を入力してください')
        self.total_amount = amount
        self.number_of_people = people

    def calculate(self):
        """
        割り勘計算を実行する。

        :return: 一人当たりの支払額と余り
        """
        share = self.total_amount // self.number_of_people
        remainder = self.total_amount % self.number_of_people
        return {'share': share, 'remainder': remainder}
```

## 4. テストを実行
テストを実行し、結果を受け取ります。
```python
import subprocess

result = subprocess.run(['pytest', 'test.py'], stdout=subprocess.PIPE, text=True)
captured_output = result.stdout
captured_output
```
3/4がPassしましたが、1ケースFailになりました。
```bash
======================================= test session starts ========================================
platform linux -- Python 3.10.12, pytest-7.4.3, pluggy-1.3.0
rootdir: /content
plugins: bdd-7.0.1, anyio-3.7.1
collected 4 items

test.py ...F                                                                                 [100%]

============================================= FAILURES =============================================
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

calculator = <func.SplitBillCalculator object at 0x7d2594b3ca60>, amount = '333」円と余り「1'

    @then(parsers.parse('アプリは一人当たりの支払額「{amount}」円を表示する'))
    def should_display_amount_per_person(calculator, amount):
>       assert calculator.amount_per_person == int(amount)
E       ValueError: invalid literal for int() with base 10: '333」円と余り「1'

test.py:26: ValueError
===================================== short test summary info ======================================
FAILED test.py::test_合計金額を均等に割り切れない場合の処理 - ValueError: invalid literal for int...
=================================== 1 failed, 3 passed in 0.10s ====================================
```

## 5. テストが通るように修正する
テストコードがパスするような修正案を考えてもらいましょう。
少しトークン消費しますが、今までの生成物をすべて与えます。

### Input
```python
refuctor_sys_msg = """あなたは、振る舞い駆動開発の手法に基づいて開発を行うプログラマーです。
pytest-bddで記述されたテストがPassするように、対象機能のプロダクトコードを実装しました。
pytestを実行したところ、「エラーメッセージ」が表示されました。
このエラーを解消するために何をどう修正すればよいか検討し、提案してください。"""

refuctor_usr_msg = """### 実装機能
{}

### 機能仕様
{}

### テストコード
{}

### エラーメッセージ
{}"""

response = client.chat.completions.create(
  model="gpt-4-1106-preview",
  messages=[
      {
        "role": "system",
        "content": refuctor_sys_msg
      },
      {
        "role": "user",
        "content": refuctor_usr_msg.format(target_func, spec, test, captured_output)
      }
  ],
  temperature=0
)

suggestion = response.choices[0].message.content
suggestion
```

### Output
```markdown
エラーメッセージを見ると、`test_合計金額を均等に割り切れない場合の処理` というテストケースで失敗しています。具体的には、`should_display_amount_per_person` 関数で `amount` の値を整数に変換しようとした際に、無効なリテラルがあるために `ValueError` が発生しています。

エラーメッセージには `amount = '333」円と余り「1'` とありますが、これは期待される整数値ではなく、パースされるべき文字列が正しく分割されていないことを示しています。これは、`@then` デコレータで使用されているパーサーの表現が間違っているために起こっています。

以下のステップで修正を行います：

1. `@then` デコレータで使用されているパーサーの表現を修正して、`amount` と `remainder` を正しく抽出できるようにします。
2. `should_display_amount_and_remainder` 関数を修正して、正しいアサーションを行います。

修正後のテストコードは以下のようになります：

@then(parsers.parse('アプリは一人当たりの支払額「{amount}」円と余り「{remainder}」円を表示する'))
def should_display_amount_and_remainder(calculator, amount, remainder):
    assert calculator.amount_per_person == int(amount)
    assert calculator.remainder == int(remainder)

この修正により、`amount` と `remainder` の値が正しく抽出され、それぞれのアサーションが正しく実行されるようになります。これでテストがパスするはずです。
もしまだエラーが発生する場合は、プロダクトコードの `SplitBillCalculator` クラスにも問題がある可能性がありますので、そちらも確認してください。
```

# 気づき
Open InterpreterやAssistant APIを使うことができれば、修正案作成だけでなく、修正自体をやってくれそうですね。
LLMがうまく自律的に開発ループを回すことができると、使い物になりそうです。
※[AutoGen](https://github.com/microsoft/autogen)とかうまく使えるとループが回りだすかも！

コンセプトレベルで作ったので、各プロセスの完成度より、全体が一巡することを意識して作ってみました。
「説明可能性を担保しつつ、LLMと一緒に高速でモノづくりをする」というコンセプトが担保できれば、BDDベースではなくてもよさそうかもしれません。
もしかすると、同様のコンセプトを体現したOSSが出てくるかもしれないですね。（もうあるのかも）

Webアプリの開発などに適用してみると、この仕組みをブラッシュアップできそうな気がします。
また、私自身BDD自体の知識が浅い状態なので、いろいろ調べてみたいと思います。

最後まで目を通していただき、ありがとうございました！