OpenAI が質問に答えてくれる Slackスラッシュコマンド の作り方
はじめに
(ちょっと前?)話題になった OpenAI の ChatGPT
そのベースモデルと言われているGPT-3のトレーニング済みモデルは公開されていて、APIから利用することが出来ます。
これを使って実験的な社内ツール(という名目)で簡単なアプリを作ってみました。
結果的に出来たのは
Slackのチャンネルメッセージ入力欄から、今回自作したコマンド「/query
」で質問を投げると
こんな風に返してくれるSlackアプリ。
ゴール
- Slackのスラッシュコマンドを使い
- OpenAIのGPT-3モデル(Davinci)に質問を投げかけて
- その回答をSlackに返す
というシンプルなSlackアプリを作ります。
今回、実行基盤は手軽にWebhookを使えるPipedreamを選びました。
使うサービス
- Pipedream
- HTTP/Webhook API
- Slack App
- Slash Commands
- OpenAI API
- Completion Endpoint
流れ
おおまかに2つ
- Slackから質問するフロー
- その回答をSlackに返すフロー
です。具体的には
質問フロー
- Slack → Pipedream → OpenAI
回答フロー
- OpenAI → Pipedream → Slack
という形で、準備していきます。
Pipedream
まず先にエンドポイントとなるWebhook APIから用意しましょう。
(Pipedream自体の詳しい使い方は割愛)
- HTTP / Webhook API
外部に公開されたAPIエンドポイントを簡単に作成出来ます
ワークフローを新規作成
Triggerに「HTTP / Webhook」を選択して新しいワークフローを作成します。
Event Dataに「Full HTTP Request」
HTTP Responseには、「Return a static response」を選択
ヘッダーは、'Content-type: application/json'
ステータスは'200'で、Bodyはとりあえず以下を指定。
{
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "🤖< チョットカンガエテイマス..."
}
},
]
}
Save & Continue をクリックすると、エンドポイントURLが表示。
このURLを、Slackアプリ作成時にRequest先として設定するので、控えておきます。
Slack
今回はSlackアプリの
を使います。
Slash Commandsを使った質問時の流れは
-
/command [なんかのメッセージ]
のコマンド形式でSlackから質問 - Slackからエンドポイント(Pipedream)にメッセージがPOST
- Pipedream側で受け取ってメッセージを処理
結果の返し方は、Slash Commandsには2つのパターンがあります。
-
POSTに対する「レスポンスとしてメッセージを返す」パターン
- このとき、Content-Typeを指定せず(or
text/plain
)に返せば、テキストがそのままメッセージ本文になる - Content-Typeを
application/json
として、所定のJSONフォーマットで返すとメッセージの見た目をリッチに出来る(ブロックメッセージが使える - 3秒以内に応答する必要がある
- このとき、Content-Typeを指定せず(or
-
POST時のパラメータ 「
response_url
で指定されるURLに対してメッセージをPOSTする」パターン-
response_url
時限付きのWebhookエンドポイント - 時間のかかる処理の場合はこちら
- 受付時のレスポンスは、200などを返しておく
- このレスにも3秒ルール(3秒以内にレスポンスを返す)
-
今回の様に時間がかかる処理では、2のレスポンス用URL使った方法を採用します。
まずSlackアプリの作成
既存のアプリがあればそれを転用してもOK
なければ新規作成していきます。
アプリの作成
- 任意の名前
- 接続するSlackワークスペースを選択(アプリの追加先
Slash Commands の作成
Settingメニュー > Basic Information から Slash Commands を選択
- 任意のコマンド名
- Request URL
- 最初に作成したPipedreamのWebhook APIのエンドポイントURL
- コマンドについての説明
- コマンドの使い方
を入力して作成。
Slackワークスペース
Settingメニュー > Basic Informationに戻り、任意のワークスペースに作成したアプリをインストール。
疎通確認
ここまで出来たら一旦「Slackアプリ ↔ Pipedream」の疎通確認だけ行っていきます。
(一気に行く場合は飛ばしてOK)
先程決めたコマンドを、インストールしたSlackワークスペースから実行してみます。
適当なチャンネルか、自身のDM上から
/query hello
入力して送信。
うまくいくと、Slack側では以下の様なJSONテキストが返ってきます。
{
"success": true,
"info": "To customize this response, check out our docs at https://pipedream.com/docs/workflows/steps/triggers/#customizing-the-http-response"
}
Pipedream側では、以下の様な形での受信が確認出来ると思います。
Pipedream側でのSlackアプリ認証
Pipedream側でリクエスト内容をチェックして、Slackアプリからのリクエストだけを処理するようにします。
具体的にはSlackアプリからのリクエストごとに、署名済みシークレットが送られてくるので、これを受けた側で検証します。
詳しくはこちら
Pipedreamのステップで検証します。
import crypto from 'crypto'
const isNullOrUndefined = (value) => {
return (value === null || typeof value === 'undefined')
}
export default defineComponent({
async run({ steps, $ }) {
const now = Math.floor(Date.now() / 1000)
const ts = steps.trigger.event.headers["x-slack-request-timestamp"]
if( isNullOrUndefined(ts) ){
$.flow.exit('Request Timestamp Error')
return
}
// 発行から5分経過していたら蹴る
const passed = Math.abs(now - ts)
if( passed > 60 * 5 ){
$.flow.exit('this request timestamps is more than 5 min. ' + `${passed} sec passed` )
return
}
// Bodyをパラメータに
const body = steps.trigger.event.body
const bodyParams = new URLSearchParams(body).toString();
// シグネチャー
const sigBase = 'v0:' + ts + ':' + bodyParams
// ハッシュ化
const secret = process.env.SLACK_SIGNING_SECRET
const sig = 'v0=' + crypto.createHmac('sha256', secret)
.update(sigBase)
.digest('hex');
console.log(sig);
// 送られきたシグネチャーと比較
const slackSig = steps.trigger.event.headers["x-slack-signature"]
if( isNullOrUndefined(slackSig) ){
$.flow.exit('Slack Signature Error')
return
}
if( sig === slackSig ){
$.flow.exit('Verifying Signature Did Success')
return steps.trigger.event
}
$.flow.exit('this signature is invalid')
return
},
})
ざっくり流れ
発行タイムスタンプチェック
まず、タイムスタンプをチェックして5分経過してれば弾きます。
タイムスタンプはヘッダー
x-slack-request-timestamp
にあります。
const ts = steps.trigger.event.headers["x-slack-request-timestamp"]
シグネチャーのベースとなる文字列を作成
シグネチャーは
v0
- タイムスタンプ
- トークン以下のBody
を、:
で連結した文字列です。
トークン以下のBody
は、pipedreamの場合、JSONにパースされているのでこれをクエリパラメータの形に戻しておきます。
const body = steps.trigger.event.body
const bodyParams = new URLSearchParams(body).toString();
token=XXXXXXXXXXX&team_id=XXXXXXXX&team_domain=XXXX&channel_id=XXXXXX&channel_name=directmessage&user_id=XXXXXXX&user_name=....
:
で連結したシグネチャー文字列
v0:999999999999:token=XXXXXXXXXXX&team_id=XXXXXXXX&team_domain=XXXX&channel_id=XXXXXX&channel_name=directmessage&user_id=XXXXXXX&user_name=....
作成したシグネチャー文字列をハッシュ
Slackアプリの検証用シークレットを取得
SHA256ハッシュを作成
const sig = 'v0=' + crypto.createHmac('sha256', secret)
.update(sigBase)
.digest('hex');
最後に先頭に v0=
を付与
v0=fcdfeaeed6caecbdc037e1f5cd6dedbccf0fd9bade6e7a130f6ca8302c070f0d
作成したハッシュと送信されてきたシグネチャーを比較
bcrypt が npm install error
になって使えなかったので、普通に比較チェックしています。
シグネチャーはヘッダーの
x-slack-signature
から取得できます。
const slackSig = steps.trigger.event.headers["x-slack-signature"]
OpenAI API
OpenAI APIの、completionsエンドポイントを利用します。
質問などを投げかけると、指定したモデルが予測した結果を返します。
POST https://api.openai.com/v1/completions
Completions API
今回は、一番頭が良いとされる「Davinci」モデルの2021年6月にトレーニングが完了した「text-davinci-003
」を指定しました。
モデル詳細
とりあえず試す
curl https://api.openai.com/v1/completions \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer {your api key}' \
-d '{
"model": "text-davinci-003",
"prompt": "ハンターハンターっていつ終わるの?",
"max_tokens": 4000
}'
レスポンス
{
"id": "cmpl-6KeDiOQ13ZVT5cikSXAH2DSQeSuOL",
"object": "text_completion",
"created": 1670379186,
"model": "text-davinci-003",
"choices": [
{
"text": "\n\n「HUNTER×HUNTER」の終了日は特に宣言されていません。最新話の続きがいつ始まるかも分かりません。",
"index": 0,
"logprobs": null,
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 24,
"completion_tokens": 66,
"total_tokens": 90
}
}
Pipedreamに実装
Pipedreamでステップを作成して、OpenAIにPOSTする実装をしていきます。
import axios from "axios"
export default defineComponent({
async run({ steps, $ }) {
const endpoint = 'https://api.openai.com/v1/completions'
const token = process.env.OPEN_API_SECRET
const client = axios.create({
baseURL: endpoint,
headers: {
'Content-type': 'application/json',
'Authorization': ` Bearer ${token}`
},
})
const prompt = steps.trigger.event.body.text
const body = {
"model": "text-davinci-003",
"prompt": prompt,
"max_tokens": 4000
}
try{
const resp = await client.post('', body)
console.log(resp.data)
return resp.data
}catch(err){
console.log(err)
return null
}
},
})
Slackに回答を返す
回答送信先のURLは response_url
にあります。
これは一時的なIncoming Webhookの様なもの?なので、認証情報などは特に必要ありません。
そこに対して、そのままメッセージをPOSTすればOK。
PipedreamでSlackへ回答する用のステップを追加。
import axios from "axios"
const makeMessageBody = (tite, prompt, reply) => {
return {
"text": tite,
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": `*「${prompt}」*`
}
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": `🤖< ガガガピガ...${reply}`
}
}
]
}
}
export default defineComponent({
async run({ steps, $ }) {
const responseUrl = steps.trigger.event.body.response_url
const client = axios.create({
baseURL: responseUrl,
headers: {
'Content-type': 'application/json',
},
})
// Result
const results = steps.request_gpt_api.$return_value
if( results === null ){
try{
await client.post(
'',
makeMessageBody(
"❓🤖",
steps.trigger.event.body.text,
'ちょっと何言ってるか分かんないです',
),
)
}catch(err){
console.log(err)
}
$.flow.exit()
}
const reply = results.choices[0].text
try{
const resp = await client.post(
'',
makeMessageBody(
"💡🤖",
steps.trigger.event.body.text,
reply,
),
)
console.log(resp)
}catch(err){
console.log(err)
}
return
},
})
最終的に出来たステップ
Pipedreamの各ステップの全体はこちら
使ってみる
Slack側から実際にSlash Commandを使って試してみます。
コマンドは
/query [ここに質問クエリを書く]
の形式で書きます。
このコマンドをチャンネルメッセージ等に入力して送信すればOK
質問
/query アベノミクスって、つまりなんだっけ?
回答
おわりに
モデルが分からない物事をこちらが尋ねた際、それっぽい適当な単語を並べて、いかにも理解している体で返してくるケースが時々ありました。
これ、もしこちらが全く知らない用語だったりすると、ウソと見抜けず結構困りますね😅
今は質問クエリをそのままOpenAIに投げていますが、ここの事前加工を上手にやるともっといい感じに出来そうです。
今回実験ツールとして作ってみましたが、もし社内などでガチ展開するなら、ドメイン用語などでファインチューニングする形になるのかなーと思いました。
面白そうなので機会があればやってみたい。
Fine-tuning
Discussion