Azure Bot × Teams × Azure OpenAIの環境構築② ~Azure OpenAIの組み込み~

# まえがき

前回の続きです。Teams × Azure OpenAIでチャットボットを作っていきます。

https://qiita.com/sakue_103/items/c88f6dc19e0dfb9cc842

# Prerequisites

（前回までの手順でオウム返しが出来ている前提で話が進みます:bow_tone1:）

前回と基本的に必要なものを変わりませんが、今回はAzure OpenAIを使用します。
Azure Portalから作成 & モデルデプロイを済ませておきます。[参考](https://qiita.com/sakue_103/items/582198236c59d4d8147c)


- [Microsft開発アカウント](https://developer.microsoft.com/en-us/microsoft-365/dev-program) (無料登録可能)
- VSCode
- Azureアカウント
- Azure OpenAI (本家OpenAIでも大丈夫です)


# 動作イメージと構成

まずは、動作イメージです。（MAX_TOKENSで回答が途切れていますが気にしないでください、、）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/7fc920a5-24c3-db76-cd5e-dfddaef52216.png)


次に今回の構成図です。
下記の図を見てわかる通り、TeamsやAzure OpenAIが登場するからと言って特別構成が難しくなるかと言われるとそうではないです。あくまで私の中のイメージですが、フロントエンド・バックエンドに分けるとなんとなく理解しやすいかと思います。

>フロントエンド・バックエンドを分けていますが、AppService(Bot Framework)から直接Azure OpenAIにリクエストを送信することも可能です。その場合、Container Appsは存在しないことになります。今回のこの構成では、今後の拡張機能(チャット履歴の記録やプロンプトの管理、エージェント機能など)を実装することを見越して処理を分けています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/d106000f-9b5d-16ac-75d4-59c0735ccfe0.png)


**処理の流れ**
- ユーザがTeamsからメッセージを入力して送信
- Azure Bot Serviceが受け取り、Bot Frameworkが理解できる形に変換してAppServiceに流す
- AppServiceはユーザの入力を取り出し、「 **Azure OpenAIとやり取りしたいです。** 」というリクエストを作成して送信
- Container Appsがリクエストを受け取り、Azure OpenAIとやり取りする

Azure OpenAIとやり取りした結果は逆方向に経路を辿って最終的にTeamsにたどり着きます。

# バックエンド

まずはバックエンドから作成していきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/cd044f7f-1bfb-f182-2275-334dcddeda94.png)


## Webサーバを作成

Flaskで適当なWebサーバを立てておきます。下記コードでは大きく2つの機能を提供しています。

- ```/api/chat```でPOSTリクエストを受け付ける
- 入力されたメッセージに```:)```を付与してレスポンスを返す


```py:main.py
from flask import Flask, request, jsonify, Response
from flask_restful import Resource, Api
from flask_cors import CORS
from schemas import (
    ChatRequest,
    ChatResponse,
)

# アプリケーションの設定
app = Flask(__name__)
api = Api(app)
CORS(app)

# APIリソースの定義
class Chat(Resource):
    def post(self):
        # リクエストボディからデータを取得
        req = request.get_json()

        # リクエストの形式を確認
        data = ChatRequest(**req)

        # OpenAIにリクエストを送信
        res = ChatResponse(message=data.message+":)")

        # レスポンスを返却
        return jsonify(res.dict())


# ルーティング設定
api.add_resource(Chat, "/api/chat")


if __name__ == "__main__":
    app.run(debug=True)
```

リクエストしてみます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/dd8adfb1-bee9-1990-bc9b-35af750b19e8.png)

レスポンスを確認してみます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/558a4779-b511-8980-81d8-17eda8a8237d.png)

よさそうですね。ここにAzure OpenAIに対してリクエストを投げる処理を追加すれば、ユーザの入力に対してAzure OpenAIを用いて回答する仕組みが実装できます。

## Azure OpenAIとWebサーバを繋ぐ

では、Azure OpenAIにリクエストを投げる処理を作成していきます。

>Langchainを使用していますが、OpenAI APIにリクエストを投げられればなんでもOKです。
メンテのしやすさや個人的に好きな順序は以下の通りですが、勉強がてらLangchainを使います、、。
「生のOpenAI API > Semantic Kernel > Langchain」


**ファイル構成**
今回は、以下のような構成としています。

- ```main.py```: リクエスト・レスポンスをさばくコード
- ```modules/openai_client.py```: Azure OpenAIとやり取りするためのコード


```py:main.py
import os
from dotenv import load_dotenv
from flask import Flask, request, jsonify
from flask_restful import Resource, Api
from flask_cors import CORS
from schemas import (
    ChatRequest,
    ChatResponse,
)
from modules.openai_client import chatcompletion

load_dotenv()

# アプリケーションの設定
app = Flask(__name__)
api = Api(app)
CORS(app)
app.secret_key = os.getenv("SESSION_KEY")

# APIリソースの定義
class Chat(Resource):
    def post(self):
        # リクエストボディからデータを取得
        req = request.get_json()

        # リクエストの形式を確認
        data = ChatRequest(**req)

        # OpenAIにリクエストを送信
        result = chatcompletion(data.message)
        
        # レスポンスを返却
        res = ChatResponse(message=result)
        return jsonify(res.dict())


# ルーティング設定
api.add_resource(Chat, "/api/chat")


if __name__ == "__main__":
    app.run(debug=True, host='0.0.0.0')

```


```py:modules/openai_client.py
from langchain.chat_models import AzureChatOpenAI
from langchain.schema import (
    SystemMessage,
    HumanMessage,
    AIMessage,
)

import os
from dotenv import load_dotenv
load_dotenv()

MODEL_MAX_LENGTH = 300
MAX_TOKENS = 100

chat_histroy = []

def chatcompletion(userMessage:str) -> str:
    # LLMの初期化
    chat_llm = AzureChatOpenAI(
        openai_api_type="azure",
        deployment_name=os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME"),
        openai_api_base=os.getenv("OPENAI_API_BASE"),
        openai_api_version=os.getenv("AZURE_OPENAI_API_VERSION"),
    )
   
    # メッセージを初期化
    messages = []

    # ユーザの入力トークン数を先に計算
    user_message = HumanMessage(content=userMessage)
    user_message_length = chat_llm.get_num_tokens_from_messages([user_message])

    # 履歴を反映 (新しい順に入れていく)
    if chat_histroy != []:
        chat_histroy.reverse()
        for chat in chat_histroy:
            messages.append(
                AIMessage(content=chat["ai"])
            )
            messages.append(
                HumanMessage(content=chat["user"])
            )

            # 履歴のトークン数を計算
            log_length = chat_llm.get_num_tokens_from_messages(messages)
            
            # 履歴のトークン数が最大トークン数を超えていたら、履歴を削除
            if log_length + user_message_length > MODEL_MAX_LENGTH - MAX_TOKENS:
                messages = messages[:-2] # 最も古い1ラリー分削除
                break
        
        # システムメッセージを末尾に追加
        messages.append(
            SystemMessage(content="あなたはユーザを助けるアシスタントです。")
        )

        # 古い順に戻す
        messages.reverse()
        print(messages)
    

    # ユーザの入力を追加
    messages.append(user_message)
    
    # 推論実行
    response = chat_llm(
        messages=messages,
        callbacks=[],
        max_tokens=MAX_TOKENS,
    )

    # 履歴に追加
    chat_histroy.append({"user": userMessage, "ai": response.content})
    

    return response.content

```

やっていることは単純で、「 **ユーザの入力を受け取り、Azure OpenAIに投げる** 」という処理です。会話履歴の保持をゴニョニョしていますが、今回はTeams×Azure OpenAIのざっくりとした全体像の紹介に留めたいので、触れないでおきます。

では、リクエストを投げてレスポンスを確認してみます。

「リクエスト」
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/4d2b3fc4-c185-8fb1-d931-e9b81d4f0e5b.png)

「レスポンス」
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/69c9fc45-36fc-d9cc-6fa7-b8b91309af5c.png)

MAX_TOKENSによって回答が途中が途切れていますがよさそうですね


## Container AppsにWebサーバをデプロイする

詳細な説明は割愛しますが、Container AppsというAzureサービス上に先ほど作成したWebサーバをデプロイします。
(詳細が気になる方はGithubのリポジトリをご参照ください。)

https://github.com/is0356xi/azure-bot-backend/tree/main

- 先ほど作成したWebサーバのコードを基にDockerイメージを作成
- Container AppsやAzure Container Registryなどのリソース群を作成
- Azure Container Registryにイメージをpush
- Container AppsでWebサーバ起動


上記手順で作成されたContainer AppsをAzure Portal上から確認してみます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/839eeee5-c280-1f55-e621-80d504242d11.png)

小さくて見にくいですが、**アプリケーションURL**にリクエストを送信すればOKです。
リクエストを送信してレスポンスを確認してみます。

「リクエスト：先ほどと同じ内容をContainer Appsに向けて送信」
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/8763a9d6-70c2-0ded-9d1c-d3419b84b6ab.png)

「レスポンス」
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/e7cf0e39-d76c-f383-8d45-ab7c3b3f02cf.png)

ちゃんと返ってきますね、よさそうです。あとは、AppServiceのリクエストの向き先をContainer Appsに変更すれば、**Teams → Azure Bot Service → AppService→Container Apps → Azure OpenAI**という流れが完成します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/2571f8e8-0d62-87e4-49d5-d2aa22dcabc4.png)

# フロントエンド (Teams SDK)

## ソースコード修正

前回作成したものを少しだけ修正します。
前回はオウム返しでしたので、その部分を先ほど作成したバックエンドにリクエストを投げるといった処理に変更します。
これにより、ユーザが入力したメッセージがバックエンド(=Azure OpenAI)にたどり着き、モデルからの返答を得られることになります。
返答をユーザに表示してあげれば、**最も単純なTeams × Azure OpenAIのチャットボット**が完成しますね。

**ファイル構成**
今回は以下のような構成としました。バックエンド側と似ていますね。

- ```teamsBot.ts```: リクエスト・レスポンスをさばくコード
- ```api/chat.ts```: 実際の処理内容を記述するコード。バックエンドにリクエストを送信する



```ts:teamsBot.ts
import {
  TeamsActivityHandler,
  CardFactory,
  TurnContext,
  AdaptiveCardInvokeValue,
  AdaptiveCardInvokeResponse,
  MessageFactory,
} from "botbuilder";
import rawWelcomeCard from "./adaptiveCards/welcome.json";
import rawLearnCard from "./adaptiveCards/learn.json";
import { AdaptiveCards } from "@microsoft/adaptivecards-tools";

//【追記】API呼出し時の型と処理をインポート
import {components} from "./api/types"
type chatRequest = components["schemas"]["ChatRequest"];
import chat from "./api/chat"

export interface DataInterface {
  likeCount: number;
}


export class TeamsBot extends TeamsActivityHandler {
  // record the likeCount
  likeCountObj: { likeCount: number };

  constructor() {
    super();

    this.likeCountObj = { likeCount: 0 };

    this.onMessage(async (context, next) => {
      console.log("Running with Message Activity.");

      let txt = context.activity.text;
      const removedMentionText = TurnContext.removeRecipientMention(context.activity);
      if (removedMentionText) {
        // Remove the line break
        txt = removedMentionText.toLowerCase().replace(/\n|\r/g, "").trim();
      }
      
      //【追記】API呼出し処理
      const req: chatRequest = {
        userId: context.activity.from.id,
        message: txt,
      };
      const data = await chat(req);

      //【追記】ユーザのチャット画面にレスポンスを表示
      await context.sendActivity(MessageFactory.text(data.message))

      // Trigger command by IM text
      switch (txt) {
        case "welcome": {
          const card = AdaptiveCards.declareWithoutData(rawWelcomeCard).render();
          await context.sendActivity({ attachments: [CardFactory.adaptiveCard(card)] });
          break;
        }
        case "learn": {
          this.likeCountObj.likeCount = 0;
          const card = AdaptiveCards.declare<DataInterface>(rawLearnCard).render(this.likeCountObj);
          await context.sendActivity({ attachments: [CardFactory.adaptiveCard(card)] });
          break;
        }
        /**
         * case "yourCommand": {
         *   await context.sendActivity(`Add your response here!`);
         *   break;
         * }
         */
      }

      // By calling next() you ensure that the next BotHandler is run.
      await next();
    });

    this.onMembersAdded(async (context, next) => {
      const membersAdded = context.activity.membersAdded;
      for (let cnt = 0; cnt < membersAdded.length; cnt++) {
        if (membersAdded[cnt].id) {
          const card = AdaptiveCards.declareWithoutData(rawWelcomeCard).render();
          await context.sendActivity({ attachments: [CardFactory.adaptiveCard(card)] });
          break;
        }
      }
      await next();
    });
  }

  // Invoked when an action is taken on an Adaptive Card. The Adaptive Card sends an event to the Bot and this
  // method handles that event.
  async onAdaptiveCardInvoke(
    context: TurnContext,
    invokeValue: AdaptiveCardInvokeValue
  ): Promise<AdaptiveCardInvokeResponse> {
    // The verb "userlike" is sent from the Adaptive Card defined in adaptiveCards/learn.json
    if (invokeValue.action.verb === "userlike") {
      this.likeCountObj.likeCount++;
      const card = AdaptiveCards.declare<DataInterface>(rawLearnCard).render(this.likeCountObj);
      await context.updateActivity({
        type: "message",
        id: context.activity.replyToId,
        attachments: [CardFactory.adaptiveCard(card)],
      });
      return { statusCode: 200, type: undefined, value: undefined };
    }
  }
}

```


```ts:api/chat.ts
// バックエンドのURLを環境変数から取得
const url:string = process.env.BACKEND_URL || "http://127.0.0.1:5000";

// API呼出しの型を定義
import {components} from "./types"

type ChatRequest = components["schemas"]["ChatRequest"];
type ChatResponse = components["schemas"]["ChatResponse"];

// バックエンドにリクエストを投げる関数
export default async function chat(req: ChatRequest): Promise<ChatResponse> {

    // バックエンド(Azure OpenAI)にリクエストを投げる
    const response = await fetch(url + "/api/chat", {
        method: "POST",
        headers: {
        "Content-Type": "application/json",
        },
        body: JSON.stringify(req),
    })
      
    return await response.json(); 
}

```


## フロントエンドとバックエンドを繋ぐ

```api/chat.ts```では、環境変数からバックエンドのURLを取得するようにしています。

- まず、上記のコード群はAppSerivceに置かれています。
- AppServiceでは環境変数を設定可能です。
- Container AppsのURLを環境変数に設定します

AppServiceの環境変数にContainer AppsのURLを設定してあげることで、リクエストがContainer Appsに向かうことになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/28032e7a-6a45-7599-d9e8-b36d22184a4d.png)


環境変数を設定します。**Azure Portal → App Service → 構成 → 新しいアプリケーション設定**から、環境変数として「名前：値」を登録できます。ここに以下の環境変数を登録します。

- 名前：BACKEND_URL
- 値：Container AppsのアプリケーションURL


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/5471e0e8-06b9-bd96-4216-6c9bc0f6f918.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/aa383b49-8cd4-9b3b-c6d6-603c8ccb0e25.png)

**OK→保存**とクリックし、環境変数を反映させます。



## Teamsアプリケーションの再デプロイ

ソースコードの修正・環境変数の設定を終えました。再度アプリケーションをデプロイし、動作確認を行います。Teams Toolkitからデプロイを実施します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/6c6be3cf-42b5-56c3-a469-747aa7018590.png)



## 動作確認

Teams Toolkitから```Preview App```をクリックし、```Teams```をクリックします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/0314efb8-36bc-d061-92b3-1789f189528b.png)

現在のプロジェクトのmanifest.jsonを選択します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/d0215f9b-3bd1-e75c-20f9-5e11f8ec78b0.png)

サインインを求められるので、サインインします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/76fe7706-57fa-ebbf-08bb-b92075d16412.png)

サインイン後、Teamsが立ち上がりアプリケーションの追加画面に遷移します。追加をクリックします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/89d6170b-f93d-fa3e-fa37-941622a47732.png)

追加をクリックすると、先ほど開発したアプリケーションがTeams上に追加されます。
では、質問をしてみます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/fcba748d-a81d-a6af-c6b8-faf21d62f42a.png)

よさそうです。これで以下の図の構成が達成できました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/d969f11a-ce24-9408-d3d6-8727b9458d9d.png)


# まとめ

Teams × Azure Bot Service × Azure OpenAIで簡単なチャットボットを作成してみました。
実際に実装してみると、意外と楽にデプロイまでできるなと感じました。（Teams Toolkitが便利でした）

>今回はバックエンド(Container Apps)を挟んでいるので少し複雑になってしまいましたが、
AppServiceから直接Azure OpenAIにリクエストを送信する構成であれば比較的簡単に実装できそうです

また、フロントエンド・バックエンドに分けて考えると、従来通りのWebアプリケーションのようなイメージで開発できそうだなあと思っていました。

今後は以下の機能を追加してきたいですね。
- ユーザごとの会話履歴を外部データベースで管理
- TeamsのUIを活かした機能
    - 👍や:no_good:が押された会話を記録 → ユーザのフィードバック代わりにして分析


# おまけ

フロントエンド・バックエンドのソースコードをGithubに置いています。

- フロントエンド
 
https://github.com/is0356xi/azure-bot-teams/tree/main


- バックエンド

https://github.com/is0356xi/azure-bot-backend/tree/main