5
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

皆さん、こんにちは!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

4. Vertex AIの準備

今回は便宜上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

NotebookLMに入れて質問してみる

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

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

おわりに

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

5
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?