SlackでChatGPTとの会話履歴をスレッドごとに分けて運用する
Slackでのコミュニケーションを最適化したいエンジニアの皆さん、ChatGPTとのやり取りをスレッドごとに整理することで、ストレスなく生産性を高めることができます。さあ、手軽に実践してみましょう。
※このイントロ文は、ChatGPTに作ってもらいました。
まえおき
皆さん、ChatGPTを使い倒していらっしゃいますか?
ほとんどの方はChatGPTを試すのにWebサイトから行われているかと思いますが、企業などで利用される場合が想定され、ChatGPT APIを使ってSlack Next-gen platformでSlack Botを動かす方法が、LayerXさんの記事で紹介されています。
こちらの記事を思いっきり参考にさせていただき、弊社でもSlackワークスペースからChatGPTを呼び出せるようにしました。
しかし、単にこれだけでは単発のプロンプトにのみ回答が得られるだけで、対話的に回答を引き出していく方法が取れません。
そこで、会話履歴を保持し、一定のコンテキストが維持できるよう改修することにしました。
Slack Next-gen platform で会話履歴を保持する方法については、Leaner Technologiesさんの記事で紹介されています。こちらは先程のLayerXさんの記事を参考にされているということで、流れ的にも非常に参考になりました。
弊社では
- 社内の様々なメンバーがそれぞれChatGPTを有効活用できるようにしつつ
- プロンプトをどのように工夫して利用しているかお互いに見えるようにしてノウハウ共有を促したい
この2つの目的があったため
- 会話履歴はあくまでスレッド単位で保持する
- 最初のChatGPTからの回答のみ本スレッドにも投稿
という仕様で実装しました。
今回は、この実装を行う上で詰まってしまったポイントなどを紹介したいと思います。
実装の注意点
thread_ts
は app_mentioned
イベントでは直接受け取れない
スレッドで行われる会話に対してChatGPTに回答させる場合、必要になるのはSlackメッセージの中の thread_ts
という値です。
thread_ts
は、スレッド内のメッセージから見て、スレッドがぶら下がっている親メッセージのIDを示すもので、ChatGPTの新しい回答はこのthread_ts
を指定して投稿することになります。
しかし、この thread_ts
は app_mentioned
イベントには付与されていません。
そのため、このイベントトリガーの仕組みで取得したい場合は message_posted
イベントに変更する必要があります。
それに合わせ、functionの処理の冒頭で「自分へのメンションでない場合は終了する」という処理を入れる必要があります。
...
const botUserId = env.BOT_USER_ID;
const regex = new RegExp("<\@" + botUserId + "\>", "g");
if (!inputs.question.match(regex)) {
// Botへのメンションで無ければ終了
return await { outputs: {} };
}
...
message_posted
イベントでは filter の設定が必須
ただし message_posted
に対応する場合、注意点があります。それはトリガーの定義の中で filter
の設定が必須になるということです。
これは、Botが投稿した内容をBot自身が拾ってしまい、またBotが投稿して・・・というような無限ループを防ぐためにSlack側で設けられている制約です。
しかし、この filter
ですが記法が詳細にドキュメント上で書かれているわけではなく・・・意図通りのfilterを書くことができず、ドハマりしてしまいました。
最終的に、下記で提供されているサンプルコード集の中から記法のサンプルを見つけ出したことで解消しました。
例えば「アプリでないユーザー投稿かつスレッドへの投稿でない」としたい場合は以下のように記載します。
...
filter: {
version: 1,
root: {
operator: "AND",
inputs: [
{
operator: "NOT",
inputs: [{
// Filter out posts by apps
statement: "{{data.user_id}} == null"
}],
},
{
// Filter out thread replies
statement: "{{data.thread_ts}} == null",
},
],
},
},
...
みなさんは、statementの中で !=
するのではなく、operator NOT
を利用するということをお忘れなきよう。
トリガー定義ファイル内のNPEがログもなく処理されてしまう
以下のように thread_ts
があればワークフローの入力に含めるように定義していました。
...
inputs: {
channel_id: { value: "{{data.channel_id}}" },
user_id: { value: "{{data.user_id}}" },
message_ts: { value: "{{data.message_ts}}" },
thread_ts: { value: "{{data.thread_ts}}" },
question: { value: "{{data.text}}" },
},
...
data.thread_ts
は Nullableであることは把握していたものの、ワークフローの任意パラメータとしていたため問題ないと考えていました。しかし、実際に thread_ts
が nullなデータが来た場合、エラーログが何も表示されないまま処理が止まります。
前述のfilterの問題と相まって、全くログもなくワークフローが起動しないという状態に陥ってしまい、時間が溶けてしまいました。
まとめ
Slack Next-gen platformを利用して、スレッド内でのChatGPT実装を行う際に特にハマったポイントを記載しました。
みなさんも、この点に注意して実装してみてくださいね。
最後に、弊社で修正したファイルをいくつか紹介して終わりたいと思います。
トリガー
どちらも同じワークフローを呼び出しますが、thread_ts
パラメータが渡される場合とそうでない場合がある点にのみ差があります。これは前述の、data.thread_ts
がNullableのときの挙動が影響しており、同じファイルで処理させることができなかったためです。
import { Trigger } from "deno-slack-api/types.ts";
import ChatGPTWorkflow from "../workflows/chatgpt_workflow.ts";
const messagePostedTrigger: Trigger<typeof ChatGPTWorkflow.definition> = {
type: "event",
name: "Trigger workflow with message posted",
workflow: `#/workflows/${ChatGPTWorkflow.definition.callback_id}`,
event: {
event_type: "slack#/events/message_posted",
channel_ids: [
"XXXXXX", // customize your channel id.
],
filter: {
version: 1,
root: {
operator: "AND",
inputs: [
{
operator: "NOT",
inputs: [{ statement: "{{data.user_id}} == null" }],
},
{
statement: "{{data.thread_ts}} == null",
},
],
},
},
},
inputs: {
channel_id: { value: "{{data.channel_id}}" },
user_id: { value: "{{data.user_id}}" },
message_ts: { value: "{{data.message_ts}}" },
question: { value: "{{data.text}}" },
},
};
export default messagePostedTrigger;
import { Trigger } from "deno-slack-api/types.ts";
import ChatGPTWorkflow from "../workflows/chatgpt_workflow.ts";
const threadMessageTrigger: Trigger<typeof ChatGPTWorkflow.definition> = {
type: "event",
name: "Trigger workflow with thread message posted",
workflow: `#/workflows/${ChatGPTWorkflow.definition.callback_id}`,
event: {
event_type: "slack#/events/message_posted",
channel_ids: [
"XXXXXX", // customize your channel id.
],
filter: {
version: 1,
root: {
operator: "AND",
inputs: [
{
operator: "NOT",
inputs: [{ statement: "{{data.user_id}} == null" }],
},
{
operator: "NOT",
inputs: [{ statement: "{{data.thread_ts}} == null" }],
},
],
},
},
},
inputs: {
channel_id: { value: "{{data.channel_id}}" },
user_id: { value: "{{data.user_id}}" },
message_ts: { value: "{{data.message_ts}}" },
thread_ts: { value: "{{data.thread_ts}}" },
question: { value: "{{data.text}}" },
},
};
export default threadMessageTrigger;
ファンクション
datastoreから、質問があったthread_ts内の会話履歴があるかを確認し、それを付け加えつつ次の回答を生成します。
...
export default SlackFunction(
ChatGPTFunction,
async ({ inputs, env, token }) => {
const botUserId = env.BOT_USER_ID;
const regex = new RegExp("<\@" + botUserId + "\>", "g");
if (!inputs.question.match(regex)) {
// Botへのメンションで無ければ終了
return await { outputs: {} };
}
const role = "user";
const content = inputs.question.replaceAll(regex, " ");
const apiKey = env.OPENAI_API_KEY;
const client = SlackAPI(token);
const historyResponse = await client.apps.datastore.get({
datastore: "talkHistories",
id: inputs.thread_ts ? inputs.thread_ts : inputs.message_ts,
});
const history = (historyResponse.item.history || []).map((json: string) => {
// 参照元と同様、object型だとうまくいかずJSON文字列形式で保存している
return JSON.parse(json);
});
const answer = await requestOpenAI(content, role, apiKey, history);
if (answer.outputs) {
await client.chat.postMessage({
channel: inputs.channel_id,
thread_ts: inputs.thread_ts ? inputs.thread_ts : inputs.message_ts,
reply_broadcast: !inputs.thread_ts, // 初回のChatGPT回答だけチャンネルにも送信する
text: answer.outputs.answer,
});
const newHistories = [
...history,
{ role: "user", content },
{ role: "assistant", content: answer.outputs.answer },
].slice(MAX_CONVERSATIONS * -1);
const thread_ts = inputs.thread_ts ? inputs.thread_ts : inputs.message_ts;
await client.apps.datastore.update({
datastore: "talkHistories",
item: {
thread_ts,
history: newHistories.map((v) => JSON.stringify(v)),
},
});
} else {
await client.chat.postMessage({
channel: inputs.channel_id,
thread_ts: inputs.thread_ts ? inputs.thread_ts : inputs.message_ts,
reply_broadcast: !inputs.thread_ts,
text: answer.error,
});
}
return await { outputs: {} };
},
);
...
おまけ
もし message_posted
を使わず、 app_mentioned
のままで thread_ts
を使いたい場合は、app_mentioned
で受け取った message_ts
を以下のWebAPIの conversations.replies
メソッドにパラメータとして与えることでも取得できます。
(実装し終えてから気付いてしまったのですが、こちらのほうがトリガーを複数作らなくて済みそうですね。)
TimeTreeのエンジニアによる記事です。メンバーのインタビューはこちらで発信中! note.com/timetree_inc/m/m4735531db852
Discussion