OpenAIのFunction callingとTypetalk､Backlog APIを使って、自然言語で課題を作成,更新する

# はじめに
この記事は､前回の記事 [OpenAIのFunction callingとSlack App､Backlog APIを使って、自然言語で課題を作成,更新する](https://qiita.com/revsystem/items/8bc8056d18dab2b9eb03) のTypetalk版です｡前回の記事では､Slack APPのリクエスト再送を避けるためにアプリケーションフレームワークにAWS SAMを使用しました｡TypetalkではSlack APPのような制約がないためAWS Chaliceを使用します｡構築するAWSリソースは変わらないので､どちらでも使いやすい方で良いと思います｡

# デモ画像
このように､ボットに自然言語で依頼すると､Backlog課題の登録や更新が行えます｡ボットからの返信に含まれる課題キーは､Backlog課題へのリンクとなっています｡
ボットからの返信はSlack Appのようにストリームで表示されません｡(ストリーム出力を行うよう実装すれば実現可能かもしれません｡)

![Backlog_Typetalk_Chalice_OpenAI001.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61809/97f50df9-db28-cc93-e312-2bf59e86317a.png)

![Backlog_Typetalk_Chalice_OpenAI002.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61809/5d044502-1a51-46d0-3a99-cce93c7c777e.png)

はじめに､課題登録を依頼する以下のプロンプトをポストしました｡OpenAI APIのFunction callingが｢課題を登録｣というキーワードをもとに、課題登録を行うfunctions定義を選択し､Backlog APIに渡すパラメータ名と具体的な値を判断しました｡
課題種別や優先度､期日などはそれぞれプロンプトにあるものが反映され､依頼内容をもとに課題タイトルや課題本文が作成されました｡

```text:課題登録を依頼するプロンプト
APPプロジェクトに課題を登録してください｡
課題本文は以下のとおりです｡課題本文の概要をもとに課題のタイトルをつけてください｡
開発工程には､アプリケーション開発工程10項目を以下の制約条件と出力形式に従って作成してください｡
開発工程とは、例えば要件定義や基本設計などです。

出力形式
|タスク|解説|
|---|---|
|要件定義|要件定義を行う|

課題種別は"要望"です｡
優先度は"高"です｡
開始日は2023年12月5日､期限日は4ヶ月後です｡

"""課題本文
## 開発工程
{ここに開発工程10項目を出力する}

## プロジェクトの概要
新規にXXXを実現する機能を実装する｡
テストユーザーによるテストを行い､不具合がないことを確認した後､本番環境にリリースする｡

## 目的
ユーザーからのフィードバックによると､XXXができないことが課題として挙げられている｡
この課題を解決するため､XXXを実現する機能を実装する｡

## 完了条件
- テストユーザーによるテストを行い､不具合がないこと
- 本番環境へのリリースを行い､ユーザーからの不具合報告がないこと

## 数値目標
- テストユーザーによるテストにおいて､軽微なバグの件数を10件以下にする
- テストユーザーによるテストにおいて､重大なバグの件数を0件にする
- リリース後､1週間以内にユーザーからの不具合報告が0件になる
"""
```

次に､課題更新を依頼する以下のプロンプトをポストしました｡この場合は､Funcrtion Callingが｢課題を更新｣というキーワードをもとに更新を行うfunctions定義を選択しました｡
そして､指定した課題キーをもつBacklog課題が更新されました｡

```text:課題更新を依頼するプロンプト
Backlogの課題を更新してください｡
対象の課題は APP-343 です。

課題の件名を｢アプリケーションに新規機能とヘルプページを追加する｣とします｡
課題種別をタスクに変更します｡
優先度を中にします。
期限日は2024年1月27日です｡
```

以降、環境構築やコードの詳解です。

# 参考情報
各種参考情報は､下記記事内のリンクを参照してください｡

https://qiita.com/revsystem/items/c7762f4d246301e4dc9d

https://qiita.com/revsystem/items/8bc8056d18dab2b9eb03

# 前提条件

以下のリソースを作成･削除･変更できる権限をもつAWSユーザー

- AWS IAM
  - AWS Lambda
  - Amazon API Gateway
  - Amazon S3
  - AWS Chalice
- 使用するAWSリージョンは、us-east-1 (リージョンの制約はないため、他のリージョンも可)
- OpenAIのAPIキーを取得済み
- Typetalkトークンを取得済み
- Backlog APIキーを取得済み
- BacklogのAPIキーやコード内で設定するissueTypeId(課題種別ID)などの準備ができている (Backlog APIの利用に必要なAPIキーやパラメータの確認方法は､以下の記事が参考になります｡)

https://dev.classmethod.jp/articles/backlog-api-many-issues/

 Typetalkトークンの取得は､[OpenAIのChatGPT APIを利用してTypetalkボットを作成する](https://qiita.com/revsystem/items/ac1d5e0111626755e647#typetalk%E3%83%88%E3%83%BC%E3%82%AF%E3%83%B3%E3%81%AE%E5%8F%96%E5%BE%97)の`Typetalkトークンの取得`を参照してください｡


# 環境構築

## OSのバージョン
Windows 11上のWSLでUbuntu 23.04を動かしています。

```text
PRETTY_NAME="Ubuntu 23.04"
NAME="Ubuntu"
VERSION_ID="23.04"
VERSION="23.04 (Lunar Lobster)"
VERSION_CODENAME=lunar
ID=ubuntu
ID_LIKE=debian
HOME_URL="https://www.ubuntu.com/"
SUPPORT_URL="https://help.ubuntu.com/"
BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/"
PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy"
UBUNTU_CODENAME=lunar
LOGO=ubuntu-logo
```

## Python環境

```console
$ python3 -m venv .venv
$ source .venv/bin/activate
```

```console
$ python3 --version
Python 3.11.4
```

```console
$ pip3 install --upgrade pip
$ pip3 --version
pip 23.3.2 from /home/xxx/.venv/lib/python3.11/site-packages/pip (python 3.11.4)
```

## Typetalkボットの作成

Typetalkボットの作成は､[OpenAIのChatGPT APIを利用してTypetalkボットを作成する](https://qiita.com/revsystem/items/ac1d5e0111626755e647#typetalk%E3%83%88%E3%83%BC%E3%82%AF%E3%83%B3%E3%81%AE%E5%8F%96%E5%BE%97)の`ボットの作成`を参照してください｡

chaliceとboto3はデプロイを行うdeploy.sh実行時にインストール/アップデートされます｡

# アプリケーションの構築

ディレクトリ構造は以下のとおりです｡

```text
.
├── .chalice
│   ├── config.json
│   └── policy-dev.json
├── app.py
├── deploy.sh
└── requirements.txt
```

## AWS Chaliseの環境設定

```text:requirements.txt
requests
openai >= 1.0.0
pydantic_core
pydantic
langchain
```

## AWS Chaliseの設定

### config.json

.chalice/config.jsonを作成します｡ここにOpenAIのAPIキーやTypetalkのトークンなどを定義します｡Lambda関数からBedrockやS3にアクセスするIAMポリシーを後述のpolicy-dev.jsonで追加するため､autogen_policyはfalseとします｡app_nameを`aws-chalice-typetalk-backlog-assistant-app`としました｡後述のapp.pyでもこの名前を使用するため､他の名前を設定する場合はapp.py内の名前も変更します｡

```json:config.json
{
  "version": "2.0",
  "app_name": "aws-chalice-typetalk-backlog-assistant-app",
  "stages": {
    "dev": {
      "api_gateway_stage": "api",
      "environment_variables": {
        "OPENAI_API_KEY": "OpenAI APIのAPIキー",
        "TYPETALK_TOKEN": "Typetalkトークン",
        "TYPETALK_TOPIC_ID": "TypetalkのトピックID",
        "BACKLOG_BASE_URL": "https://example.backlog.com",
        "BACKLOG_API_KEY": "BacklogのAPIキー"
      },
      "autogen_policy": false,
      "automatic_layer": true
    }
  }
}
```

ここでは、"dev"というステージを定義しました。"prod"などの名称で別のステージを定義することで環境を別々に管理できるようです。

### IAMポリシーの設定

.chalice/policy-dev.jsonを作成します｡Lambda関数の実行に必要な権限と､CloudWatch Logsへのログ書き込みに必要な権限を定義します｡

```json:.chalice/policy-dev.json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "lambda:InvokeFunction"
            ],
            "Resource": [
                "*"
            ]
        },
        {
            "Effect": "Allow",
            "Action": [
                "logs:CreateLogGroup",
                "logs:CreateLogStream",
                "logs:PutLogEvents"
            ],
            "Resource": "*"
        }
    ]
}
```

### アプリ本体

以下はボットに指示を与えるプロンプトです｡[前回の記事](https://qiita.com/revsystem/items/8bc8056d18dab2b9eb03)で使用したプロンプトでは､ボットからの返信が`課題を作成しました。タスクのキーはAPP-342です`のように内容がない場合が多かったため､プロンプトに`作成したタスクの内容を返してください。`という指示を追加しました｡



```text:app.py(抜粋)
# システムメッセージ
OPENAI_SYSTEM_TEXT = """
あなたはタスクマネージャーです。あなたには関数のリストと、関数で使用するパラメータが与えられています。
与えられた関数とパラメータを使ってタスクを作成する必要があります。
タスクを作成し､作成したタスクの内容を返してください。返信には、タスクのキーを含めてください。
"""
```

<details><summary>app.py (長いので折りたたんでいます｡クリックして展開)</summary>

```python
import json
import logging
import os
import re
from datetime import datetime, timedelta, timezone

import requests
from chalice.app import Chalice
from langchain.chat_models import ChatOpenAI
from langchain.schema import FunctionMessage, HumanMessage, SystemMessage
from openai import OpenAI

TYPETALK_TOPIC_ID = os.environ["TYPETALK_TOPIC_ID"]
TYPETALK_TOKEN = os.environ["TYPETALK_TOKEN"]

# BacklogのURlとAPIキー、OpenAIのAPIキーを環境変数から取得する
BACKLOG_BASE_URL = os.environ.get("BACKLOG_BASE_URL")
BACKLOG_API_KEY = os.environ.get("BACKLOG_API_KEY")
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY")

# システムメッセージ
OPENAI_SYSTEM_TEXT = """
あなたはタスクマネージャーです。あなたには関数のリストと、関数で使用するパラメータが与えられています。
与えられた関数とパラメータを使ってタスクを作成する必要があります。
タスクを作成し､作成したタスクの内容を返してください。返信には、タスクのキーを含めてください。
"""

logging.basicConfig(
    format="%(asctime)s [%(levelname)s] %(message)s", level=logging.INFO
)

# OpenAI APIのクライアントを作成する
client = OpenAI(api_key=OPENAI_API_KEY)

# Chaliceのインスタンスを作成する
app = Chalice(app_name="aws-chalice-typetalk-backlog-assistant-app")


def create_task(arguments):
    headers = {"Content-Type": "application/x-www-form-urlencoded"}
    data = {
        "projectId": arguments.get("projectId"),
        "summary": arguments.get("summary"),
        "description": arguments.get("description"),
        "issueTypeId": arguments.get("issueTypeId"),
        "startDate": arguments.get("startDate"),
        "dueDate": arguments.get("dueDate"),
        "priorityId": arguments.get("priorityId"),
    }

    response = requests.post(
        f"{BACKLOG_BASE_URL}/api/v2/issues?apiKey={BACKLOG_API_KEY}",
        data=data,
        headers=headers,
        timeout=10,
    )
    logging.info("response: %s", response)
    return json.dumps(response.json())


def update_task(arguments):
    headers = {"Content-Type": "application/x-www-form-urlencoded"}
    data = {
        "summary": arguments.get("summary"),
        "description": arguments.get("description"),
        "issueTypeId": arguments.get("issueTypeId"),
        "startDate": arguments.get("startDate"),
        "dueDate": arguments.get("dueDate"),
        "priorityId": arguments.get("priorityId"),
    }

    response = requests.patch(
        f"{BACKLOG_BASE_URL}/api/v2/issues/{arguments.get('issueKey')}?apiKey={BACKLOG_API_KEY}",
        data=data,
        headers=headers,
        timeout=10,
    )
    logging.info("response_update: %s", response)
    return json.dumps(response.json())


@app.route("/backlogIssue", methods=["POST"])
def lambda_handler():
    request = app.current_request

    body = request.json_body
    user_message = re.sub("@.*\\+", "", body["post"]["message"])
    user_postid = body["post"]["id"]

    # Step 1: send the conversation and available functions to the model
    functions = [
        {
            # 課題を登録する関数
            "name": "create_task",
            "description": """
            Create tasks using a Backlog task management system.
            """,
            # Backlog APIに合わせてパラメータを定義する
            "parameters": {
                "type": "object",
                "properties": {
                    "summary": {
                        "type": "string",
                        "example": "〇〇を××する",
                        "description": " 件名だけ読めば「何を」「どうすれば」タスクが終わるのかわかる件名を付ける｡",
                    },
                    "projectId": {
                        "type": "number",
                        "description": """
                            課題を登録するプロジェクトのID
                            APPプロジェクトの場合、projectIdは 129821
                            """,
                    },
                    "description": {
                        "type": "string",
                        "description": "課題の詳細",
                    },
                    "issueTypeId": {
                        "type": "number",
                        "enum": [624223, 624224, 624225, 624226],
                        "description": """
                        課題の種別のID
                        課題種別が'タスク'の場合､  IssueTypeId は 624224
                        課題種別が'バグ'の場合､ IssueTypeId は 624223
                        課題種別が'要望'の場合､ IssueTypeId は 624225
                        課題種別が'その他'の場合､ IssueTypeId は 624226
                        """,
                    },
                    "startDate": {
                        "type": "string",
                        "description": "課題の開始日(yyyy-MM-dd)",
                        "format": "yyyy-MM-dd",
                        "example": "2019-11-13",
                    },
                    "dueDate": {
                        "type": "string",
                        "description": "課題の期日(yyyy-MM-dd)",
                        "format": "yyyy-MM-dd",
                        "example": "2019-11-13",
                    },
                    "priorityId": {
                        "type": "number",
                        "enum": [2, 3, 4],
                        "description": """
                        課題の優先度のID
                        優先度が'低'の場合､  PriorityId は 4
                        優先度が'中'の場合､  PriorityId は 3
                        優先度が'高'の場合､  PriorityId は 2
                        """,
                    },
                },
                "required": [
                    "summary",
                    "projectId",
                    "issueTypeId",
                    "priorityId",
                ],
            },
        },
        {
            # 課題を更新する関数
            "name": "update_task",
            "description": """
            Update tasks using a Backlog task management system.
            """,
            "parameters": {
                "type": "object",
                "properties": {
                    "issueKey": {
                        "type": "string",
                        "description": "課題のキー",
                        "example": "ABC-1",
                    },
                    "summary": {
                        "type": "string",
                        "description": "課題の件名",
                    },
                    "projectId": {
                        "type": "number",
                        "description": "課題を登録するプロジェクトのID",
                    },
                    "description": {
                        "type": "string",
                        "description": "課題の詳細",
                    },
                    "issueTypeId": {
                        "type": "number",
                        "enum": [624223, 624224, 624225, 624226],
                        "description": """
                        課題の種別のID
                        課題の種別が'タスク'の場合､ IssueTypeId は 624224
                        課題の種別が'バグ'の場合､ IssueTypeId は 624223
                        課題の種別が'要望'の場合､ IssueTypeId は 624225
                        課題の種別が'その他'の場合､ IssueTypeId は 624226
                        """,
                    },
                    "startDate": {
                        "type": "string",
                        "description": "課題の開始日(yyyy-MM-dd)",
                        "format": "yyyy-MM-dd",
                        "example": "2023-11-01",
                    },
                    "dueDate": {
                        "type": "string",
                        "description": "課題の期日(yyyy-MM-dd)",
                        "format": "yyyy-MM-dd",
                        "example": "2023-11-11",
                    },
                    "priorityId": {
                        "type": "number",
                        "enum": [2, 3, 4],
                        "description": """
                        課題の優先度のID
                        優先度が'低'の場合､  PriorityId は 4
                        優先度が'中'の場合､  PriorityId は 3
                        優先度が'高'の場合､  PriorityId は 2
                        """,
                    },
                },
            },
        },
    ]

    messages = [
        {
            "role": "system",
            "content": OPENAI_SYSTEM_TEXT,
        },
        {
            "role": "user",
            "content": user_message
            + "\n"
            + "現在時刻: "
            + str(datetime.now(timezone(timedelta(hours=+9), "JST"))),
        },
    ]

    logging.info("messages: %s", messages)
    # プロンプトと関数をOpenAI APIに渡す
    response = client.chat.completions.create(
        model="gpt-3.5-turbo-1106",
        messages=messages,
        temperature=0,
        functions=functions,
        function_call="auto",
    )
    response_message = response.choices[0].message
    logging.info("response_message: %s", response_message)

    # モデルが関数呼び出し行うと判断した場合
    if response_message.function_call:
        # 実行する関数名
        available_functions = {
            "create_task": create_task,
            "update_task": update_task,
        }

        # function callが選択した関数名
        function_name = response_message.function_call.name
        # function callが選択した関数名に対応するPython関数
        function_to_call = available_functions[function_name]
        # function callが選択した関数に渡すパラメータ
        function_args = json.loads(response_message.function_call.arguments)
        # Python関数を実行し結果を取得
        function_response = function_to_call(function_args)
        # システムメッセージのプロンプト
        messages = [SystemMessage(content=OPENAI_SYSTEM_TEXT)]
        # Slackで入力した内容
        messages.append(HumanMessage(content=user_message))
        # Python関数の実行した結果から得られた情報
        messages.append(FunctionMessage(name=function_name, content=function_response))
        llm = ChatOpenAI(
            model="gpt-3.5-turbo-1106",
            temperature=0,
            streaming=True,
        )

        logging.info("messages: %s", messages)
        ai_message = llm(messages).content

        # ユーザーのメッセージと関数の実行結果をTypetalkに返す
        headers = {"X-TYPETALK-TOKEN": TYPETALK_TOKEN}
        payload = {
            "message": ai_message,
            "replyTo": user_postid,
        }

        response = requests.post(
            f"https://typetalk.com/api/v1/topics/{TYPETALK_TOPIC_ID}",
            headers=headers,
            data=payload,
            timeout=10,
        )
        logging.info("response: %s", response)
```

</details>

## デプロイ

deploy.shを実行しデプロイします｡Lambda関数に加え､Lambda LayerやAPI Gateway､IAMロール､IAMポリシーがまとめてデプロイされます｡

```shell:deploy.sh
#!/bin/bash

pip install -U chalice boto3
pip install --no-cache-dir --upgrade -r requirements.txt

chalice deploy
```

```console
$ ./deploy.sh
```

2回目以降は以下のコマンドでデプロイできます｡

```console
$ chalice deploy
```

デプロイが成功すると､以下のような情報がコンソールに出力されます｡

```console
Resources deployed:
  - Lambda Layer ARN: arn:aws:lambda:us-east-1:123456789012:layer:bolt-python-chalice-dev-managed-layer:1
  - Lambda ARN: arn:aws:lambda:us-east-1:123456789012:function:bolt-python-chalice-dev
  - Rest API URL: https://xxxxxxxxxx.execute-api.us-east-1.amazonaws.com/api/
```

## Typetalkボットの設定

Typetalkボットの設定は､[OpenAIのChatGPT APIを利用してTypetalkボットを作成する](https://qiita.com/revsystem/items/ac1d5e0111626755e647#typetalk-bot%E3%81%AE%E8%A8%AD%E5%AE%9A)の`Typetalk botの設定`を参照してください｡

ボット作成時はチェックを外していたOutgoing Webhookを使うにチェックを入れます｡入力欄に､deploy.sh実行時に出力された`Rest API URL:`の末尾に`backlogIssue`を追加したURL
`https://xxxxxxxxxx.execute-api.us-east-1.amazonaws.com/api/slack/backlogIssue`を入力します｡そして､`メンション - Typetalkはボットへの @ メンションがついたメッセージだけを Webhook URLに送信します`を選択します｡`backlogIssue`は､app.pyの以下の箇所でエンドポイントURIとして定義しています｡

```python:app.py(抜粋)
@app.route("/backlogIssue", methods=["POST"])
def lambda_handler():
    request = app.current_request

    body = request.json_body
    user_message = re.sub("@.*\\+", "", body["post"]["message"])
    user_postid = body["post"]["id"]
```

# 動作確認

ボットを設定したトピックでボットにメンション形式で課題登録や課題更新の依頼をポストします｡動作しない場合のログやOpenAI APIが返すレスポンスは CloudWatch Logs に出力されているログが参考になります｡


# Chaliceが作成したリソース

## 作成したリソース

.chalis/deployed/dev.json にChaliceが作成したリソースの情報が記録されています｡

```json:.chalis/deployed/dev.json
{
  "resources": [
    {
      "name": "managed-layer",
      "resource_type": "lambda_layer",
      "layer_version_arn": "arn:aws:lambda:us-east-1:123456789012:layer:bolt-python-chalice-dev-managed-layer:1"
    },
    {
      "name": "api_handler_role",
      "resource_type": "iam_role",
      "role_arn": "arn:aws:iam::123456789012:role/bolt-python-chalice-dev-api_handler",
      "role_name": "bolt-python-chalice-dev-api_handler"
    },
    {
      "name": "api_handler",
      "resource_type": "lambda_function",
      "lambda_arn": "arn:aws:lambda:us-east-1:123456789012:function:bolt-python-chalice-dev"
    },
    {
      "name": "rest_api",
      "resource_type": "rest_api",
      "rest_api_id": "xxxxxxxxxx",
      "rest_api_url": "https://xxxxxxxxxx.execute-api.us-east-1.amazonaws.com/api/"
    }
  ],
  "schema_version": "2.0",
  "backend": "api"
}
```

## 作成したリソースの削除

デプロイコマンドでデプロイされたリソースは以下のコマンドで削除できます｡

```console
$ chalice delete
```

削除後に再度デプロイするとRest API URLが以前とは別のもになります｡その場合､Typetalkボットの再設定が必要です｡


# まとめ
Slack同様にTypetalkでもボットを作成することができました｡AWS SAM同様にアプリケーションやAWSリソースを容易にデプロイすることもできました｡TypetalkとBacklogが連携しているため､ボットの応答に課題キーを含めることでリンク生成など実装することなく課題にアクセスすることができます｡
一方､OpenAI APIへのアクセスとその応答までの合計が約20秒~30秒かかるため処理が進んでいるのか若干不安になります｡Slack APPのようにボットがいったん`お待ちください...`のようなメッセージを返し､応答がストリーム出力されるとユーザー体験が向上すると思います｡