Slackに音声を送信して生成AIで議事録作ってみた

# 1. はじめに

Slackに音声を送信して、生成AIで議事録を作成するということをやってみました！
結論から言うとめっちゃいいです！

# 2. プロジェクトのきっかけ

仕事の中で議事録の作成は、メモを完璧に取れないことや後から録音した音声を聞き直すことなど結構大変ですよね。
それで「音声データを文字起こしして生成AIに要約をお願いすればいいのでは？」と思いアプリを作成しました。

Slackは部署内のメンバーもよく使用しているアプリなので気軽に音声を送信できるかなと思いました。

# 3. 使用した技術

- Slack API: 音声データを送信するため
- API Gateway: Slackのデータをlambdaに送るため
- AWS Transcribe: 音声をテキストに変換するため
- Chat GPT: 生成AIで議事録を作るため。

# 4. 実装の流れ

### step1: Slackに音声を送信

まずは、Slackに音声を送る仕組みを作りました。スマホやパソコンから簡単に音声を録音して、Slackに送信。これで準備OK。

### step2: AWS Transcribeで文字起こし

次に、AWS Transcribeが音声を受け取ってテキストに変換します。これが思ったよりも正確で感動！日本語でもちゃんと変換してくれるので、便利です。

### step3: GPTで議事録生成

最後に、文字起こしされた結果をOpenAIのGPTに送って、議事録に整形してもらいます。

# 5. アーキテクチャー図

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1991303/0e70165b-1693-0190-0e34-1b34aa107cf8.png)


# 6. 実装
これから実際に書いたコードの説明を行います。Slack APIの設定とそれぞれのIAM権限については割愛しています。

## 6-1. 音声データの取得 (Lambda1)
まず、ユーザーがSlackに音声を送信すると、それがAPI Gateway経由でLambda関数に渡されます。このLambda関数は音声ファイルを受け取り、それをS3バケットに保存します。

Slackからの送られてくるデータはmp4を想定しており、Lambda内でmp3に変換しています。

Slackは処理が失敗した場合やレスポンスが遅延した場合に再試行を行います。これが原因で同じファイルがS3に複数回アップロードされることがあります。Slackの再試行回数を確認し、初回のみ処理を実行することで重複アップロードを防ぎます。また、ファイルのダウンロード、変換、アップロードの各ステップで適切なエラーハンドリングを行うことで、処理の失敗時にも再試行が発生しないようにしています。
上記のような内容で対策しましたが、より良い方法があればぜひ教えてください。

```javascript
const { S3Client, PutObjectCommand, ListObjectsV2Command } = require("@aws-sdk/client-s3");
const axios = require('axios');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { spawn } = require('child_process');

// Lambdaハンドラー関数のエントリーポイント
exports.handler = async (event) => {
    // 処理を5秒間待機させる（遅延させるため）
    await new Promise(resolve => setTimeout(resolve, 5000));

    // URL確認リクエストに対する処理
    if (event.type === 'url_verification') {
        return {
            statusCode: 200,
            headers: { 
                "Content-Type": "text/plain"
            },
            body: event.challenge // チャレンジコードを返す
        };
    }

    // Slackイベントの再試行回数をチェック
    const retryNum = event.headers["X-Slack-Retry-Num"] || '0';
    console.log(retryNum);
    if (parseInt(retryNum) === 1) {
        console.log("Event", event);

        // イベントがファイル情報を含んでいるか確認
        let file_info;
        if (event.body.event.files && event.body.event.files.length > 0) {
            file_info = event.body.event.files[0];
        } else if (event.body.event.file) {
            file_info = event.body.event.file;
        }

        if (file_info) {
            console.log(file_info);
            console.log("file_path", file_info.url_private_download);
            const file_url = file_info.url_private_download; // ファイルのダウンロードURL
            const bucketName = 'slack-events-data'; // アップロード先のS3バケット名
            const key = `uploaded_files/${Date.now()}_output.mp3`; // S3にアップロードする際のファイルキー

            try {
                const token = process.env.SLACK_OAUTH_TOKEN; // SlackのOAuthトークンを環境変数から取得
                const videoFilePath = await downloadFile(file_url, token); // ファイルをダウンロード
                const audioFilePath = await convertToMp3(videoFilePath); // ダウンロードしたファイルをMP3に変換
                await uploadFile(audioFilePath, bucketName, key); // 変換したファイルをS3にアップロード
                return {
                    statusCode: 200,
                    headers: {
                        'X-Slack-No-Retry': 1, // Slackが再試行しないようにするヘッダー
                        "Content-Type": "text/plain",
                    },
                    body: JSON.stringify('File uploaded successfully')
                };
            } catch (error) {
                console.error('Error:', error);
                return {
                    statusCode: 500,
                    headers: {
                        'X-Slack-No-Retry': 1,
                        "Content-Type": "text/plain",
                    },
                    body: JSON.stringify({ message: 'Error uploading file' }) // エラーメッセージを返す
                };
            }
        } else {
            console.log('No file found in the event'); // ファイルがイベントに含まれていない場合のログ
            return {
                statusCode: 200,
                headers: {
                    'X-Slack-No-Retry': 1,
                    "Content-Type": "text/plain",
                },
                body: JSON.stringify('No file found in the event') // メッセージを返す
            };
        }
    } else {
        console.log("実行中止"); // 再試行回数が1でない場合のログ
        return {
            statusCode: 500,
            headers: {
                'X-Slack-No-Retry': 1,
                "Content-Type": "text/plain",
            },
            body: 'No action taken due to retry number not being 1' // 処理を中止するメッセージを返す
        };
    }
};

// ファイルをURLからダウンロードする関数
async function downloadFile(fileUrl, token) {
    const tempDir = os.tmpdir(); // 一時ディレクトリを取得
    const videoFilePath = path.join(tempDir, `video_${Date.now()}.mp4`); // ダウンロードするファイルのパスを生成
    try {
        const response = await axios({
            method: 'get',
            url: fileUrl,
            headers: {
                'Authorization': `Bearer ${token}`, // 認証ヘッダーを設定
            },
            responseType: 'stream', // ストリーミングとしてレスポンスを受け取る
        });
        const writer = fs.createWriteStream(videoFilePath); // ファイル書き込みストリームを作成
        response.data.pipe(writer); // レスポンスデータをファイルに書き込む
        await new Promise((resolve, reject) => {
            writer.on('finish', resolve); // 書き込み完了を待つ
            writer.on('error', reject); // エラーを待つ
        });
        return videoFilePath; // ダウンロードしたファイルのパスを返す
    } catch (error) {
        if (fs.existsSync(videoFilePath)) {
            fs.unlinkSync(videoFilePath); // エラーが発生した場合、ダウンロードしたファイルを削除
        }
        throw error; // エラーをスロー
    }
}

// ダウンロードしたファイルをMP3に変換する関数
async function convertToMp3(videoFilePath) {
    const tempDir = os.tmpdir(); // 一時ディレクトリを取得
    const audioFilePath = path.join(tempDir, `audio_${Date.now()}.mp3`); // 変換後のファイルのパスを生成
    return new Promise((resolve, reject) => {
        const ffmpeg = spawn('ffmpeg', ['-i', videoFilePath, '-acodec', 'libmp3lame', '-b:a', '128k', audioFilePath]); // ffmpegを使用して変換
        ffmpeg.on('close', (code) => {
            fs.unlinkSync(videoFilePath); // 変換が終わったらビデオファイルを削除する
            if (code === 0) {
                resolve(audioFilePath); // 変換が成功した場合、オーディオファイルのパスを返す
            } else {
                if (fs.existsSync(audioFilePath)) {
                    fs.unlinkSync(audioFilePath); // エラーが発生した場合、変換したファイルを削除
                }
                reject(new Error(`ffmpeg process exited with code ${code}`)); // エラーメッセージをスロー
            }
        });
    });
}

// 同じタイムスタンプのファイルが存在するかをチェックする関数
async function checkExistingFiles(bucketName, newKey) {
    const s3Client = new S3Client({ region: 'ap-northeast-1' }); // S3クライアントを作成
    const listParams = {
        Bucket: bucketName,
        Prefix: 'uploaded_files/' // プレフィックスを設定
    };
    try {
        const data = await s3Client.send(new ListObjectsV2Command(listParams)); // S3バケット内のファイル一覧を取得
        const newTimestamp = parseInt(newKey.match(/\d+/)[0]); // 新しいファイルのタイムスタンプを取得
        for (const item of data.Contents) {
            const existingTimestamp = parseInt(item.Key.match(/\d+/)[0]); // 既存ファイルのタイムスタンプを取得
            if (Math.abs(newTimestamp - existingTimestamp) <= 600000) { // タイムスタンプの差が10分以内か確認
                console.log(`A similar file found: ${item.Key}`);
                return false; // 同じようなファイルが存在する場合、falseを返す
            }
        }
        return true; // 同じようなファイルが存在しない場合、trueを返す
    } catch (error) {
        console.error('Error checking existing files:', error);
        throw error; // エラーをスロー
    }
}

// ファイルをS3にアップロードする関数
async function uploadFile(audioFilePath, bucketName, key) {
    try {
        const s3Client = new S3Client({ region: 'ap-northeast-1' }); // S3クライアントを作成
        const uploadParams = {
            Bucket: bucketName,
            Key: key,
            Body: fs.createReadStream(audioFilePath), // アップロードするファイルのストリーム
            ContentType: 'audio/mpeg', // コンテンツタイプを設定
        };
        const uploadCommand = new PutObjectCommand(uploadParams); // アップロードコマンドを作成
        await s3Client.send(uploadCommand); // ファイルをアップロード
        console.log('File uploaded successfully to S3');
    } catch (error) {
        throw error; // エラーをスロー
    } finally {
        fs.unlinkSync(audioFilePath); // アップロードが終わったらオーディオファイルを削除する
    }
}
```

## 6-2 音声データ文字起こし(Lamnda2)

S3にアップロードされた音声ファイルをトリガーにして、Amazon Transcribeを使って自動的に文字起こしを行うLambda関数を実装します。
文字起こしされたデータは自動的にS3に保存されますが、今回は指定のバケットに保存するようにしています。

```javascript
const { TranscribeClient, StartTranscriptionJobCommand } = require("@aws-sdk/client-transcribe");
const { createReadStream } = require("fs");
const { join } = require("path");
const region = "ap-northeast-1";

async function startTranscriptionRequest(bucketName, objectKey, jobName, outputBucketName) {
  const transcribeConfig = {
    region,
  };
  const transcribeClient = new TranscribeClient(transcribeConfig);
  const fileUri = `s3://${bucketName}/${objectKey}`;
  console.log("fileUri", fileUri)
  const input = {
    TranscriptionJobName: jobName,
    LanguageCode: "ja-JP",
    Media: {
      MediaFileUri: fileUri
    },
    OutputBucketName: outputBucketName,
  };
  const transcribeCommand = new StartTranscriptionJobCommand(input);
  try {
    const transcribeResponse = await transcribeClient.send(transcribeCommand);
    console.log("Transcription job created, the details:");
    console.log(transcribeResponse.TranscriptionJob);
  } catch(err) {
    console.log(err);
  }
}

exports.handler = async (event) => {
    const bucketName = event.Records[0].s3.bucket.name;
    const objectKey = decodeURIComponent(event.Records[0].s3.object.key.replace(/\+/g, ' '));
    

    const jobName = `Transcription-${Date.now()}`;
    const outputBucketName = 'slack-events-transcribe'; // ここに結果を保存するバケット名を設定
    console.log(bucketName, objectKey, jobName, outputBucketName)

    await startTranscriptionRequest(bucketName, objectKey, jobName, outputBucketName);
};
```

## 6-3 ChatGPTで要約とSlackへの送信(Lambda3)
AWS Transcribeが文字起こしを完了すると、その結果がS3に保存されます。さらに別のLambda関数がその結果を検出し、ChatGPTを使って要約を行います。要約結果は再びSlackに送信されます。
プロンプトはxml形式が良いと聞いたのでこちらで書いてみました。

```javascript
const { S3Client, GetObjectCommand } = require("@aws-sdk/client-s3");
const axios = require('axios');

exports.handler = async (event) => {
  console.log("Event", event);
  
  const s3Client = new S3Client();
  const bucketName = event.Records[0].s3.bucket.name;
  const fileKey = event.Records[0].s3.object.key;
  
  const openaiApiKey = process.env.OPENAI_API_KEY;
  const slackWebhookUrl = process.env.SLACK_WEBHOOK_URL;
  
  // S3バケットからJSONファイルを読み込む
  const getObjectCommand = new GetObjectCommand({ Bucket: bucketName, Key: fileKey });
  const fileContent = await s3Client.send(getObjectCommand);
  const jsonData = JSON.parse(await fileContent.Body.transformToString());
  
  // JSONファイルから文字起こしテキストを抽出
  const transcription = jsonData.results.transcripts[0].transcript;
  
  // OpenAI APIを使用してテキストを要約
  const openaiUrl = 'https://api.openai.com/v1/chat/completions';
  const headers = {
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${openaiApiKey}`
  };
  const data = {
    model: "gpt-4o",
    messages: [
      { role: "system", content: "You are a helpful assistant." },
      { role: "user", content: `<prompt>
    <expertise>あなたは議事録作成における重要なテクニックを熟知している専門家です。</expertise>
    <task>提供されたミーティングノートを確認し、ミーティング中に特定の個人または部署に割り当てられた重要なポイントとアクションアイテムに焦点を当てて、重要な情報を捉えた簡潔なサマリーを作成するのがあなたのタスクです。</task>
    <instructions>
        <instruction>フォーマットに記載できない情報は「不明」としてください。</instruction>
        <instruction>明確で専門的な言葉遣いを使用すること。</instruction>
        <instruction>見出し、小見出し、箇条書きなどの適切なフォーマットを使用してサマリーを論理的に整理すること。</instruction>
        <instruction>サマリーが理解しやすく、ミーティングの内容を包括的かつ簡潔に概説していることを確認すること。</instruction>
        <instruction>特に各アクションアイテムの責任者を明確に示すことに重点を置くこと。</instruction>
        <instruction>以下のsampleを参考に作成してください。</instruction>
        <instruction>議事録作成にはaudioDataを使用してください。</instruction>
    </instructions>
    <format>
        <![CDATA[
        会議名：[会議名]
        日時：[会議の日付と時間]
        参加者：[参加者のリスト]

        ■[議題名]
        ・[会議の内容記載]

        ■[議題名]
        ・[会議の内容記載]

        ■重要な決定事項
        ・[重要な決定事項]

        ■アクションアイテム
        ・[アクションアイテム1]
        ・[アクションアイテム2]

        ■次回の会議予定
        [次回の会議の日付と時間]
        ]]>
    </format>
    <sample>
        <![CDATA[
        会議名：プロジェクト進捗会議
        日時: 2024年6月25日 14:00
        参加者: xxxx, xxxx, xxxx

        ■議題1: プロジェクトの進捗報告
        

        ■議題2: 次のステップ
        
        
        ■重要な決定事項
        ・プロジェクトの進捗報告の頻度を毎週にする。
        ・ユーザーインターフェースの改善を優先する。

        ■アクションアイテム
        ・xxがタイムラインを更新する。
        ・xxがリソース配分のプランを作成する。

        ■次回の会議予定
        2024年7月2日 14:00
        ]]>
    </sample>
    <audioData>
      ${transcription}
    </audioData>
</prompt>
` }
    ],
    max_tokens: 1000
  };
  try {
    const response = await axios.post(openaiUrl, data, { headers });
    const summary = response.data.choices[0].message.content;
    console.log("response", response);
    console.log(summary);

    // Slack Incoming Webhookを使用して要約結果をSlackに送信
    const slackMessage = {
      text: "要約結果",
      attachments: [
        {
          color: "#36a64f",  // 緑色のバー
          title: "要約結果",
          text: summary
        }
      ]
    };
    await axios.post(slackWebhookUrl, slackMessage);
  } catch (error) {
    console.error("Error occurred:", error);

    // エラーメッセージと文字起こし結果をSlackに送信
    const errorSlackMessage = {
      text: "エラーが発生しました",
      attachments: [
        {
          color: "#ff0000",  // 赤色のバー
          title: "エラーメッセージ",
          text: error.message
        },
        {
          color: "#ffcc00",  // 黄色のバー
          title: "文字起こし結果",
          text: transcription
        }
      ]
    };
    await axios.post(slackWebhookUrl, errorSlackMessage);
  }
};
```

# 参考
https://qiita.com/bSRATulen2N90kL/items/f061a6a1a604dfbf989d

# まとめ

これらを実装することでミーティングの音声から議事録を自動作成することができました！
興味があれば、ぜひ試してみてください。