Semantic Kernelのファイル検索のチュートリアルをやってみる

## 概要
Semantic KernelのOpenAI Assistantを使ったファイル検索のチュートリアルをやってみる。

公式に書かれている手順がやや分かりにくく、学習させるファイルが日本語対応されていないため、方法を簡単にまとめる。技術的詳細が知りたい方や英語で問題ない方は公式をご確認ください。

[AIアシスタントエージェントを開く ファイル検索](https://learn.microsoft.com/ja-jp/semantic-kernel/frameworks/agent/examples/example-assistant-search?pivots=programming-language-csharp)


#### Semantic Kernelとは？
> セマンティック カーネルは、AI エージェントを簡単に構築し、最新の AI モデルを C#、Python、または Java コードベースに統合できる、軽量のオープンソース開発キットです。

[セマンティック カーネルの概要](https://learn.microsoft.com/ja-jp/semantic-kernel/overview/?form=MG0AV3)

#### OpenAI Assistant とは？
> OpenAI Assistant APIは、開発者がアプリケーション内で強力なAIアシスタントを簡単に構築できるようにするAPIです。このAPIは、会話履歴の管理を不要にし、Code InterpreterやFile SearchなどのOpenAIホストツールへのアクセスを追加します。また、サードパーティツールの関数呼び出しもサポートしています。

[Assistants API (v2) FAQ](https://help.openai.com/en/articles/8550641-assistants-api-v2-faq?form=MG0AV3)

## デモ
「桃太郎」「浦島太郎」「一寸法師」のテキストファイルを読み込ませて、回答してもらう。
![semantic.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/581044/f98240a0-a82b-41e1-afed-220fa8df3e7e.gif)


## 環境・準備するもの
C#
Visual Studio
OpenAIのAPIキー
※公式ではAzureOpenAIを使っている。どちらでもOK。

## 手順
### 1. アプリで必要な設定を準備する
#### コンソールアプリを作成する
Visual Studioを開き、新しいプロジェクトの作成＞コンソールアプリ を作成する。プロジェクト名は任意（例として「SemanticKernelConsole」で作成）

#### 必要なpackageを追加する
必要なpackageをNuget or コマンドから追加。
コマンドで追加する場合、表示＞ターミナル＞開発者用PowerShell から以下のコマンドで追加する。

```
dotnet add [プロジェクト名] package Azure.Identity
dotnet add [プロジェクト名] package Microsoft.Extensions.Configuration
dotnet add [プロジェクト名] package Microsoft.Extensions.Configuration.Binder
dotnet add [プロジェクト名] package Microsoft.Extensions.Configuration.UserSecrets
dotnet add [プロジェクト名] package Microsoft.Extensions.Configuration.EnvironmentVariables
dotnet add [プロジェクト名] package Microsoft.SemanticKernel
dotnet add [プロジェクト名] package Microsoft.SemanticKernel.Agents.OpenAI --prerelease
```
例）
```
dotnet add SemanticKernelConsole package Azure.Identity
```

#### リリース前のpackageに対して警告が出ないように対応
csprojファイルを開き、PropertyGroup内に以下の<NoWarn>タグを追加する。
```
<PropertyGroup>
  // 既存のProperyGroup項目
  <NoWarn>$(NoWarn);CA2007;IDE1006;SKEXP0001;SKEXP0110;OPENAI001</NoWarn>
</PropertyGroup>
```

#### APIキーをUserSecretに追加する
APIキーとChatモデルをUserSecretとして追加する。UserSecretは暗号化されているため、漏洩のリスクを防ぐことができる。

```
dotnet user-secrets init --project [プロジェクト名]
dotnet user-secrets set "OpenAISettings:ApiKey" "<api-key>" --project [プロジェクト名]
dotnet user-secrets set "OpenAISettings:ChatModel" "gpt-4o" --project [プロジェクト名]
```

UserSecretの詳細は、[ASP.NET CoreにおけるUserSecretを使用した設定情報の保存](https://shuhelohelo.hatenablog.com/entry/2019/11/13/101328)を参考。

#### 検索用のテキストファイルを追加する
「[桃太郎](https://www.aozora.gr.jp/cards/000329/files/18376_12100.html)」「[浦島太郎](https://www.aozora.gr.jp/cards/000329/files/3390_33153.html)」「[一寸法師](https://www.aozora.gr.jp/cards/001779/files/58053_62875.html)」の内容をテキストファイルとしてダウンロードしておく。

プロジェクトを右クリック＞追加＞既存の項目でテキストファイルを追加。
ファイルを右クリック＞プロパティで出力ディレクトリにコピーを「常にコピーする」を選択して保存する。



#### OpenAPIの接続情報を利用するためのクラスを追加する
Settings.csを追加し、下記のファイルをそのままコピーする。
※ OpenAIのキーを使う場合向けに修正しているため、AzureOpenAIを使っている人は公式のSetting.csを参照すること。

```cs
namespace SemanticKernelConsole
{
    public class Settings
    {
        private readonly IConfigurationRoot configRoot;

        private OpenAISettings openAI;
        public OpenAISettings OpenAI => this.openAI ??= this.GetSettings<Settings.OpenAISettings>();

        public class OpenAISettings
        {
            public string ChatModel { get; set; } = string.Empty;
            public string ApiKey { get; set; } = string.Empty;
        }

        public TSettings GetSettings<TSettings>() =>
            this.configRoot.GetRequiredSection(typeof(TSettings).Name).Get<TSettings>()!;

        public Settings()
        {
            this.configRoot =
                new ConfigurationBuilder()
                    .AddEnvironmentVariables()
                    .AddUserSecrets(Assembly.GetExecutingAssembly(), optional: true)
                    .Build();
        }
    }
}

```
※usingは省略している。

### 2. プログラムを実装する
#### ①セットアップ
プログラム起動後のセットアップとして、以下の処理を行う。
・OpenAIのAPIキーを元にOpenAIClientProviderを定義
・OpenAIのVectorStore(ファイルの内容をベクトル形式で保存する領域)の作成
・ファイルをOpenAIのストレージにアップロード
・アップロードしたファイルをVectorStoreに追加

```csharp
Settings settings = new();
OpenAIClientProvider clientProvider =
    OpenAIClientProvider.ForOpenAI(
        new ApiKeyCredential(settings.OpenAI.ApiKey)
        );

// VectorStore作成
Console.WriteLine("Creating store...");
VectorStoreClient storeClient = clientProvider.Client.GetVectorStoreClient();
CreateVectorStoreOperation operation = await storeClient.CreateVectorStoreAsync(waitUntilCompleted: true);
string storeId = operation.VectorStoreId;

// ファイルアップロード
Dictionary<string, OpenAIFile> fileReferences = [];

Console.WriteLine("Uploading files...");
OpenAIFileClient fileClient = clientProvider.Client.GetOpenAIFileClient();
foreach (string fileName in _fileNames)
{
    OpenAIFile fileInfo = await fileClient.UploadFileAsync(fileName, FileUploadPurpose.Assistants);
    // VectorStoreにファイルを追加
    await storeClient.AddFileToVectorStoreAsync(storeId, fileInfo.Id, waitUntilCompleted: true);
    fileReferences.Add(fileInfo.Id, fileInfo);
}
```

#### ②エージェントの定義
AIエージェントを定義する。
エージェントに対する命令を日本語で定義する。

```csharp
Console.WriteLine("Defining agent...");
OpenAIAssistantAgent agent =
    await OpenAIAssistantAgent.CreateAsync(
        clientProvider,
        new OpenAIAssistantDefinition(settings.OpenAI.ChatModel)
        {
            Name = "SampleAssistantAgent",
                Instructions =
                    """
            このドキュメントストアは物語を含んでいます。
            ユーザへの回答には常にこのドキュメントを分析して答えてください。
            決してこのドキュメントに含まない知識に頼らないでください。
            回答は常にマークダウン形式を使用してください。
            """,
            EnableFileSearch = true,
            VectorStoreId = storeId,
        },
        new Kernel());
Console.WriteLine("Creating thread...");
string threadId = await agent.CreateThreadAsync();

Console.WriteLine("Ready!");
```

#### ③ チャットを送信する

```csharp
Func<string, string> ReplaceUnicodeBrackets = content =>
    content?.Replace('【', '[').Replace('】', ']');

try
{
    bool isComplete = false;
    do
    {
        Console.WriteLine();
        Console.Write("> ");
        string input = Console.ReadLine();
        if (string.IsNullOrWhiteSpace(input))
        {
            continue;
        }
        if (input.Trim().Equals("EXIT", StringComparison.OrdinalIgnoreCase))
        {
            isComplete = true;
            break;
        }

        await agent.AddChatMessageAsync(threadId, new ChatMessageContent(AuthorRole.User, input));
        Console.WriteLine();

        List<StreamingAnnotationContent> footnotes = [];
        await foreach (StreamingChatMessageContent chunk in agent.InvokeStreamingAsync(threadId))
        {
            footnotes.AddRange(chunk.Items.OfType<StreamingAnnotationContent>());
            Console.Write(ReplaceUnicodeBrackets(chunk.Content));
        }

        Console.WriteLine();

        // Render footnotes for captured annotations.
        if (footnotes.Count > 0)
        {
            Console.WriteLine();
            foreach (StreamingAnnotationContent footnote in footnotes)
            {
                Console.WriteLine($"#{ReplaceUnicodeBrackets(footnote.Quote)} - {fileReferences[footnote.FileId!].Filename} (Index: {footnote.StartIndex} - {footnote.EndIndex})");
            }
        }
    } while (!isComplete);
}
finally
{
    Console.WriteLine();
    Console.WriteLine("Cleaning-up...");
    // スレッド、VectorStoreId、ファイルは残ってしまうので、削除する
    await Task.WhenAll(
        [
            agent.DeleteThreadAsync(threadId),
            agent.DeleteAsync(),
            storeClient.DeleteVectorStoreAsync(storeId),
            ..fileReferences.Select(fileReference => fileClient.DeleteFileAsync(fileReference.Key))
        ]);
}
```

### program.csの内容まとめ（コピペ）
```csharp
class Program
{
    static async Task Main(string[] args)
    {
        // ① セットアップ
        // キー設定
        Settings settings = new();
        OpenAIClientProvider clientProvider =
            OpenAIClientProvider.ForOpenAI(
                new ApiKeyCredential(settings.OpenAI.ApiKey)
                );

        // VectorStore作成
        Console.WriteLine("Creating store...");
        VectorStoreClient storeClient = clientProvider.Client.GetVectorStoreClient();
        CreateVectorStoreOperation operation = await storeClient.CreateVectorStoreAsync(waitUntilCompleted: true);
        string storeId = operation.VectorStoreId;

        // ファイルアップロード
        Dictionary<string, OpenAIFile> fileReferences = [];

        Console.WriteLine("Uploading files...");
        OpenAIFileClient fileClient = clientProvider.Client.GetOpenAIFileClient();
        foreach (string fileName in _fileNames)
        {
            OpenAIFile fileInfo = await fileClient.UploadFileAsync(fileName, FileUploadPurpose.Assistants);
            await storeClient.AddFileToVectorStoreAsync(storeId, fileInfo.Id, waitUntilCompleted: true);
            fileReferences.Add(fileInfo.Id, fileInfo);
        }

        // ② エージェント定義
        Console.WriteLine("Defining agent...");
        OpenAIAssistantAgent agent =
            await OpenAIAssistantAgent.CreateAsync(
                clientProvider,
                new OpenAIAssistantDefinition(settings.OpenAI.ChatModel)
                {
                    Name = "SampleAssistantAgent",
                    Instructions =
                            """
                    このドキュメントストアは物語を含んでいます。
                    ユーザへの回答には常にこのドキュメントを分析して答えてください。
                    決してこのドキュメントに含まない知識に頼らないでください。
                    回答は常にマークダウン形式を使用してください。
                    回答は短く質問されたことだけを答えてください。
                    """,
                    EnableFileSearch = true,
                    VectorStoreId = storeId,
                },
                new Kernel());
        Console.WriteLine("Creating thread...");
        string threadId = await agent.CreateThreadAsync();

        Console.WriteLine("Ready!");

        Func<string, string> ReplaceUnicodeBrackets = content =>
            content?.Replace('【', '[').Replace('】', ']');
        // ③ チャット
        try
        {
            bool isComplete = false;
            do
            {
                Console.WriteLine();
                Console.Write("> ");
                string input = Console.ReadLine();
                if (string.IsNullOrWhiteSpace(input))
                {
                    continue;
                }
                if (input.Trim().Equals("EXIT", StringComparison.OrdinalIgnoreCase))
                {
                    isComplete = true;
                    break;
                }

                await agent.AddChatMessageAsync(threadId, new ChatMessageContent(AuthorRole.User, input));
                Console.WriteLine();

                List<StreamingAnnotationContent> footnotes = [];
                await foreach (StreamingChatMessageContent chunk in agent.InvokeStreamingAsync(threadId))
                {
                    footnotes.AddRange(chunk.Items.OfType<StreamingAnnotationContent>());
                    Console.Write(ReplaceUnicodeBrackets(chunk.Content));
                }

                Console.WriteLine();

                // Render footnotes for captured annotations.
                if (footnotes.Count > 0)
                {
                    Console.WriteLine();
                    foreach (StreamingAnnotationContent footnote in footnotes)
                    {
                        Console.WriteLine($"#{ReplaceUnicodeBrackets(footnote.Quote)} - {fileReferences[footnote.FileId!].Filename} (Index: {footnote.StartIndex} - {footnote.EndIndex})");
                    }
                }
            } while (!isComplete);
        }
        finally
        {
            Console.WriteLine();
            Console.WriteLine("Cleaning-up...");
            await Task.WhenAll(
                [
                    agent.DeleteThreadAsync(threadId),
                    agent.DeleteAsync(),
                    storeClient.DeleteVectorStoreAsync(storeId),
                    ..fileReferences.Select(fileReference => fileClient.DeleteFileAsync(fileReference.Key))
                ]);
        }
    }

    private static readonly string[] _fileNames =
    [
        "桃太郎.txt",
        "浦島太郎.txt",
        "一寸法師.txt",
    ];
}
```