ChatGPT APIを使ってモデルコースを生成するWebアプリケーションを開発した話

# はじめに
旅行自体は楽しいけれど、計画立てるのが面倒臭すぎる！！！
計画を立てるハードルをどうにかして下げることはできないだろうか。。。

そんなふうに考えていた頃、世間ではちょうどChatGPTなる技術が注目され始めていました。

**これを使えば、いい感じの旅行計画を手軽に作成できる（してもらえる）のでは？！**

そのような思いが、モデルコース生成アプリ『[excirouteR](https://plan.exciroute.com/)』を開発するきっかけとなりました。

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">ChatGPT APIを使って、観光やデートのモデルコースを作成する ”excirouteR” をβリリースしました🎉<br><br>無料で利用できるので、ぜひお試しください！！<br>URL -&gt; <a href="https://t.co/hBL3lKUBur">https://t.co/hBL3lKUBur</a><a href="https://twitter.com/hashtag/ChatGPT?src=hash&amp;ref_src=twsrc%5Etfw">#ChatGPT</a> <a href="https://t.co/K03rCR2uJo">pic.twitter.com/K03rCR2uJo</a></p>&mdash; Kotaro (@rorosawa255) <a href="https://twitter.com/rorosawa255/status/1644606275186221056?ref_src=twsrc%5Etfw">April 8, 2023</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

ChatGPTのAPIを用いて旅行計画を生成するようなWebアプリケーションは、[Roam Around](https://www.roamaround.io/)など、すでにいくつか話題になっているものがありましたが、どれも詳細な指定ができなかったり、出力結果がテキストのみの無機質なものであったため、よりユーザにとって使いやすいモデルコース生成アプリケーションを目指すことにしました。

開発にあたっては、ChatGPTのAPIを使うWebアプリケーション特有の課題に直面することもありました。本記事には、それらの対処法や開発において工夫した点などを記します。

今後ChatGPTのAPIを用いたWebアプリケーションの作成に挑戦する方々の助けとなれば幸いです。

# excirouteRの概要
excirouteRは
- 訪れたい地域
- モデルコースに含むスポット数
- 出発時刻
- コースの最大合計時間
- お出かけの目的
- 移動手段

を入力することで、ChatGPTがモデルコースを作成してくれます。
その結果として、ユーザに対して
- 全てのスポットをMAPに表示
- 各スポットごとにスポット名、滞在する時間、住所、簡単な説明を表示

します。
<img src="https://media.discordapp.net/attachments/1012704388277616640/1094228723429036082/IMG_5402.png?width=415&height=702" width="320">  <img src="https://media.discordapp.net/attachments/1012704388277616640/1094228723051544716/IMG_5401.png?width=416&height=702" width="320">

# excirouteRの構成
![構成図v2.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/726756/58d05618-6289-d1e8-fc46-10f18a1ff890.jpeg)

# 実装 -課題と工夫-
## プロンプト
試行錯誤して、プロンプトは次のようになりました。
なお、`{}`で囲まれた部分にユーザーが入力した値が入ります。
```python
Create a one-day model course in {region_text},
starting at {formatted_departure_time}, 
that takes no more than {request.max_total_time} hours to complete,
{category}{transportation_text}.
Allow 1.2 times the travel time between spots.
Extract the values in the following format in Japanese.
\`\`\`json {
{spots: [{
    name: Spotname,
    address: Address,
    coordinate: [latitude, longitude],
    note: explanation,
    time: The time of day you are visiting the spot, the format is 00:00-00:00, with the appropriate length of stay.}
  },] 
  // The length of spots is around {request.total_spots}
}
}\`\`\`
```
以下が工夫した点です。
### リクエストは英語で書く
ChatGPT API(gpt-3.5-turbo)の利用料金は、リクエストとレスポンスに用いられるトークンが、1000トークンあたり0.002ドルとなっています。

トークンという単位についてですが、日本語の場合、ひらがな1文字が1トークン、漢字1文字が2から3トークンとなります。
一方、英語の場合は1単語が１トークンとカウントされます。
そのため「自転車」という単語を送るときに、そのまま日本語で"自転車"と送ると最低でも6トークンが消費されます。一方で、英語で"bicycle"と送れば1トークンで済み、日本語の時の1/6となります。

英語で書いた方が、**圧倒的にお得**です。

ちなみに、上のプロンプトでは150トークン前後消費します。

### レスポンスの形式を指定する
今回は、結果を加工して表示する必要があるため、レスポンスのフォーマットが指定されているとかなり嬉しいです。

上のプロンプトのように指定をすると、そのフォーマットに沿ってレスポンスをしてくれます。

実際に、レスポンスを整形すると、以下のようになります。
(整形については後の章で詳しく説明します。)
```json
{
  "spots": [
    {
      "name": "清水寺",
      "address": "〒605-0862 京都市東山区清水1丁目294",
      "coordinate": [
        34.994835,
        135.785029
      ],
      "note": "日本を代表する寺院の一つ。世界遺産に登録されている。",
      "time": "9:00-11:00"
    },
    {
      "name": "祇園",
      "address": "〒605-0074 京都市東山区祇園町南側",
      "coordinate": [
        35.003298,
        135.77816
      ],
      "note": "京都を代表する芸舞妓の街。伝統的な建物やお茶屋が多く残る。",
      "time": "11:30-13:00"
    },
    {
      "name": "金閣寺",
      "address": "〒603-8361 京都市北区金閣寺町1",
      "coordinate": [
        35.039024,
        135.729332
      ],
      "note": "鹿苑寺とも呼ばれ、世界遺産に登録されている。金箔で覆われた美しい建物が特徴。",
      "time": "14:00-16:00"
    },
  ]
}
```
### 移動時間に余裕を持たせる
この工夫は、ルートを提示するようなアプリならではの工夫です。

`Allow 1.2 times the travel time between spots.`

のような指定をしました。移動時間に1.2倍の余裕を持ってくださいという意味です。

元々はこのような指定をしていなかったのですが、実験をすると、スポット間の移動時間がかなりシビアな場合が多く、旅が終わった頃にはかなり疲弊してしまいそうだったので、移動時間に1.2倍の余裕を持たせることにしました。

このような感じで、検証を繰り返し、ユーザーにとって最適なレスポンスが帰ってくるように工夫をする必要があります。

## レスポンスの整形とエラーハンドリング
以下に、ChatGPTにリクエストを送り、結果を整形して返却するメソッドを示します。

```python
def request_to_openai(request: RequestToPlanner):
    openai.api_key = API_TOKEN

    # ChatGPTのAPIを叩く
    try:
        response = openai.ChatCompletion.create(
            model=MODEL_NAME,
            messages=[message(request)],
            temperature=MODEL_TEMP,
            max_tokens=MAX_TOKENS
        )
    except Exception as e:
        Logger.error(
            f'Request to openAI failed.Error message is "{e}".')
        raise HTTPException(
            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
            detail='Request to openAI failed.')

    usage = response["usage"]

    regex = re.compile(r'\\`\\`\\`json([\s\S]*?)\\`\\`\\`', re.MULTILINE)
    match = regex.search(response["choices"][0]["message"]["content"].strip())

    if match is None or match.group(1) is None:
        try:
            content = json.loads(
                response["choices"][0]["message"]["content"].strip())
        except json.decoder.JSONDecodeError:
            Logger.error(
                f'OpenAI could not find the course. The response is:\n{response}')
            raise HTTPException(
                status_code=status.HTTP_404_NOT_FOUND,
                detail='Course not found.')
    else:
        content = json.loads(match[1])

    return [usage, content]

```
### レスポンスの整形について
リクエストを送ると、ChatGPTからは次のようなcontentの返却が期待されます。
```
"\\`\\`\\`json\n{\n  \"spots\": [\n    {\n      \"name\": \"\u938c\u5009\u5927\u4ecf\u6bbf\",\n      \"address\": \"\u795e\u5948\u5ddd\u770c\u938c\u5009\u5e02\u9577\u8c374-2-28\",\n      \"coordinate\": [\n        35.316112,\n        139.535\n      ],\n      \"note\": \"\u938c\u5009\u306e\u30b7\u30f3\u30dc\u30eb\u3067\u3042\u308b\u5927\u4ecf\u69d8\u3092\u62dd\u89b3\u3067\u304d\u308b\u3002\",\n      \"time\": \"10:00-11:30\"\n    },\n    {\n      \"name\": \"\u938c\u5009\u99c5\u524d\u306e\u304a\u571f\u7523\u5c4b\u3055\u3093\",\n      \"address\": \"\u795e\u5948\u5ddd\u770c\u938c\u5009\u5e02\u5c0f\u753a2-11-7\",\n      \"coordinate\": [\n        35.319722,\n        139.546667\n      ],\n      \"note\": \"\u938c\u5009\u306e\u304a\u571f\u7523\u3092\u8cb7\u3046\u3053\u3068\u304c\u3067\u304d\u308b\u3002\",\n      \"time\": \"12:00-12:30\"\n    },\n    {\n      \"name\": \"\u9db4\u5ca1\u516b\u5e61\u5bae\",\n      \"address\": \"\u795e\u5948\u5ddd\u770c\u938c\u5009\u5e02\u7531\u6bd4\u30ac\u6d5c2-1-31\",\n      \"coordinate\": [\n        35.319722,\n        139.546667\n      ],\n      \"note\": \"\u938c\u5009\u306e\u30d1\u30ef\u30fc\u30b9\u30dd\u30c3\u30c8\u3068\u3057\u3066\u6709\u540d\u306a\u795e\u793e\u3067\u3042\u308b\u3002\",\n      \"time\": \"13:00-14:30\"\n    }\n  ]\n}\n\\`\\`\\`"
```
ところが、実際は、上のような形で返されることもあれば、下のように、\`\`\`jsonが省略されて返されることもあります。

```
"{\n  \"spots\": [\n    {\n      \"name\": \"\u6d45\u8349\u5bfa\",\n      \"address\": \"\u3012111-0032 \u6771\u4eac\u90fd\u53f0\u6771\u533a\u6d45\u83492\u4e01\u76ee3\u22121\",\n      \"coordinate\": [\n        35.7148,\n        139.7967\n      ],\n      \"note\": \"\u6771\u4eac\u3067\u6700\u3082\u53e4\u3044\u5bfa\u9662\u306e\u3072\u3068\u3064\u3067\u3001\u591a\u304f\u306e\u89b3\u5149\u5ba2\u304c\u8a2a\u308c\u308b\u3002\",\n      \"time\": \"9:00-10:30\"\n    },\n    {\n      \"name\": \"\u4e0a\u91ce\u516c\u5712\",\n      \"address\": \"\u3012110-0007 \u6771\u4eac\u90fd\u53f0\u6771\u533a\u4e0a\u91ce\u516c\u5712\",\n      \"coordinate\": [\n        35.7144,\n        139.7724\n      ],\n      \"note\": \"\u5e83\u5927\u306a\u516c\u5712\u3067\u3001\u591a\u304f\u306e\u7f8e\u8853\u9928\u3084\u52d5\u7269\u5712\u304c\u3042\u308b\u3002\",\n      \"time\": \"11:00-13:00\"\n    },\n    {\n      \"name\": \"\u6771\u4eac\u30b9\u30ab\u30a4\u30c4\u30ea\u30fc\",\n      \"address\": \"\u3012131-0045 \u6771\u4eac\u90fd\u58a8\u7530\u533a\u62bc\u4e0a1\u4e01\u76ee1\u22122\",\n      \"coordinate\": [\n        35.7101,\n        139.8107\n      ],\n      \"note\": \"\u9ad8\u3055634\u30e1\u30fc\u30c8\u30eb\u306e\u30bf\u30ef\u30fc\u3067\u3001\u5c55\u671b\u53f0\u304b\u3089\u306e\u666f\u8272\u304c\u7d20\u6674\u3089\u3057\u3044\u3002\",\n      \"time\": \"14:00-15:30\"\n    }\n  ]\n}"
```
従って、そのどちらのパターンも考慮して整形する必要があります。
整形は以下のプログラムで行なっています。
```python
regex = re.compile(r'\\`\\`\\`json([\s\S]*?)\\`\\`\\`', re.MULTILINE)
match = regex.search(response["choices"][0]["message"]["content"].strip())

if match is None or match.group(1) is None:
    try:
        content = json.loads(response["choices"][0]["message"]["content"].strip())
    except json.decoder.JSONDecodeError:
        Logger.error(
            f'OpenAI could not find the course. The response is:\n{response}')
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail='Course not found.')
else:
    content = json.loads(match[1])
```
まずは、\`\`\`jsonから始まるcontentが返されているかを正規表現を用いて確認します。
ここでクリアすれば、\`\`\`jsonを取り除いた部分を整形します。

ここでクリアできない場合は、\`\`\`jsonを取り除かず、そのまま整形をします。

そして、ここでクリアできない場合は、ChatGPTがコースを生成できなかったとみなし、エラーとします。

また、整形前の文字列中に含まれる`\n`やスペースは、`.strip()`で削除しています。

### エラーハンドリングについて
**ChatGPTはあくまで自然言語生成AIです。**

必ず指定したフォーマット通りにデータを返してくれるとは限らない、ということは上でも示した通りです。

そのため、今後、さまざまな予期せぬエラーが発生すると考えられます。
そのようなときにいち早くエラーの発生を検知し、対応することが重要です。
(でないと、ユーザー体験も下がってしまいますし、無駄にトークンを消費してしまいます)

今回のアプリケーションでは、例外が発生すると以下のように、エラーログを送信する仕様にしています。
```python
Logger.error(f'Request to openAI failed.Error message is "{e}".')
```
このLoggerについては、別の記事で詳しく書きたいと思いますが、やっていることは、

**Discord Botが、エラーのログをチャンネルにメンション付きで送信し、いち早くエラーを報告する**

ことです。

実際にエラーが発生したときのDiscordの画面を以下に示します。
![スクリーンショット 2023-04-08 22.51.33.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/726756/7054e2f6-8bf5-063b-de6c-786104f8ea8a.png)

このようにして、エラーが発生した場合は即座に通知が来るようになっているため、素早く対応することができます。


## レスポンスに時間がかかる問題
今回のアプリケーションの場合、ChatGPTにリクエストを送信してから、レスポンスを得るまでには2分近い時間がかかります。
これにより、以下の課題が生じます。
1. 結果が出るまで、ユーザーが退屈に感じてしまう
2. タイムアウトが発生してしまう

以下では、本アプリケーションにおいて、これらの課題を解決するために行った工夫について紹介します。
### 1. 結果が出るまで、ユーザーが退屈に感じてしまう
本アプリケーションでは、以下のように、今後このアプリケーションを拡張していくにあたって必要となる仮説を立てるためのアンケートを設置しました。

<img src="https://media.discordapp.net/attachments/1012704388277616640/1094272861281190009/plan.exciroute.com__1.png?width=472&height=701" width="400">


アンケートは普通に設置していても、なかなか回答していただくことができません。
しかし、このような待ち時間にアンケートを設けることで、ユーザーは待ち時間を退屈することなく過ごすことができ、こちら側としてもアンケートへの回答率が上がってお互いハッピーな状態になれると考えました。

**もちろん、アンケートへの回答は強制ではありません！！**

ただ、アンケートに回答することで出現する、ちょっとした嬉しい仕掛けもご用意しました。

そう、ﾈｺﾁｬﾝです(犬派の方はごめんなさい)。
![スクリーンショット 2023-04-08 23.40.50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/726756/5a5d4d0d-defa-2591-5caa-6795132f34b5.png)

このアイデアは、こちらのツイートを参考にさせていただきました。

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">ChatGPT API 時間がかかる問題は猫動画を埋め込むことで解決。<a href="https://t.co/6IvsbREjuR">https://t.co/6IvsbREjuR</a> <a href="https://t.co/esX5jpN2gq">pic.twitter.com/esX5jpN2gq</a></p>&mdash; seya (@sekikazu01) <a href="https://twitter.com/sekikazu01/status/1642115855155863552?ref_src=twsrc%5Etfw">April 1, 2023</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

ﾈｺﾁｬﾝの動画を設置することで、アンケートの回答を早く終えてしまった場合でも、ユーザーがなるべく退屈をしないように工夫をしました。

ﾈｺﾁｬﾝの動画は約２分あるので、この動画を見終わる頃にはユーザーは癒され、結果は出力されていることでしょう。

表示させていただいているﾈｺﾁｬﾝの動画：[https://www.youtube.com/watch?v=Ob3z4eNTqvU&t=1s](https://www.youtube.com/watch?v=Ob3z4eNTqvU&t=1s)

### 2. タイムアウトが発生してしまう
ChatGPT APIに対してリクエストを送ってから、レスポンスが返却されるまでの間に、さまざまな場所でタイムアウトが発生してしまう可能性があります。

今回のWebアプリケーション開発では、最初、リバースプロキシ(Nginx)において

`504 Gateway Timeout`

が発生してしまいました。

この課題は、`nginx.conf`に
```conf
proxy_read_timeout 180;
```
を追加してあげることで解決しました。

>proxy_read_timeout…渡されたデータを後続が処理をし、レスポンスを返すタイムアウト値。
　　　　　　　　　　　後続のサーバでもタイムアウト値より長めに設定します。

[引用元: [https://qiita.com/noblin_1031/items/ba7ef5dd06b1519deb5e](https://qiita.com/noblin_1031/items/ba7ef5dd06b1519deb5e)]

180は、180秒を超えたらタイムアウトするということです。
今回の待ち時間は2分間(120秒間)程度なので、これぐらいの設定がちょうどいいと考えました。

# 最後に
いかがでしたでしょうか。
さまざまな課題に対する工夫などを述べてきましたが、私がここで強調したいのは

**ChatGPTは自然言語生成AIであって、モデルコース生成AIではない**

ということです。

それっぽいモデルコースを用意してくれることが多いです。
しかし、たまに、かなりハードなスケジュールを強いられるコースや、
ここ、観光するところやないやろ...みたいなスポットを提案してくることもあります。

そのことを考慮して、今後は、ユーザーがある程度自由に生成された計画をカスタマイズする機能の追加や、
モデルコース生成用のAIモデル開発、ChatGPTのfine-tuningなども検討していきたいです。

ただ、それでも、一からの計画作成に比べると、かなり楽になったと思います。

今回のWebアプリケーション開発を通して、AIの可能性を感じる非常に良い経験をすることができました。
これをご覧になった皆様も、ぜひ、ChatGPTを用いたアプリケーション開発に挑戦してみませんか？