Azure OpenAI Service の SDK を利用し、フロントエンドを Power Apps にする


# はじめに


以前、こちらの記事で、フロントエンドを Power Apps にして、Azure OpenAI Service の ChatGPT と連携する方法を紹介しました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3074511/6196726b-9866-8a67-4a97-898652e83de3.png)

https://qiita.com/Takashi_Masumori/items/8a07fb6d1869967ce1f9

こちらについて、どんなアーキテクチャになっているかというと、以下のようなイメージです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3074511/94f71441-b006-50fd-103d-9dbef101a6a1.png)

ネットワークセキュリティ周りがちょっときになったので、以下のようなアーキテクチャに組み替えてみました。今回、コアとなる Azure Functions の実装部分と、Power Apps からの呼び出し部分について紹介します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3074511/ac0d7be4-dd50-9923-e5b9-ba902fe0769f.png)

:::note
今回は API キーを利用していますが、最終的にはマネージド ID に変更しようと思います
:::

# Azure Functions 側

まず、Azure OpenAI Service の利用について、以下のライブラリを利用しています。


https://www.nuget.org/packages/Azure.AI.OpenAI/1.0.0-beta.5

結果的に、Visual Studio で以下のライブラリを利用しています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3074511/1b56ac97-1e30-6b13-5417-39ee8ff53a76.png)

コードは以下のような感じです。長らく、C# でコード書いていなかったので ChatGPT さんのお世話になりました。あくまで、サンプルなので一旦以下のような感じで試してみようと思います。

一応、Json を受け取って、会話履歴を保持し、文脈を読んで回答することが出来るようにしています。

```
using System.IO;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using System.Collections.Generic;
using Azure;
using Azure.AI.OpenAI;
using Azure.Identity;
using Azure.Security.KeyVault.Secrets;
using System;

namespace QuantumAIChat
{
	public class Message
	{
		public string Role { get; set; }
		public string Content { get; set; }
	}

	public class InputData
	{
		public string Model { get; set; }
		public List<Message> Messages { get; set; }
	}

	public static class QuantumMessageEngine
	{
		[FunctionName("QuantumChatProcessor")]
		public static async Task<IActionResult> Run(
			[HttpTrigger(AuthorizationLevel.Function, "post", Route = null)] HttpRequest req,
			Microsoft.Azure.WebJobs.ExecutionContext context,
			ILogger log)
		{
			try
			{
				log.LogInformation("C# HTTP trigger function processed a request.");

				string apiUrl = "API の URL";

				string keyVaultUrl = "***"; // Please specify the URL of Key Vault.
				string secretName = "ChatGPTKey"; // Please specify the name of the secret you want to retrieve.

				// Use ManagedIdentityCredential to utilize managed ID.
				var credential = new ManagedIdentityCredential();

				log.LogInformation("Connect to Key Vault.");

				// Create a KeyVaultSecretClient to communicate with Key Vault.
				var client = new SecretClient(new Uri(keyVaultUrl), credential);

				// Retrieve the secret.
				KeyVaultSecret secret = await client.GetSecretAsync(secretName);

				log.LogInformation("Get input data.");

				string requestBody = await new StreamReader(req.Body).ReadToEndAsync();
				InputData data = JsonConvert.DeserializeObject<InputData>(requestBody);

				log.LogInformation("Initialize message.");

				var options = new ChatCompletionsOptions();

				foreach (Message message in data.Messages)
				{
					Azure.AI.OpenAI.ChatRole chatRole;

					// Create an appropriate ChatRole instance based on message.Role.
					if (message.Role.Equals("User", StringComparison.OrdinalIgnoreCase))
					{
						chatRole = Azure.AI.OpenAI.ChatRole.User;
					}
					else if (message.Role.Equals("Assistant", StringComparison.OrdinalIgnoreCase))
					{
						chatRole = Azure.AI.OpenAI.ChatRole.Assistant;
					}
					else
					{
						// Record an error in the log, or perform appropriate processing.
						log.LogError($"Error: Unable to parse '{message.Role}' as a valid Azure.AI.OpenAI.ChatRole.");
						continue;
					}

					// Pass the created ChatRole instance to ChatMessage.
					options.Messages.Add(new ChatMessage(chatRole, message.Content));
				}
				options.MaxTokens = 200;

				log.LogInformation("Initialize OpenAI client.");

				// Initialize OpenAI client
				var openAIClient = new OpenAIClient(new Uri(apiUrl), new AzureKeyCredential(secret.Value));

				log.LogInformation("Get result from ChatGPT");

				var result = await openAIClient.GetChatCompletionsAsync(
					"モデル名",
					options
				);

				log.LogInformation("Return result.");

				// Return the result
				return new OkObjectResult(result.Value);
			}
			catch (Exception ex)
			{
				log.LogError($"Error: {ex.Message}");
				return new StatusCodeResult(StatusCodes.Status500InternalServerError);
			}
		}
	}
}


```

apiUrl はこちらで確認できるエンドポイント。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3074511/6aa3f87e-f9c7-7177-7cf5-b72c8d6ae02d.png)

モデル名はこちらで確認できる名前を入れました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3074511/c6d41313-2906-33da-276d-8a2b3ff6baa6.png)

Azure Functions に発行してテストしてみます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3074511/1f748765-3046-40ea-19cb-8adb951ee7a1.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3074511/37e7b2cd-4057-bdfb-7259-34eaaf4d047e.png)

あとは、こちらを API Management (APIM) 経由でも使えるようにして、必要に応じてネットワーク観点でのセキュリティ対策を実施します。

# Power Apps 側

## ロジック部分
Power Apps からはカスタムコネクタを介して利用します。APIM を介しますが、アプローチとしては、以下と大きくは変わらないです。

https://qiita.com/Satoshi_Yoshino/items/e2a47a3aaedef7ea5079

APIM を OAuth で保護するところについては、いつもいろいろな記事を見ながらなんとかやっています。。

https://papemk2.hateblo.jp/entry/2021/12/15/132239

https://logico-jp.io/2019/09/30/protecting-apis-with-oauth2-authorization-code-grant-in-azure-api-management/

https://tech.fusic.co.jp/posts/2021-04-22-azure-api-management-ad-oauth2/

APIM の情報を Azure AD に API として登録し、そちらの API を利用するクライアントも登録し、必要な設定を行った後、カスタムコネクタのセキュリティ設定等を行います。Azure AD のユーザーで認証が出来れば OK です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3074511/1585c10e-8d03-be52-726d-e74d5a1e465d.png)

URL は APIM の URL になっています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3074511/c7e18f0f-d42f-fd98-4c06-c34f99790781.png)

リクエストの Body の構造は代えていないので、サンプルから作成しています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3074511/fc4ff89c-4d5a-703a-d51a-d017c0c51786.png)

アプリ側もほとんど変えておらず、以下の送信ボタンを押した際の処理は以下のような感じです。ポイントは、返答を Collection に格納していくところですかね。

:::note
New Chat ボタンを押した際などに Collection をクリアしております。
:::

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3074511/2ad7008a-12f6-87c2-1474-bc7baae9429f.png)

```
UpdateContext({locLoading: true});
Set(
    gblChatGPTCustomConnectorResponse,
    CallChatGPT.callChatGPT(
        "gpt-3.5-turbo",
        Collect(
            colChat,
            {
                role: "user",
                content: txtChatMessage.Text
            }
        );
    );
    
);
Collect(
    colChat,
    {
        role: First(gblChatGPTCustomConnectorResponse.choices).message.role.label,
        content: First(gblChatGPTCustomConnectorResponse.choices).message.content
    }
);
Select(
    galChat,
    CountRows(galChat.AllItems)
);
Reset(txtChatMessage);
UpdateContext({locLoading: false});
//Notify("成功しました",NotificationType.Success)
```

## Power Apps でチャットエクスペリエンスを実装する (ギャラリー部分)

ギャラリーで以下のようにチャットエクスペリエンスを実装するアプローチについても補足します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3074511/7f3261fd-0048-382e-a936-0afa92195652.png)

まず、ギャラリーの種類として、以下を選択します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3074511/76088a0b-e413-bd09-92d0-006667a9ac7a.png)

ギャラリー内のラベルについて、以下のように、高さの自動調整をオンにします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3074511/cedcb8d2-dcf5-e8c3-9e51-6f7274921a9a.png)

イメージコントロールを追加し、以下のように assistant と user で写真を変更します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3074511/37359ed7-22c0-e540-11df-2adaa35c20c6.png)

また、ギャラリーの`[TemplateFill]`を以下のように、自分と ChatGTP からの返答で色を分けます。

```
If(ThisItem.role="user",gblFontDark,ColorFade(gblFontDark,30%))
```

また、チャットのようなエクスペリエンスにするために、以下のようにチャットのやり取りが続いた際、一番最新のやり取り (ギャラリーで言うと一番下) が選択されるようにします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3074511/d04558cc-8d09-a5b4-7173-8692c2653652.png)

こちらは、上述した送信ボタンの以下の処理により実現しております。こちらを実施しない場合、チャットが長くなった場合、一番したのやり取りが隠れてしまい、わざわざ下にスクロールしないといけなくなり、チャットのエクスペリエンスが低下すると思います。

```
Select(
    galChat,
    CountRows(galChat.AllItems)
);
```

# まとめ

Azure OpenAI Service の SDK を利用し、フロントエンドを Power Apps にする際のアプローチについて紹介しました。構築コストは少なからず上がりますが、セキュリティ対策という観点や拡張性 (例：トークン数のコントロール、分析用のログの取得などの作りこみ)という観点でメリットがあると思います。

また、引き続きフロントエンドは Power Apps のため、こちらの構築コストは低く、有益な活用シナリオをアプリ内で共有したり (補足プロンプトを自動入力)、使い方サンプルや注意事項などを簡単に共有することが簡単にできますし、ちょっとしたカスタマイズもすぐにできるメリットは残ります。