Backlog の課題登録時 GPT に評価してもらう（Livebook アプリで実装）

## はじめに

Elixir の Livebook から Backlog の API を呼び出してみます

ついでに、 Backlog API で取得した課題の記述について、 GPT に評価してもらって、評価コメントを課題に登録します

なお、この記事は JBUG（Backlog ユーザー会）の LT 用に書いています

興味のある方はご参加ください

https://jbug.connpass.com/event/328560/

実装したノートブックはこちら

https://github.com/RyoWakabayashi/elixir-learning/blob/main/livebooks/backlog/backlog_api.livemd

https://github.com/RyoWakabayashi/elixir-learning/blob/main/livebooks/backlog/backlog_webhook.livemd

## 前提知識

### Livebook について

Livebook は Elixir をブラウザ上で実行できるツールです

簡単にインストールできて、誰でもすぐに使えます

https://livebook.dev/

Python で言う Jupyter だと思ってください

### Backlog について

Backlog は課題管理を中心に据えたプロジェクト管理ツールです

https://backlog.com/ja/

API が公開されているので、外部の様々なサービスや自作ツールとも連携可能です

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

### OpenAI API について

OpenAI 社が提供する AI 用の API です

https://openai.com/index/openai-api/

GPT-4o など、 ChatGPT で使っている LLM を API 経由で使えます

## 事前準備

### Livebook のインストール

公式サイトから macOS や Windows 用のインストーラーをダウンロードし、実行するだけでインストールできます

https://livebook.dev/

### Backlog API の API キー作成

Backlog のワークスペースにログインし、右上ドロップダウンメニューから「個人設定」をクリックします

![スクリーンショット 2024-11-08 13.34.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/921ef1a8-bf02-423a-df31-2bfd7e0e3644.png)

個人設定の左メニューから「API」を開き、「メモ」に適当な値を入れて「登録」をクリックすれば作成できます

![スクリーンショット 2024-11-08 13.34.07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/5c68b0ac-453a-254e-85b0-ac6119493b16.png)

### OpenAI API の API キー作成

公式サイトにアクセスし、サインアップしてください

https://openai.com/index/openai-api/

:::note warn
OpenAI 社が提供する ChatGPT とは別契約になります

すでに ChatGPT のアカウントを保有をしていたとしても、別途 OpenAI API のサインアップが必要です
:::

初回アカウント作成時は無償トライアルを受けることが可能ですが、基本的に前払いでクレジットを購入しておき、 API 利用のたびに消費していく契約になります

https://openai.com/api/pricing/

左メニュー API keys を開き、右上 "+ Create new secret key" をクリックします

![スクリーンショット 2024-09-23 10.35.55.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/5cc70ee0-7a9d-36ac-267a-0f67e2f3b889.png)

モーダルが開くので、適当な名前を入力して "Create secret key" をクリックします

![スクリーンショット 2024-09-23 10.36.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/7188f002-654f-d2dc-29d3-64c4cad97aa8.png)

API キーが作成されるので、安全な場所に保管しておいてください

![スクリーンショット 2024-09-23 10.36.53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/f561c389-cd94-1e3f-26cb-d9364f57e1c2.png)

## Livebook の実装

### セットアップ

Livebook で新しいノートブックを開き、先頭のセットアップセル（`Notebook dependencies and setup` と書いてある枠）に以下のコードを入力します

```elixir
Mix.install([
  {:req, "~> 0.5"},
  {:kino, "~> 0.14"}
])
```

セルの左上 `Reconnect and setup` ボタンをクリックすると、処理に必要な依存モジュールがインストールされます

- [Req](https://github.com/wojtekmach/req): HTTP クライアント（ API の呼び出しに使用）
- [Kino](https://github.com/livebook-dev/kino): Livebook の UI/UX を提供

### API 設定

次のセル（黒い四角）内に以下のコードを貼り付け、 `Evaluate` ボタンをクリックします

```elixir
workspace_input = Kino.Input.text("WORKSPACE")
api_key_input = Kino.Input.password("API_KEY")

[workspace_input, api_key_input]
|> Kino.Layout.grid(columns: 2)
```

以下のようなテキスト入力エリアが表示されるので、ワークスペース名と API キーの値を入力します

![スクリーンショット 2024-11-08 13.47.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/bd9d2585-c532-67f8-2dda-f1e2fc7e929d.png)

:::note warn
API キーはワークスペース毎に認可されています

違うワークスペースで作成した API キーは使えません
:::

API キー入力エリアの下あたりにマウスカーソルを持っていくと、下画像のように 3 つボタンが表示されます

`+ Elixir` をクリックすると、下に次のセル（黒い四角）が追加されます

![スクリーンショット 2024-11-08 13.53.02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/51ec63a1-c620-0cc0-2f37-fff9bc2417a9.png)

追加されたセルに以下のコードを貼り付け、 `Evaluate` ボタンで実行してください

```elixir
workspace = Kino.Input.read(workspace_input)
api_key = Kino.Input.read(api_key_input)

endpoint = "https://#{workspace}.backlog.jp/api/v2"
```

実行結果として、セルの下に API の URL が表示されます

### ユーザー情報の取得

次のセルで以下のコードを実行します

```elixir
user_info =
  "#{endpoint}/users/myself?apiKey=#{api_key}"
  |> Req.get!()
  |> Map.get(:body)
```

これにより、 API キーを発行した本人のユーザー情報が取得できます

https://developer.nulab.com/ja/docs/backlog/api/2/get-own-user/

実行結果は以下のようになります

```elixir
%{
  "id" => 123123,
  "keyword" => "<氏名> <英字氏名>",
  "lang" => "ja",
  "lastLoginTime" => "2024-11-08T01:37:42Z",
  "mailAddress" => "<メールアドレス>",
  "name" => "<氏名>",
  "nulabAccount" => %{
    "iconUrl" => "<アイコン画像のURL>",
    "name" => "<氏名>",
    "nulabId" => "abcabcabcabc",
    "uniqueId" => "abc-xyz"
  },
  "roleType" => 1,
  "userId" => "xyzxyzxyz"
}
```

次のコードを実行することで、アイコン画像がそのままダウンロードできます

```elixir
# アイコン画像の取得
"#{endpoint}/users/#{user_info["id"]}/icon?apiKey=#{api_key}"
|> Req.get!()
|> Map.get(:body)
```

Livebook では画像のバイナリデータをそのまま画像として表示してくれます

![スクリーンショット 2024-11-08 14.03.38.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/338403a8-88b6-b573-7ff8-d10344c6169a.png)

### プロジェクトの取得

扱いたいプロジェクトのプロジェクトキーを確認しておきます

Backlog でプロジェクトの左メニュー「プロジェクト設定」開き、「基本設定」内の「プロジェクトキー」で確認できます

![スクリーンショット 2024-11-08 14.13.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/2a97d351-9a76-89f2-a824-25056e929d13.png)

:::note info
プロジェクトホーム画面の URL 末尾でも確認できます
:::

Livebook でプロジェクトキーの入力エリアを作成し、プロジェクトキーを入力します

```elixir
project_key_input = Kino.Input.text("PROJECT KEY")
```

プロジェクトキーを指定してプロジェクトの情報を取得します

https://developer.nulab.com/ja/docs/backlog/api/2/get-project/

```elixir
project_key = Kino.Input.read(project_key_input)

project =
  "#{endpoint}/projects/#{project_key}?apiKey=#{api_key}"
  |> Req.get!()
  |> Map.get(:body)
```

実行結果

```elixir
%{
  "archived" => false,
  "chartEnabled" => true,
  "displayOrder" => 123123,
  "id" => 123456,
  "name" => "<プロジェクト名>",
  "projectKey" => "<プロジェクトキー>",
  "projectLeaderCanEditProjectLeader" => false,
  "subtaskingEnabled" => false,
  "textFormattingRule" => "backlog",
  "useDevAttributes" => true,
  "useFileSharing" => true,
  "useGit" => false,
  "useOriginalImageSizeAtWiki" => false,
  "useResolvedForChart" => false,
  "useSubversion" => false,
  "useWiki" => true,
  "useWikiTreeView" => true
}
```

### 課題の取得

取得したプロジェクトのIDを指定し、課題の一覧を取得します

```elixir
issues =
  "#{endpoint}/issues?apiKey=#{api_key}&projectId[]=#{project["id"]}"
  |> Req.get!()
  |> Map.get(:body)
```

先頭の課題の説明文を Markdown 文書として表示します

```elixir
issues
|> hd()
|> Map.get("description")
|> Kino.Markdown.new()
```

実行結果(例)

![スクリーンショット 2024-11-08 14.24.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/b1f52889-a9eb-7cf6-dd26-f62b2c669a88.png)

Backlog 上と同じ内容になっていることが確認できます

![スクリーンショット 2024-11-08 14.25.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/5aa8d149-a05f-dd58-caf0-645e81539ed6.png)

### OpenAI API による課題の評価、コメント追加

OpenAI API キーの入力エリアを用意し、値を入力します

```elixir
openai_api_key_input = Kino.Input.password("OpenAI API KEY")
```

OpenAI API で GPT-4o mini を指定し、課題説明文の評価を指示します

```elixir
openai_api_key = Kino.Input.read(openai_api_key_input)
openai_base_url = "https://api.openai.com/v1/chat/completions"
openai_model_id = "gpt-4o-mini"
openai_headers = %{
  "Content-Type" => "application/json",
  "Authorization" => "Bearer #{openai_api_key}"
}

system_content = """
あなたは優秀なプロジェクトマネージャーです。
プロジェクトの課題がユーザーの入力として与えられるので、以下の観点で課題を評価してください。

課題評価の観点
- 明確さ（10点満点）
- 簡潔さ（10点満点）
- 整合性（10点満点）

評価結果は各評価点の合計点と、それぞれの観点に対するコメント、総評を以下の形式で出力してください。

## 評価結果の出力形式

課題記述の評価

- 明確さ（<点数>/10点）: <コメント>
- 簡潔さ（<点数>/10点）: <コメント>
- 整合性（<点数>/10点）: <コメント>

総合 <点数> 点
総評: <総評>
"""

user_content =
  issues
  |> hd()
  |> Map.get("description")

request_body = %{
  model: openai_model_id,
  messages: [
    %{
      role: "system",
      content: system_content
    },
    %{
      role: "user",
      content: user_content
    }
  ]
}

openai_response =
  "#{openai_base_url}"
  |> Req.post!(json: request_body, headers: openai_headers)
  |> Map.get(:body)
```

実行結果

```elixir
%{
  "choices" => [
    %{
      "finish_reason" => "stop",
      "index" => 0,
      "logprobs" => nil,
      "message" => %{
        "content" => "課題記述の評価\n\n- 明確さ（9/10点）:...",
        "refusal" => nil,
        "role" => "assistant"
      }
    }
  ],
  "object" => "chat.completion",
  "system_fingerprint" => "fp_8bfc6a7dc2",
  "usage" => %{
    "completion_tokens" => 337,
    "completion_tokens_details" => %{
      "accepted_prediction_tokens" => 0,
      "audio_tokens" => 0,
      "reasoning_tokens" => 0,
      "rejected_prediction_tokens" => 0
    },
    "prompt_tokens" => 887,
    "prompt_tokens_details" => %{"audio_tokens" => 0, "cached_tokens" => 0},
    "total_tokens" => 1224
  }
}
```

GPT-4o mini の返答を Markdown として表示します

```elixir
evaluation_message =
  openai_response["choices"]
  |> Enum.at(0)
  |> Map.get("message")
  |> Map.get("content")

Kino.Markdown.new(evaluation_message)
```

実行結果

![スクリーンショット 2024-11-08 14.29.50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/710430d6-10a7-69fd-7742-48e887eef691.png)

指示した通り、ちゃんと評価してくれています

評価内容を課題のコメントとして登録します

```elixir
issue_id =
  issues
  |> hd()
  |> Map.get("id")

issue_comment_headers = %{
  "Content-Type" => "application/x-www-form-urlencoded"
}

encoded_comment = URI.encode(evaluation_message)

comment_response =
  "#{endpoint}/issues/#{issue_id}/comments?apiKey=#{api_key}&content=#{encoded_comment}"
  |> Req.post!(headers: issue_comment_headers)
```

実行結果からコメントの URL を取得します

```elixir
comment_response
|> Map.get(:headers)
|> Map.get("location")
|> hd()
|> Kino.Markdown.new()
```

実行結果

![スクリーンショット 2024-11-08 14.32.49.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/2bb3b529-8241-9d74-bde6-b0b5c5af6309.png)

実行結果はリンクになっているので、クリックすると Backlog 上のコメントが確認できます

![スクリーンショット 2024-11-08 14.34.34.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/742de54f-48cc-2761-e616-c05ea6dad2cb.png)

## 課題登録時の自動実行

ここまでやってきた内容を課題登録時に自動実行するよう、 Livebook でアプリケーションを立ち上げます

### 自動実行のための前提知識

#### Livebook アプリケーション

Livebook には、ノートブック上に実装した機能を Web アプリケーションとして公開する機能が存在しています

https://qiita.com/RyoWakabayashi/items/9cd0032d1bd4ddad5d6c

さらに、 Web 画面だけでなく、 Web API として動作させることも可能です

https://qiita.com/RyoWakabayashi/items/437080f99649be8c726d

今回は手軽にローカル起動しますが、クラウド上にデプロイし、本番運用することも可能です

https://qiita.com/RyoWakabayashi/items/8ef32cb58b339291ca5d

#### Backlog Webhook

Backlog はプロジェクト単位に「〇〇したときは https://xxx/yyy を呼び出す」という Webhook の設定ができます

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

これを利用し、課題登録時に Livebook アプリケーションの URL を呼び出します

#### ngrok

ngrok は外部からの通信をローカルの HTTP 通信に転送するサービスです

https://ngrok.com/

今回はローカル起動した Livebook アプリケーションに ngrok 経由で Webhook を接続します

### 自動実行のための事前準備

#### ngrok の認証設定

公式サイトから ngrok のアカウントを作成し、認証トークンを取得しておきます

https://ngrok.com/

ngrok CLI をインストールして、認証トークンを設定しておきます

```bash
ngrok config add-authtoken <ngrok の認証トークン>
```

### 自動実行のセットアップ

Livebook で新しいノートブックを開きます

セットアップセルで依存モジュールをインストールします

```elixir
Mix.install([
  {:kino, "~> 0.14"},
  {:req, "~> 0.5"},
  {:plug, "~> 1.16"}
])
```

Livebook 左メニューの錠前アイコンをクリックし、`+ New secret` ボタンで secrets （秘密情報）を登録します

- BACKLOG_API_KEY: Backlog の API キー
- BACKLOG_WORKSPACE: Backlog のワークスペース
- OPENAI_API_KEY: OpenAI の API キー

![スクリーンショット 2024-11-08 15.34.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/a33e1810-40a5-f77f-22af-532ed51450a9.png)

`only this session` と `in Personal` の選択肢では必ず `in Personal` を指定してください（ secrets をアプリケーション上で利用するため）

下画像のようになっていれば OK です

![スクリーンショット 2024-11-08 15.34.33.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/13893a17-d6fd-41dc-33a7-2bff6e57d022.png)

### アプリケーションの定義

以下のコードをセルに貼り付けます

```elixir
defmodule ApiRouter do
  use Plug.Router

  @openai_base_url "https://api.openai.com/v1/chat/completions"
  @openai_model_id "gpt-4o-mini"

  @system_content """
  あなたは優秀なプロジェクトマネージャーです。
  プロジェクトの課題がユーザーの入力として与えられるので、以下の観点で課題を評価してください。
  
  課題評価の観点
  - 明確さ（10点満点）
  - 簡潔さ（10点満点）
  - 整合性（10点満点）
  
  評価結果は各評価点の合計点と、それぞれの観点に対するコメント、総評を以下の形式で出力してください。
  
  ## 評価結果の出力形式
  
  課題記述の評価
  
  - 明確さ（<点数>/10点）: <コメント>
  - 簡潔さ（<点数>/10点）: <コメント>
  - 整合性（<点数>/10点）: <コメント>
  
  総合 <点数> 点
  総評: <総評>
  """

  @issue_comment_headers %{
      "Content-Type" => "application/x-www-form-urlencoded"
  }

  plug(:match)
  plug(Plug.Parsers, parsers: [:json], json_decoder: Jason)
  plug(:dispatch)

  post "/" do
    process_webhook(conn.body_params)

    conn
    |> put_resp_content_type("application/json")
    |> send_resp(200, ~s({"message": "ok"}))
  end

  match _ do
    conn
    |> put_resp_content_type("application/json")
    |> send_resp(404, ~s({"message": "not found"}))
  end

  defp process_webhook(webhook_payload) do
    backlog_workspace = System.get_env("LB_BACKLOG_WORKSPACE")
    backlog_api_key = System.get_env("LB_BACKLOG_API_KEY")
    openai_api_key = System.get_env("LB_OPENAI_API_KEY")

    endpoint = "https://#{backlog_workspace}.backlog.jp/api/v2"

    openai_headers = %{
      "Content-Type" => "application/json",
      "Authorization" => "Bearer #{openai_api_key}"
    }

    %{
      "id" => issue_id,
      "description" => issue_description
    } =
      Map.get(webhook_payload, "content")

    IO.inspect(issue_description)

    request_body = %{
      model: @openai_model_id,
      messages: [
        %{
          role: "system",
          content: @system_content
        },
        %{
          role: "user",
          content: issue_description
        }
      ]
    }
    
    openai_response =
      "#{@openai_base_url}"
      |> Req.post!(json: request_body, headers: openai_headers)
      |> Map.get(:body)

    evaluation_message =
      openai_response["choices"]
      |> Enum.at(0)
      |> Map.get("message")
      |> Map.get("content")

    IO.inspect(evaluation_message)

    encoded_comment = URI.encode(evaluation_message)
    
    "#{endpoint}/issues/#{issue_id}/comments?apiKey=#{backlog_api_key}&content=#{encoded_comment}"
    |> Req.post!(headers: @issue_comment_headers)
    |> IO.inspect()
  end
end

# API を起動
Kino.Proxy.listen(ApiRouter)
```

詳しい解説は割愛しますが、以下のような流れを実装しています

- `/` に POST リクエストを受けたときに以降の処理を実行する
- リクエスト内に含まれる課題説明文を OpenAI API に渡し、評価してもらう
- 評価結果を Backlog API でコメントとして登録する 

### アプリケーションの起動

Livebook 左メニューのロケットアイコンをクリックし、アプリケーション設定を開きます

![スクリーンショット 2024-11-08 16.24.51.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/64e50959-d105-7f14-03f9-ae1831a85879.png)

`Configure` ボタンでアプリケーション設定モーダルを開き、以下の通り入力してください

- `Slug` （アプリケーション URL の末尾）: `backlog`
- Password-protected: チェックを外す

![スクリーンショット 2024-11-08 16.23.58.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/953752a9-abcc-47e0-17d8-92cf6d3868d6.png)

`Save` ボタンで設定を保存します

`Launch preview` ボタンをクリックすると、アプリケーションがローカルで起動します

![スクリーンショット 2024-11-08 16.28.34.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/77d08487-56be-af2b-c9f0-9fe6fdb1597d.png)

起動後の表示（Version の値は最初 v1 で、 Relaunch する度に増加）

![スクリーンショット 2024-11-08 16.28.14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/b59bcc10-cb68-ea49-66ad-b5cd0755c721.png)

### ngrok の起動

ターミナルで以下のコマンドを実行し、 ngrok による転送を開始します

```bash
ngrok http <Livebook の起動しているポート番号>
```

実行すると、ターミナルに以下のように出力されます

```bash
ngrok                                                                                                                                                                                                                            (Ctrl+C to quit)

Share what you're building with ngrok https://ngrok.com/share-your-ngrok-story

Session Status                online
Account                       xxx@yyy.com (Plan: Free)
Update                        update available (version 3.18.3, Ctrl-U to update)
Version                       3.3.4
Region                        Japan (jp)
Latency                       98ms
Web Interface                 http://127.0.0.1:4040
Forwarding                    https://xxxx-xxxx-xx-xxxx-xxxx-xxx-xxxx-xxxx-xxxx.ngrok-free.app -> http://localhost:<Livebook の起動しているポート番号>
```

`Forwarding` に表示されている URL からローカルに転送されています

### Webhook 設定

Backlog の「プロジェクト設定」 |> 「インテグレーション」から「Webhook」の「設定」をクリックします

「Webhook を追加する」をクリックし、フォームに以下のように入力します

- Webbook名: 任意の名前
- 説明: 任意の値
- WebHook URL: ngrok の `Forwarding` URL + `/proxy/apps/backlog`
- 課題の追加: チェックを付ける

![スクリーンショット 2024-11-08 16.36.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/53fe754e-ece8-919e-317d-b81a2dc01bdc.png)

フォームの一番下にある「Webhook を追加する」をクリックします

以下のように Webhook が追加されていれば OK です

![スクリーンショット 2024-11-08 16.40.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/41c6f891-fe54-061d-5efe-afc4157c54f4.png)

### 課題登録

Backlog で適当な課題を登録すると、辛辣な評価コメントが付けられます

![スクリーンショット 2024-11-08 16.42.00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/8eeef2d2-94c4-3c26-c7f1-954c22728871.png)

## まとめ

Livebook から Backlog API と OpenAI API を呼び出し、課題説明文を評価できました

Backlog には他にも色々な API があるので、もっと色々できそうです

Webhook アプリケーションを Fly.io 等にデプロイしておけば、様々な自動化にも活用できます

Livebook はグラフや表の出力も得意なので、 Backlog の拡張コンソールも実装できると思います