簡単なチャットボットを作ってみよう（Azure OpenAI連携編）

船井総研デジタルのtakizawaです。
当記事は [こちらの記事](https://qiita.com/fsd-takizawa/items/6ba20a788e48fc74b36c) の続編になります。
今回はいよいよAzure OpenAIと連携させます。

# C#(.NET)とAzure OpenAIを連携させる方法

Azure OpenAIとの連携にはいくつかの方法が考えられます。
* 各言語で用意されているクライアントライブラリを使う。
* RestAPIを使う。
* semantic-kernelを使う。　等々...

今回は３番目のsemantic-kernelを使用する方法で連携しようと思います。１番目と２番目の方法は[Microsoftのドキュメント](https://learn.microsoft.com/ja-jp/azure/ai-services/openai/chatgpt-quickstart?tabs=command-line&pivots=programming-language-csharp)をご覧下さい。

# Semantic Kernelとは？

[公式ドキュメント](https://learn.microsoft.com/en-us/semantic-kernel/overview/)の冒頭を参照すると、
__Semantic Kernel は、OpenAI、Azure OpenAI、Hugging Faceなどの AI サービスと C# や Python などの従来のプログラミング言語を簡単に組み合わせることができるオープンソース SDK です。__
とあります。[こちらの記事](https://qiita.com/nohanaga/items/430b59209b02c298ef2a)がとても参考になるかと思います。

# Semantic kernelのプロジェクトへの追加

バックエンドプロジェクトのターミナルで次のコマンドを実行します。
```dotnet add package Microsoft.SemanticKernel --version 0.23.230906.2-preview```

:::note warn
```--version```パラメータは記事執筆時のものです。最新版を使用する場合は [このページ](https://www.nuget.org/packages/Microsoft.SemanticKernel/) の```.NET CLI```欄を確認してください。
:::

# Kernelを構成するコード

まずはKernelの設定を読み込むクラスです。

```cs: semanticKernel/config/KernelSettings.cs
using System.Text.Json.Serialization;

internal class KernelSettings
{
    public const string DefaultConfigFile = "semanticKernel/config/azureopenai_appsettings.json";

    [JsonPropertyName("serviceType")]
    public string ServiceType { get; set; } = string.Empty;

    [JsonPropertyName("serviceId")]
    public string ServiceId { get; set; } = string.Empty;

    [JsonPropertyName("deploymentOrModelId")]
    public string DeploymentOrModelId { get; set; } = string.Empty;

    [JsonPropertyName("endpoint")]
    public string Endpoint { get; set; } = string.Empty;

    [JsonPropertyName("apiKey")]
    public string ApiKey { get; set; } = string.Empty;

    [JsonPropertyName("orgId")]
    public string OrgId { get; set; } = string.Empty;

    [JsonPropertyName("logLevel")]
    public LogLevel? LogLevel { get; set; } = Microsoft.Extensions.Logging.LogLevel.Warning;

    [JsonPropertyName("systemPrompt")]
    public string SystemPrompt { get; set; } = "You are a friendly, intelligent, and curious assistant who is good at conversation.";

    /// <summary>
    /// Load the kernel settings from settings.json if the file exists and if not attempt to use user secrets.
    /// </summary>
    internal static KernelSettings LoadSettings()
    {
        try
        {
            if (File.Exists(DefaultConfigFile))
            {
                return FromFile(DefaultConfigFile);
            }

            Console.WriteLine($"Semantic kernel settings '{DefaultConfigFile}' not found, attempting to load configuration from user secrets.");

            return FromUserSecrets();
        }
        catch (InvalidDataException ide)
        {
            Console.Error.WriteLine(
                "Unable to load semantic kernel settings, please provide configuration settings using instructions in the README.\n" +
                "Please refer to: https://github.com/microsoft/semantic-kernel-starters/blob/main/sk-csharp-hello-world/README.md#configuring-the-starter"
            );
            throw new InvalidOperationException(ide.Message);
        }
    }

    /// <summary>
    /// Load the kernel settings from the specified configuration file if it exists.
    /// </summary>
    internal static KernelSettings FromFile(string configFile = DefaultConfigFile)
    {
        if (!File.Exists(configFile))
        {
            throw new FileNotFoundException($"Configuration not found: {configFile}");
        }

        var configuration = new ConfigurationBuilder()
            .SetBasePath(System.IO.Directory.GetCurrentDirectory())
            .AddJsonFile(configFile, optional: true, reloadOnChange: true)
            .Build();

        return configuration.Get<KernelSettings>()
               ?? throw new InvalidDataException($"Invalid semantic kernel settings in '{configFile}', please provide configuration settings using instructions in the README.");
    }

    /// <summary>
    /// Load the kernel settings from user secrets.
    /// </summary>
    internal static KernelSettings FromUserSecrets()
    {
        var configuration = new ConfigurationBuilder()
            .AddUserSecrets<KernelSettings>()
            .Build();

        return configuration.Get<KernelSettings>()
               ?? throw new InvalidDataException("Invalid semantic kernel settings in user secrets, please provide configuration settings using instructions in the README.");
    }
}
```

1. デフォルトとしてコーディングした設定ファイルから設定を読み込む
1. ファイルパスを指定して指定ファイルから読み込む
1. ユーザーシークレットから設定を読み込む

上記3つの方法を実装しています。今回実際に使うのは1.のメソッドになります。またデフォルトの設定ファイルは後ほど作成します。

次にKernelを構成するコードです。

```cs: semanticKernel/config/ServiceTypes.cs
internal static class ServiceTypes
{
    internal const string OpenAI = "OPENAI";
    internal const string AzureOpenAI = "AZUREOPENAI";
}
```

このクラスは次のコードの内部で使用する定数です。

```cs: semanticKernel/config/KernelBuilderExtensions.cs
using Microsoft.SemanticKernel;

internal static class KernelBuilderExtensions
{
    /// <summary>
    /// Adds a chat completion service to the list. It can be either an OpenAI or Azure OpenAI backend service.
    /// </summary>
    /// <param name="kernelBuilder"></param>
    /// <param name="kernelSettings"></param>
    /// <exception cref="ArgumentException"></exception>
    internal static KernelBuilder WithCompletionService(this KernelBuilder kernelBuilder, KernelSettings kernelSettings)
    {
        switch (kernelSettings.ServiceType.ToUpperInvariant())
        {
            case ServiceTypes.AzureOpenAI:
                kernelBuilder = kernelBuilder.WithAzureChatCompletionService(deploymentName: kernelSettings.DeploymentOrModelId, endpoint: kernelSettings.Endpoint, apiKey: kernelSettings.ApiKey, serviceId: kernelSettings.ServiceId);
                break;

            case ServiceTypes.OpenAI:
                kernelBuilder = kernelBuilder.WithOpenAIChatCompletionService(modelId: kernelSettings.DeploymentOrModelId, apiKey: kernelSettings.ApiKey, orgId: kernelSettings.OrgId, serviceId: kernelSettings.ServiceId);
                break;

            default:
                throw new ArgumentException($"Invalid service type value: {kernelSettings.ServiceType}");
        }

        return kernelBuilder;
    }
}
```

```KernelBuilder```と```KernelSetting```を引数として受け取り、設定ファイルの```ServiceType```の設定値に従ってAzureOpenAIまたはOpenAIに接続するために必要な設定値を引数としてChatCompletionServiceをKernelBuilderに組み込みます。最後にBuilderを返却します。

| 接続するサービス | 呼び出すメソッド                  |
|:----------------|:--------------------------------|
| AzureOpenAI     | WithAzureChatCompletionService  |
| OpenAI          | WithOpenAIChatCompletionService |

この記事ではAzureOpenAIのみを扱うのでOpenAIに関連する設定の実装は省いても問題ありません。

# Skillの実装

Semantic Kernelではやりたいことをスキルとして定義し、定義したスキルをプランナーに登録します。このプランナーを実行することで、Semantic-Kernelが適切にスキルを実行していきます。またこのプランナーの機能が特色でもあります。
では実際にスキルを定義していきます。

```cs: semanticKernel/skills/ChatSkill.cs
using System.ComponentModel;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.AI.ChatCompletion;
using Microsoft.SemanticKernel.Orchestration;
using Microsoft.SemanticKernel.SkillDefinition;

namespace semanticKernel.skills;

internal class ChatSkill
{
    private readonly IChatCompletion _chatCompletion;
    private readonly ChatHistory _chatHistory;

    private readonly Dictionary<AuthorRole, string> _roleToDisplayRole = new()
    {
        {AuthorRole.System, "System:    "},
        {AuthorRole.User, "User:      "},
        {AuthorRole.Assistant, "Assistant: "}
    };

    public ChatSkill(IKernel kernel, KernelSettings kernelSettings)
    {
        // Set up the chat completion and history - the history is used to keep track of the conversation
        // and is part of the prompt sent to ChatGPT to allow a continuous conversation
        this._chatCompletion = kernel.GetService<IChatCompletion>();
        this._chatHistory = this._chatCompletion.CreateNewChat(kernelSettings.SystemPrompt);
    }

    private readonly Dictionary<AuthorRole, ConsoleColor> _roleToConsoleColor = new()
        {
            {AuthorRole.System, ConsoleColor.Blue},
            {AuthorRole.User, ConsoleColor.Yellow},
            {AuthorRole.Assistant, ConsoleColor.Green}
        };

    [SKFunction, Description("Send a prompt to the AzureOpenAI then recieve and return assistant message.")]
    [SKParameter(name: "inputMessage", description: "User message", DefaultValue = "こんにちは！")]
    public async Task<string> PromptAsync(SKContext context)
    {
        string inputUserMessage = context.Variables["inputMessage"];
        var reply = string.Empty;
        try
        {
            if (30 < this._chatHistory.Messages.Count)
            {
                this._chatHistory.Clear();
            }

            this._chatHistory.AddMessage(AuthorRole.User, inputUserMessage);
            reply = await this._chatCompletion.GenerateMessageAsync(this._chatHistory);
            this._chatHistory.AddMessage(AuthorRole.Assistant, reply);
        }
        catch (Exception aiex)
        {
            reply = $"OpenAIがエラーを返しました（{aiex.Message}）。もう一度お試しください。";
        }

        return reply;
    }

    [SKFunction, Description("Chat history write log skill. Run after excecuting Prompt skill.")]
    public Task LogChatHistory(SKContext context)
    {
        Console.WriteLine();
        Console.WriteLine("Chat history:");
        Console.WriteLine();

        foreach (var message in this._chatHistory.Messages)
        {
            string role = "None:      ";

            // Depending on the role, use a different color
            if (this._roleToDisplayRole.TryGetValue(message.Role, out var displayRole))
            {
                role = displayRole;
            }

            if (this._roleToConsoleColor.TryGetValue(message.Role, out var color))
            {
                Console.ForegroundColor = color;
            }

            // Write the role and the message
            Console.WriteLine($"{role}{message.Content}");
        }

        return Task.CompletedTask;
    }
}
```
ここではスキルとしてSKFunctionを2つ定義しています。
1つ目は主の機能となる、AzureOpenAIとやり取りするするFunctionです。( __PromptAsync__ )
2つ目は会話の履歴をバックエンドのコンソールに書き出すFunctionです。( __LogChatHistory__ )
SKFunctionを定義するにはメソッドにIndexcerデコレーションで```SKFunction```を指定します。また同時に```Description```でそのFunctionの説明を付与します。 __Plannerはこの```Description```をもとに実行計画を組み立てます。つまり、ここの記載内容によって実行計画が変化します。__
またパラメータはIndexcerデコレーションの```SKParameter```で記載します。またパラメータから値を取り出す場合はメソッドの引数として```SKContext```を受け取りここから```Variables["name"]```にアクセスして取得します。ここで```name```はSKParameterのname属性に設定した値です。

Semantic KernelはLLMとのチャットのためユーティリティが実装されています。
* ChatCompletion
* ChatHistory

使い方は```ChatCompletion```でチャットを始めるとともに、```ChatHistory```を取得します。そして```ChatHistory```に各ロールのメッセージを追加して、```_chatCompletion.GenerateMessageAsync(this._chatHistory)```でAzureOpenAIからの応答を生成します。

__※ロール__
| ロール | 説明 |
|:------|:-----|
| System | AIの生成の前提になるメッセージ |
| User | ユーザーのメッセージ |
| Assistant | AIの生成したメッセージ |

# Skillの実行サービス

先で定義したSkillをKernelにインポートして、Plannerにより実行計画を生成、計画に沿ってスキルが実行されます。これはとても簡単に実装できます。

```cs: semanticKernel/SemanticKernelService.cs
using System.Text.Json;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Orchestration;
using Microsoft.SemanticKernel.Planning;
using semanticKernel.skills;

internal class SemanticKernelService
{
    private readonly IKernel kernel;
    private readonly KernelSettings settings;
    public SemanticKernelService()
    {
        KernelBuilder orgBuilder = Kernel.Builder;
        this.settings = KernelSettings.LoadSettings();
        KernelBuilder builder = orgBuilder.WithCompletionService(this.settings);
        this.kernel = builder.Build();
        this.kernel.ImportSkill(new ChatSkill(this.kernel, this.settings), "chat");
    }

    public async Task<string> GetReply(string inputMessage)
    {
        var planner = new SequentialPlanner(kernel);
        Plan plan = await planner.CreatePlanAsync(inputMessage);

        Console.WriteLine(JsonSerializer.Serialize(plan, options: new() {
            WriteIndented = true,
            Encoder = System.Text.Encodings.Web.JavaScriptEncoder.Create(System.Text.Unicode.UnicodeRanges.All),
        }));

        SKContext result = await kernel.RunAsync(plan);

        return result.Result;
    }
}
```

コンストラクタでは
1. Kernelビルダーを生成
1. 設定のロード
1. ロードした設定をKernelに適用
1. Kernelのビルド
1. スキルのKernelへのインポート
を行っています。

メソッド```GetReply```はコントローラから呼ぶもので、順番に
1. プランナーを作成
1. 実行計画を生成
1. コンソールに実行計画を出力
1. スキルを実行計画に沿って実行
1. 結果を返却
と実行していきます。

# コントローラの修正

ここまで実装できたら、仮実装していたコントローラを、今のメソッドを呼び出すように修正します。

```cs: Controllers/ReplyController.cs
using Microsoft.AspNetCore.Mvc;

namespace test_bot_back.Controllers;

[ApiController]
[Route("api/[controller]")]
public class ReplyController : ControllerBase
{
    private readonly ILogger<ReplyController> _logger;
    private static SemanticKernelService service = new SemanticKernelService();

    public ReplyController(ILogger<ReplyController> logger)
    {
        _logger = logger;
    }

    [HttpPost]
    public IEnumerable<ReplyMessage> Get(ReceiveMessage receive)
    {
        string replyMessage = service.GetReply(receive.Message).Result;

        ReplyMessage reply = new ReplyMessage
        {
            SendMessages = new string[] { receive.Message },
            Reply = replyMessage,
        };

        return new ReplyMessage[] { reply };
    }
}
```

```string replyMessage = service.GetReply(receive.Message).Result;```がサービスの呼び出し部分となります。

# Azure OpenAI の準備

:::note warn
Azure OpenAIの使用には料金がかかりますのでご注意ください。
またテストが終了したらリソースを削除することをお勧めします。
:::


Azureポータルにログインして任意のリソースグループにAzure OpenAIを作成します。
作成されたらAzure OpenAIリソースを開き __概要__ タブの```Exploer```ボタンをクリックしてAzure OpenAI Studioを開きます。

次に左側のペインパネルから __Deployments__ をクリックしてください。
![スクリーンショット 2023-09-15 164008.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3473569/28252a0a-379e-3ba3-701c-8df262d317cc.png)

次にCreate new deploymentボタンをクリックして、次のように設定して```Create```ボタンをクリックします。
![スクリーンショット 2023-09-15 164733.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3473569/714de166-1d13-2770-2b88-781e84b79ddf.png)

:::note warn
```input-deployment-name```は自身で決めた名前で置き換えてください。
:::
ここで設定した __Deployment name__ ・・・「１」 を控えておいてください。
Azureポータルに戻りAzure OpenAIリソースを再度開きます。
Keys and Endpointタブを表示して、
__キー 1__ ・・・「２」
__エンドポイント__ ・・・「３」
をコピーしてください。

バックエンドプロジェクトに設定ファイルを作成し、「１」～「３」を設定します。
```json: semanticKernel/config/azureopenai_appsettings.json
{
  "serviceType": "AzureOpenAI",
  "serviceId": "gpt-35-turbo",
  "deploymentOrModelId": "コピーした 「１」 の値",
  "endpoint": "コピーした 「３」 の値",
  "apiKey": "コピーした 「２」 の値",
  "systemPrompt": "You are a friendly, intelligent, and curious assistant who is good at conversation. Using langauge is Japanese."
}
```

:::note alert
apiKey(キー1)の値は公開しないように取り扱いには十分注意してください。
また```azureopenai_appsettings.json```は公開リポジトリに公開しないようにしてください。
:::

ここで```systemPrompt```、Systemロールで送信する会話生成の前提を指定しています。

# 稼働テスト

バックエンドとフロントエンドを起動してメッセージを送信してみましょう。

画面とコンソールがそれぞれこのように出力されれば成功です。
![スクリーンショット 2023-09-15 170922.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3473569/7f63f86e-d81f-36f0-303e-effd4fb0c7cd.png)
![スクリーンショット 2023-09-15 171032.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3473569/41adb7b6-ad3c-1fd1-34c6-fafe1fb894af.png)

会話がおかしいといった場合はsystemPromptを、実行計画がおかしい場合はSkillのdescriptionを調整してみてください。

# 最後に

全5回にわたって簡単なチャットボットを作成してみました。いかがでしたでしょうか？
今回Azure OpenAIとバックエンドをつなぐライブラリはsemantic-kernelを使用しましたが他にも方法はありますので調べて試してみるのも良いと思います。またsemantic-kernelは今回ネイティブスキルとプランナーを使用しましたが、他にも実現する方法があります。試してみても面白いと思います。