C# で Semantic Kernel：複数プラグインを連携させる

# C# で Semantic Kernel：複数プラグインを連携させる

こんにちは、@studio_meowtoon です。今回は、WSL Ubuntu 22.04 の C# で Semantic Kernel を使用する方法を紹介します。
![semantic-kernel_with_csharp.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3206449/97e06d2d-bd4e-df85-0b14-e61f96a7f98c.png)

## 実現すること

C# から Semantic Kernel を使用して、複数プラグインを連携させ、目的に沿った結果を導き出す方法を学びます。

:::note alert
C# には本来[厳格なコーディング規則](https://learn.microsoft.com/ja-jp/dotnet/csharp/fundamentals/coding-style/coding-conventions)がありますが、この記事では可読性のために、一部規則に沿わない表記方法を使用しています。ご注意ください。
:::

## 技術トピック

### Semantic Kernel とは？  

<details>
<summary>こちらを展開してご覧いただけます。</summary>

### [Semantic Kernel](https://learn.microsoft.com/en-us/semantic-kernel/overview/)

:::note info
Semantic Kernel は、 OpenAI、Azure OpenAI、Hugging Face などの AI サービスと C# や Python などの従来のプログラミング言語を簡単に組み合わせることができるオープンソース SDK です。そうすることで、両方の長所を組み合わせた AI アプリを作成できます。
:::

</details>

## 開発環境

+ Windows 11 Home 22H2 を使用しています。  

:::note info
[WSL](https://learn.microsoft.com/ja-jp/windows/wsl/about) の Ubuntu を操作していきますので macOS の方も参考にして頂けます。  
:::

<details>

WSL (Microsoft Store アプリ版) [※ こちらの関連記事からインストール方法をご確認いただけます](https://qiita.com/studio_meowtoon/items/2aaa6579cb8e4fa1d874)

```sh
> wsl --version
WSL バージョン: 1.0.3.0
カーネル バージョン: 5.15.79.1
WSLg バージョン: 1.0.47
```

Ubuntu [※ こちらの関連記事からインストール方法をご確認いただけます](https://qiita.com/studio_meowtoon/items/2aaa6579cb8e4fa1d874)

```sh
$ lsb_release -a
No LSB modules are available.
Distributor ID: Ubuntu
Description:    Ubuntu 22.04.1 LTS
Release:        22.04
```

.NET SDK [※ こちらの関連記事からインストール方法をご確認いただけます](https://qiita.com/studio_meowtoon/items/d68031c91462f6808dff)

```sh
$ dotnet --list-sdks
7.0.202 [/usr/share/dotnet/sdk]
$ dotnet --version
7.0.202
```

</details>

## Semantic Kernel を使用する手順

### OpenAI API のキーを取得します

こちらの以前の記事を参考にして頂けます。

https://qiita.com/studio_meowtoon/items/2675b596191e4fa2d0e3

**Ubuntu** に **OPENAI_API_KEY** 環境変数を作成します。

```sh
$ export OPENAI_API_KEY=sk-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
```

:::note alert
この環境変数が**一時的なもの**であることに注意してください。
:::

### プロジェクトの作成

プロジェクトルートフォルダに移動します。
※ ~/tmp をプロジェクトルートフォルダとします。

```sh
$ cd ~/tmp
```

コンソールアプリを作成します。
※ SKApp がアプリ名です。

```sh
$ dotnet new console -o SKApp -f net7.0
```

一度コンソールアプリをビルド・実行します。

```sh
$ cd ~/tmp/SKApp
$ dotnet run
Hello, World!
```

:::note info
ここまでの手順で、C# / .NET コンソールアプリの雛形が作成できました😋
:::

### ライブラリの追加

[**Semantic Kernel**](https://www.nuget.org/packages/Microsoft.SemanticKernel/) パッケージを [**NuGet**](https://learn.microsoft.com/ja-jp/nuget/what-is-nuget) で取得します。

```sh
$ dotnet add package Microsoft.SemanticKernel --prerelease
```

パッケージを確認します。

```sh
$ dotnet list package
プロジェクト 'SKApp' に次のパッケージ参照が含まれています
   [net7.0]:
   最上位レベル パッケージ                    要求済み                    解決済み

   > Microsoft.SemanticKernel      0.17.230629.1-preview   0.17.230629.1-preview
```

:::note info
ここまでの手順で、プロジェクトに **Semantic Kernel** パッケージを組み込むことができました😋
:::

### セマンティック関数の作成

まず、**Semantic Kernel** をシンプルに使用してみます。

```mermaid
graph LR;
    Input-->GetIntent;
    GetIntent-->Output;
```

以下のように **プラグイン** を作成します。

```sh
├── PluginsDirectory
│   └── Plugins
│       └── GetIntent
│           ├── config.json
│           └── skprompt.txt
```

config.json の内容

```json:config.json
{
  "schema": 1,
  "type": "completion",
  "description": "ユーザーの意図を要約します。",
  "completion": {
       "max_tokens": 500,
       "temperature": 0.0,
       "top_p": 0.0,
       "presence_penalty": 0.0,
       "frequency_penalty": 0.0
  },
  "input": {
       "parameters": [
            {
                 "name": "input",
                 "description": "ユーザーの入力値",
                 "defaultValue": ""
            }
       ]
  }
}
```

skprompt.txt の内容

```txt:skprompt.txt
あなた: AI アシスタントを演じます。
ユーザー: {{$input}}

---------------------------------------------

ユーザーの意図を5単語以内の文章に要約します。
```

:::note warn
ユーザーが入力した文章を要約するプラグインを作成しています。
:::

Program.cs を修正します。

```sh
$ vim Program.cs
```

ファイルの内容

```csharp:Program.cs
using static System.Console;
using static System.ConsoleColor;
using static System.Environment;

using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Orchestration;
using Microsoft.SemanticKernel.SkillDefinition;

namespace SKApp {
    class Program {
        [Obsolete]
        static async Task Main(string[] args) {
            // get an api key.
            string? api_key = GetEnvironmentVariable("OPENAI_API_KEY");

            // create a kernel object.
            IKernel kernel = new KernelBuilder().Build();
            kernel.Config.AddOpenAIChatCompletionService(
                modelId: "gpt-3.5-turbo",
                apiKey: api_key
            );

            // import plugins from the plugins directory.
            string? plugins_directory = Path.Combine(System.IO.Directory.GetCurrentDirectory(), "PluginsDirectory");
            IDictionary<string, ISKFunction>? plugins = kernel.ImportSemanticSkillFromDirectory(
                plugins_directory, "Plugins"
            );

            // read a prompt from the console.
            ForegroundColor = Yellow;
            WriteLine("Enter a prompt: ");
            ResetColor();
            string? prompt = ReadLine();

            // get the GetIntent function from the plugins and run it
            SKContext? result = await plugins["GetIntent"].InvokeAsync(prompt);

            // show the result.
            ForegroundColor = Blue;
            WriteLine(result);
            ResetColor();
        }
    }
}
```

実行します。

```sh
$ dotnet run
Enter a prompt:
ひさかたの 光のどけき 春の日に しづ心なく 花の散るらむ
```

実行結果

```sh
春の花が散る日。
```

悪くないと思います。きちんと文章の内容を理解して要約できているようです。

:::note info
ここまでの手順で、Semantic Kernel をシンプルに使用することができました😋
:::

### 複数のセマンティック関数を作成

次に、複数のセマンティック関数を連結して使用してみます。

```mermaid
graph LR;
    Input-->GetJoke;
    GetJoke-->GetPoem;
    GetPoem-->GetMenu;
    GetMenu-->Output;
```

以下のように **プラグイン** を作成します。

```sh
├── PluginsDirectory
│   └── Plugins
│       ├── GetJoke
│       │   ├── config.json
│       │   └── skprompt.txt
│       ├── GetMenu
│       │   ├── config.json
│       │   └── skprompt.txt
│       └── GetPoem
│           ├── config.json
│           └── skprompt.txt
```

#### GetJoke セマンティック関数

config.json の内容

```json:config.json
{
  "schema": 1,
  "type": "completion",
  "description": "ジョークを作成します。",
  "completion": {
     ※省略
  },
  "input": {
       "parameters": [
            {
                 "name": "input",
                 "description": "ユーザーの入力値",
                 "defaultValue": ""
            }
       ]
  }
}
```

skprompt.txt の内容

```txt:skprompt.txt
あなた: テレビの人気司会者を演じます。

---------------------------------------------

{{$input}} についての短いジョークを話してください。
```

#### GetPoem セマンティック関数

config.json の内容

```json:config.json
{
  "schema": 1,
  "type": "completion",
  "description":  "童謡に変換します。",
  "completion": {
     ※省略
  },
  "input": {
       "parameters": [
            {
                 "name": "input",
                 "description": "ユーザーの入力値",
                 "defaultValue": ""
            }
       ]
  }
}
```

skprompt.txt の内容

```txt:skprompt.txt
あなた: 老練な童謡作家を演じます。

---------------------------------------------

このジョーク "{{$input}}" を童謡に変換して話してください。
```

#### GetMenu セマンティック関数

config.json の内容

```json:config.json
{
  "schema": 1,
  "type": "completion",
  "description":  "ショップのメニュー名に反映します。",
  "completion": {
     ※省略
  },
  "input": {
       "parameters": [
            {
                 "name": "input",
                 "description": "ユーザーの入力値",
                 "defaultValue": ""
            }
       ]
  }
}
```

skprompt.txt の内容

```txt:skprompt.txt
あなた: コーヒーショップの店長を演じます。

---------------------------------------------

この童謡 "{{$input}}" からコーヒーショップのメニューの3つの商品名を作成します。
商品名には短い解説文を付けてください。

メニューは列挙形式で表示してください。
```

Program.cs を修正します。

```sh
$ vim Program.cs
```

ファイルの内容

<details>
<summary>コードの全体を表示する</summary>

```csharp:Program.cs
using static System.Console;
using static System.ConsoleColor;
using static System.Environment;

using Microsoft.Extensions.Logging;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Orchestration;
using Microsoft.SemanticKernel.SkillDefinition;

namespace SKApp {
    class Program {
        [Obsolete]
        static async Task Main(string[] args) {
            // get an api key.
            string? api_key = GetEnvironmentVariable("OPENAI_API_KEY");

            // create a kernel object.
            IKernel kernel = new KernelBuilder().WithLogger(new Logger()).Build();
            kernel.Config.AddOpenAIChatCompletionService(
                modelId: "gpt-3.5-turbo",
                apiKey: api_key
            );

            // import plugins from the plugins directory.
            string? plugins_directory = Path.Combine(System.IO.Directory.GetCurrentDirectory(), "PluginsDirectory");
            IDictionary<string, ISKFunction>? plugins = kernel.ImportSemanticSkillFromDirectory(
                plugins_directory, "Plugins"
            );

            // read a prompt from the console.
            ForegroundColor = Yellow;
            WriteLine("Enter a prompt: ");
            ResetColor();
            string? prompt = ReadLine();

            // get functions from the plugins and run it
            SKContext? result = await kernel.RunAsync(
                new ContextVariables(prompt),
                plugins["GetJoke"],
                plugins["GetPoem"],
                plugins["GetMenu"]
            );

            // show the result.
            ForegroundColor = Blue;
            WriteLine(result);
            ResetColor();
        }
    }
    class Logger : ILogger {
        public IDisposable BeginScope<TState>(TState state) => throw new NotImplementedException();
        public bool IsEnabled(LogLevel log_level) => true;
        public void Log<TState>(LogLevel log_level, EventId event_id, TState state, Exception? exception, Func<TState, Exception?, string> formatter) {
            ForegroundColor = Red;
            WriteLine($"## {log_level}: {formatter(state, exception)}");
            ResetColor();
        }
    }
}
```

</details>
 
以下、ポイントを説明します。

```csharp:Program.cs ※抜粋
// import plugins from the plugins directory.
string? plugins_directory = Path.Combine(System.IO.Directory.GetCurrentDirectory(), "PluginsDirectory");
IDictionary<string, ISKFunction>? plugins = kernel.ImportSemanticSkillFromDirectory(
    plugins_directory, "Plugins"
);

// 省略

// get functions from the plugins and run it
SKContext? result = await kernel.RunAsync(
    new ContextVariables(prompt),
    plugins["GetJoke"],
    plugins["GetPoem"],
    plugins["GetMenu"]
);
```

:::note info
複数のセマンティック関数を設定しています。この方法の場合では、順番に処理されていきます。
:::

実行します。

```sh
$ dotnet run
Enter a prompt:
ミッキー・マウス
```

結果(※除くログ表示)

```sh
メニュー:

1. ミッキーのモカデライト
   - ミッキーマウスの童謡にインスパイアされた、指紋が残らないモカデライトです。甘 くてクリーミーな味わいが特徴です。

2. フレンドリーグローブラテ
   - 友達に教えてあげたくなる、指紋が消えるラテです。豊かなコーヒーの香りとミルク のまろやかさが楽しめます。

3. スマイルフィンガーパンケーキ
   - 笑顔で答えるミッキーマウスの童謡をイメージした、指紋が残らないパンケーキです 。ふわふわで美味しい朝食にぴったりです。

どれもミッキーマウスの童謡に触発されたユニークな商品名です。お楽しみいただけることを願っています。
```

悪くないです。きちんとミッキー・マウス風？のメニュー名になっていると思います。

:::note info
ここまでの手順で、Semantic Kernel で複数の関数(プラグイン)を順番に使用することができました😋
:::

### 処理の途中の内容をログから確認

:::note warn
それぞれのセマンティック関数の処理の結果は、どのようになっているのでしょうか？🤔
:::

**GetJoke** セマンティック関数の出力は以下のようでした。

```sh
ミッキー・マウスのジョークですね。では、どうでしょうか？

ミッキー・マウスが友達に聞かれました。「ミッキー、なぜいつも手袋をしているの？」
ミッキーは笑顔で答えました。「実は手袋をしていると、指紋が残らないんだよ！」

どうでしたか？お楽しみいただけましたでしょうか？
```

**GetPoem** セマンティック関数の出力は以下のようでした。

```sh
ミッキー・マウスの童謡

ミッキー、手袋の謎
指紋残らぬ手袋の謎
友達に聞かれて
笑顔で答える

ミッキー、ミッキー
なぜ手袋をしているのか
ミッキー、ミッキー
指紋が残らないんだよ

手袋をしていると
指紋が消えるのさ
友達に教えてあげる
笑顔で答える

ミッキー、ミッキー
なぜ手袋をしているのか
ミッキー、ミッキー
指紋が残らないんだよ

楽しんでいただけましたでしょうか？童謡に変換することで、より楽しい雰囲気になりましたね。もし他にも童謡に変換してほしいジョークやお題があれば、お知らせください。お手伝いいたします。
```

このように、Semantic Kernel を使用することにより、複数回の API コールから結果を導き出していることが理解できます。

:::note info
ここまでの手順で、Semantic Kernel で複数プラグインを連携させる方法を学ぶことが出来ました！😆
:::

## まとめ

+ C# から Semantic Kernel ライブラリを使用することができました。
+ Semantic Kernel は煩雑になりがちなプロンプト処理を、フレームワークに準じた形式で実装できるので、より拡張性の高い AI 連携プログラムが開発できると思います。

どうでしたか？  Window 11 の WSL Ubuntu に、.NET で OpenAI の開発環境を手軽に構築することができます、ぜひお試しください。今後も OpenAI、C# / .NET の開発トピックなどを紹介していきますので、ぜひお楽しみにしてください。

## 推奨コンテンツ

https://qiita.com/studio_meowtoon/items/875583d556552ff14d4d

## 参考資料

https://learn.microsoft.com/en-us/semantic-kernel/overview/