C# で Semantic Kernel：AI で作曲する (複数プラグインを連携させる)

# C# で Semantic Kernel：AI で作曲する (複数プラグインを連携させる)

こんにちは、@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/d14a19a8-4213-e921-75a0-969fde46fe73.png)

## 実現すること

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

:::note warn
この記事では、**従来のプログラム**では**難しい**とされる**クリエイティブな作業**である作曲において、**AI がどのように役立つか**を実際に試してみることを目的の一つとしています。
:::

:::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 のシンプルな使い方

こちらの前回の記事を参照頂けます。

https://qiita.com/studio_meowtoon/items/1fe094f4f1a0556045ae

## 今回、どのように AI にアプローチするのか？

以前に紹介した記事の内容を、もう一度 Semantic Kernel を利用して新たに考え直してみます。

https://qiita.com/studio_meowtoon/items/30fbf924e1c8681863ec

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

|プラグイン|関数|タイプ|概要|
|:--|:--|:--|:--|
|**SemanticPlugins**|**GetFeel**|セマンティック|ユーザーの入力値から気分を分析して1～7の数値に変換する。|
|**SemanticPlugins**|**GetNumbers**|セマンティック|1～8までの数値をランダムに32個並べる。|
|**NativePlugins**|**CreateExample**|ネイティブ|例となる32個の数値を生成する。|
|**NativePlugins**|**ConvertMode**|ネイティブ|気分を分析した1～7の数値に対応したチャーチモードを取得する。|
|**NativePlugins**|**ConvertNote**|ネイティブ|32個の数値をチャーチモードに準じた MIDI ノート番号に変換する。|

:::note warn
今回はユーザーの入力値により、さわやかな曲、寂しい曲など AI に**ユーザーの気分を分析させ**て作曲を行います。※あくまでも旋律の断片です…😅
:::

## Semantic Kernel で作曲する手順

### OpenAI API のキーを設定します

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

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

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

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

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

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

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

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

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

```sh
$ cd ~/tmp/SKMIDIApp
$ 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
プロジェクト 'SKMIDIApp' に次のパッケージ参照が含まれています
   [net7.0]:
   最上位レベル パッケージ                    要求済み                    解決済み
   > Microsoft.SemanticKernel      0.17.230629.1-preview   0.17.230629.1-preview
```

### プラグインの作成

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

```sh
├── PluginsDirectory
│   ├── NativePlugins.cs
│   └── SemanticPlugins
│       ├── GetFeel
│       │   ├── config.json
│       │   └── skprompt.txt
│       └── GetNumbers
│           ├── config.json
│           └── skprompt.txt
```

:::note warn
今回の例では各プラグインは後述する **context** オブジェクトを介して連携動作します。
:::

```mermaid
graph LR;
    CreateExample-->context;
    Input-->GetFeel;
    context-->GetNumbers;
    GetNumbers-->context;
    GetFeel-->context;
    context-->GetMode;
    GetMode-->context;
    context-->GetNote;
    GetNote-->Output;
```

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

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": "2"
              }
         ]
    }
}
```

skprompt.txt の内容

```txt:skprompt.txt
あなた: 心理学者を演じます。
ユーザー: {{$input}}

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

ユーザーの入力値から気分を分析して1～7の数値に変換します。
見出しや解説文は必要ありません、出力は数値のみとします。

・ {{$input}} のような気分は、以下のどの数値がふさわしいですか？

1:爽やか
2:楽しい
3:興奮
4:寂しい
5:悲しい
6:怠惰
7:困惑

・出力
n
```

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

config.json の内容

```json:config.json
{
    "schema": 1,
    "type": "completion",
    "description": "1～8までの数値をお手本を参考にに32個並べます。",
    "completion": {
         "max_tokens": 500,
         "temperature": 0.0,
         "top_p": 0.0,
         "presence_penalty": 0.0,
         "frequency_penalty": 0.0
    }
}
```

skprompt.txt の内容

```txt:skprompt.txt
あなた: 作曲家を演じます。

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

・お手本
{{$example}}

1～8までの数値を、お手本を参考にしてスペースなしのカンマ区切りで旋律のように32個並べます。
見出しや解説文は必要ありません、出力は数値とカンマのみとします。
数値は 1,2,3,4,5,6,7,8 のような単純な並び方ではありません。
数値は必ず32個必要です。

・出力例
{{$example}}
```

#### ネイティブ関数

NativePlugins.cs を作成します。

```sh
$ vim PluginsDirectory/NativePlugins.cs
```

ファイルの内容

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

```csharp:PluginsDirectory/NativePlugins.cs
using static System.Linq.Enumerable;

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

namespace SKMIDIApp {
    [Obsolete]
    public class NativePlugins {
        [SKFunction("例となる32個の数値を生成する。")]
        public string CreateExample(SKContext context) {
            int seed = (int) DateTime.Now.Ticks;
            Random random = new(seed);
            int[] numbers = Range(1, 8).ToArray();
            int[] random_numbers = numbers.SelectMany(x => Repeat(x, 4)).OrderBy(x => random.Next()).ToArray();
            return string.Join(",", random_numbers.Select(x => x.ToString()));
        }
        [SKFunction("気分を分析した1～7の数値に対応したチャーチモードを取得する。")]
        [SKFunctionContextParameter(Name = "feel", Description = "気分を分析した1～7の数値が提供されます。")]
        public string ConvertMode(SKContext context) {
            int number = int.Parse(context["feel"]);
            return number switch {
                1 => "Lydian", 2 => "Ionian", 3 => "Mixolydian", 4 => "Dorian", 5 => "Aeolian", 6 => "Phrygian", 7 => "Locrian",
                _ => throw new ArgumentOutOfRangeException(nameof(number))
            };
        }
        [SKFunction("32個の数値をチャーチモードに準じた MIDI ノート番号に変換する。")]
        [SKFunctionContextParameter(Name = "mode", Description = "チャーチモードを表す文字列が提供されます。")]
        [SKFunctionContextParameter(Name = "numbers", Description = "32個のランダムな数値が提供されます。")]
        public string ConvertNote(SKContext context) {
            string mode = context["mode"];
            int[] numbers = context["numbers"].Split(',').Select(int.Parse).ToArray();
            return string.Join(",", convertNumbersToModeNote(mode, numbers).Select(x => x.ToString()));
        }
        static int[] convertNumbersToModeNote(string mode, int[] numbers) {
            return mode switch {
                "Lydian" => numbers.Select(x => convertLydianNote(x)).ToArray(),
                "Ionian" => numbers.Select(x => convertIonianNote(x)).ToArray(),
                "Mixolydian" => numbers.Select(x => convertMixolydianNote(x)).ToArray(),
                "Dorian" => numbers.Select(x => convertDorianNote(x)).ToArray(),
                "Aeolian" => numbers.Select(x => convertAeolianNote(x)).ToArray(),
                "Phrygian" => numbers.Select(x => convertPhrygianNote(x)).ToArray(),
                "Locrian" =>numbers.Select(x => convertLocrianNote(x)).ToArray(),
                _ => throw new ArgumentOutOfRangeException($"Invalid mode: {mode}")
            };
        }
        static int convertLydianNote(int number) {
            return number switch {
                1 => 60, 2 => 62, 3 => 64, 4 => 66, 5 => 67, 6 => 69, 7 => 71, 8 => 72,
                _ => throw new ArgumentOutOfRangeException($"Invalid number: {number}"),
            };
        }
        static int convertIonianNote(int number) {
            return number switch {
                1 => 60, 2 => 62, 3 => 64, 4 => 65, 5 => 67, 6 => 69, 7 => 71, 8 => 72,
                _ => throw new ArgumentOutOfRangeException($"Invalid number: {number}"),
            };
        }
        static int convertMixolydianNote(int number) {
            return number switch {
                1 => 60, 2 => 62, 3 => 64, 4 => 65, 5 => 67, 6 => 69, 7 => 70, 8 => 72,
                _ => throw new ArgumentOutOfRangeException($"Invalid number: {number}"),
            };
        }
        static int convertDorianNote(int number) {
            return number switch {
                1 => 60, 2 => 62, 3 => 63, 4 => 65, 5 => 67, 6 => 69, 7 => 70, 8 => 72,
                _ => throw new ArgumentOutOfRangeException($"Invalid number: {number}"),
            };
        }
        static int convertAeolianNote(int number) {
            return number switch {
                1 => 60, 2 => 62, 3 => 63, 4 => 65, 5 => 67, 6 => 68, 7 => 70, 8 => 72,
                _ => throw new ArgumentOutOfRangeException($"Invalid number: {number}"),
            };
        }
        static int convertPhrygianNote(int number) {
            return number switch {
                1 => 60, 2 => 61, 3 => 63, 4 => 65, 5 => 67, 6 => 68, 7 => 70, 8 => 72,
                _ => throw new ArgumentOutOfRangeException($"Invalid number: {number}"),
            };
        }
        static int convertLocrianNote(int number) {
            return number switch {
                1 => 60, 2 => 61, 3 => 63, 4 => 65, 5 => 66, 6 => 68, 7 => 70, 8 => 72,
                _ => throw new ArgumentOutOfRangeException($"Invalid number: {number}"),
            };
        }
    }
}
```

</details>

以下、ポイントを説明します。

```csharp:PluginsDirectory/NativePlugins.cs ※抜粋
[SKFunction("例となる32個の数値を生成する。")]
public string CreateExample(SKContext context) {
    int seed = (int) DateTime.Now.Ticks;
    Random random = new(seed);
    int[] numbers = Range(1, 8).ToArray();
    int[] random_numbers = numbers.SelectMany(x => Repeat(x, 4)).OrderBy(x => random.Next()).ToArray();
    return string.Join(",", random_numbers.Select(x => x.ToString()));
}
```

:::note info
ネイティブ関数 **CreateExample** を定義しています。この関数の中では引数 context は使用していません。
:::

```csharp:PluginsDirectory/NativePlugins.cs ※抜粋
[SKFunction("気分を分析した1～7の数値に対応したチャーチモードを取得する。")]
[SKFunctionContextParameter(Name = "feel", Description = "気分を分析した1～7の数値が提供されます。")]
public string ConvertMode(SKContext context) {
    int number = int.Parse(context["feel"]);
    return number switch {
        1 => "Lydian", 2 => "Ionian", 3 => "Mixolydian", 4 => "Dorian", 5 => "Aeolian", 6 => "Phrygian", 7 => "Locrian",
        _ => throw new ArgumentOutOfRangeException(nameof(number))
    };
}
```

:::note info
ネイティブ関数 **ConvertMode** を定義しています。引数 context からパラメータを取得しています。
:::

```csharp:PluginsDirectory/NativePlugins.cs ※抜粋
[SKFunction("32個の数値をチャーチモードに準じた MIDI ノート番号に変換する。")]
[SKFunctionContextParameter(Name = "mode", Description = "チャーチモードを表す文字列が提供されます。")]
[SKFunctionContextParameter(Name = "numbers", Description = "32個のランダムな数値が提供されます。")]
public string ConvertNote(SKContext context) {
    string mode = context["mode"];
    int[] numbers = context["numbers"].Split(',').Select(int.Parse).ToArray();
    return string.Join(",", convertNumbersToModeNote(mode, numbers).Select(x => x.ToString()));
}
```

:::note info
ネイティブ関数 **ConvertNote** を定義しています。引数 context からパラメータを取得しています。
:::

:::note info
ここまでの手順で、プラグインとしてそれぞれ**セマンティック関数**、**ネイティブ関数**を実装することができました😋
:::

### メインプログラムの修正

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.SemanticKernel;
using Microsoft.SemanticKernel.Orchestration;
using Microsoft.SemanticKernel.SkillDefinition;

namespace SKMIDIApp {
    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 semantic plugins from the plugins directory.
            string? plugins_directory = Path.Combine(System.IO.Directory.GetCurrentDirectory(), "PluginsDirectory");
            IDictionary<string, ISKFunction>? semantic_plugins = kernel.ImportSemanticSkillFromDirectory(
                plugins_directory, "SemanticPlugins"
            );

            // import native plugins from the source code.
            IDictionary<string, ISKFunction>? native_plugins = kernel.ImportSkill(new NativePlugins(), "NativePlugins");

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

            // create a context.
            SKContext context = kernel.CreateNewContext();

            // run a native function with the context.
            SKContext example = await native_plugins["CreateExample"].InvokeAsync(context);
            context["example"] = example.ToString();
#if DEBUG
            ForegroundColor = Red;
            WriteLine($"example: {example}\n");
            ResetColor();
#endif
            // get semantic functions from the plugins and run it
            SKContext numbers = await kernel.RunAsync(semantic_plugins["GetNumbers"]);
            SKContext feel = await kernel.RunAsync(new ContextVariables(prompt), semantic_plugins["GetFeel"]);
            context["numbers"] = numbers.ToString();
            context["feel"] = feel.ToString();
#if DEBUG
            ForegroundColor = Red;
            WriteLine($"numbers: {numbers}\n");
            WriteLine($"feel: {feel}\n");
            ResetColor();
#endif
            // run a native function with the context.
            SKContext mode = await native_plugins["ConvertMode"].InvokeAsync(context);
            context["mode"] = mode.ToString();
#if DEBUG
            ForegroundColor = Red;
            WriteLine($"mode: {mode}\n");
            ResetColor();
#endif
            // run a native function with the context.
            SKContext notes = await native_plugins["ConvertNote"].InvokeAsync(context);

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

</details>

以下、ポイントを説明します。

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

// import native plugins from the source code.
IDictionary<string, ISKFunction>? native_plugins = kernel.ImportSkill(new NativePlugins(), "NativePlugins");
```

:::note info
定義した、セマンティック関数、ネイティブ関数をインポートしています。
:::

```csharp:Program.cs ※抜粋
// create a context.
SKContext context = kernel.CreateNewContext();

// run a native function with the context.
SKContext example = await native_plugins["CreateExample"].InvokeAsync(context);
context["example"] = example.ToString();

// get semantic functions from the plugins and run it
SKContext numbers = await kernel.RunAsync(semantic_plugins["GetNumbers"]);
SKContext feel = await kernel.RunAsync(new ContextVariables(prompt), semantic_plugins["GetFeel"]);
context["numbers"] = numbers.ToString();
context["feel"] = feel.ToString();

// run a native function with the context.
SKContext mode = await native_plugins["ConvertMode"].InvokeAsync(context);
context["mode"] = mode.ToString();

// run a native function with the context.
SKContext notes = await native_plugins["ConvertNote"].InvokeAsync(context);
```

:::note info
セマンティック関数、ネイティブ関数をそれぞれのタイミングで順番に実行しています。それぞれのプラグイン関数から得られた結果は、**context** に格納して後段のプラグイン処理と連携します。 
:::

### 実行してみる

コマンドラインから実行します。

```sh
$ dotnet run
Enter a prompt:
ルンルン気分で出かけよう！
```

実行結果

```sh
example: 5,4,3,3,8,2,6,3,2,2,8,1,1,1,8,6,6,4,4,5,6,7,2,4,5,1,7,8,7,5,3,7

numbers: 3,5,2,6,1,4,8,7,2,4,6,8,1,3,5,7,8,6,4,2,7,5,3,1,8,7,6,5,4,3,2,1

feel: 2

mode: Ionian

64,67,62,69,60,65,72,71,62,65,69,72,60,64,67,71,72,69,65,62,71,67,64,60,72,71,69,67,65,64,62,60
```

悪くないと思います。AI が "ルンルン気分で出かけよう！" を "楽しい" と判定出来てることにとても価値があります😆

:::note info
ここまでの手順で、Semantic Kernel からユーザーの入力に合致した気分の音楽(※旋律の断片…)を作成することができました😋
:::

### MIDI ファイルとして出力

:::note warn
今回の記事で得られる MIDI ノート番号から実際に再生できる MIDI ファイルを作成するには、以下の以前の記事を参照頂けます。
:::

https://qiita.com/studio_meowtoon/items/30fbf924e1c8681863ec

## まとめ

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

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

## 参考資料

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