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

# はじめに

前回の記事､[OpenAIのFunction callingとBacklog APIを使って、自然言語で課題を作成,更新する](https://qiita.com/revsystem/items/357daf64a911fc2a5713)では､ハードコーディングしたプロンプトを用いてBacklog課題の登録や更新を行いました｡今回は､Slack Appを作成し､ボットにメンション形式でプロンプトを渡してBacklog課題の登録や更新を行います｡

僕はBacklogに課題を登録する際にどのような件名にするか迷うことがあります｡そこで､課題の内容をもとに件名を自動作成するようにしました｡
また､OpenAI APIに課題詳細の一部を自動作成してもらうようなプロンプトを作ってみました｡
このボットはChatGPTのように履歴をもとにした会話を続けることはできないため､シンプルなアシスタントとして捉えてもらえればと思います｡

今回はSlackボットを作成しましたが、別記事で同様の動作をするTypetalkボットの作成を紹介しています｡

https://qiita.com/revsystem/items/2dfb4c767d3b14c611d8



# デモ画面

実際に動かしてみた画面のスクリーンショットがこちらです｡このように､ボットに自然言語で依頼すると､Backlog課題の登録や更新が行えます｡
(アニメーションGIFが記事内で表示されないため、スクリーンショットを載せます。)

プロンプトの入力
![プロンプトの入力](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61809/1825d420-7fc9-3530-0780-6e21d45d7812.png)
スレッドで課題の作成を確認
![スレッドで課題の作成を確認](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61809/7afa3c64-49da-3dd8-0ef0-b22e1952a8a2.png)
実際に作成されたBacklog課題
![実際に作成されたBacklog課題](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61809/61f1bf52-4c5e-24b6-cd1c-e57f8b673aac.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-326 です。

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

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

# 参考情報

https://gihyo.jp/book/2023/978-4-297-13839-4

https://platform.openai.com/docs/guides/function-callingretry_counts

https://github.com/aws/serverless-application-model/blob/master/docs/internals/generated_resources.rst

https://github.com/openai/openai-python/discussions/742

https://developer.nulab.com/ja/docs/backlog/

https://slack.dev/bolt-python/ja-jp/concepts#lazy-listeners

https://slack.dev/bolt-python/ja-jp/tutorial/getting-started#create-an-app

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

https://qiita.com/seratch/items/6d142a9128c6831a6718

https://qiita.com/kp047i/items/43f2d99a8b7fa4770342

https://www.inoue-kobo.com/aws/slack-gpt-backend/

https://zenn.dev/d2c_mtech_blog/articles/293d8c641e3361

https://repost.aws/questions/QUhJBhhFBzRDSLnuP1ZKaeWg/why-calling-openai-api-from-lambda-is-much-slower-than-calling-from-ec2-t2-micro

# 前提条件

* 以下のリソースを作成･削除･変更できる権限をもつAWSユーザーを利用すること
    * AWS IAM
    * AWS Lambda
    * AWS CloudFormation
    * Amazon API Gateway
    * Amazon S3
    * Amazon CloudWatch Logs

* 使用するAWSリージョンは、us-east-1 (リージョンの制約はないため、他のリージョンも可)
* OpenAIのAPIキーを取得済み
* Slack Appを作成するためのアカウントや権限を持っている
* BacklogのAPIキーやコード内で設定するissueTypeId(課題種別ID)などの準備ができている (Backlog APIの利用に必要なAPIキーやパラメータの確認方法は､以下の記事が参考になります｡)

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

# 環境構築

## OSのバージョン

Windows 11上のWSLでUbuntu 23.04を動かしています。

```console
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 --version
Python 3.11.4
```

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

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

## AWS環境構築

`aws configure`コマンドでデフォルトのリージョンやクレデンシャルを設定するか､もしくは~/.aws/configや~/.aws/credentialsを用意します。

### AWS ChaliceではなくAWS SAMを使う

以下の記事ではAWS Chaliceを使ってSlack Appを作成しました｡

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

今回も同様にAWS Chaliceを使って構築を行おうとしたのですが､Lambdaのコールドスタートの影響を回避できない事象に遭遇しました｡

https://twitter.com/revsystem/status/1726642146181365787

以下の様なコメントもあり､AWS ChaliceではコールドスタートやSlack APIのタイムアウト制約に遭遇してしまうようです｡そのため､AWS SAMを使うことにしました｡

https://twitter.com/seratch_ja/status/1726787201084866908



## AWS SAM CLIインストール

AWS上でサーバーレスアプリケーションを構築､実行するAWS SAMを使用します｡

[Installing the AWS SAM CLI](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html) の手順に従い、AWS SAM CLIをインストールします。今回はx86_64環境でLinux OSを使用するため、`x86_64 - command line installer`の手順を実行します｡

```console
$ sam --version
SAM CLI, version 1.103.0
```

## Slack Appの作成

[Slac API](https://api.slack.com/apps?new_app=1)を開き､From scratchからSlack Appを作成します｡ここでは、App Nameを`backlog-assistant`とします｡

Basic Information画面のApp Credentialsに表示されているクレデンシャルは`SlackSigningSecret`として後述のデプロイ時に使用します。

![App Credentialsに表示されているクレデンシャルを参照](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61809/dcdec7e0-a9f6-6109-8e01-b4798c9716ab.png)

OAuth & Permissions画面のOAuth Tokens for Your WorkspaceにあるBot User OAuth Tokenは、`SlackBotToken`として後述のデプロイ時に使用します。

![OAuth Tokens for Your Workspaceに表示されているトークンを参照](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61809/7d19fb7c-908b-4c6b-ce5a-5ccfe807da7d.png)

OAuth & Permissions画面のBot Token Scopesにapp_mentions:readとchat:writeを追加します｡

![Bot Token Scopeでボットのスコープを設定](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61809/0e8fd56d-79d3-f11a-7ace-e05a1c3f635d.png)

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

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

```cosole
.
├── openai_slack_backlog_assitant
│   ├── __init__.py
│   ├── app.py
│   └── requirements.txt
├── samconfig.toml
└── template.yaml
```

`__init__.py`は空のファイルです｡

requirements.txtは以下のとおりです｡openaiライブラリはv1.0.0で仕様が大幅に変更されたため､バージョン範囲を指定します｡詳しくは､[v1.0.0 Migration Guide](https://github.com/openai/openai-python/discussions/742)に記載されています｡

```python:requirements.txt
slack-bolt
slack-sdk
langchain
openai>=1.0.0
```

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

```python
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Register tasks using a Backlog task management system.

Resources:
  BacklogAssitantFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: openai_slack_backlog_assitant/
      Handler: app.lambda_handler
      Runtime: python3.11
      Role: !GetAtt LambdaRole.Arn
      Timeout: 180
      MemorySize: 384
      Architectures:
        - x86_64
      Environment:
        Variables:
          SLACK_SIGNING_SECRET: !Ref SlackSigningSecret
          SLACK_BOT_TOKEN: !Ref SlackBotToken
          OPENAI_API_KEY: !Ref OpenaiApiKey
          BACKLOG_API_KEY: !Ref BacklogApiKey
          BACKLOG_BASE_URL: !Ref BacklogBaseUrl
      Events:
        Slack:
          Type: Api
          Properties:
            Method: POST
            Path: /slack/events
            TimeoutInMillis: 1000

  LambdaRole:
    Type: "AWS::IAM::Role"
    Properties:
      RoleName: backlog-assitant-lambda-role
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: allow-lambda-invocation
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - lambda:InvokeFunction
                  - lambda:InvokeAsync
                Resource: "*"
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole

  BacklogAssitantLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub /aws/lambda/${BacklogAssitantFunction}
      RetentionInDays: 14

Parameters:
  SlackSigningSecret:
    Type: String
    Default: ""
  SlackBotToken:
    Type: String
    Default: ""
  OpenaiApiKey:
    Type: String
    Default: ""
  BacklogApiKey:
    Type: String
    Default: ""
  BacklogBaseUrl:
    Type: String
    Default: ""
Outputs:
  BacklogAssitantApi:
    Description: "The URL of Slack Event Subscriptions"
    Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/slack/events"
  BacklogAssitantFunction:
    Description: "Hello World Lambda Function ARN"
    Value: !GetAtt BacklogAssitantFunction.Arn
  BacklogAssitantFunctionIamRole:
    Description: "Implicit IAM Role created for Hello World function"
    Value: !GetAtt LambdaRole.Arn
```

</details>

AWS SAM テンプレートファイル(template.yaml)に､作成するAWSリソースを定義します｡Lambda関数用ロールやLambdaの環境変数､API Gatewayの設定が含まれます｡
Lambdaのメモリサイズは384MBに設定しました｡256MBでも動作しますが､OpenAI APIからのレスポンス時間が1~2分かかるためメモリ量を調整しました｡この結果､レスポンス時間は数秒に短縮されました｡この調整については､[Why calling OpenAI API from Lambda is much slower than calling from EC2 t2.micro](https://repost.aws/questions/QUhJBhhFBzRDSLnuP1ZKaeWg/why-calling-openai-api-from-lambda-is-much-slower-than-calling-from-ec2-t2-micro) が参考になりました｡

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

```ini
# More information about the configuration file can be found here:
# https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-config.html
version = 0.1

[default]
[default.global.parameters]
stack_name = "openai-slack-backlog-assitant"

[default.build.parameters]
cached = true
parallel = true

[default.validate.parameters]
lint = true

[default.deploy.parameters]
capabilities = "CAPABILITY_NAMED_IAM"
confirm_changeset = true
resolve_s3 = true
region = "us-east-1"

[default.package.parameters]
resolve_s3 = true

[default.sync.parameters]
watch = true

[default.local_start_api.parameters]
warm_containers = "EAGER"

[default.local_start_lambda.parameters]
warm_containers = "EAGER"
```

</details>

SAM CLIの実行設定ファイル(samconfig.toml)に､SAM CLIを実行する際の設定を定義します｡AWS SAMの[チュートリアル: Hello World アプリケーションのデプロイ](https://docs.aws.amazon.com/ja_jp/serverless-application-model/latest/developerguide/serverless-getting-started-hello-world.html)を実行した際に作成されるsamconfig.tomlをもとにしています｡今回の例では､以下の点を変更しています｡
* `[default.global.parameters]`セクションの`stack_name`を"sam-app"から"openai-slack-backlog-assitant"に変更
*  `[default.deploy.parameters]`セクションに`region`指定を追加
* `[default.deploy.parameters]`セクションの`capabilities`を"CAPABILITY_IAM"から"CAPABILITY_NAMED_IAM"に変更


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


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

import requests
from langchain.callbacks.base import BaseCallbackHandler
from langchain.chat_models import ChatOpenAI
from langchain.schema import FunctionMessage, HumanMessage, LLMResult, SystemMessage
from openai import OpenAI
from slack_bolt import App
from slack_bolt.adapter.aws_lambda import SlackRequestHandler

# 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")
# チャットの更新間隔
CHAT_UPDATE_INTERVAL_SEC = 1
# システムメッセージ
OPENAI_SYSTEM_TEXT = """
あなたはタスクマネージャーです。あなたには関数のリストと、関数で使用するパラメータが与えられています。
与えられた関数とパラメータを使ってタスクを作成する必要があります。
タスクを作成し､作成したタスクを返してください。
"""

# ログの設定
SlackRequestHandler.clear_all_log_handlers()
logging.basicConfig(
    format="%(asctime)s [%(levelname)s] %(message)s",
    level=logging.INFO
)

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

# Slackアプリの設定
app = App(
    signing_secret=os.environ.get("SLACK_SIGNING_SECRET"),
    token=os.environ["SLACK_BOT_TOKEN"],
    process_before_response=True,
)


def create_task(arguments):
    """
    課題を登録する関数
    arguments: Function callingが判定した、Backlog APIに渡すパラメータ
    """

    # Backlog APIに渡すパラメータを設定する
    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,
    )

    return json.dumps(response.json())


def update_task(arguments):
    """
    課題を更新する関数
    arguments: Function callingが判定した、Backlog APIに渡すパラメータ
    """

    # Backlog APIに渡すパラメータを設定する
    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,
    )

    return json.dumps(response.json())


class SlackStreamingCallbackHandler(BaseCallbackHandler):
    """
    Slackのメッセージをストリーミングで更新するためのコールバックハンドラ
    """
    last_send_time = time.time()
    message = ""

    def __init__(self, channel, ts):
        self.channel = channel
        self.ts = ts
        self.interval = CHAT_UPDATE_INTERVAL_SEC
        # 投稿を更新した累計回数カウンタ
        self.update_count = 0

    def on_llm_new_token(self, token: str, **kwargs) -> None:
        """
        LLMがトークンを生成したら、メッセージを更新する
        メッセージをストリーミングで更新する
        """
        self.message += token

        now = time.time()
        if now - self.last_send_time > self.interval:
            app.client.chat_update(
                channel=self.channel,
                ts=self.ts,
                text=f"{self.message}\n\n..."
            )
            self.last_send_time = now
            self.update_count += 1

            # update_countが現在の更新間隔X10より多くなるたびに更新間隔を2倍にする
            if self.update_count / 10 > self.interval:
                self.interval = self.interval * 2

    def on_llm_end(self, response: LLMResult, **kwargs: Any) -> Any:
        """
        LLMの実行が終了したら、注意書きを追加してメッセージを更新する
        https://api.slack.com/block-kit
        """
        message_context = """
            OpenAI APIで生成される情報は不正確または不適切な場合があります。
            """
        message_blocks = [
            {
                "type": "section",
                "text":
                    {
                        "type": "mrkdwn",
                        "text": self.message
                    }
            },
            {
                "type": "divider"
            },
            {
                "type": "context",
                "elements": [
                    {
                        "type": "mrkdwn",
                        "text": message_context
                    }
                ],
            },
        ]
        app.client.chat_update(
            channel=self.channel,
            ts=self.ts,
            text=self.message,
            blocks=message_blocks,
        )


# @app.event("app_mention")
def handle_app_mentions(event, say):
    """
    Slackのメンションを受け取ったら、メッセージをOpenAI APIに送信する
    """

    channel = event["channel"]
    thread_ts = event["ts"]
    # Slackでメンションを受け取ったら、メッセージからメンションを取り除く
    user_message = re.sub(r'<@.*?> ', "", event["text"])
    # botからの回答を待つために、一度メッセージを送信する
    result = say("\n\nお待ちください...", thread_ts=thread_ts)
    ts = result["ts"]

    # 利用可能な関数とプロンプトを設定する
    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))
        callback = SlackStreamingCallbackHandler(channel=channel, ts=ts)
        llm = ChatOpenAI(
            model="gpt-3.5-turbo-1106",
            temperature=0,
            streaming=True,
            callbacks=[callback],
        )

        logging.info("messages: %s", messages)
        # モデルにプロンプトを送信し､結果をSlackに送信する
        llm(messages)


def respond_to_slack_within_3_seconds(ack):
    ack()


app.event("app_mention")(
    ack=respond_to_slack_within_3_seconds,
    lazy=[handle_app_mentions]
)


def lambda_handler(event, context):

    logging.info(json.dumps(event))

    retry_counts = event.get("multiValueHeaders", {}).get("X-Slack-Retry-Num", [0])

    if retry_counts[0] != 0:
        logging.info("Skip slack retrying(%s).", retry_counts)
        return {}

    slack_handler = SlackRequestHandler(app=app)
    return slack_handler.handle(event, context)

```

</details>

ボットプログラムの本体です｡メンションをストリーミングで出力するコールバックハンドラや､Bolt for Python を使ったアプリをAWS Lambda上で動かしやすくするための[Lazy Listeners](https://slack.dev/bolt-python/ja-jp/concepts#lazy-listeners)を利用しています｡ Function callingを扱う箇所は､[OpenAIのドキュメント](https://platform.openai.com/docs/guides/function-calling)などを参考にしています｡

## ビルド

template.yamlがあるディレクトリで､ビルドコマンドを実行します｡

```console
$ sam build
```

ビルドに成功すると､いかのようなメッセージが表示されます｡

```console
Starting Build use cache
Manifest is not changed for (BacklogAssitantFunction), running incremental build
Building codeuri: /home/xxx/openai_slack_backlog_assitant runtime: python3.11 metadata: {} architecture: x86_64 functions:
BacklogAssitantFunction
 Running PythonPipBuilder:CopySource
 Running PythonPipBuilder:CopySource

Build Succeeded

Built Artifacts  : .aws-sam/build
Built Template   : .aws-sam/build/template.yaml

Commands you can use next
=========================
[*] Validate SAM template: sam validate
[*] Invoke Function: sam local invoke
[*] Test Function in the Cloud: sam sync --stack-name {{stack-name}} --watch
[*] Deploy: sam deploy --guided
```

## デプロイ

sam deployコマンドを実行し､デプロイを行います｡引数には､前述のSlack App設定画面で取得したSlackSigningSecretやSlackBotToken､OpenAPIのAPIキー､BacklogのAPIキー､BacklogのURlを指定します｡BacklogのURL内のドメインは､ユーザーによってはbacklog.jpの場合があります｡
ここではコマンドラインの引数に直接キーを渡していますが､本運用する場合はAWS Secrets Managerにキーを保存するなど安全に管理するほうが良いと思います｡

```console
 sam deploy \
    --parameter-overrides SlackSigningSecret=xxxxxxxxxx \
    SlackBotToken=xoxb-xxx-xxxx-xxxxx \
    OpenaiApiKey=sk-xxxxxxxxxx \
    BacklogApiKey=xxxxxxxxxx \
    BacklogBaseUrl=https://xxxx.backlog.com/

```

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

```console
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Outputs
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Key                 BacklogAssitantApi
Description         API Gateway endpoint URL for Prod stage for BacklogAssitantFunction function
Value               https://xxxxxxxxxx.execute-api.us-east-1.amazonaws.com/Prod/slack/events

Key                 BacklogAssitantFunction
Description         Hello World Lambda Function ARN
Value               arn:aws:lambda:us-east-1:xxxxxxxxxxxx:function:openai-slack-backlog-assit-BacklogAssitantFunction-xxxxxxxxxxxx

Key                 BacklogAssitantFunctionIamRole
Description         Implicit IAM Role created for Hello World function
Value               arn:aws:iam::xxxxxxxxxxxx:role/backlog-assitant-lambda-role
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
```

### Slack Appの設定

メンションイベントに応答するために､Event Subscriptions画面のSubscribe to bot eventsにapp_mentionを追加します｡

![Subscribe to bot eventsにapp_mentionを追加](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61809/27e712e6-9106-66cc-b28c-cc8158f88e01.png)

Event Subscriptions画面のEnable EventsをOnにし､Request URLにさきほど出力されたURL https://xxxxxxxxxx.execute-api.us-east-1.amazonaws.com/Prod/slack/events を入力します｡
`Verified✓` と表示されれば､正しいURLが入力されたことになります｡

![Enable Eventsに､API GatewayのエンドポイントURLを設定](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61809/d0daeada-23c8-c170-0898-6672773f9717.png)



設定を追加後､画面最下部にあるSave Changesをクリックし内容を保存します｡

### Slack Workspaseにアプリをインストール

Install App画面のInstall to Workspaceをクリックし､Slack AppをWorkspaceにインストールします｡
インストールに成功すると､Thank you!画面が表示されます｡Slackアプリをインストールしている場合は`click here`のリンク､Webブラウザを使用している場合は､`this link`をクリックしてSlackを開きます｡

# 動作確認
任意のチャンネンルに`@backlog-assistant`を招待し､メンション形式で依頼をポストします｡動作しない場合のログやOpenAI APIが返すレスポンスは CloudWatch Logs に出力されているログが参考になります｡

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

最後に､作成したアプリケーションを削除する手順です｡リソースを削除するには `sam delete` コマンドを実行します｡

```console
$ sam delete
```


# コードについて

コードのなかから､Function callingのポイントとなる課題の登録と更新を行うfunctionを抜粋して紹介します｡

## 課題の登録

Backlog APIの[課題の追加](https://developer.nulab.com/ja/docs/backlog/api/2/add-issue/)のリクエストパラメーターをもとにcreate_task関数を定義しました｡LLMが自然言語をもとに使用するパラメータと値を判断できるよう､descriptionに日本語に対応する値を記述しました｡issueTypeIdはBacklogのスペースによって異なるため前述の前提条件のとおり､確認が必要です｡

### create_task関数

functionsにcreate_taskという名前の関数を定義します｡`summary`,`projectId`,`issueTypeId`などBacklog APIのパラメーター名を定義し､それぞれどのような値が入るかをdescriptionに記述します｡期日に関するパラメータは日付のフォーマットが決まっているため､`format`と`example`を用いて例を示します｡Backlog APIにはprojectIdを渡す必要があります｡課題登録を依頼するポストでは`APPプロジェクトに課題を登録してください｡`としたため､プロジェクトキー`APP`をprojectIdに変換する必要があります｡そのため､projectId{}のdescriptionでprojectIdとAPPの対応を補足しました｡このような補足も自然言語で記述でき､OpenAI APIが読み取って内容を判断してくれます｡

```python:create_task
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",
            ],
        },
    }
]
```

### LLMからのレスポンス

前述の課題登録を依頼するポストのメッセージとfunctionsをOpenAI APIに渡すと､LLMから以下のレスポンスが得られました｡`name='create_task'`とあるように､LLMによってcreate_taskが選択され､課題を登録する依頼に記述した内容がそれぞれパラメータに当てはめられているのが分かります｡`アプリケーション開発工程10項を作成してください｡`と指示した内容も反映されています｡(descriptionには実際は\nが出力されていますが､表記上分かりやすくするため改行をいれました｡)

```text
ChatCompletionMessage(
    content=None,
    role='assistant',
    function_call=FunctionCall(
        arguments='{
            "summary": "開発工程のタスク作成",
            "projectId": 129821,
            "description":
            "|タスク|解説|
            |---|---|
            |要件定義|要件定義を行う|
            |基本設計|基本設計を行う|
            |詳細設計|詳細設計を行う|
            |実装|機能を実装する|
            |単体テスト|単体テストを実施する|
            |結合テスト|結合テストを実施する|
            |システムテスト|システムテストを実施する|
            |ユーザーテスト|ユーザーテストを実施する|
            |テスト結果分析|テスト結果を分析する|
            |リリース|本番環境にリリースする|

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

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

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

            ## 数値目標
            - テストユーザーによるテストにおいて､軽微なバグの件数を10件以下にする
            - テストユーザーによるテストにおいて､重大なバグの件数を0件にする
            - リリース後､1週間以内にユーザーからの不具合報告が0件になる",
            "issueTypeId": 624226,
            "startDate": "2023-12-05",
            "dueDate": "2024-04-05",
            "priorityId": 2
        }',
        name='create_task'),
    tool_calls=None
)


```

ChatCompletionMessage内のargumentsの内容をPython関数のcreate_task()に渡しBacklog APIにリクエストを送信します｡そして､課題を登録する依頼のポストとBaclog APIからのレスポンスを合わせてLLMに渡し､ボットの返信メッセージを作成します｡

## 課題情報の更新

つづいて､課題の更新です｡Backlog APIの[課題情報の更新](https://developer.nulab.com/ja/docs/backlog/api/2/update-issue/)のリクエストパラメーターをもとにupdate_task関数を追加します｡課題の追加とほぼ同じですが､更新対象を特定するためにissueKeyパラメータを定義しています｡

### update_task関数

```python:update_task
functions = [
    {
        # 課題を更新する関数
        "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
                    """,
                },
            },
        },
    }
]
```

### LLMからのレスポンス

前述の課題情報の更新を依頼するポストのメッセージとfunctionsをOpenAI APIに渡すと､LLMから以下のレスポンスが得られました｡`name='update_task'`とあるように､LLMによってupdate_taskが選択され､課題情報の更新依頼に記述した内容がそれぞれパラメータに当てはめられているのが分かります｡

```text
ChatCompletionMessage(
    content=None,
    role='assistant',
    function_call=FunctionCall(
        arguments='{
            "issueKey": "APP-326",
            "summary": "アプリケーションに新規機能とヘルプページを追加する",
            "issueTypeId": "624224",
            "priorityId": "3",
            "dueDate": "2023-12-27"
        }',
        name='update_task'
    ),
    tool_calls=None
)
```

ChatCompletionMessage内のargumentsの内容をPython関数のupdate_task()に渡しBacklog APIにリクエストを送信します｡そして､課題情報の更新を依頼するポストとBaclog APIからのレスポンスを合わせてLLMに渡し､ボットの返信メッセージを作成します｡

# まとめ

OpenAI APIのFunction callingを使用すると､自然言語による入力に応じた関数を呼び出しやBacklog APIの実行に必要なパラメータの組み立てを簡単に実装することができました｡functions内のパラメータ定義でも`優先度が'低'の場合､  PriorityId は 4`といった記述で条件分岐やパラメータと値の紐付けが行えるため､API連携の実装が簡単に行えました｡

課題の登録を依頼するポストでは､以下のように`開発工程とは`と例示したことで安定して意味のある出力が得られました｡この例示を省略した場合､"項目1","項目2"...といった無意味な出力となる場合があります｡Backlog課題を一定のフォーマットで記述できるよう､LangChainのtemplateと組み合わせたり出力フォーマットを指定するなどの工夫でより精度の高い出力が得られると思います｡

```text:課題を登録するポストの例
課題の本文には、アプリケーション開発工程10項目を以下の制約条件と出力形式に従って作成してください｡
開発工程とは、例えば要件定義や基本設計などです。
```

一方で期待したとおりの出力が得られない点もありました｡ポストしたプロンプトに現在時刻をタイムゾーン付きで追加しています｡そのため､`期限日は1週間後です｡`とすることで､課題の期日が登録日の1週間後に設定されます｡しかし､`期限日は明明後日(しあさって)です｡`とすると､なぜか明後日の日付が出力されました｡gpt-3.5-turbo-1106､gpt-4-1106-preview､ChatGPT 3.5のいずれも誤った結果を出力したため､現時点の言語モデルが`明明後日`を正しい日付に変換できないのかもしれません｡
ほかにも､課題登録を依頼するプロンプトには`プロジェクトの概要`や`目的`などを含めていますが､Backlog APIにそれらが渡されない場合がありました｡

今回のアプリケーションでは自然言語をもとにBacklog課題を操作することができました｡これを応用して､課題やWikiの要約を依頼することもできそうです｡OpenAIの[Speech to text](https://platform.openai.com/docs/guides/speech-to-text)を利用して音声を文字起こしした結果からBacklogを操作し､その結果を[Text to speach](https://platform.openai.com/docs/guides/text-to-speech)を利用して音声で応答することもできそうです。