Semantic Kernelで社内向けAIアシスタントを作った

こんにちは。

テックリードのTerukiです。

世間ではAIが大変流行っていますが、社内でも便利に使えるようなAIアシスタントが欲しくなっているところです。

Azure OpenAIは契約済みですが、これだけあってもしょうがないのでずっと前から気になっていたSemantic Kernelというフレームワークを使って社内向けAIアシスタントを作ってみます。

# Semantic Kernel

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

>Semantic Kernel is a lightweight, open-source development kit that lets you easily build AI agents and integrate the latest AI models into your C#, Python, or Java codebase. It serves as an efficient middleware that enables rapid delivery of enterprise-grade solutions.

軽くてオープンソースのAIエージェントやモデルを簡単に組み込めるエンタープライズグレードのソリューションである、くらいでしょうか。

Microsoftの@okazukiさんの記事は結構読み込んでいるつもりですが、実際に触るのは今回が初めてなのであんまりよく分かっていません。

https://zenn.dev/microsoft/articles/semantic-kernel-1

# Slackに繋いでみる

単にSlackでGPTとやり取りできるできるだけでは劣化版ChatGPTなので社内特有の何かができるようなものにしたいところです。

ただ、それをやる前にまずはSlackと繋いでみます。

いつ追加されたのか不明ですが、SlackアプリにAIエージェント用の機能が追加されています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3192685/7bd997e0-a69b-4bca-a095-8ccca853b901.png)

これを有効にするとSlack上にいつでもAIエージェントを呼び出せるボタンが追加できます。

イメージ
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3192685/34d0812a-a7f2-4e26-8f81-c339a7f74f7a.png)

これはこれで便利ですね。

これだけでは物足りないので、Notionにある情報を取りに行かせてみます。
NotionにはFAQが保存されているとします。

```cs
var kernelBuilder = Kernel.CreateBuilder()
    .AddAzureOpenAIChatCompletion(Configuration["Azure:OpenAI:DeploymentName"]!,
        Configuration["Azure:OpenAI:Endpoint"]!, Configuration["Azure:OpenAI:ApiKey"]!);
    
var kernel = kernelBuilder.Build();

// INotionServiceを解決するためにアプリのIServiceProviderを指定しておく
kernel.Plugins.AddFromType<NotionPlugin>(null, ServiceProvider);

var agent = new ChatCompletionAgent {
    Name = "Agent",
    Instructions = """
        質問への応答や回答を考えて返答してください。
        FAQはNotionに保存されています。
        """,
    InstructionsRole = AuthorRole.Assistant,
    Kernel = kernel,
    Arguments = new(new AzureOpenAIPromptExecutionSettings {
        FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(),
    }),
};

var thread = new ChatHistoryAgentThread();

// Agent を呼び出し
await foreach (var agentResult in agent.InvokeAsync(new ChatMessageContent(AuthorRole.User, text), thread)) {
    // ~~
    // agentResult.Message.ContentをSlackに投稿
}
```

NotionPluginのイメージはこんな感じです。

```cs
public class NotionPlugin(INotionService NotionService) {

    [KernelFunction]
    [Description("FAQを取得する。")]
    public async Task<string[]> QueryFaqAsync() {

        var response = await NotionService.QueryDatabaseAsync("NotionデータベースのID").ConfigureAwait(false);

        return response.Where(w => w["有効"] == "true")
            .Select(s => $"""
            タイトル：{s["タイトル"]}
            分類：{s["分類"]}
            内容：
            {s["内容"]}
            """).ToArray();
    }
}
```

NotionServiceが実際にNotionのAPIを叩いているところです。
有効のチェックボックスが入っているもののみ絞るようにしてみました。
（本来はNotion APIのパラメータとして渡すべきではあります）

Notionデータベースにはこんな感じでデータを入れてみました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3192685/2fb327bd-6cc6-4bf2-b7fc-c4f920d76d66.png)

パースが大変なのでページ本文には何も書かずにプロパティに内容を書いてしまうのが良いです。

結果がこちら。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3192685/28b61218-9a94-4b23-9129-9948da42904f.png)

ちゃんと内容を汲み取って返信してきていますね！

ただ、この実装ではSlackのスレッドの文脈を全く覚えていないのでさすがにそこは覚えて欲しいところです。

ということでChatHistoryをIDistributedCacheに入れて処理を終えた後に保存するようにします。

SlackはスレッドIDというタイムスタンプっぽいIDを持っているのでそれを使うと良さそうです。
（厳密にやるならチャンネルIDとセットにしたほうが良さそうです。）


```cs
var kernelBuilder = Kernel.CreateBuilder()
    .AddAzureOpenAIChatCompletion(Configuration["Azure:OpenAI:DeploymentName"]!,
        Configuration["Azure:OpenAI:Endpoint"]!, Configuration["Azure:OpenAI:ApiKey"]!);
    
var kernel = kernelBuilder.Build();

kernel.Plugins.AddFromType<NotionPlugin>(null, ServiceProvider);

var agent = new ChatCompletionAgent {
    Name = "Agent",
    Instructions = $"""
        質問への応答や回答を考えて返答してください。あまり堅すぎないトーンにすること。
        返信を考える時はユーザーのステータス、メッセージ履歴、FAQを必ず参照し、
        {(user is not null ? "\nユーザーID: " + user.ScreenId : "")}
        """.Trim(),
    InstructionsRole = AuthorRole.Assistant,
    Kernel = kernel,
    Arguments = new(new AzureOpenAIPromptExecutionSettings {
        FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(),
    }),
};

var cacheHistory = await Cache.GetStringAsync(threadId).ConfigureAwait(false);
var chatHistory = cacheHistory is not null ? JsonSerializer.Deserialize<ChatHistory>(cacheHistory) : null;

// Thread を作成
var thread = chatHistory is not null ? new ChatHistoryAgentThread(chatHistory) : new ChatHistoryAgentThread();

// Agent を呼び出し
await foreach (var agentResult in agent.InvokeAsync(new ChatMessageContent(AuthorRole.User, text), thread)) {
    // ~~
    // agentResult.Message.ContentをSlackに投稿
}

// ChatHistoryをキャッシュに保存
var json = JsonSerializer.Serialize(thread.ChatHistory);
await Cache.SetStringAsync(threadId, json, new DistributedCacheEntryOptions {
    AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30)
}).ConfigureAwait(false);
```

これで試してみます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3192685/49d17c76-2955-4dea-b220-fe060e24db05.png)

1つ目のメッセージではネットの情報から回答をしていますが、2つ目のメッセージはFAQを参照していますね！

私の2つ目のメッセージにOh my teethという文字列が含まれていないのでしっかり文脈を認識できていることが分かります。

---

まだまだ使い始めたばかりですが、しっかり作り込んだらめちゃくちゃ便利なAIアシスタントが作れそうです。
ちなみに今回はGPT4.1を使用しています。

インターフェースがSlackなのでやろうと思えば押せるボタンを追加して追加処理をさせるみたいなこともできそうです。

今後もどんどんアップデートしていこうと思っています。

# Oh my teethについて
[Oh my teeth](https://www.oh-my-teeth.com/?tags=qiita)では未来の歯科体験を創るために日々活動しています。

Techチームではより良いユーザー体験を提供するべく、Webフロントエンドからバックエンド、スマホアプリに機械学習モデルなど、さまざまなプロダクトを開発しています。

一緒に未来の歯科体験を創りませんか？興味がある方は是非こちらを確認してください。

https://oh.careers/

カジュアル面談も可能なので気軽に応募してみてください！

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3192685/0138b492-e02b-852e-61fa-fc065b53d285.png" width="450" />