Flutter/DartGPT APIと対戦できるでオセロアプリ用の処理を書いてみる

## はじめに
これを利用してchatGPTのAPIと対戦するオセロアプリを作ってみます
GPT APIの利用方法などは私が個人で書いてるブログで紹介してます
https://nbaboston.net/1824.html

今回はソースコードを全量ではなく、プロンプトとGPTAPIに関わる部分のみを記述しています

## プロンプト
オセロアプリにおいてchatGPTから帰ってくる回答の解析をして、AIがどの場所に置くのが最善になるかを考えてもらい、正確にオセロの位置を出してくれるプロンプトが必要になります。
そのため、質問には正確な現在の盤面と回答の形式を考えます。
まずは、最初に現在プレイしているゲームをいいます。「`I am playing a game of Othello (Reversi) and I need your help to make the best move. `」これは「私はオセロ（リバーシ）のゲームをしているのですが、最善の手を打つためにあなたの助けが必要です。」という意味になり、今オセロをプレイしていて最善の手をうつように促します。
その後、「`I am the white player, and the current state of the game board is:`」（私は白番のプレイヤーで、現在のゲームボードの状態は）と切り出し、現在の盤面とAIが指示すべき色を指定します

そして、「`Here are the valid moves for the white player (0-indexed): [$validMovesString]`」（以下は白番の有効手です（0番台）： [\$validMovesString] です。）として、オセロのルールとして１マス以上裏返すことができないといけないルールがあるため、１枚以上裏返すことができる座標を一覧にします

最後に回答の形式を一定にするために「Please provide the best move ONLY in the format "row: x, col: y" without any additional explanation or context.」（最良の手を「row: x, col: y」の形式で、説明や文脈を加えずに記入してください。）として、形式が「row: x, col:y」で回答が帰ってくるように促します
最終的なプロンプトは下記のようになります
```
I am playing a game of Othello (Reversi) and I need your help to make the best move. I am the white player, and the current state of the game board is:

$gameBoardString

Here are the valid moves for the white player (0-indexed): [$validMovesString]

Please provide the best move ONLY in the format "row: x, col: y" without any additional explanation or context.

```
ここでgameBoardStringには現在の盤面の状態、validMovesStringには置くことができるコマの座標になります
validMovesStringには[[2,3],[1,2]]のような座標の一覧が入る予定になっています。
gameBoardStringには下記のように盤面を文字列にしています
[
EMPTY,EMPTY,EMPTY,EMPTY,EMPTY,EMPTY,EMPTY,EMPTY,
EMPTY,EMPTY,EMPTY,EMPTY,EMPTY,EMPTY,EMPTY,EMPTY,
EMPTY,EMPTY,EMPTY,EMPTY,EMPTY,EMPTY,EMPTY,EMPTY,
EMPTY,EMPTY,EMPTY,BLACK,WHITE,EMPTY,EMPTY,EMPTY,
EMPTY,EMPTY,EMPTY,WHITE,BLACK,EMPTY,EMPTY,EMPTY,
EMPTY,EMPTY,EMPTY,EMPTY,EMPTY,EMPTY,EMPTY,EMPTY,
EMPTY,EMPTY,EMPTY,EMPTY,EMPTY,EMPTY,EMPTY,EMPTY,
EMPTY,EMPTY,EMPTY,EMPTY,EMPTY,EMPTY,EMPTY,EMPTY,
]

## DartからGPT APIにアクセスする
### まずはGPTのモデルについて定義する
- lib/values/gpt_model.dart
```dart
enum GPTModelType { gpt3turbo, gpt4 }

extension GPTModelTypeExtension on GPTModelType {
  static GPTModelType byName(String name) {
    switch (name) {
      case 'GPT-3.5 Turbo':
        return GPTModelType.gpt3turbo;
      case 'GPT-4':
        return GPTModelType.gpt4;
      default:
        throw Exception('Invalid GPTModelType');
    }
  }

  int get getRateLimitRPM {
    switch (this) {
      case GPTModelType.gpt3turbo:
        return 3;
      case GPTModelType.gpt4:
        return 60;
      default:
        throw Exception('Invalid GPTModelType');
    }
  }

  int get getRateLimitTPM {
    switch (this) {
      case GPTModelType.gpt3turbo:
        return 40000;
      case GPTModelType.gpt4:
        return 40000;
      default:
        throw Exception('Invalid GPTModelType');
    }
  }

  bool equals(String name) {
    return this.name == name;
  }

  String get model {
    switch (this) {
      case GPTModelType.gpt3turbo:
        return 'gpt-3.5-turbo';
      case GPTModelType.gpt4:
        return 'gpt-4';
      default:
        throw Exception('Invalid GPTModelType');
    }
  }

  String get text {
    switch (this) {
      case GPTModelType.gpt3turbo:
        return 'GPT-3.5 Turbo';
      case GPTModelType.gpt4:
        return 'GPT-4';
      default:
        throw Exception('Invalid GPTModelType');
    }
  }
}
```
上記コードでは、dartのenumとextensionを使って、
扱えるmodelがGPT-3.5 Turbo,GPT-4に固定します（[他のモデル](https://platform.openai.com/docs/models)もありますがもっとも有名どころを使っています）
さらにRateLimitを定義することで過度なアクセスを行えないようにもします
### アクセスするための処理
- lib/services/chat_gpt_service.dart
 ```dart

const String apiKey = 'your api key';

Future<List<int>> getGpt4Move(
    GPTModelType model, String gameBoardString, String validMovesString) async {
  String prompt = '''
I am playing a game of Othello (Reversi) and I need your help to make the best move. I am the white player, and the current state of the game board is:

$gameBoardString

Here are the valid moves for the white player (0-indexed): [$validMovesString]

Please provide the best move ONLY in the format "row: x, col: y" without any additional explanation or context.
''';
  const String apiUrl = 'https://api.openai.com/v1/chat/completions';

  final response = await http.post(
    Uri.parse(apiUrl),
    headers: {
      'Authorization': 'Bearer $apiKey',
      'Content-Type': 'application/json',
    },
    body: json.encode({
      'model': model.model,
      "messages": [
        {"role": "user", "content": prompt}
      ],
      'max_tokens': 50,
      "temperature": 0.7
    }),
  );

  if (response.statusCode == 200) {
    final data = json.decode(response.body);
    RegExp exp = RegExp(r'row: (\d), col: (\d)');
    RegExpMatch? match =
        exp.firstMatch(data['choices'][0]['message']["content"]);

    if (match != null) {
      return [
        int.parse(match.group(1) ?? '0'),
        int.parse(match.group(2) ?? '0')
      ];
    }

    return [];
  } else {
    print(response);
    throw Exception('Error: ${response.statusCode}');
  }
}

```
上記コードでは引数に指定されたmodel、現在の盤面、動かすことができるコマの座標の一覧を文字列にした変数からプロンプトとAPIのリクエストを作成して、postします。
APIアクセスのために今回はhttpパッケージを使っています
レスポンスの回答が「row: (\d), col: (\d)」のフォーマットになるのでこれを解析して[{row},{col}]のように座標にした結果を返すようにします
※APIキーについては環境変数や設定ファイルにするといいと思います

## できたアプリ
上記の処理からUIを色々と作成してできたアプリが下記のようなアプリになります
- トップの画面ではモデルを選択してゲームスタートボタンでゲームがスタートします（過去の記録は作ろうとしましたが諦めました）
<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3367262/dba3bb1c-7dc4-e841-b2e8-f9393d42bafe.png"/>

- 初期表示
ゲームスタートの初期表示は下記のように白黒のコマが配置されています
<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3367262/d384229a-745f-c02c-6cba-548a9a9089ad.png"/>

- プレイヤーの黒のコマを置くと自動的に白のコマが配置されます
<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3367262/7bc667d9-7c3f-631c-7288-b37a53da2dfc.png"/>