【Power Apps&GPT-4o】今から飲みたいんだけど、おすすめの店ある？

# はじめに
こんにちは、reireです。

GPT-4oついに発表されましたね。

その影響か、「Power AppsとAIサービス合わせて見た！」系の記事も再度盛り上がってきているように感じます。

ということで私は「GPT-4oに飲食店を探してもらうApps」を作成してみたので、ぜひ見ていっていただければと思います。

# 動作イメージ

長くなりそうなので結果から

<img width="750" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3461887/4c0acf96-908f-6291-b1ab-d81692752a92.gif"></img>

こんな感じで、お店について尋ねると何店舗か紹介してくれます。

:::note warn
※GIFファイルでも見やすくなるようにカットと倍速編集をしているので実際の実行速度とは異なります。
　実際の実行速度については後述。
:::

お店のデータソースとしてはホットペッパーグルメさんの[グルメサーチAPI](https://webservice.recruit.co.jp/doc/hotpepper/reference.html)を利用させていただいております。
APIの仕様に関しては以下の公式ドキュメントをご覧ください。

https://webservice.recruit.co.jp/doc/hotpepper/reference.html



また、今回はゴリゴリにプレミアムライセンスが必要になります。

# 機能要件

ざっくり以下の機能を実装していく。

- GPT-4oとチャットができる
- ある程度コンテキストを保持できる
- 場所やジャンルなどを指定してお店を尋ねると、条件を加味してグルメサーチAPIで飲食店を検索、検索結果をもとに紹介してくれる

:::note warn
今回は業務関係なくとりあえず動くものを作ろう、という勢いだけで構築したのでエラー処理系の機能については特に実装しておりません。
もしご参考にしていただける場合は、皆さまの環境に合わせた処理を実装いただきますようお願いいたします。
:::

# さっそく実装

## 1. まずはアプリの画面を作成

とりあえず画面を作る。
普通にシンプルな構成だと思います。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3461887/7c086af9-076c-e030-a70d-d56bc3cf3086.png)

ちょっとしたポイントとして、
- GPTは基本Markdown形式でレスポンスを返してくるため、
メッセージの表示はLabelではなく**HTMLテキスト**で行っている。(詳しい仕組みは後述)
- ユーザーが入力を行うInputBoxを改行に応じて高さが可変するようにしたかったので、
テキストによって動的に高さを変えられるLabelを置いて、その高さを参照させている。
- 右上のボタンでアプリ全体のテーマカラーを変更できる([別の記事にて解説しております](https://qiita.com/reire/items/8b3a35818aadecba024a))

https://qiita.com/reire/items/8b3a35818aadecba024a

くらいでしょうか。
その他はまぁ見ての通りかと思いますので詳しい説明は省きます。

## 2. Assistants APIの設定をするよ

今回はグルメサーチAPIを使って飲食店を検索するのですが、
その辺との連携を考えるとGPTにはただチャットに答えてもらうだけではなくコンテキストの保持、
グルメグルメサーチAPIにリクエストするためのパラメーターとなるJsonテキストの作成、フラグ検知まで頑張ってもらう必要があります。

これを通常のGPTのAPIだけで実現するのは結構大変…、というか動作が安定しない。

ということで今回は、発表から微妙に影の薄いAssistants APIと、そのモデルにGPT-4oを利用していく形で行きます。

:::note info
Assistants APIは通常のChatと比べて手間がかかりますが、その分便利です。
詳しい仕様は公式ドキュメントや有志の諸先輩方の解説をご覧ください。
:::

Assistantの振る舞いは以下の通り指示。

```text
あなたは日本各地の美味しい飲食店を知る美食家です。
ユーザーは様々なシチュエーションや条件でお店を探しています。
お腹をすかせた私たちの要望に合わせて、おすすめのお店を紹介してあげてください。
特に指定がない場合は、3~5店ほど紹介してください。
```

そしてグルメサーチAPIを叩くために以下の通りFunctionを定義。
```Json
{
  "name": "search_restaurant",
  "description": "ユーザーからの要望や条件を汲み取って飲食店を探す関数",
  "parameters": {
    "type": "object",
    "properties": {
      "name": {
        "type": "string",
        "description": "店の名前。"
      },
      "keyword": {
        "type": "string",
        "description": "駅名。複数指定された場合は半角スペース区切り。"
      },
      "address": {
        "type": "string",
        "description": "住所。"
      },
      "party_capacity": {
        "type": "string",
        "description": "人数。範囲で指示された場合は最大値をとる。"
      },
      "genre": {
        "type": "string",
        "enum": [
          "居酒屋",
          "ダイニングバー・バル",
          "創作料理",
          "和食",
          "洋食",
          "イタリアン・フレンチ",
          "中華",
          "焼肉・ホルモン",
          "韓国料理",
          "アジア・エスニック料理",
          "各国料理",
          "カラオケ・パーティ",
          "バー・カクテル",
          "ラーメン",
          "お好み焼き・もんじゃ",
          "カフェ・スイーツ",
          "その他グルメ"
        ],
        "description": "料理のジャンル。"
      },
      "free_drink": {
        "type": "string",
        "enum": [
          "0",
          "1"
        ],
        "description": "デフォルトは0、ユーザーが飲み放題メニューのある店を指定した場合は1を選択。"
      },
      "free_food": {
        "type": "string",
        "enum": [
          "0",
          "1"
        ],
        "description": "デフォルトは0、ユーザーが食べ放題メニューのある店を指定した場合は1を選択。"
      },
      "private_room": {
        "type": "string",
        "enum": [
          "0",
          "1"
        ],
        "description": "デフォルトは0、ユーザーが個室席がある店を指定した場合は1を選択。"
      }
    },
    "required": [
      "keyword",
      "range"
    ]
  }
}
```

これによりユーザーが
「東京駅で個室のある居酒屋」
と発言すると、
```Json
{
    "keyword":"東京駅",
    "genre":"居酒屋",
    "private_room":"1"
}
```
というパラメーターのJsonテキストを作ってグルメサーチAPIに渡せるというわけですね。

## 3. フロー作ってくよ

Power Platformに戻ってきました。

あまり細かく説明していると私の頭がハゲるし年も暮れてしまうので、
この記事ではAssistants APIとグルメサーチAPIを使用するにあたっての注意点などをメインに簡素な解説をさせていただきます。

:::note info
通常のGPTのAPIに関しては[出戻りガツオ](https://qiita.com/DEmodoriGatsuO)様がわかりやすい記事をすでにアップされていますので、そちらをご覧いただくのがおすすめです。
:::
https://qiita.com/DEmodoriGatsuO/items/4c4734b78973970e0d3d

それでは作っていきましょう。
APIを実行するにあたりPower Automateでフローを作成することになりますが、今回は3つのフローを作成しています。

- CreateThread：Assistants APIでスレッドを作成
- SendMessage：作成したスレッドにユーザーが入力したメッセージを紐づけ
- CreateRun：メッセージが紐づけられたスレッドの実行、レスポンスによってはグルメサーチAPIとの連携を行う

以下、それぞれのフローの詳細になります。

### CreateThread

1. HTTPはこんな感じ
    もろもろのAPIキーやAssistantIDなどはソリューションの環境変数に格納しています
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3461887/5f2cd026-377d-a828-90fd-d656a6aff57f.png)
2. OpenAIから提供されているサンプルレスポンスを渡してスキーマを定義
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3461887/6d5bb137-6046-070d-b200-716ce88a6a78.png)
3. 最後にレスポンスから作成したスレッドのIDをAppsに返して終わり
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3461887/d9bdf5db-74aa-c602-cd31-186390b92825.png)


### SendMessage
1. こんな感じで作成アクションを使ってAPIに渡すパラメーターを定義
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3461887/3662bfe5-4569-ee52-fe01-735e19cd51e9.png)
    
2. そしてBodyに置いてあげる
    (ちなみにフローの引数としてInputBoxのテキストとスレッドIDを持ってきています)
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3461887/541e3f57-398b-e1ad-76be-22fba1d10e25.png)
    
3. ここで一旦List messagesを行いスレッド内のメッセージを取得する
   本当はそのままRunさせてもいいんだけど、そうすると全体の実行が完了するまでユーザー側のメッセージも画面に表示されません
   なんとなく気持ち悪いしカッコ悪いので一旦メッセージの取得、再描画を行います
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3461887/253f2b07-09c3-2174-d9c4-34045db7ee30.png)
    
4. そして取得したメッセージをGithubのAPIを利用してMarkdownからHTML形式に変換してあげる
   これでGPTがMarkdownの見出しタグなんかを使ってきてもキレイに描画できます
    (故にメッセージの表示にHTMLテキストを使用)
    
    ちなみにMarkdownをHTMLに変換する手法については、[こちらの記事](https://qiita.com/h-nagao/items/e69d7fe047c7197e3b2e)を参考にさせていただきました
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3461887/2a961200-2c56-3b0a-788f-fb4eef2d1d1e.png)

    
5. メッセージリストは配列になってしまうので、応答アクションでAppsに返します
    内容としては最低限のメッセージの内容とロール(メッセージがUserかAssistantかの判断用)
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3461887/846a414a-275f-2d22-f101-cd819104be30.png)

### CreateRun
1. スレッドをAssistantに渡して実行させます
    昔はRunの実行状態をポーリングしてあげる必要がありましたが、現在はstreamが可能になっています。
    ありがたいですね、活用させていただきましょう
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3461887/219446ab-474c-d21b-b88b-fb5cc0fe0362.png)

 2. と意気揚々で進んでいると、
    streamをtrueにするとレスポンスがイベントメッセージになってしまうんですよね…
    そのためRunが完了したタイミングはわかるが結果がわからない…、RunIDすらわからない…
    ということで苦肉の策ですが、OffiseScriptを利用してイベントメッセージからRunIDを正規表現で抽出
    このRunIDをもとにRunのレスポンスを取得します
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3461887/d7190a75-a0db-39ed-895e-01b6078a4084.png)
3.  そしてRunのステータスが「requires_action」であれば、Assistantに付与したFunctionが反応したということになります
    条件分岐でグルメサーチAPIを実行しに行きます
    ちなみにこの辺からアクションの命名が面倒になって手付かずです、ご容赦ください…
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3461887/95251fb7-cc81-7c2c-c7eb-c234b8f5c667.png)
    
4. RunのレスポンスにあるArgumentがグルメサーチAPI用のステータスになりますので、Jsonテキストとして取得します
    またグルメサーチAPI側の仕様により、
    飲食店のジャンル指定を行う際ジャンル名とジャンルコードの変換が必要になりますので、ホットペッパーから提供されているAPIでそちらの変換を行います。
    (例："居酒屋"→"G001"など)
    あとグルメサーチAPIはデフォルトだとレスポンスがxmlで返ってくるので、クエリでフォーマットをJsonに指定するのを忘れないようにしましょう
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3461887/77342c5a-37d8-2dc4-f3a0-4275e7576911.png)

5. ジャンルコードの取得が完了したらいざグルメサーチ！
    ちなみに、countで検索数に制限を付けないと飲食店情報を300件とか平気で拾ってきます。
    もしそれをそのままGPTに渡してしまうと想定外のAPI利用料になりかねないので、この段階である程度制限しておきましょう
    その他、このレスポンスのJson解析の際にrequiredを必要な情報のみに絞ってあげると、GPTに渡すテキスト量を各段に減らすことができるので、実行速度や料金を抑えることが可能です
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3461887/2f146732-dbc7-8a31-548b-292f6d9c3fec.png)

6. あとはステータスrequires_actionで待機してくれているGPTに飲食店の情報を渡せば、それをもとに作文してくれます
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3461887/d3f510e1-1405-dbb7-1245-b6d4022e5caa.png)
7. そしたらSendMessageで行ったようにメッセージを取得しなおせば完了！
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3461887/3adca39e-e167-34ec-5fdb-1afe01493b93.png)



## 3. あとはAppsから実行させるだけ

ここまで来たら楽勝ですね

```App.OnStart
//アプリ起動時にスレッドを作成してスレッドIDを取得
Set(ThreadID,CreateThread.Run())
```

```Icon_Reset.OnSelect
//ユーザーが任意のタイミングでもスレッドをリセットできるようにしておく
Set(ThreadID,CreateThread.Run())
```


```Icon_Send.OnSelect
//まず自分のメッセージをスレッドに紐づけてメッセージ取得
ClearCollect(res,SendMessage.Run(TextInput_User.Text,ThreadID.threadid));

//自分のメッセージは表示されたのでTextInputはクリア
Reset(TextInput_User);

//Runを行い、GPTからの返答を取得
ClearCollect(res,CreateRun.Run(ThreadID.threadid));
```

## 4. ついに完成

これでやっとユーザーのメッセージから意図をくみ取って飲食店を検索してくれます！
<img width="750" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3461887/4c0acf96-908f-6291-b1ab-d81692752a92.gif"></img>

個室のあるお店や
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3461887/6558265d-bbeb-6b77-26dd-dd196672df5f.png)

飲み放題のあるお店など…
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3461887/0263b22a-1a27-7905-5c39-f35ff82e8895.png)

Assistant側のFunctionの設定で細かい注文にも対応してくれます！
(さっきから居酒屋ばっか聞いてんな)

また、Assistants APIを使用しているおかげでコンテキストの保持もばっちり！
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3461887/accc2062-bce7-887b-013e-7a82c4bc66b4.png)

更に、
「次の水曜日に池袋で3人で飲みたいんだけど、おすすめの店ある？」
といった文面で聞いた際に「池袋、3人」といった情報はもちろん、
グルメサーチAPIから返された飲食店情報を見て、
「このお店もおすすめですが、水曜日が定休日となっているため注意が必要です」
といった気を利かせてくれることもあります。

ここはFunctionで定義した動作ではないので毎回確実に言及してくれるわけではありませんが、GPT-4oの賢さを感じますね。
なんならAssistantの振る舞い設定で指示してあげれば安定するかもです。

## 5. ところで気になる実行速度

結局ユーザーやグルメサーチAPIのレスポンス次第としか言いようはないですが、
テキスト送信からお店の紹介まで大体25~30秒って感じでした。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3461887/cb45ab41-6728-24c3-7a8a-c0cfb6d2c362.png)

……微妙に遅いかな…？

というのも、Assistants APIが発表された去年にも同じようなものを作成していたのですが、
GPT-4は実行が遅いし料金も高めということであまり使い物にならなかったんですよね。

これでもGPT-4oになったおかげでだいぶ速くなっています。(あと何より安い)

まぁ少なくとも自分でグルメサイトにアクセスして、テキストボックスに書いたり、プルダウンやチェックボックスをポチポチするよりは楽なのかなとは思います。
自分で探していると気に留めないようなお店もおすすめしてくれるかもしれませんしね。

# おわり
以上、Power AppsとGPT-4o(Assistants API経由)による飲食店検索アプリでした。

今まではAssistants APIってやり取りが何度も発生したりする関係で、
どうしてもレスポンスの速さ、賢さ、料金のどれかを犠牲にしなきゃいけないイメージでしたが、
GPT-4oの登場により一気に解消されてきた感じがします。

今後GPT-4oでリリースされていくであろう音声や動画などのマルチモーダル機能も考えると、
Assistants APIも合わせてさらに自由度が広がる予感がしてすごく楽しみですね。

かなり長い記事となってしまいましたが、ここまで読んでいただき光栄です。

それでは。

## 参考


- Power AppsとGPT-4oの設定など

https://qiita.com/DEmodoriGatsuO/items/4c4734b78973970e0d3d

- Markdown表示対策

https://qiita.com/h-nagao/items/e69d7fe047c7197e3b2e

- アプリのテーマカラー変更

https://qiita.com/reire/items/8b3a35818aadecba024a