はじめに
こんにちは、@_mkazutaka です。
今回は、メールの内容をChatGPTで解析してSpreadsheetとNotionに転記する方法について紹介します。
背景
Explazaでは、営業の問い合わせのメールから、業界、相談者の部署、相談者の役職、導入時期、会社規模、予算、対象などのいくつかの項目を取得し、その内容をSpreadsheetに転記し、Spreadsheet上で各営業ごとのステータスを管理しています。またステータス以上の詳しい状況や実際にお客さまと話した内容等は、Spreadsheetとは別でNotionの一つのページにまとめています。
このため、メールを受け取るごとに営業チームの一人が上記の項目の整備とNotionページの作成を行っています。問い合わせ数が増えて行くごとに仕事が増えるので、ここをなんとか自動化できないかということで今回ChatGPTを使って実装を試みてみました。
実装方針
実装のフローをシンプルに考えると、以下のような流れにはなると思います。
- 定期的にGmail (弊社Gmailをつかっています)から営業メールを取得する
- メール内容をChatGPTを使って項目を取得
- メール内容を記述したNotionページの作成
- 取得した項目をSpreadsheetに追記
ただし、このフローにはいくつか問題点があります。
一つ目は、特定の誰かにGmailにアクセスする必要がある点です。その人が部署異動した際にボットが動かなくなる可能性があったりその人の見てはいけないメールまで見ることができます。
二つ目は、リアルタイムに処理が見れない点です。Gmailはメールを受け取ったらどこかにリクエストを送って処理を開始するみたいなことができないので、どうしても定期的にGmailの内容を見に行くといった処理になります。
三つ目は、プログラム処理の途中経過が見れないことです。プログラムが正しく動いているかどうかが、営業の人からみてもわかりにくく、開発者からみてもわかりにくい点です。頻繁にメンテンナンスが起きうるプログラムならこれでも問題はないのですが、今回のケースだとそうではないので、ここも考慮したいです。
というわけで以上の三点の問題点を踏まえ、多少方針を切り替えつつ、最終的には以下の流れにしました。
- 営業メールの内容をSlack上に転送
- Slackからメール内容を取得
- メール内容をChatGPTを使って項目を取得
- メール内容を記述したNotionページの作成
- 取得した項目をSpreadsheetに追記
弊社はメッセージアプリにSlackを使っているためこれを活用します。Slackにはメールインテグレーションという機能があり、チャンネルごとにメールアドレスを取得することができます。そのメールアドレスにメッセージを送ることで、メールの内容をそのチャンネルに投稿することができます。
営業のメールを受け取る人が取得したメールアドレスへの転送処理さえすれればGmailにログインせずとも処理ができます。これの嬉しい点は、チャンネルのメールアドレスに転送してしまえばいいので、特定のだれかやGmailに依存することはない点です。また、Slackは、メッセージごとにイベントが発行されボットで処理することができます。これを用いればGmailの際にはできなかったリアルタイムに処理をすることができます。投稿には、メッセージを紐付けられるので、そこに処理状況を投稿すれば、進行状況なども見ることができます。
実際の動作の様子
Slackチャンネル上でメールを受け取ると以下の画像のようにBotが稼働します。Slackの投稿を経由して、ボットが何をしているのかわかるようになっています。
実装詳細
具体的な実装を紹介します。実装のフローは以下の様になっています。
Slackbotは、Cloudflare Worker上で動かしています。Cloudflare Worker上でSpreadsheetを操作する場合、googleが提供しているライブラリではエラーがでるため(Issue)、直接POSTリクエストで書き込みを行います。Google認証まわりもCloudflare Worker用のライブラリを使っています。
多少抜粋していますが、コードは以下のように感じになっています。FunctionCallingを使ってメールから必要な情報を取得します。langchainのcreateExtractionChainFromZod
を使うと、Zodのスキーマで必要な情報を手に入れる事ができるので便利でした。Cloudflare Worker上でlangchainが動くのも嬉しかったです。
import { z } from "zod"; import { FileElement, SlackApp, SlackEdgeAppEnv, } from "slack-cloudflare-workers"; import { ChatOpenAI } from "langchain/chat_models/openai"; import { createExtractionChainFromZod } from "langchain/chains"; import GoogleAuth, { GoogleKey } from 'cloudflare-workers-and-google-oauth' import { SlackAppContextWithChannelId } from "slack-edge/dist/context/context"; export interface Env { SPREADSHEET_ID: string SPREADSHEET_ACCOUNT_KEY: string SPREADSHEET_SHEET_NAME: string OPENAI_API_KEY: string NOTION_API_SECRET: string NOTION_DATABASE_ID: string } // メッセージを受け取るSlackChannelIDを入れる const CHANNEL_IDS: string[] = [] const schema = z.object({ "company": z.string(), "department": z.string(), "position": z.string(), "anticipated_introduction_timing": z.string(), "company_size": z.string(), "budget": z.string(), "estimated_industry": z.string(), "target": z.union([z.literal("チャットボット"), z.literal("他")]), }); type Schema = z.infer<typeof schema> export default { async fetch( request: Request, env: SlackEdgeAppEnv & Env, ctx: ExecutionContext ): Promise<Response> { const app = new SlackApp({env}); app.event("message", async ({context, payload}) => { if (!CHANNEL_IDS.includes(context.channelId)) { return } const replies = await context.client.conversations.replies({ channel: context.channelId, ts: payload.event_ts, }) if (!replies.messages || !replies.messages[0].files) { console.log('not found replies.message or files') return; } const file = replies.messages[0].files[0] as FileElement try { await context.client.chat.postMessage({ channel: context.channelId, text: "メールを受け取りました。内容の解析を始めます...", thread_ts: payload.event_ts, }) await main(context, payload.event_ts, env, file) await context.client.chat.postMessage({ channel: context.channelId, text: "すべての処理が終わりました。終了します...", thread_ts: payload.event_ts, }) } catch (e) { await context.client.chat.postMessage({ channel: context.channelId, text: `予期せぬエラーが発生しました。${e}`, thread_ts: payload.event_ts, }) return } }) return await app.run(request, ctx); }, }; async function main(context: SlackAppContextWithChannelId, ts: string, env: Env, file: FileElement) { if (!file.plain_text) { await context.client.chat.postMessage({ channel: context.channelId, text: 'エラー: ファイルにテキストがありません', thread_ts: ts, }) return } const content = file.plain_text; const chatModel = new ChatOpenAI({ modelName: "gpt-4-0613", temperature: 0, openAIApiKey: env.OPENAI_API_KEY, }); const chain = createExtractionChainFromZod(schema, chatModel); const res = (await chain.run(content) as any)[0] as Schema await context.client.chat.postMessage({ channel: context.channelId, mrkdwn: true, text: `解析が終了しました。Notionにページを作成及びSpreadsheetに書き込みます`, thread_ts: ts, }) // Create Notion const notionURL = await addNotionItem(env, res.company, content) await context.client.chat.postMessage({ channel: context.channelId, mrkdwn: true, text: `Notionにページを作成しました <${notionURL} | (リンク)>`, thread_ts: ts, }) // Save TO Sheet const output: string[] = [ `=HYPERLINK("${notionURL}", "${res.company}")`, // 会社名 res.estimated_industry, // 業界 res.department, // 相談者: 部署 res.position, // 相談者: 役職 res.anticipated_introduction_timing, // 導入時期 res.company_size, // 会社規模 res.budget, // 予算 res.target, // 対象 ] await writeToSheet(env, 5, [output]) const sheetURL = `https://docs.google.com/spreadsheets/d/${env.SPREADSHEET_ID}` await context.client.chat.postMessage({ channel: context.channelId, mrkdwn: true, text: `Spreadsheetにデータを書き込みました <${sheetURL} | (シート)>\n\`\`\`${output.toString()}\`\`\``, thread_ts: ts, }) } async function getAccessToken(env: Env): Promise<string> { const googleAuth: GoogleKey = JSON.parse(env.SPREADSHEET_ACCOUNT_KEY) const scopes = ['https://www.googleapis.com/auth/spreadsheets'] const oauth = new GoogleAuth(googleAuth, scopes) const token = await oauth.getGoogleAuthToken() if (!token) throw new Error("Failed to get Token") return token } async function writeToSheet(env: Env, rowIndex: number, values: string[][]) { const token = await getAccessToken(env); const sheetName = encodeURIComponent(env.SPREADSHEET_SHEET_NAME) const URL = `https://sheets.googleapis.com/v4/spreadsheets/${env.SPREADSHEET_ID}/values/${sheetName}!A${rowIndex}:append?valueInputOption=USER_ENTERED&insertDataOption=INSERT_ROWS`; const result = await fetch(URL, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ values: values }) }) await result.json() } async function addNotionItem(env: Env, title: string, content: string): Promise<string> { const API_URL = "https://api.notion.com/v1/pages" const response = await fetch(API_URL, { method: "POST", headers: { 'Authorization': `Bearer ${env.NOTION_API_SECRET}`, 'Content-Type': 'application/json', 'Notion-Version': '2022-06-28', }, body: JSON.stringify({ "parent": { "type": "database_id", "database_id": env.NOTION_DATABASE_ID, }, "properties": { "Name": { "type": "title", "title": [{ "type": "text", "text": { "content": title } }] }, }, "children": [ { "heading_2": { "rich_text": [ { "text": { "content": "問い合わせ" } } ] } }, { "code": { "rich_text": [{ "type": "text", "text": { "content": content } }], "language": "plain text" } } ] }) }) const result = await response.json() as any return result["url"] }
まとめ
問い合わせメールをChatGPTで解析してSpreadsheetとNotionに転記する方法について紹介しました。
その他・感想
Cloudflare Worker上でSlackアプリを動かす際は以下の記事が非常に参考になりました。