皆さん、こんにちは!Govtech事業本部開発部の八巻です!
今日はポケモンGOのイベント日(ポケモンGO グローバル 2025)ですね!私はワクワクして今日朝6時に起きてしまいました!
早速家を出るために身支度をしていると、「やることやってから遊ぶやつがかっこいいよな」 という元上司の言葉を急に思い出した ため、急遽デスクに戻って記事を書いています。(泣)
さて今回は、新卒の頃から実現したかったお問い合わせの要約→ナレッジ化が、ついに自動でできそうなことに気づいたため、その手順をまとめてみました。
前提として自チームでは、別種類のお問い合わせに関してはナレッジ化してNotebookLMに食べさせており、工数削減が実現できているため、今回の作戦もかなり期待大のものとなっております。
誰かの工数削減につながるヒントになれば幸いです。
前提
- 社内コミュニケーションツーンはslackを使用
- 営業サイド方のお問い合わせは、特定のチャンネルのワークフローを使用し、エンジニアに問い合わせが来る
- 今までの問い合わせ内容はスプレッドシートに、問い合わせ者、本文、スレッドurlが蓄積されている
- 営業サイド・エンジニア共に問い合わせ内容のナレッジ化は進んでいない(slack検索して探している)
- 特に入りたてのメンバーが検索に苦労する
- vertex aiの使用権限が付与されている
今回実現したいこと
お問い合わせの質問・回答を要約してNotebookLMに学習させる。お問い合わせ回答bot(RAG)を完成させる。
実現手順
- slack apiを叩き、urlからお問い合わせのスレッド情報を取得
- gemini apiを使用して、取得したきた情報を要約
- 要約した内容をNotebookLMにが学習させる
事前準備
1. slackのスレッドの用意
今回は3つのLLMについて記載されているスレッドを用意しました。
(slackのアイコンは気にしないでくださいw)
スレッドの中身はそれぞれのLLMについての500文字程度の説明が記載されています。
2. スプレッドシートの準備
業務で使用しているスプレッドシートの簡易版を作成しました。(今回はurlだけあれば大丈夫です)
3. slack apiの設定
今回実現したいことは、権限としてはchanels:history
だけで大丈夫です。以下に設定手順をまとめます。
1. Slack Appの作成
まずはSlack Appを作成しましょう。
- Slack API公式サイトにアクセス
- 「Create New App」をクリック
- 「From scratch」**を選択
- App名と連携したいワークスペースを指定してアプリを作成
2. 権限(Scopes)の設定
アプリがSlack内で何ができるかを定義する「Scopes」を設定します。
- OAuth & Permissions画面で設定
- 左サイドバーから 「OAuth & Permissions」 をクリック
- 「Scopes」セクションまで下にスクロール
- 「Bot Token Scopes」で以下の権限を追加
channels:history - パブリックチャンネルのメッセージ履歴を読み取ります。
3. アプリのインストール
設定した権限をワークスペースに適用します。
- 「Install to Workspace」ボタンをクリック
- 権限の確認画面が表示されるので、「許可する」をクリック
これにより、Bot User OAuth Tokenが生成されます。
xoxb- で始まるトークンです。
このトークンが、APIリクエストを行う際に使用する認証情報となります。
4. チャンネルへ招待
URLをから本文を取得したいチャンネルに、先ほど作成したアプリを招待して下さい
4. Vertex AIの準備
今回は便宜上vertex aiからgemini apiを使用します。ファインチューニング等を行うわけではないので、google studioで発行する方が良い気がします。
公式で設定手順をまとめているので、ご覧ください。
- GASの準備
各組織のシート等に合うように調整して下さい。一応私がデモで作成したスクリプトを貼り付けておきます。
/**
* ===================================================================================
* Slackスレッド要約スクリプト(OAuth2ライブラリ不要・手動JWT認証版)
* ===================================================================================
* *【事前準備】
* 1. 下記の `setupCredentials` 関数内の `YOUR_SLACK_BOT_TOKEN_HERE` を
* ご自身のSlackボットトークンに書き換えてください。
* * 2. 関数 `setupCredentials` を選択し、一度だけ実行して認証情報を保存します。
* *【実行方法】
* 1. GoogleスプレッドシートのB列にSlackスレッドのリンクを貼り付けます。
* 2. GASエディタで関数 `summarizeSlackThreads` を選択し、実行します。
*/
/**
* 認証情報(SlackトークンとVertex AIのキー)をスクリプトプロパティに保存するための関数。
* ★最初に一度だけ実行してください★
*/
function setupCredentials() {
// 1. Slackのボットトークンを設定
const slackToken = "手順1で取得したslackのトークン"; // ★ここにSlackボットトークンを記入
// 2. Vertex AIのサービスアカウントキー(JSON)を設定
const vertexAiKey = {
"type": "service_account",
"project_id": "xxxxxxxxxxx",
"private_key_id": "xxxxxxxxxxx",
"private_key": "xxxxxxxxxxx",
"client_email": "xxxxxxxxxxx",
"client_id": "xxxxxxxxxxx",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "xxxxxxxxxxx",
"universe_domain": "googleapis.com"
};
// プロパティに保存
const properties = PropertiesService.getScriptProperties();
properties.setProperty('SLACK_BOT_TOKEN', slackToken);
properties.setProperty('VERTEX_AI_KEY', JSON.stringify(vertexAiKey));
Logger.log('✅ SlackとVertex AIの認証情報を保存しました。');
}
/**
* ===============================================================
* メインの処理を実行する関数
* ===============================================================
*/
function summarizeSlackThreads() {
const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
const dataRange = sheet.getRange(2, 1, sheet.getLastRow() - 1, 3);
const values = dataRange.getValues();
for (let i = 0; i < values.length; i++) {
const row = values[i];
const slackLink = row[1]; // B列のリンク
const summaryCell = row[2]; // C列の要約
if (slackLink && !summaryCell) {
try {
Logger.log(`処理中: ${i + 2}行目 - ${slackLink}`);
const threadText = getSlackThreadText(slackLink);
if (threadText) {
const summary = getVertexAISummary(threadText); // ここで新しい要約関数を呼び出す
sheet.getRange(i + 2, 3).setValue(summary);
Logger.log(`✅ ${i + 2}行目に要約を記載しました。`);
SpreadsheetApp.flush(); // 変更を即時反映
}
} catch (e) {
Logger.log(`❌ ${i + 2}行目の処理でエラーが発生しました: ${e.message}`);
}
}
}
Logger.log('✨ 全ての処理が完了しました。');
}
/**
* ===============================================================
* ヘルパー関数: Slackリンクからスレッドの全会話を1つのテキストにまとめる
* ===============================================================
*/
function getSlackThreadText(slackUrl) {
const slackToken = PropertiesService.getScriptProperties().getProperty('SLACK_BOT_TOKEN');
const match = slackUrl.match(/\/archives\/(C[A-Z0-9]+)\/p(\d+)/);
if (!match) {
Logger.log(`無効なSlackリンクです: ${slackUrl}`);
return null;
}
const channelId = match[1];
const threadTsRaw = match[2];
const threadTs = `${threadTsRaw.slice(0, -6)}.${threadTsRaw.slice(-6)}`;
const apiUrl = `https://slack.com/api/conversations.replies?channel=${channelId}&ts=${threadTs}`;
const options = {
'method': 'get',
'headers': { 'Authorization': 'Bearer ' + slackToken },
'muteHttpExceptions': true
};
const response = UrlFetchApp.fetch(apiUrl, options);
const data = JSON.parse(response.getContentText());
if (!data.ok) {
Logger.log(`Slack APIエラー: ${data.error}`);
return null;
}
return data.messages.map(msg => msg.text).join('\n---\n');
}
// ===============================================================
// ここから下がVertex AIとの通信部分(手動JWT認証方式)
// ===============================================================
/**
* JWTトークンを生成する関数
*/
function createJWT() {
const key = JSON.parse(PropertiesService.getScriptProperties().getProperty('VERTEX_AI_KEY'));
const header = { "alg": "RS256", "typ": "JWT" };
const now = Math.floor(Date.now() / 1000);
const payload = {
"iss": key.client_email,
"scope": "https://www.googleapis.com/auth/cloud-platform",
"aud": "https://oauth2.googleapis.com/token",
"exp": now + 3600, // 1 hour expiration
"iat": now
};
const headerEncoded = Utilities.base64EncodeWebSafe(JSON.stringify(header)).replace(/=/g, '');
const payloadEncoded = Utilities.base64EncodeWebSafe(JSON.stringify(payload)).replace(/=/g, '');
const signatureInput = headerEncoded + '.' + payloadEncoded;
const signature = Utilities.computeRsaSha256Signature(signatureInput, key.private_key);
const signatureEncoded = Utilities.base64EncodeWebSafe(signature).replace(/=/g, '');
return signatureInput + '.' + signatureEncoded;
}
/**
* アクセストークンを取得する関数
*/
function getAccessToken() {
const jwt = createJWT();
const response = UrlFetchApp.fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
payload: {
'grant_type': 'urn:ietf:params:oauth:grant-type:jwt-bearer',
'assertion': jwt
},
muteHttpExceptions: true
});
const responseCode = response.getResponseCode();
const responseText = response.getContentText();
if (responseCode !== 200) {
throw new Error(`アクセストークン取得に失敗しました: ${responseCode} - ${responseText}`);
}
const data = JSON.parse(responseText);
if (!data.access_token) {
throw new Error('アクセストークンがレスポンスに含まれていません。');
}
return data.access_token;
}
/**
* Vertex AI APIを呼び出してテキストを要約する関数
*/
function getVertexAISummary(textToSummarize) {
try {
const key = JSON.parse(PropertiesService.getScriptProperties().getProperty('VERTEX_AI_KEY'));
const accessToken = getAccessToken();
const location = "asia-northeast1"; // または "asia-northeast1" など
const url = `https://${location}-aiplatform.googleapis.com/v1/projects/${key.project_id}/locations/${location}/publishers/google/models/gemini-1.5-flash-002:generateContent`;
const requestBody = {
"contents": [{
"role": "user",
"parts": [{
"text": `以下のSlackスレッドの会話を、重要なポイントがわかるように簡潔に日本語で要約してください。\n\n---\n${textToSummarize}`
}]
}]
};
const response = UrlFetchApp.fetch(url, {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
payload: JSON.stringify(requestBody),
muteHttpExceptions: true
});
const responseCode = response.getResponseCode();
const responseText = response.getContentText();
if (responseCode !== 200) {
throw new Error(`API呼び出しに失敗しました: ${responseCode} - ${responseText}`);
}
const data = JSON.parse(responseText);
if (data.candidates && data.candidates[0].content && data.candidates[0].content.parts[0].text) {
return data.candidates[0].content.parts[0].text.trim();
} else {
Logger.log(`APIからの予期しないレスポンス形式: ${responseText}`);
return "不明 (APIからの応答解析エラー)";
}
} catch (error) {
Logger.log(`getVertexAISummary内エラー: ${error.toString()}`);
return `不明 (エラー: ${error.message})`;
}
}
実際に吐き出したものがこちらです(一部スクリプトをチューニングしています)
NotebookLMに入れて質問してみる
chatgptについて質問してみました。
ちゃんとスレッド元のURLを提示してくれるのは非常に嬉しいポイントです。
スレッド内にはなかったperplexityについても質問してみました。
正しく記載がないと答えてくれるのが非常に優秀です。
ありもしない回答を推論で答えないので、安心して活用することができます。
おわりに
新卒1年目の頃に、自分の知識等がなく問い合わせ対応に入れない無力さから、実現したかったものがようやく実現できそうです!
営業サイドの方にも使っていただくことで、開発に質問をするという工数削減などにも繋がりそうで、一刻も早く実現したいです!!
それではポケモンGOに行ってきます!!良い週末を!!!