【Unity】AIが無限に作問するクイズゲームを作ったので実装メモ

# はじめに
unity1weekというゲームジャムでChatGPTを使ったクイズゲームを作りました。
歴史上の人物、事件、出来事が2つ選ばれるので、どちらが古いかを選択します。
(テーマが"ふる"だったのでこうなりました)
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">公開しました😎<br>モバイルでも遊べます！<a href="https://t.co/KcFgpbHgvf">https://t.co/KcFgpbHgvf</a> <a href="https://t.co/Ci0OglPmtz">https://t.co/Ci0OglPmtz</a></p>&mdash; KENTO⚽️XRエンジニア😎 (@kento_xr) <a href="https://twitter.com/kento_xr/status/1672942702655324160?ref_src=twsrc%5Etfw">June 25, 2023</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>


# ChatGPTによる作問
作問はChatGPTのAPIによって実現しています。
以下がプロンプトとそれを実行するサンプルコードです。

```py: gpt_api_test.py

import openai

openai.api_key = "sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
system_content = '命題に沿ったクイズをJSON形式で返すボットとして振舞うこと。口語は不要。'
user_content = '''世の中に存在する既に没した人物、歴史上の事件、歴史上の出来事のうち、以下の条件を満たすことができるもの1つを選択せよ。
                  - 西暦であり、年代ではなく明確な年数で表せること (紀元前○○年、○○世紀、○○年代、○○年頃と表現するものは不可)
                  - 人物の場合は没年数を100~2020年の西暦で明示できること
                  - 事件、出来事の場合は開始年数か終了年数のいずれかを100~2020年の西暦で明示できること

                  それぞれは以下のフォーマットに従ってJSON形式で出力せよ。

                  {
                      "answer" : "人物名、歴史上の事件名、歴史上の出来事名のうち1つ"
                      "year" : "没年数、開始年数、終了年数、発生年数のうち該当するもの、いずれか1つの年数"
                      "explanation" : "人物、事件、出来事のうち該当するもの、いずれか1つの説明"
                  }

                  出力例)
                  {
                      "answer" : "第一次世界大戦の開戦",
                      "year" : "1914年",
                      "explanation" : "第一次世界大戦は1914年に開戦しました。"
                  }
                      
                  以下のルールで出力すること。
                  - JSON以外の口語は出力しない
                  - 人物の場合、名前のみを出力し「死亡」や「没」などの口語は出力しない'''
          
res = openai.ChatCompletion.create(
    model="gpt-3.5-turbo",
    messages=[
        {
            "role": "system",
            "content": system_content
        },
        {
            "role": "user",
            "content": user_content
        }
    ],
)

print(res["choices"][0]["message"]["content"])
```

実行結果の例は以下です。

``` : Terminal
{
    "answer": "トーマス・エジソン",
    "year": "1931年",
    "explanation": "トーマス・エジソンは1931年に亡くなりました。"
}
```

:::note warn

普通に口語で話しかけてきたり、紀元前や未来の話をし始めたりと、毎回のレスポンスで要求したものが必ずしも返ってくるというわけではありませんでした。

この辺りはより精度の高いモデルを選んだり、プロンプトの書き方を見直したりする必要がありそうです。
:::


# APIキー盗難の対策
先ほどのPythonコードをUnityで利用可能なC#に書き換える、というのが実装の近道に思えますが、ここで1つ問題になることがあります。

APIキーが盗まれる危険性があることです。
以下のツイートで注意喚起されているように、UnityのビルドにAPIキーを内包すると漏洩の危険性があります。


<blockquote class="twitter-tweet"><p lang="ja" dir="ltr"><a href="https://twitter.com/hashtag/unity1week?src=hash&amp;ref_src=twsrc%5Etfw">#unity1week</a> 注意喚起です🙏<br>ChatGPTとの通信を行うゲームからトークンが盗まれ不正利用されたとの報告が入っています。<br>Unityから直接ChatGPTのAPIを利用している方はご確認いただけますと幸いです。<br>不明な点がありましたらDMいただければ分かる範囲でサポートいたします。</p>&mdash; naichi@びはんとマルの森 (@naichilab) <a href="https://twitter.com/naichilab/status/1641055932355592192?ref_src=twsrc%5Etfw">March 29, 2023</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>


## LambdaのFunctionURLs
そこで今回はLambdaのFunctionURLsを利用しました。
LambdaはAWSのソリューションで、簡単に言うとクラウド上に置いたコードを外部から実行できます。

これまではAPIGatewayと組み合わせてLambdaのコードを外部から実行するというやり方が主流のようでしたが、
FunctionURLsの登場により、より簡潔な作りを実現できました。
FunctionURLsという名の通り、"Lambdaに配置したコードの関数"を呼び出すためのURLを発行してくれます。

【参考リンク】
- [AWS Lambda Function URLs の提供開始: 単一機能のマイクロサービス向けの組み込み HTTPS エンドポイント](https://aws.amazon.com/jp/blogs/news/announcing-aws-lambda-function-urls-built-in-https-endpoints-for-single-function-microservices/)
- [[アップデート]LambdaがHTTPSエンドポイントから実行可能になる、AWS Lambda Function URLsの機能が追加されました！](https://dev.classmethod.jp/articles/aws-lambda-function-urls-built-in-https-endpoints/)

まとめると、以下フローになります。

**1. UnityからFunctionURLにリクエストを送る**
**2. Unityからのリクエストを受け、Lambda上に定義した関数が実行される**
**3. 関数はChatGPTにリクエストを送り、その結果を受け取り次第、Unityにレスポンスとして返す。**

この仕組みにより、Unity側からChatGPTのAPIの存在を切り離すことができます。

---

### Lambdaの設定手順

Lambdaの関数作成メニューに`Advanced settings`があり、こちらからFunctionURLの発行が可能です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/244071/103faa5a-3085-6075-d005-8a948c8e14ac.png)


次に、Lambdaの関数がopenaiのモジュールを読み込める状態にする必要があります。
以前書いた記事でpythonのモジュールが足りずに同じ内容で怒られたのを思い出しました。

【参考リンク】：[【LINE Notify API,AWS】バズってるツイートをグループLINEに定期送信](https://qiita.com/OKsaiyowa/items/3ce72d5c46e11c1f5b6d#%E5%AE%9F%E9%9A%9B%E3%81%AE%E6%89%8B%E9%A0%86)

過去の記事ではZipでモジュールとコードをまとめてアップロードする方法でしたが、
Layerという機能があるそうなので試しました。

モジュールだけZip化して配置すればよいとのことです。
以下コマンドでpythonフォルダにopenaiモジュールが入るのでpython.zipにしてLayerに追加します。

```:コマンドプロンプトで実行
$ mkdir python
$ pip install -t ./python openai
```

以下画面からLayerを追加できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/244071/0392dd94-f983-c8f9-28d0-738d099f5471.png)

ChatGPTのAPIのリクエストからレスポンスまで、結構な時間がかかることがあるので、
Lambdaのタイムアウト時間を延長しておくことが望ましいです。Configurationから設定可能です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/244071/e59ed9a0-3f74-970e-a7fb-96fa765d2970.png)

また、Environment variablesにAPIキーを登録しておきます。
![タイトルなし.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/244071/c9bd32c7-c3d4-d310-0e0c-07a9f70cd200.png)




設定が完了したらfunctionを定義します。
Codeを選択し、functionのタブを開きます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/244071/59a46416-6bd0-0f64-9159-3762183d54ba.png)

以下の処理を記述します。

```py
import os
import json
import openai

def lambda_handler(event, context):
    openai.api_key = os.environ['API_KEY']
    system_content = '命題に沿ったクイズをJSON形式で返すボットとして振舞うこと。口語は不要。'
    user_content = '''世の中に存在する既に没した人物、歴史上の事件、歴史上の出来事のうち、以下の条件を満たすことができるもの1つを選択せよ。
                      - 西暦であり、年代ではなく明確な年数で表せること (紀元前○○年、○○世紀、○○年代、○○年頃と表現するものは不可)
                      - 人物の場合は没年数を100~2020年の西暦で明示できること
                      - 事件、出来事の場合は開始年数か終了年数のいずれかを100~2020年の西暦で明示できること

                      それぞれは以下のフォーマットに従ってJSON形式で出力せよ。

                      {
                          "answer" : "人物名、歴史上の事件名、歴史上の出来事名のうち1つ"
                          "year" : "没年数、開始年数、終了年数、発生年数のうち該当するもの、いずれか1つの年数"
                          "explanation" : "人物、事件、出来事のうち該当するもの、いずれか1つの説明"
                      }

                      出力例)
                      {
                          "answer" : "第一次世界大戦の開戦",
                          "year" : "1914年",
                          "explanation" : "第一次世界大戦は1914年に開戦しました。"
                      }
                          
                      以下のルールで出力すること。
                      - JSON以外の口語は出力しない
                      - 人物の場合、名前のみを出力し「死亡」や「没」などの口語は出力しない'''
          
    res = openai.ChatCompletion.create(
          model="gpt-3.5-turbo",
          messages=[
             {
                 "role": "system",
                 "content": system_content
             },
             {
                 "role": "user",
                 "content": user_content
             }
          ],
    )
    
    return {
        'statusCode': 200,
        'body': json.loads(res["choices"][0]["message"]["content"])
    }

```

:::note warn
ChatGPTのAPIのレスポンスをそのままbodyに入れて返すと以下のような文字列になります。

```
{
  "statusCode": 200,
  "body": "{\n  \"answer\": \"ウィリアム・シェイクスピア\",\n  \"year\": \"1616年\",\n  \"explanation\": \"ウィリアム・シェイクスピアはイギリスの劇作家・詩人であり、1616年に亡くなりました。\"\n}"
}
```

これは"Lambdaの関数の戻り値として文字列のJSONを指定すると、JSONとして二重にシリアライズされてしまう"という現象のようです。
【参考リンク】：[Lambda＋API GatewayのレスポンスをJSONとしてパース出来ない](https://qiita.com/nkojima/items/68082034da708c856067#%E6%AD%A3%E3%81%97%E3%81%8F%E5%8B%95%E4%BD%9C%E3%81%97%E3%81%AA%E3%81%8B%E3%81%A3%E3%81%9F%E3%82%B3%E3%83%BC%E3%83%89)

そこで`json.loads`にChatGPTが作成したJSONを渡して一度ディクショナリに変換しています。
この処理によって以下のようにJSONとしてレスポンスを返すようになります。

```
{
  "statusCode": 200,
  "body": {
    "answer": "明智光秀",
    "year": "1582年",
    "explanation": "明智光秀は信長暗殺計画を実行し、1582年に自害しました。"
  }
}
```

:::

### Unity側からの呼び出し

最後は、UnityからFunctionURLを呼び出す処理です。

```C#

using System;
using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEngine.Networking;

public class FunctionURLSample
{
    const int TIMEOUT = 10;

    public async UniTask<QuestionData> RequestAsync()
    {
        var functionURL = "発行したURL";

        using var request = new UnityWebRequest(functionURL, "POST")
        {
            downloadHandler = new DownloadHandlerBuffer()
        };

        request.timeout = TIMEOUT;
        await request.SendWebRequest();

        if (request.result is UnityWebRequest.Result.ConnectionError or UnityWebRequest.Result.ProtocolError)
        {
            Debug.LogError(request.error);
            throw new Exception();
        }

        var responseString = request.downloadHandler.text;
        var responseObject = JsonUtility.FromJson<QuestionData>(responseString);
        return responseObject;
    }

    [Serializable]
    public class QuestionData
    {
        public string answer;
        public string year;
        public string explanation;
    }
}
```

UnityがAPIキーを知らない状態を作ることができました。

# 料金について
Lambdaは無料枠があるそうなので、それを使い切ったらアラートメールが送られてくるように設定しました。

【参考リンク】
- [AWS 無料利用枠](https://aws.amazon.com/jp/free/?all-free-tier.sort-by=item.additionalFields.SortRank&all-free-tier.sort-order=asc&awsf.Free%20Tier%20Types=*all&awsf.Free%20Tier%20Categories=*all)
- [【AWS】アカウント登録後必須！AWS無料枠を超えた時にアラート通知を飛ばす設定](https://soypocket.com/it/costs-free-tier-budgets-alert/)

ChatGPTの方は今のところ以下のような状態です。(2023/6/26　公開時点)
利用上限を設定できるので、安心して使えてありがたい限りです。
ゲームジャムのおわりごろに経過報告します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/244071/7d047cfc-d46c-ee19-5258-b5c06605a509.png)


# おわりに

二重にリクエストを送っていることもあり、待機時間が長くなってしまいました。
高速化する方法はいくつかあるようなのでまたの機会に挑戦したいです。


# 参考リンク
[ChatGPT APIをLambdaで利用する時、レスポンス時間の短縮方法を調査してみた | DevelopersIO](https://dev.classmethod.jp/articles/chatgpt-api-lambda-reduced-response-time/)
[ChatGPT APIをUnityから動かす。｜ねぎぽよし](https://note.com/negipoyoc/n/n88189e590ac3)
[ChatGPTのAPIを使ってサービスを作る時に気をつけること - Crieit](https://crieit.net/posts/ChatGPT-API)
[ChatGPTを利用したWebサイトを作る最短手順](https://blog.kyubuns.dev/entry/2023/05/07/130435)