LoginSignup
25
3

GPTをドライバーにTDDでペアプロしてみる

Last updated at Posted at 2023-12-01

はじめに

本記事はリンクアンドモチベーション Advent Calendar 2023の2日目になります。
これまで自分は、バックエンドやQA領域を特に触れることが多かったですが、最近は生成AIを使った開発にもチャレンジさせていただいています。
今回は、強引にこの3つを掛け合わせて、
OpenAIのAPIをペアプログラミングのドライバーとして活用し、このアシスタスントと一緒にテスト駆動開発(TDD)をしていきたいと思います。

テスト駆動開発(TDD)に関して

TDDとは

テスト駆動開発(Test Driven Development 以下 TDD)の実践者であるt_wadaさんによると、
TDDを以下のように説明しています。

image.png

https://www.youtube.com/watch?v=Q-FJ3XmFlT8&t=1600s

TDDのサイクル

TDDのサイクルは以下です。
image.png

1.次の目標を考える
2.その目標を示すテストを書く
3.そのテストを実行して失敗させる(Red)
4.目的のコードを書く
5.2で書いたテストを成功させる(Green)
6.テストが通るままでリファクタリングを行う(Refactor)
7.1〜6を繰り返す

https://www.youtube.com/watch?v=Q-FJ3XmFlT8&t=1755s

今回は以下のステップを用いて、TDDを実践してみようと思います。

  1. タスクを区切る
  2. Red = テストを書く
  3. Green = テストを成功させるコードを書く
  4. Refactor = リファクタリングをする
  5. 2~4のステップを繰り返す

2~4のステップの間にはテストを実行するフェーズとユーザーの入力を受けるフェーズを作ります。

お題

お馴染みFizz Buzz問題です。

1から100までの数をプリントするプログラムを書け。
ただし3の倍数のときは数の代わりに「Fizz」と、5の倍
数のときは「Buzz」とプリントし、3と5両方の倍数の場
合には「FizzBuzz」とプリントすること。

実装の概要

肝となるのが、以下の3つの要素だと思います。
① これまでのやり取りを元にコードの修正をする
② どのタスクを実行するのか(テストコードを書くのか、プロダクションコードを書くのか、リファクタリングをするのか)を見分ける
③ テストの実行をする

※今回は特に、OpenAI の Assistants API を試してみたかったので、やや強引に使っていきます!

①これまでのやり取りを元にコードの修正をする

OpenAIの Assistants API を用います。
Assistants API では、会話履歴を残すことができるので、これを利用してやり取りした内容を蓄積します。

②どのタスクを実行するのかを見分ける

今回はざっと以下の流れで、処理を実行していきます。

image.png

ただ、ステップが多くなりコンテキストを正しく理解して想定したステップ通り実行できるか怪しいと思ったので、
以下のようにタスクを見分けるフェーズを用いて処理を実行していきます。

image.png

これを実現するためにfunction callingという機能を用いていきます。
function callingはユーザーの入力を元に、どの関数をどの引数で呼び出すのかを判断してくれます。
なので、こちらのインプットをもとに、テストを書くのか、プロダクションコードを書くのか、などを見分けてもらい、それに適した命令をできるようにしていきます。

③テストの実行をする

TDDをする中で、Red, Green, Refactorのどのフェーズにおいてもテストの実行もする必要があります。
これを実現するために、code interpreterを用います。
これによって、サンドボックスのPythonの実行環境でコードの実行ができます。

実装全体

サンプルコード
from openai import OpenAI

import json
import time
from openai import OpenAI

client = OpenAI()

assistant_name = "TDDのスペシャリスト"

model_name = "gpt-4-1106-preview"

instructions = """
あなたはTDDのスペシャリストである**さんです。
与えられた要求に従って、TDDのプロセスに則り、コードを書いてください。

# 要求
1から100までの数をプリントするプログラムを書け。ただし3の倍数のときは数の代わりに「Fizz」と、5の倍数のときは「Buzz」とプリントし、3と5両方の倍数の場合には「FizzBuzz」とプリントすること。
pythonで書くこと。

# TDDのプロセス
Red: 最初に失敗するテストを書きます。
Green: テストが通る最小限のコードを書きます。
Refactor: コードを改善し、保守性を高めます。
Red・Green・Refactorを繰り返して、要求を満たすコードを書く。

# 実行順序
基本的には以下の手順でTDDを実行します。
1. Red: テストを書く
2. テストの実行
3. Green: テストが通る最小限のコードを書く
4. テストの実行
5. Refactor: コードを改善し、保守性を高める
6. テストの実行
7. 1に戻る

"""

assistant = client.beta.assistants.create(
    name=assistant_name,
    model=model_name,
    instructions=instructions,
    tools=[
        {
            "type": "code_interpreter"
        },
        {
            "type": "function",
            "function": {
                "name": "get_prompt_by_phase",
                "description": "Get prompt by phase in TDD in context",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "phase": {"type": "string", "enum": ["split_into_multi_task", "write_test_code", "write_production_code", "refactor", "execute_test"]}
                    },
                    "required": ["phase"]
                }
            }
        }
    ],
)

class AIAssistant:

    def __init__(self, assistant_id: str):
        """
        初期化処理。
        """
        self.assistant_id = assistant_id
        self.client = OpenAI()
        thread = self.client.beta.threads.create()
        self.thread_id = thread.id

    def get_prompt_by_phase(self, phase: str) -> str:
        """
        フェーズに応じたプロンプトを取得する。

        Args:
            phase (str): フェーズ。

        Returns:
            str: プロンプト。
        """
        if phase == "split_into_multi_task":
            return """お題を複数のタスクに分割してください。
            タスク一覧をリスト形式で書いてください。
            タスクの分割方法は、TDDのサイクルとは関係なく、分割してください。
            ex. 1. 数を文字列に変換する
                  a. 1を渡すと文字列"1"を返す
                2. ...
            """
        elif phase == "write_test_code":
            return """テストコードを書いてください。
            境界値テストなどソフトウェアのテスト技法を用いたテストにしてください。
            コードをアウトプットしてください。
            ```python"""
        elif phase == "write_production_code":
            return """プロダクションコードを書いてください。
            テストを通すための最小限のコードを書いてください。
            コードをアウトプットしてください。
            ```python"""
        elif phase == "refactor":
            return """リファクタリングをしてください。
            コードをアウトプットしてください。
            タスク一覧をリスト形式で再度表示してください。
            完了したタスクには、✅を先頭につけてください。
            ```python"""
        elif phase == "execute_test":
            return "テストの実行をしてください。"
        else:
            return ""

    def run_thread_actions(self, text: str) -> str:
        """
        テキストをAIアシスタントに送信し、応答を取得する。

        Args:
            text (str): ユーザーからのテキスト。

        Returns:
            str: アシスタントからの応答テキスト。
        """
        self.client.beta.threads.messages.create(
            thread_id=self.thread_id,
            role="user",
            content=text,
        )

        run = self.client.beta.threads.runs.create(
            thread_id=self.thread_id,
            assistant_id=self.assistant_id,
        )

        while run.status == "in_progress" or run.status == "queued":
            print("waiting for completion..., status: ", run.status)
            time.sleep(1)
            run = client.beta.threads.runs.retrieve(
                thread_id=self.thread_id,
                run_id=run.id
            )

            # At this point, the status is either "completed" or "requires_action"
            if run.status == "completed":
                print("completed")
                messages = self.client.beta.threads.messages.list(
                    thread_id=self.thread_id, order="asc"
                )
                if len(messages.data) < 2:
                    return ""

                return messages.data[-1].content[0].text.value

            if run.status == "requires_action":
                print("requires_action")
                phase = json.loads(run.required_action.submit_tool_outputs.tool_calls[0].function.arguments)['phase']
                print("phase: ", phase)
                result = self.get_prompt_by_phase(phase)
                run = client.beta.threads.runs.submit_tool_outputs(
                    thread_id=self.thread_id,
                    run_id=run.id,
                    tool_outputs=[
                        {
                            "tool_call_id": run.required_action.submit_tool_outputs.tool_calls[0].id,
                            "output": result,
                        },
                    ]
                )

ai_assistant = AIAssistant(assistant_id=assistant.id)

iteration_count = 0

while True:
    transcript_text = input("テキストを入力してください: ")
    if transcript_text == "exit":
        break
    print(f"user: {transcript_text}")
    assistant_content = ai_assistant.run_thread_actions(transcript_text)
    print(f"assistant: {assistant_content}")
    iteration_count += 1
    if iteration_count > 1:
        text = "テストの実行をしてください"
        print(f"user: {text}")
        assistant_content_2 = ai_assistant.run_thread_actions(text)
        print(f"assistant: {assistant_content_2}")


コードの参考にさせていただきました1

結果

実際の様子

output_1.gif

ステップ

タスク分割

1. 数を受け取り、その数が3の倍数でも5の倍数でもない場合に文字列に変換する。
   a. 1を渡すと文字列"1"を返す。

2. 数を受け取り、その数が3の倍数である場合に「Fizz」と返す。
   a. 3を渡すと文字列"Fizz"を返す。

3. 数を受け取り、その数が5の倍数である場合に「Buzz」と返す。
   a. 5を渡すと文字列"Buzz"を返す。

4. 数を受け取り、その数が3と5の両方の倍数である場合に「FizzBuzz」と返す。
   a. 15を渡すと文字列"FizzBuzz"を返す。

5. 1から100までの数をループし、上記のルールに応じて適切な文字列をプリントする。
   a. ループ処理を作成し、それぞれの数が与えられたときに適切な出力を行う。

1周目

Red

タスク1の「1を渡すと文字列"1"を返す」のテストから書いてもらいます。

import unittest

class TestFizzBuzz(unittest.TestCase):

    def test_convert_number_to_string(self):
        # 境界値分析の一例として1をテスト
        self.assertEqual(fizz_buzz(1), "1")

def fizz_buzz(number):
    pass  # この関数は実装が必要

if __name__ == '__main__':
    unittest.main()

それっぽいコードになりました。

テスト結果

テストが失敗しました。予想通り、`fizz_buzz` 関数にロジックを実装していないため、`None` を返し、期待値の `"1"` と一致しませんでした。次に、テストが通る最小限のコードを書きます。

テストも期待通り失敗しました。

Green

def fizz_buzz(number):
    return str(number)  # テストを通すために数値を文字列に変換して返す

テスト結果

テストを実行した結果、すべてのテストが成功しました。次に、コードのリファクタリングを行います。

テストも成功しました

Refactor

def fizz_buzz(number):
    """
    与えられた数値を文字列に変換する。

    3の倍数の場合は"Fizz"、5の倍数の場合は"Buzz"、両方の倍数の場合は"FizzBuzz"と返すようになる予定。
    今は単に数値を文字列に変換するのみ。

    :param number: 変換対象の数値
    :return: 数値を表す文字列
    """
    return str(number)  # テストを通すために数値を文字列に変換して返す

テスト結果


テスト結果は表示されませんでした。。。

タスクの整理

そして、タスクの完了状況を以下のように更新します。

1. ✅ 数を受け取り、その数が3の倍数でも5の倍数でもない場合に文字列に変換する。
   a. ✅ 1を渡すと文字列"1"を返す。

2. 数を受け取り、その数が3の倍数である場合に「Fizz」と返す。
   a. 3を渡すと文字列"Fizz"を返す。

3. 数を受け取り、その数が5の倍数である場合に「Buzz」と返す。
   a. 5を渡すと文字列"Buzz"を返す。

4. 数を受け取り、その数が3と5の両方の倍数である場合に「FizzBuzz」と返す。
   a. 15を渡すと文字列"FizzBuzz"を返す。

5. 1から100までの数をループし、上記のルールに応じて適切な文字列をプリントする。
   a. ループ処理を作成し、それぞれの数が与えられたときに適切な出力を行う。

タスク1を完了しました。

2周目(諸事情あり、一気に残りタスクを片付けてもらいました)

Red

class TestFizzBuzz(unittest.TestCase):

    def test_number_divisible_by_3(self):
        self.assertEqual(fizz_buzz(3), "Fizz")

    def test_number_divisible_by_5(self):
        self.assertEqual(fizz_buzz(5), "Buzz")

    def test_number_divisible_by_3_and_5(self):
        self.assertEqual(fizz_buzz(15), "FizzBuzz")

    def test_number_loop_1_to_100(self):
        # このテストは実際の出力を確かめるため、assert文ではなく実際に値をプリントする。
        # 1から100までのFizzBuzzプリント結果を出力するテストですが、
        # ここでは関連するハイライトとして3, 5, 15の結果を確認します。
        expected_output = {3: "Fizz", 5: "Buzz", 15: "FizzBuzz"}
        for number in [3, 5, 15]:
            self.assertEqual(fizz_buzz(number), expected_output[number])
新しく追加されたテストがすべて失敗しました。それぞれのテストが期待する出力にはまだ対応していないため、これは予想通りの結果です。次に、これらのテストを通過するためのプロダクションコードを書いていきます。

テストは期待通り失敗しました。

Green

def fizz_buzz(number):
    """
    与えられた数値に応じて、特定のルールに基づいた結果を文字列で返す。
    3の倍数の場合は"Fizz"、5の倍数の場合は"Buzz"、両方の倍数の場合は"FizzBuzz"と返す。

    :param number: 変換対象の数値
    :return: ルールに基づく文字列
    """
    result = ""
    if number % 3 == 0:
        result += "Fizz"
    if number % 5 == 0:
        result += "Buzz"
    return result if result else str(number)

テスト結果は表示されませんでした。。。

Refactor

def fizz_buzz(number):
    """
    与えられた数値に応じて、特定のルールに基づいた結果を文字列で返す。
    3の倍数の場合は"Fizz"、5の倍数の場合は"Buzz"、両方の倍数の場合は"FizzBuzz"と返す。

    :param number: 変換対象の数値
    :return: ルールに基づく文字列
    """
    result = ""
    if number % 3 == 0:
        result += "Fizz"
    if number % 5 == 0:
        result += "Buzz"
    return result if result else str(number)

結果は変わりませんでした。

最終結果

テストコード
class TestFizzBuzz(unittest.TestCase):

    def test_convert_number_to_string(self): # 2周目には、出力されていなかった。。。
        # 境界値分析の一例として1をテスト
        self.assertEqual(fizz_buzz(1), "1")

    def test_number_divisible_by_3(self):
        self.assertEqual(fizz_buzz(3), "Fizz")

    def test_number_divisible_by_5(self):
        self.assertEqual(fizz_buzz(5), "Buzz")

    def test_number_divisible_by_3_and_5(self):
        self.assertEqual(fizz_buzz(15), "FizzBuzz")

    def test_number_loop_1_to_100(self):
        # このテストは実際の出力を確かめるため、assert文ではなく実際に値をプリントする。
        # 1から100までのFizzBuzzプリント結果を出力するテストですが、
        # ここでは関連するハイライトとして3, 5, 15の結果を確認します。
        expected_output = {3: "Fizz", 5: "Buzz", 15: "FizzBuzz"}
        for number in [3, 5, 15]:
            self.assertEqual(fizz_buzz(number), expected_output[number])

if __name__ == '__main__': # 2周目には、出力されていなかった。。。
    unittest.main()
プロダクションコード
def fizz_buzz(number):
    """
    与えられた数値に応じて、特定のルールに基づいた結果を文字列で返す。
    3の倍数の場合は"Fizz"、5の倍数の場合は"Buzz"、両方の倍数の場合は"FizzBuzz"と返す。

    :param number: 変換対象の数値
    :return: ルールに基づく文字列
    """
    result = ""
    if number % 3 == 0:
        result += "Fizz"
    if number % 5 == 0:
        result += "Buzz"
    return result if result else str(number)

GPTから一発で出力した時

GPTのテキスト出力でそのまま、テストコードとプロダクションコードを出力してもらい、違いがあるかを見ていきます。

テストコード
import unittest
class TestFizzBuzz(unittest.TestCase):
  def test_fizz(self):
    self.assertEqual(fizz_buzz(3), "Fizz")
  def test_buzz(self):
    self.assertEqual(fizz_buzz(5), "Buzz")
  def test_fizzbuzz(self):        
    self.assertEqual(fizz_buzz(15), "FizzBuzz")
  def test_number(self):
    self.assertEqual(fizz_buzz(2), "2")
    
if __name__ == \'__main__\':
  unittest.main()
プロダクションコード
  def fizz_buzz(number):
    if number % 3 == 0 and number % 5 == 0:
      return "FizzBuzz"
    elif number % 3 == 0:
      return "Fizz"
    elif number % 5 == 0:
      return "Buzz"
    else:        
      return str(number)
  
  def print_fizz_buzz():
    for number in range(1, 101):        
      print(fizz_buzz(number))
      
  print_fizz_buzz()

TDDで出力した結果と一発で取得した時の差分

結論、特に差はない、むしろ一発で取得した方が良さそうでした。
プロダクションコードの方は、FizzBuzzの出し分けに関しては、正直好みの問題なのではと思います。
ただ、TDDで出力した方は、プリント出力する部分が実装されていませんでした。
テストコードの方は、特に変わらなそうでした。

現状の課題

TDDのサイクルを回すと、途中からアウトプットがおかしくなる問題

今回、結局TDDを2サイクル分しか回しませんでした。
その背景としては、やり取りが増えて、3サイクル目に突入したあたりから、やり取りの結果が不安定になったからでした。レスポンスにコードが含まれなかったり、function callingで返ってくる引数がなかったりなどが起こりました。

この原因としては、恐らくやり取りが増えてAssistants Apiが認識するコンテキストが増えて、結果が不安定になったのではと思っています。

解決策としては、現在は一つのassistantに対して全ての処理を任せていますが、テストを書く用、プロダクションコードを書く用、リファクタリングする用のそれぞれのassistantを用意するなどの方法があるのではと思います。
それによって、一つのassistantで認識することは減るので、よりレスポンスの精度が上がると思います。

結果の精度が一発でとった場合とそんなに変わらない問題

大きく2点理由があると思います。

1つは、↑で挙げた理由により、サイクルをたくさん回せなかったことです。
一つあった問題を複数に分割しましたが、結局その後まとめて実行してしまったので、
活かせなかったと思います。

2つ目は、問題自体がそこまで難しくないことです。
より難しい問題だったら、一発で取得する精度も落ちる気がします。その場合、相対的に良さが出るのではと思いました。

まとめ

  • Assistants APIの理解を深めるために、TDDでFIZZBUZZ問題を解いてもらった。
  • よかった点より、課題の方が出てきた。(リベンジして精度高い結果を出したいと思います。)
  • ペアプロ感は出せなかった。
  1. https://zenn.dev/zuma_lab/articles/whisper-gpt4-turbo-tts-assistant-api

25
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
25
3