【Azure OpenAI開発】Static Web Apps × Azure Functionsを使って、WebアプリケーションにAzure OpenAIを組み込む

# 完成品

## 動作イメージ

※質問に対する回答時間がやや長めです。

![demo-auth-chat.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/97043988-b430-a8b8-6c4e-2639cfea1075.gif)


## 全体像

本環境で作成したリソースの構成は以下の通りです。
※ インターネット公開されているため、セキュリティ面の注意が必要です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/b21c8889-064c-8939-2538-e8594026726a.png)


次回以降は以下の図のような、プライベートまたはアクセス可能なユーザを限定した環境を構築していこうかと思います。
Azureの専門ではないため、図は正確でない部分もあるかと思います。
あくまでイメージとして捉えていただければと思います。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/dc4af664-149e-0a8e-cac7-6ca6d39bc500.png)



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

# Prerequisites

- [nvm](https://github.com/coreybutler/nvm-windows/releases)

```sh:
nvm install 16.19.0
nvm use 16.19.0
```

- Python3+
 
```sh:
python -V
Python 3.10.9 # 本環境はv3.10.9
```

- Azureアカウント
- GitHubアカウント
- [Visual Studio Code](https://code.visualstudio.com/download)
- [Azure Functions Core Tools](https://learn.microsoft.com/en-us/azure/azure-functions/functions-run-local?tabs=v4%2Cwindows%2Cpython%2Cportal%2Cbash#v2)
-----------------------------

# 1. 雛形プロジェクトを作成

## ①フロントエンド (Azure Static Web Apps)

本環境では[Vue.js](https://ja.vuejs.org/)を使用してフロントエンドを構築します。
vue-cliをインストールします。
本環境では、```vue/cli v5.0.8```を使用します。

```sh:
# インストール
npm install -g @vue/cli
vue --version
```

雛形プロジェクトを作成します。
本環境では```vue2```を使用します。

```sh:
vue create dev-app  # dev-appは任意のプロジェクト名
# vue3 or vue2を対話型で聞かれる→vue2を選択
```

作成したプロジェクトの起動確認を行います。

```sh:
cd dev-app && npm run serve

### 出力 ###
 DONE  Compiled successfully in 5932ms

  App running at:
  - Local:   http://localhost:8080/ 
  - Network: http://192.168.0.7:8080/

  Note that the development build is not optimized.
  To create a production build, run npm run build.
```

出力されたURLを開き、以下のような画面が開かれることを確認します。
URLはどちらでも構いません。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/51fb4b36-4ffc-a3d8-c188-f2afd2a42eee.png)

起動確認後buildしておきます。

```sh:
npm run build
```

## ②バックエンド (Azure Functions)

### 環境構築

本環境では、[Flask](https://flask.palletsprojects.com/en/2.3.x/)を使用してバックエンドを構築します。

VSCodeの左側```拡張機能```から**Azure Tools**を検索し、ダウンロードします。
拡張機能を使い、VSCode上からAzureにログインします。
ファンクションキー```F1```を押下し、**Azure: Sign In**をクリックします。
Webページが開かれたら、お使いのAzureアカウントを選択してログインします。


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/05a730fb-2031-82b9-d119-daecd6dc2060.png)

Static Web Appsと連携するAzure Functionsを作ります。
再度ファンクションキー```F1```を押下し、**Azure Static Web Apps: Create HTTP Function**をクリックします。


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/d1f17302-f78c-5aba-197e-d2111f4b5fc1.png)


言語選択画面にて```Python```を選択し、バージョン選択画面にて```v.3.10.9```を選択します。
※[公式ドキュメント](https://learn.microsoft.com/ja-jp/azure/azure-functions/functions-reference-python?pivots=python-mode-configuration&tabs=asgi%2Capplication-level)に記載がある通り、Python3.10.xはプレビュー版でのみ提供されています。


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/75e71eb1-db52-f0b3-38e2-354e91b61eba.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/767f637b-4bac-b44a-ddd8-334b9319c383.png)

任意の関数名を入力し、Enterを押下します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/141f6cc3-5a6c-8094-03ca-b71671b94866.png)

ここまで以下のようなフォルダ構成になるかと思います。
- ```apiフォルダ``` (Azure Functionsに該当します)
- ```dev-appフォルダ``` (Azure Static Web Appsに該当します)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/8f8cebac-65f3-a315-a6d2-a23e6f276f07.png)

Flaskの環境を構築します。
ローカル開発用に```pip install flask```を実行することに加え、
Azure Functionsにもflaskがインストールされるように```api/requirements.txt```にflaskを追記します。

```sh:
pip install flask
```

```sh:api/requirements.txt
# DO NOT include azure-functions-worker in this file
# The Python Worker is managed by Azure Functions platform
# Manually managing azure-functions-worker may cause unexpected issues

azure-functions
flask # 追記
```

### 雛形となるAzure Functionsを作成
Flaskのソースコードを作成します。
```api/flask_appフォルダ```を作成し、その配下に```app.py```を作成します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/123caaf3-48b9-8076-8ad4-4e750e5218ae.png)

```py:
from flask import Flask

app = Flask(__name__)

@app.route('/api/test', methods=['GET'])
def api_test():
    return 'This is Test'

if __name__ == '__main__':
    app.run()
```

### Azure Fuctionsの設定変更

Azure FunctionsへのHTTPリクエストがFlask側に流れるように設定を変更します。 

**① Azure FunctionsへのリクエストをWSGI handler(flask)にリダイレクトする**

- ```api/HttpTrigger1/__init__.py```を以下のように編集します。

```py:api/HttpTrigger1/__init__.py
import azure.functions as func
from flask_app.main import app

def main(req: func.HttpRequest, context: func.Context) -> func.HttpResponse:
    """
    Each request is redirected to the WSGI handler.
    """
    return func.WsgiMiddleware(app).handle(req, context)
```

**②ルーティング規則を変更する**

具体的には以下2つの変更を行います。

- ```/api```のリクエストが**Azure Functions**に流れるようにする。
- ```/api/*```のリクエストが**WSGI handler=Flask**に流れるようにする

編集するファイルは以下の2つです。
    
- ```api/host.json```
- ```api/HttpTrigger1/function.json```


```json:api/host.json
{
  "version": "2.0",
  "logging": {
    "applicationInsights": {
      "samplingSettings": {
        "isEnabled": true,
        "excludedTypes": "Request"
      }
    }
  },
  "extensionBundle": {
    "id": "Microsoft.Azure.Functions.ExtensionBundle",
    "version": "[3.*, 4.0.0)"
  },
  ### 追記 ###
  "extensions": {
    "http": {
        "routePrefix": "api"
    }
  }
  ############
}
```


```diff:api/HttpTrigger1/function.json
{
  "scriptFile": "__init__.py",
  "bindings": [
    {
      "authLevel": "anonymous",
      "type": "httpTrigger",
      "direction": "in",
      "name": "req",
      "methods": [
        "get",
        "post"
      ],
      "route": "{*route}"       ← 追記
    },
    {
      "type": "http",
      "direction": "out",
      "name": "$return"
    }
  ]
}
```

ローカル環境でAzure Functionsをテストします。
[Azure Functions Core Tools](https://learn.microsoft.com/en-us/azure/azure-functions/functions-run-local?tabs=v4%2Cwindows%2Cpython%2Cportal%2Cbash#v2)をインストールし、```func start```を実行します。

```sh:
cd api
func start

### 出力 ###
Functions:

      HttpTrigger1: [GET,POST] http://localhost:7071/api/{*route}

For detailed output, run func with --verbose flag.
[2023-05-13T15:05:43.763Z] Worker process started and initialized.
[2023-05-13T15:05:47.523Z] Host lock lease acquired by instance ID '000000000000000000000000083FF14C'.
```

別ターミナルを開き、curlでGETリクエストを送ります。

```sh:
curl http://localhost:7071/api/test

### 出力 ###
StatusCode        : 200
StatusDescription : OK
Content           : This is Test
RawContent        : HTTP/1.1 200 OK
                    Content-Length: 12
```

リクエストが成功すればAzure Functionsの雛形は完成です。

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

# 2. CI/CD環境の構築

## ①GitHubへアップロード

ここまでの手順で作成したフォルダをGitHubへアップロードします。
私の環境では、```api```フォルダと```dev-app```がアップロード対象となります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/ff5a6d81-d8b1-fa2c-734a-2e077f463267.png)


## ②Azure Static Web Appsの作成

AzureポータルからStatic Web Appsを作成します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/8367a9d6-4cfb-5be6-b6b9-62ee711da13d.png)

主な設定項目は以下の通りです。

- デプロイの詳細
    - ソース=GitHub
    - アカウント=GitHubアカウント名
- 組織：GitHubアカウント名
- リポジトリ：本環境のリポジトリ
- 分岐：main
- ビルドのプリセット：Vue.js
- アプリの場所：dev-app (vueのプロジェクト名)
- APIの場所：api (Azure Functionsフォルダ)
- 出力先：dist


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/f7fbcb21-47d9-ac23-6e3a-2e5c65cb0f19.png)

**確認及び作成→作成**とクリックし、リソースを作成します。
デプロイ画面に遷移します。成功したら**リソースに移動**をクリックします。


CI/CDプロセスのステータスを確認します。
Static Web Appsのリソース画面に移動した後、画面右側の**GitHubアクションの実行**をクリックします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/8d3322b3-cd45-1f32-16c1-4a2de4b2b606.png)

GitHubの画面に遷移します。
デプロイエラーが発生していなければデプロイが完了しています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/59621274-e070-9763-7c2f-35cc866e0939.png)

Static Web Appsの画面に戻り、画面右側に表示されている**URL**をクリックします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/64339dd9-446c-fafd-e1a6-297416c5f776.png)


1-①で作成した画面と同じものが表示されていればCI/CD環境の構築は完了です。

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





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

# 3. ユーザ認証を実装する (未完了)

認証方法は多数ありますが、今回は簡易的な認証を実装します。

- Static Web Appsからアプリケーションへのアクセスを許可するユーザを招待
- 招待リンクをユーザに送信し、ユーザ側で招待を


※他認証方法は本記事では取り扱いません。参考情報をいくつか紹介します。

https://learn.microsoft.com/ja-jp/azure/static-web-apps/authentication-authorization

https://ayuina.github.io/ainaba-csa-blog/auth-static-webapp/

https://blog.beachside.dev/entry/2021/05/21/183000


## ①ユーザ招待リンクの生成

画面左側タブ：**ロール管理**をクリックします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/faa77e34-2a38-d7dc-6752-1f89021b567a.png)

認証プロバイダーを選択し、必要な情報を入力します。
本環境では、GitHubアカウントを認証に使用します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/225b261b-18bd-1d9b-cc56-b8deaed8d768.png)


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/3cf32550-e3de-9b19-e3f9-aab6bb035276.png)

生成された招待リンクをコピーし、ユーザに送信します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/d1f66a75-ac4d-51be-a38e-0613f88834c5.png)

## ②ユーザ側で招待リンクを開き、アカウント連携を行う

①で生成したリンクを開き、招待を承認します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/9b255af8-8e5b-876d-748d-4e2832bd0a4c.png)

Static Web Appsとのアカウント連携を承認します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/492b2bab-ccea-1da5-6177-7e1306550b39.png)

## ③Static Web Appsに認証機能を追加する

②で招待したユーザのロールを使用し、認証機能を実装します。
先ほど招待したユーザのロールを確認します。
**Static Web Apps → ロール管理**を開くと、以下のように先ほど招待したユーザのロールを確認できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/2916159d-e6d7-f992-2220-090b4b28323f.png)

①で設定したロール名**invited_users**にて、招待済みユーザを表すロールが付与されています。

### a. フロントエンドに認証ページ機能を追加

ログイン機能は以下のように実装します。

```html:
# GitHubでログインする場合
<a href="/.auth/login/github">Login</a>

# GitHubでログインし、リダイレクト先を指定する場合
<a href="/.auth/login/github?post_login_redirect_uri=<Static Web AppsのURL>/<任意のパス>">Login</a>
```




本環境では、Vue.jsでフロントエンドを実装しているため、
ページ遷移の機能を追加し、以下ファイルを作成・編集します。

- ```dev-app/src/components/UserLogin.vue``` (作成)
- ```dev-app/src/components/ChatOpenAI.vue``` (作成)
- ```dev-app/src/App.vue``` (編集)
- ```dev-app/src/main.js``` (編集)

ページ遷移の機能を追加します。

```sh:
cd dev-app
# Vue2を使用している場合、vue-router3系をinstall
npm install vue-router@3.6.1
```

```dev-app/src/components/UserLogin.vue```を作成し、以下のようなコードを配置します。
ログイン後、**/homeにリダイレクトする設定**になっています。

```vue:dev-app/src/components/UserLogin.vue
<template>
  <div class="login-container">
    <h1>Login</h1>
    <a href="/.auth/login/github" class="btn btn-primary" >Login with GitHub</a>
  </div>
</template>

<script>
export default {
  name: "UserLogin",
  data: ()=>({
    user_data: "",
    userRoles: []
  }),
  methods:{
    LoginClicked: async function(){
      try{
        // ログイン情報の取得
        const ret = await fetch('/.auth/me');
        this.user_data = await ret.json();
        this.userRoles = this.user_data.clientPrincipal.userRoles

        if (this.userRoles.includes('invited_users')){
          console.log("Success.")
        }
        else{
          console.log("Not Authorized.")
        }
      }
      catch{
        this.user_data = ""
        this.userRoles = []
        console.log("Error.")
      }
    },
    CheckLogIn: async function(){
      try{
        // ログイン情報の取得
        const ret = await fetch('/.auth/me');
        this.user_data = await ret.json();
        this.userRoles = this.user_data.clientPrincipal.userRoles
        
        // invited_usersロールを持っているか確認
        if(this.userRoles.includes('invited_users')){
          this.$router.push({path: "home"})
          console.log("Login.")
        }else{
          console.log("Not LogIn.")
        }
      }
      catch{
        console.log("Not LogIn.")
      }
    }
  },
  mounted(){
    this.CheckLogIn()
  }

}
</script>

<style scoped>
.login-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
}

.btn {
margin-top: 20px;
padding: 10px 20px;
text-decoration: none;
color: #fff;
background-color: #007bff;
border: none;
border-radius: 4px;
cursor: pointer;
}

.btn:hover {
background-color: #0056b3;
}

h1 {
font-size: 24px;
margin-bottom: 20px;
color:  #b6b2b2;
}
</style>
```

```dev-app/src/components/ChatOpenAI.vue```を作成し、以下のようなソースコードを配置します。

```vue:dev-app/src/components/ChatOpenAI.vue
<template>
    <div>
        <h1> Login Successed. </h1>
    </div>
</template>
```


```dev-app/src/App.vue```を以下のように編集します。

```vue:dev-app/src/App.vue
<template>
  <div id="app">
    <router-view />
  </div>
</template>

<script>


export default {
  name: 'App',
}
</script>

<style>
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
  margin-top: 60px;
}

body {
  background-color: #383737; /* ダークモードの背景色 */
}
</style>
```


```dev-app/src/main.js```を以下のように編集します。
ルーティング設定を行います。

```js:dev-app/src/main.js
import Vue from 'vue'
import VueRouter from 'vue-router'

import App from './App.vue'
import UserLogin from './components/UserLogin.vue';
import ChatOpenAI from './components/ChatOpenAI.vue';

Vue.config.productionTip = false

// ルーティング設定
Vue.use(VueRouter)
const router = new VueRouter({
  mode: 'history',
  routes:[
    {
      path: '/',
      component: UserLogin
    },
    {
      path: '/home',
      component: ChatOpenAI
    }
  ]
})

new Vue({
  router,
  render: h => h(App),
}).$mount('#app')

```

### b. Static Web Appsの設定ファイルを作成

以下のファイルを作成し、アクセス前に認証を実施するリクエストパスを定義します。
また、

- ```dev-app/staticwebapp.config.json```
    - リクエストパス「**/**」は全てのユーザを許可
    - リクエストパス「**/home**」は**invited_users**ロールを持つユーザのみアクセス許可 
    - リクエストパス「**/api***」は**invited_users**ロールを持つユーザのみアクセス許可 

```json:dev-app/staticwebapp.config.json
{
    "$schema": "https://json.schemastore.org/staticwebapp.config.json",
    "routes": [
      {
        "route": "/", "allowedRoles" : ["anonymous"]
      }, 
      {
        "route": "/home", "allowedRoles" : ["invited_users"]
      },
      {
        "route": "/api*", "allowedRoles" : ["invited_users"]
      }
    ]
}
```

### c. Githubに変更をアップロードし、挙動を確認

GitHubにアップロードし、Static Web Appsの画面から**GitHubアクションの実行**をクリックします。
CI/CDプロセスが問題なく完了していることを確認し、Static Web AppsのURLをクリックしてアプリケーションにアクセスします。

>**※既に一度ローカル環境で認証をしている場合、ログイン情報が保持されているため認証画面に遷移しないことがあります。その際は、プライベートモードなどでタブを開いてアクセスします。**

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/30c9b419-0b4a-e8bb-c5ad-1a4ffb0dd00b.png)


**Login with GitHub**をクリックすると以下の画面に遷移します。
必要な情報を入力し、**Sign in**をクリックします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/2720fa44-7211-00d1-2d4f-e42cad543403.png)

サインイン後、以下のような画面に遷移すれば完了です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/40fd64e4-3500-f8f5-61ac-06bc2f2751eb.png)


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

# 4. Static Web AppsとAzure Functionsを連携

Azure FunctionsでAPIエンドポイントを作成し、Static Web Apps側からAPIを実行します。
実際の作業は以下のような流れです。

- **Vue.js (Static Web Apps)**
  - フォーム（入力欄＆ボタン）を作成
  - APIを叩くための関数を作成
  - ボタンが押されたら関数を実行
- **Flask (Azure Functions)**
    - 特定のURIでPOSTリクエストを受け付ける
    - Vue.jsで入力されたデータを取得
    - 取得したデータを入力として実行可能な関数を作成
    - 関数の実行結果をVue.jsに送信 


## ①Static Web Apps側の開発

はじめに、HTTPリクエストを送信するためのモジュールをインストールしておきます。

```sh:
cd dev-app
npm install axios
```

フォーム・APIを実行する関数を作成します。
以下のファイルを作成(または編集)します。

- ```dev-app/src/components/ChatOpenAI.vue```

```vue:dev-app/src/components/ChatOpenAI.vue
<template>
  <div>
    <div class="chat-container">
      <div 
        v-for="message in prev_messages" 
        :key="message.id" 
        :class="['chat-message', message.isResponse ? 'response' : 'user']"
        >
        <span class="chat-message-text">{{ message.text }}</span>
      </div>
    </div>
    <div class="input-container">
      <input
        v-model="question"
        type="text"
        @keydown.enter="ExecOpenAIApi(question)"
        placeholder="質問を入力してください"
        class="chat-input"
      />
      <button 
        @click="ExecOpenAIApi(question)" 
        class="chat-button"
        :class="{ 'button-hover': isButtonHovered }"
        @mouseover="isButtonHovered = true"
        @mouseout="isButtonHovered = false"
      >
        送信</button>
    </div>
  </div>
</template>

<script>
    const axios = require('axios').create();

    export default{
        name: "ExecOpenAI",
        data:() => ({
            post_data:{
                question: ""
            },
            response: "",
            // prev_messages:[],
            prev_messages:[],
            message:{
                id: 0,
                text: "",
                isResponse: false
            },
            isButtonHovered: false
        }),
        methods: {
            ExecOpenAIApi: async function (question){
              // Azure OpenAIの呼び出し
              this.post_data.question = question;
              await axios.post("/api/question", this.post_data)
                  .then((res) =>{
                      // ユーザの入力データを会話履歴に追加
                      const UserMessage = {
                          id: Date.now(),
                          text: question,
                          isResponse: false
                      }
                      this.prev_messages.push(UserMessage);
  
                      return res.data["message"]
                  })
                  .then((answer) => {
                      // 回答を会話履歴に追加
                      const ResponseMessage = {
                          id: Date.now(),
                          text: answer,
                          isResponse: true
                      }
                      this.prev_messages.push(ResponseMessage);
                  })
                  .catch((error) => {
                      console.log(error)
                  })
            }
        }
    }
</script>


<style scoped>
.chat-container {
  max-height: 600px;
  overflow-y: scroll;
  border: 1px solid #6d6a6a;
  background-color: #555252; /* ダークモードの背景色 */
  padding: 10px;
}

.chat-message {
  padding: 10px;
  margin-bottom: 10px;
  display: flex;
}

.response {
  justify-content: flex-start;
}

.user {
  justify-content: flex-end;
}

.chat-message-text {
  word-wrap: break-word;
  max-width: 80%;
  padding: 8px;
  border-radius: 10px;
  background-color: #353333;
  color: #dad5d5;
  text-align: left;
  font-family: "Yu Gothic", "ヒラギノ角ゴシック", "Hiragino Sans", "メイリオ", Meiryo, sans-serif;
  font-size: 18px
}

.response .chat-message-text {
  background-color: #2e2d2d;
}

.user .chat-message-text {
  background-color: #2e2d2d;
}

.input-container {
  display: flex;
  margin-top: auto;
  position: sticky;
  bottom: 0;
  left: 30px;
  right: 0;
  padding: 20px;
  height: 60px;
  width: 70%;
  background-color: #1a1919; /* ダークモードの背景色 */
}

.chat-input {
  flex: 1;
  padding: 5px;
  border: 1px solid #3b3b3b; /* 入力欄の枠線の色 */
  color: #0e0d0d; /* 入力欄の文字色 */
}

.chat-button {
  margin-left: 5px;
  padding: 8px 15px;
  font-size: 16px;
  background-color: #4477c9; /* ボタンの背景色 */
  color: #ffffff;
  border: none;
  border-radius: 5px;
  transition: background-color 0.3s ease;
}

.chat-button:hover {
  background-color: #9fb2da; /* マウスが重なった時の背景色 */
}
</style>
```

## ②Azure Functions側の開発

作成・編集するファイルは以下の2つです。

- ```api/flask_app/app.py```
- ```api/flask_app/modules/openai_client.py```

Pythonパッケージの```openai```が必要となるため、先にインストールしておきます。
追加でインストールしたパッケージがAzure Functions側でもインストールされるように**requirements.txt**への追記が必要です。

```sh:
pip install openai
```

```sh:api/requirements.txt
# DO NOT include azure-functions-worker in this file
# The Python Worker is managed by Azure Functions platform
# Manually managing azure-functions-worker may cause unexpected issues

azure-functions
flask
openai
```

### a. フロントエンドからのリクエストを処理

ユーザ(フロントエンド)からのPOSTリクエストを受け取り、入力を抽出します。
Azure OpenAIを実行するモジュールを呼び出します。
最後に、ユーザ(フロントエンド)へAzure OpenAIの回答を送信します。

```py:api/flask_app/app.py
from flask import Flask, request, jsonify

# モジュールのインポート
from .modules.openai_client import OpenAIClient

app = Flask(__name__)

@app.route('/api/test', methods=['GET'])
def api_test():
    return 'This is Test'

@app.route('/api/question', methods=['POST'])
def handle_message():
    question = request.json['question']

    # Azure OpenAIのAPIを実行
    response = OpenAIClient().chat_openai(question)
    response = {'message': response}

    return jsonify(response)

if __name__ == '__main__':
    app.run()
```

### b. Azure OpenAIをAPI経由で実行する

```py:api/flask_app/modules/openai_client.py
import openai
import os

class OpenAIClient:
    def __init__(self):
        # Azure OpenAIの基本設定
        openai.api_type = "azure"
        openai.api_base = "https://openai-for-app.openai.azure.com/"
        openai.api_version = os.getenv('API_VERSION')
        
        # Azure OpenAIのAPIキーを設定
        openai.api_key = os.getenv('OPENAI_API_KEY')
        self.model_name = os.getenv('MODEL_NAME')

        if (openai.api_key is None) or (openai.api_base is None):
            raise ValueError("エンドポイントまたはOpenAI APIキーが設定されていません。")

    def chat_openai(self, question):
        try:
            system_message = """
            You are an AWS specialist.
            Avoid using technical jargon and explain in easy-to-understand language.
            You need to help users understand with analogies that even elementary school students can understand."
            """

            response = openai.ChatCompletion.create(
                engine=self.model_name,
                messages = [
                    {"role": "system", "content": system_message},
                    {"role": "user"  , "content": question}
                ],
                temperature=0.7,
                max_tokens=800,
                top_p=0.5,
                frequency_penalty=0,
                presence_penalty=0,
                stop=None
            )

            return response.choices[0].message.content
        
        except Exception as e:
            return "Error"


if __name__ == "__main__":
    test = OpenAIClient()
    res = test.chat_openai("これはテストです。")
    print(res)
```

## ③APIの挙動をテストする

### a. Azure OpenAIを作成する

Azure OpenAIにモデルをデプロイし、API経由で実行可能な状態にしておきます。
前回リソース作成部分をまとめています。詳細はそちらをご覧ください。

https://qiita.com/sakue_103/items/582198236c59d4d8147c

### b. Static Web Appsの環境変数にAzure OpenAIの情報を登録

Static Web Appsの画面に移動し、Azure OpenAIのエンドポイントとAPIキーを環境変数として登録します。
環境変数を使う主なメリットを一部紹介します。

- クレデンシャル情報のハードコーディングを避けられます。
- モデルを変更したい場合など、ソースコードを編集せずに済みます。

~~環境変数として設定するものは以下の3つです。~~
環境変数として設定するものは以下の4つです。
※以降、モデルのエンドポイントも環境変数と扱えるように読み替えていただければと思います。

- **モデルのエンドポイント** (エンドポイントの環境変数化を失念しており、追記致しました。)
- モデルのデプロイ名
- モデルのバージョン
- APIキー

プレイグラウンドから、**コードの表示**をクリックすると全て取得できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/947038dd-1374-c485-1ddd-287736d3d038.png)

設定していきます。
Static Web Appsの画面左側タブの**構成**をクリックします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/15c9b006-0495-4c80-2bbe-af21811f7f5b.png)


**追加**をクリックします。
（ステージング環境を構築している場合は、**環境**を選択することで設定を分離できます。）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/8e59bece-9a3e-c85d-29f2-f84c71379976.png)


必要な情報を入力し、OKをクリックします。
- **名前**：環境変数の変数名。バックエンドで変数を読み込む際にこの名前を指定する
- **値**: 環境変数の値。


モデルのデプロイ名・モデルのバージョン・APIキーをそれぞれ設定し、画面上部の**保存**をクリックします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/dd1be32c-405c-89ee-3656-77f6c8cb8a9a.png)



以上で環境変数の設定は完了です。
API経由で実行するモデルの参照先を変更する場合は、同様の手順でエンドポイントなどを変更します。


### c. ローカル環境からAPI経由でAzure OpenAIを呼び出す

本環境で使用している**Static Web Apps×Azure Functions**をローカル環境で動かしてデバッグするためには、**azure/static-web-apps-cli**が必要です。([参考](https://learn.microsoft.com/ja-jp/azure/static-web-apps/add-api?tabs=vue))

```npm install```でインストールし、```swaコマンド```を実行することで、
Static Web Apps×Azure Functionsの環境がローカルで実行されます。

```sh:
# インストール
cd dev-app
npm install -g @azure/static-web-apps-cli

# 依存関係を基に必要なモジュールをインストールし、buildする
npm install && npm run build 

# ローカル環境で実行する
swa start dist --api-location ../api


### 出力 ###
 Serving static content:
[swa]   Azure-OpenAI-test\dev-app\dist
[swa]
[swa] Serving API:
[swa]   Azure-OpenAI-test\api
[swa]
[swa] Azure Static Web Apps emulator started at http://localhost:4280. Press CTRL+C to exit.       
[swa]
[swa]
[api] [2023-05-13T16:23:50.785Z] Host lock lease acquired by instance ID '000000000000000000000000083FF14C'.
```

プライベートブラウザで```http://localhost:4280```を開きます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/5bb7c2cc-e5cb-c878-585d-33e2f2c7d05e.png)

**Login with GitHub**をクリックします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/8eb5ed0d-ee51-90fd-d965-f2e896ddaeeb.png)

上記の画面にて、**Username**と**User's roles**を編集します。
実際にログインを行っているわけではなく、Static Web Appsの認証フローをシミュレーションしたものとなっています。
そのため、**3-①ユーザ招待リンクの生成**で設定した値を入力し、本番環境をシミュレーションします。

- Username: <ユーザ名>
- User's roles: invited_users

上記2項目を入力します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/13ade9ec-36ea-4f65-2ea0-8f6e3c0374a4.png)

ログインをクリックすると、**/home**にリダイレクトされます。
ユーザ名・ロール名が正しくない場合は、ログイン画面に戻ります。
このように、認証フローをシミュレーション可能です。

質問を入力し、送信ボタンまたはEnterを押下します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/2e440406-5e84-6127-9233-c275da267c21.png)

以下のように、Azure OpenAIからの回答が得られます。
これでローカル環境でのデバッグは完了です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/e79d8830-ba33-37c9-823f-f34fbc80371d.png)


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

# 5. GitHubに変更をアップロード

ここまでの変更をGitHubリポジトリに反映し、CI/CDプロセスを開始します。
問題なくCI/CDプロセスが完了したらStatic Web AppsのURLをクリックし、一通り動作検証を行います。

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

# まとめ
様々なAzureサービスと連携させてみると活用方法の幅が広がりそうです。
普段はAWSに触れているので、これを機にAzureサービスについても色々と勉強しようかと思います。
また、プライベート環境の構築が完了していないため、引き続き本環境を触っていきます。

以前、ファイルをアップロードしてEmbeddingを作成し、
アップロードしたファイルの内容を基に回答を生成するという機能を作成したため、
そちらを本環境に組み込みたいと思います。

余談ですが、今回フロントエンドのデザイン部分は全てChatGPTに任せました。
例えば、本環境ではユーザの質問・Azure OpenAIの回答がチャット形式で表示されます。
これはChatGPTに以下のようにお願いしました。

>**チャット形式で会話履歴を表示したい。LINEのようなイメージです。
右側にユーザの質問を表示し、左側には質問への回答を表示してください。**

これ以外にもプロンプトエンジニアリングで多少カスタマイズはしていますが、
話し言葉のようにお願いするだけで意図を汲み取りコードを提案してくれるのは非常に便利でした。

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

# 【追記予定】プライベートな環境を構築する (未完了)

※今後追記予定です。

>**アプリケーションへのパブリックアクセスを許可する場合、この手順は不要です。
また、Static Web AppsをStandardプランにする必要があります。**

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/24782b4f-3d53-3e86-5d51-f263ead40f6a.png)



Static Web Appsにプライベートエンドポイントを構築することで、
アプリケーションにアクセスできる経路を**プライベートな仮想ネットワーク(VNet)経由**のみに限定します。
特定のVnetに所属するAzure仮想マシンや、閉域網からのアクセスに対してアプリケーションを公開し、
パブリックIPからのアクセスを拒否することが可能となります。 

## ①仮想ネットワークの作成

以下リソースをAzureポータルから作成します。

- 仮想ネットワーク ×1
- サブネット ×2
    - クライアント用に用意する仮想マシンを配置
    - Static Web Apps用プライベートリンクのNICを配置

仮想ネットワークの作成画面を開き、必要な情報を入力します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/d015142d-7216-811a-75ea-f12b880880d5.png)

IPアドレスの設計を行うと同時に、サブネットを追加します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/7b343392-3294-c6bf-dab8-5c4d4408477b.png)

他の設定はデフォルトのままとし、リソースを作成します。


## ②プライベートリンクの作成

https://learn.microsoft.com/ja-jp/azure/static-web-apps/private-endpoint

Static Web Appsの画面に移り、画面左側のタブ:**プライベートエンドポイント**をクリックします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/693d7e07-0133-0f71-695d-5fe22ba2d2b9.png)

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