日曜日, 6月 29, 2025
日曜日, 6月 29, 2025
- Advertisment -
ホームニューステックニュースSlack API x gemini api でお問い合わせ工数削減作戦考えてみた #GAS

Slack API x gemini api でお問い合わせ工数削減作戦考えてみた #GAS



Slack API x gemini api でお問い合わせ工数削減作戦考えてみた #GAS

皆さん、こんにちは!Govtech事業本部開発部の八巻です!

今日はポケモンGOのイベント日(ポケモンGO グローバル 2025)ですね!私はワクワクして今日朝6時に起きてしまいました!
早速家を出るために身支度をしていると、「やることやってから遊ぶやつがかっこいいよな」 という元上司の言葉を急に思い出した ため、急遽デスクに戻って記事を書いています。(泣)

さて今回は、新卒の頃から実現したかったお問い合わせの要約→ナレッジ化が、ついに自動でできそうなことに気づいたため、その手順をまとめてみました。
前提として自チームでは、別種類のお問い合わせに関してはナレッジ化してNotebookLMに食べさせており、工数削減が実現できているため、今回の作戦もかなり期待大のものとなっております。
誰かの工数削減につながるヒントになれば幸いです。

  • 社内コミュニケーションツーンはslackを使用
  • 営業サイド方のお問い合わせは、特定のチャンネルのワークフローを使用し、エンジニアに問い合わせが来る
    • 今までの問い合わせ内容はスプレッドシートに、問い合わせ者、本文、スレッドurlが蓄積されている
  • 営業サイド・エンジニア共に問い合わせ内容のナレッジ化は進んでいない(slack検索して探している)
    • 特に入りたてのメンバーが検索に苦労する
  • vertex aiの使用権限が付与されている

お問い合わせの質問・回答を要約してNotebookLMに学習させる。お問い合わせ回答bot(RAG)を完成させる。

  1. slack apiを叩き、urlからお問い合わせのスレッド情報を取得
  2. gemini apiを使用して、取得したきた情報を要約
  3. 要約した内容をNotebookLMにが学習させる

上記全てGASをフックに実現したいと思いますスクリーンショット 2025-06-09 21.32.28.png

1. slackのスレッドの用意

今回は3つのLLMについて記載されているスレッドを用意しました。
(slackのアイコンは気にしないでくださいw)
スクリーンショット 2025-06-28 7.32.57.png

スレッドの中身はそれぞれのLLMについての500文字程度の説明が記載されています。

2. スプレッドシートの準備

業務で使用しているスプレッドシートの簡易版を作成しました。(今回はurlだけあれば大丈夫です)
スクリーンショット 2025-06-28 7.34.16.png

3. slack apiの設定

今回実現したいことは、権限としてはchanels:historyだけで大丈夫です。以下に設定手順をまとめます。

1. Slack Appの作成

まずはSlack Appを作成しましょう。

  1. Slack API公式サイトにアクセス
  2. 「Create New App」をクリック
  3. 「From scratch」**を選択
  4. App名と連携したいワークスペースを指定してアプリを作成

2. 権限(Scopes)の設定

アプリがSlack内で何ができるかを定義する「Scopes」を設定します。

  1. OAuth & Permissions画面で設定
  2. 左サイドバーから 「OAuth & Permissions」 をクリック
  3. 「Scopes」セクションまで下にスクロール
  4. 「Bot Token Scopes」で以下の権限を追加
    channels:history - パブリックチャンネルのメッセージ履歴を読み取ります。

3. アプリのインストール

設定した権限をワークスペースに適用します。

  1. 「Install to Workspace」ボタンをクリック
  2. 権限の確認画面が表示されるので、「許可する」をクリック

これにより、Bot User OAuth Tokenが生成されます。
xoxb- で始まるトークンです。
このトークンが、APIリクエストを行う際に使用する認証情報となります。

4. チャンネルへ招待

URLをから本文を取得したいチャンネルに、先ほど作成したアプリを招待して下さい
スクリーンショット 2025-06-28 7.50.04.png

今回は便宜上vertex aiからgemini apiを使用します。ファインチューニング等を行うわけではないので、google studioで発行する方が良い気がします。

公式で設定手順をまとめているので、ご覧ください。

  1. 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})`; 
  }
}

実際に吐き出したものがこちらです(一部スクリプトをチューニングしています)
スクリーンショット 2025-06-28 8.00.15.png

chatgptについて質問してみました。スクリーンショット 2025-06-28 8.02.14.png
ちゃんとスレッド元のURLを提示してくれるのは非常に嬉しいポイントです。

スレッド内にはなかったperplexityについても質問してみました。
スクリーンショット 2025-06-28 8.02.46.png
正しく記載がないと答えてくれるのが非常に優秀です。
ありもしない回答を推論で答えないので、安心して活用することができます。

新卒1年目の頃に、自分の知識等がなく問い合わせ対応に入れない無力さから、実現したかったものがようやく実現できそうです!
営業サイドの方にも使っていただくことで、開発に質問をするという工数削減などにも繋がりそうで、一刻も早く実現したいです!!
それではポケモンGOに行ってきます!!良い週末を!!!





Source link

Views: 0

RELATED ARTICLES

返事を書く

あなたのコメントを入力してください。
ここにあなたの名前を入力してください

- Advertisment -