0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

mediba Advent Calendar 2024Advent Calendar 2024

Day 6

Teams会議の議事録自動化作成

Last updated at Posted at 2024-12-05

はじめに

議事録作成の手間を減らし、作業を自動化するため、Microsoft Graph APIやAzureのサービスをを活用した仕組みを構築しました。

この仕組みでは以下を実現しています:

  1. 会議後の文字起こしデータを自動取得
    Microsoft Graph APIを使ってTeams会議の文字起こしを取得します。

  2. 文字起こしの要約
    Azure OpenAIを活用し、文字起こしを要約して簡潔にまとめます。

  3. 要約の自動共有
    生成した要約をTeamsチャットに自動投稿し、迅速な情報共有を可能にします。

必要なツールと技術

ツール/技術 役割
Microsoft Teams 会議の開催、文字起こし機能を使用。
Azure Microsoft Entra ID API利用のための認証設定を行う。
Microsoft Graph API 会議の文字起こしデータを取得し、更新を検知。
Azure Functions トリガー処理を行い、データの取得・要約・投稿処理を実行。
Azure OpenAI トランスクリプトデータを要約する。
Teamsワークフロー Teamsチャットに要約データを自動で投稿する。

必要なMicrosoft Graph API権限

  • OnlineMeetings.Read.All (アプリケーション) - 管理者同意が必要
  • OnlineMeetingTranscript.Read.All (アプリケーション) - 管理者同意が必要

自動化の流れ

  1. Teams会議で文字起こしを行う
    Teamsの文字起こし機能を有効化し、会議内容を自動で記録します。

  2. 文字起こしデータの作成
    会議終了後、Teams内にトランスクリプトデータが生成されます。

  3. Graph APIで更新を検知
    Graph APIを使用して、トランスクリプトデータの更新をリアルタイムで検知します。

  4. Azure Functionsでデータを取得
    Microsoft Graph APIを通じて、最新の文字起こしデータを取得します。

  5. Azure OpenAIで要約
    トランスクリプトデータをAzure OpenAIを用いて要約します。

  6. Teamsチャットに投稿
    要約された内容を指定されたTeamsチャットに1分以内に投稿します。

構築手順

1. Azure Microsoft Entra IDでアプリ登録

  1. Azureポータルにサインイン
  2. 「Microsoft Entra ID」で新規アプリケーションを登録
  3. クライアントIDやテナントIDを取得し、メモします

2. Microsoft Graph APIの権限設定

  1. 登録したアプリに以下のAPI権限を追加:
    • OnlineMeetings.Read.All
    • OnlineMeetingTranscript.Read.All
  2. 「管理者同意」を行い、権限を有効化

3. Azure Functionsで関数アプリを作成

  1. Azureポータルで新規「関数アプリ」を作成
  2. 関数内で以下の処理を実装:
    • トランスクリプトデータの取得
    • 要約処理(Azure OpenAIを使用)
    • Teamsチャットへの投稿

4. 関数アプリの環境変数設定

関数で使用する以下の情報を環境変数として設定します:

  • クライアントID
  • テナントID
  • Graph APIの認証キー
  • Azure OpenAIの認証情報

5. Graph APIのサブスクリプション作成

Graph APIを利用し、トランスクリプトデータの更新を検知するサブスクリプションを作成します。

6. Teamsワークフローを設定

Teamsのテンプレート「Webhook要求を受信したらチャットに投稿する」を使用し、会議チャットへの自動投稿を設定します。

実装コード

トランスクリプト取得・要約処理

  • main関数 → Teams会議のトランスクリプトデータの取得、要約と投稿を行います。
  • lifecycle関数 → 初期設定で、Microsoft Graph APIが特定の会議のトランスクリプトデータ作成を検知するための「サブスクリプション」を作成しますが、サブスクリプション作成時にバリデーション用ライフサイクル通知がこの関数に送信されます。
import logging
import azure.functions as func
from azure.identity import ClientSecretCredential

app = func.FunctionApp(http_auth_level=func.AuthLevel.FUNCTION)

@app.route(route="main")
def main(req: func.HttpRequest) -> func.HttpResponse:
    import os
    import requests
    import re
    import threading
    import urllib.parse
    from openai import AzureOpenAI

    logging.info('議事録自動化のHTTPトリガー関数が実行されました。')

    validation_token = req.params.get('validationToken')
    if validation_token:
        logging.info("このリクエストはバリデーション通知です。")
        return func.HttpResponse(
            validation_token,
            status_code=200
        )

    # 環境変数からAzure OpenAIのエンドポイントとAPIキーを取得
    client = AzureOpenAI(
        azure_endpoint = os.getenv("AZURE_OPENAI_ENDPOINT"),
        api_key=os.getenv("AZURE_OPENAI_API_KEY"),
        api_version="2024-06-01"
    )

    def process_request(req_body, thread_id, chat_url):
        # この中に長時間の処理を実行
        # トランスクリプトデータ取得や要約生成処理
        try:
            # 受信したリクエストのclientStateをチェック
            received_client_state = req_body[0].get('clientState')
            expected_client_state = os.getenv('AUTH_KEY') # サブスクリプション作成時に設定した値

            if received_client_state != expected_client_state:
                logging.error("無効なclientStateが受信されました。")
                return func.HttpResponse(
                    "認証エラーです",
                    status_code=403
                )

            # 環境変数から認証情報を取得
            tenant_id = os.getenv('AZURE_TENANT_ID')
            client_id = os.getenv('AZURE_CLIENT_ID')
            client_secret = os.getenv('AZURE_CLIENT_SECRET')

            # Microsoft Graph APIに認証
            credential = ClientSecretCredential(tenant_id, client_id, client_secret)
            token = credential.get_token("https://graph.microsoft.com/.default")

            # リクエストヘッダーを設定
            headers = {
                'Authorization': f'Bearer {token.token}',
                'Content-Type': 'application/json'
            }

            # ここに対象のスレッドIDを入力し、会議の管理者ID(organizer_id)を取得
            chat_entity_url = f'https://graph.microsoft.com/v1.0/chats/{thread_id}'

            response = requests.get(chat_entity_url, headers=headers)

            if response.status_code == 200:
                chat_entity = response.json()
                organizer_id = chat_entity.get('onlineMeetingInfo', {}).get('organizer', {}).get('id')

                # トランスクリプトデータのURLを取得
                resource = req_body[0].get('resource')
                # resourceからcommunications/ を削除
                transcript_path = resource.replace("communications/", "")

                # APIリクエストを送信してトランスクリプト内容の取得
                transcript_content_url = f"https://graph.microsoft.com/v1.0/users/{organizer_id}/{transcript_path}/content?$format=text/vtt"
                transcript_content_response = requests.get(transcript_content_url, headers=headers)

                if transcript_content_response.status_code == 200:
                    # WebVTTコンテンツをUTF-8としてデコード
                    transcript_content = transcript_content_response.content.decode('utf-8')

                    # WebVTT形式のタイムスタンプとキュー(例: "00:00:01.000 --> 00:00:05.000")を削除
                    transcript_content = re.sub(r"\d{2}:\d{2}:\d{2}\.\d{3} --> \d{2}:\d{2}:\d{2}\.\d{3}", "", transcript_content)

                    # '<v 発話者>' タグから発話者と内容を抽出し、フォーマットを整える
                    # 例: <v Taro>会議の進行を始めます</v> -> Taro: 会議の進行を始めます
                    transcript_content = re.sub(r"<v\s*([^>]+)>(.*?)</v>", r"\1: \2", transcript_content)

                    # 残っているメタデータや不要な空行を削除(例: "WEBVTT"ヘッダー、空行)
                    transcript_content = re.sub(r"WEBVTT|\n\s*\n", "\n", transcript_content).strip()

                    try:

                        # AZURE OPENAI APIでトランスクリプトデータを要約する
                        # トランスクリプトを要約するためのリクエストを送信
                        response = client.chat.completions.create(
                            model="gpt-4o-lab",
                            messages=[
                                {
                                    "role": "system",
                                    "content": (
                                        "あなたは、提供された会議の記録のみを使ってレポートを作成するアシスタントです。\n\n"
                                        "# 必ず守るべきルール\n"
                                        "* 会議レコードに含まれていない情報は絶対に生成しない。\n"
                                        "* 推測や仮説に基づく内容は含めない。\n"
                                        "* 提供された会議レコードの内容に厳密に従って、正確なレポートを作成してください。\n"
                                        "* 資料が不足している場合でも、追加の推測を行わず、その部分を空欄のままにするか、記載しない。\n\n"
                                        "# 目的\n"
                                        "* 会議に参加できなかった人が効率的に内容を理解できるようにすることです。\n\n"
                                        "# 出力フォーマット\n"
                                        "for each 議題 in 議題リスト:\n"
                                        "## 議題のタイトル: {議題のタイトルを作成}\n"
                                        "* 提起者: {議題の提起者一覧}\n"
                                        "* カテゴリー: {進捗共有・相談・提案・雑談}\n"
                                        "* 議題に対する具体的な議論内容: {記録がある場合のみ}\n"
                                        "* 結論: {記録がある場合のみ}\n"
                                        "* ネクストアクション: {記録がある場合のみ}\n"
                                    )
                                },
                                {
                                    "role": "assistant",
                                    "content": transcript_content
                                }
                            ],
                            temperature=0.0,  # より決定的な応答を生成するため、創造的な要素を抑える
                            max_tokens=4000  # 生成される応答の最大トークン数を制限
                        )


                        summary_result = response.choices[0].message.content
                    except Exception as e:
                        logging.error(f"要約の生成中にエラーが発生しました: {e}")
                        return func.HttpResponse(
                            "要約の生成中にエラーが発生しました",
                            status_code=200
                        )

                    logging.info(f"要約の作成は完了しました。")

                    # Teamsのチャットにメッセージを送信
                    message_payload = {
                        "type":"message",
                        "attachments":[
                            {
                                "contentType":"application/vnd.microsoft.card.adaptive",
                                "content":{
                                    "$schema":"http://adaptivecards.io/schemas/adaptive-card.json",
                                    "type": "AdaptiveCard",
                                    "version":"1.2",
                                    "body":[
                                        {
                                        "type": "TextBlock",
                                        "wrap": True,
                                        "text": f"会議要約\n\n{summary_result}"
                                        }
                                    ]
                                }
                            }
                        ]
                    }

                    # リクエストヘッダーを設定
                    headers = {
                        'Content-Type': 'application/json'
                    }
                    message_response = requests.post(chat_url, headers=headers, json=message_payload)

                    if message_response.status_code == 202:
                        logging.info(f"メッセージがTeamsに正常に送信されました。")
                        return func.HttpResponse(
                            f"メッセージがTeamsに正常に送信されました。",
                            status_code=200,
                        )
                    else:
                        logging.error(f"Teamsチャットへのメッセージ送信に失敗しました: {message_response.status_code} - {message_response.text}")
                        return func.HttpResponse(
                            f"Teamsチャットへのメッセージ送信に失敗しました: {message_response.status_code} - {message_response.text}",
                            status_code=message_response.status_code
                        )
                else:
                    logging.error(f"トランスクリプトデータの取得に失敗しました: {transcript_content_response.status_code} - {transcript_content_response.text}")
                    return func.HttpResponse(
                        f"トランスクリプトデータの取得に失敗しました: {transcript_content_response.status_code} - {transcript_content_response.text}",
                        status_code=transcript_content_response.status_code
                    )
            else:
                logging.error(f"会議の管理者IDの取得に失敗しました: {response.status_code} - {response.text}")
                return func.HttpResponse(
                    f"会議の管理者IDの取得に失敗しました: {response.status_code} - {response.text}",
                    status_code=response.status_code
                )
        except Exception as e:
            logging.error(f"エラーが発生しました: {e}")
            return func.HttpResponse(
                "エラーが発生しました",
                status_code=500
            )

    try:
        req_body = req.get_json().get('value', [])
        thread_id = req.params.get('threadId')
        encoded_chat_url = req.params.get('chatUrl')
        # URLデコード
        chat_url = urllib.parse.unquote(encoded_chat_url)
        threading.Thread(target=process_request, args=(req_body, thread_id, chat_url)).start()

        logging.info("通知を受信しました。")
        return func.HttpResponse("通知を受信しました", status_code=200)
    except ValueError:
        logging.error("無効なリクエストボディです。")
        return func.HttpResponse(
            "無効なリクエストボディです",
            status_code=400
        )
    except Exception as e:
        logging.error(f"エラーが発生しました: {e}")
        return func.HttpResponse(
            "エラーが発生しました",
            status_code=500
        )

@app.route(route="lifecycle")
def lifecycle(req: func.HttpRequest) -> func.HttpResponse:
    import requests
    import os
    from datetime import datetime, timedelta, timezone

    validation_token = req.params.get('validationToken')
    if validation_token:
        logging.info("このリクエストはバリデーション通知です。")
        return func.HttpResponse(
            validation_token,
            status_code=200
        )

    try:
        req_body = req.get_json().get('value', [])
    except ValueError:
        logging.error("無効なリクエストボディです。")
        return func.HttpResponse(
            "無効なリクエストボディです",
            status_code=400
        )

    # 受信したリクエストのclientStateをチェック
    received_client_state = req_body[0].get('clientState')
    expected_client_state = os.getenv('AUTH_KEY') # サブスクリプション作成時に設定した値

    if received_client_state != expected_client_state:
        logging.error("無効なclientStateが受信されました。")
        return func.HttpResponse(
            "認証エラーです",
            status_code=403
        )

    logging.info("このリクエストはライフサイクル通知です。")

    return func.HttpResponse(
        "ライフサイクル通知が正常に完了しました。",
        status_code=200
    )

会議トランスクリプト作成の検知と通知のためのサブスクリプション設定処理

Azure Functions 議事録自動化の初期設定用関数アプリの関数ファイルで特定の会議のトランスクリプトデータ作成を検知設定を書きます。

  • create_subscription関数 → 初期設定でMicrosoft Graph APIが特定の会議のトランスクリプトデータ作成を検知と通知するための「サブスクリプション」を作成します。
  • update_subscription関数 → トランスクリプトデータ検知のMicrosoft Graph APIサブスクリプションの有効期限は現日時から最大3日間しか設定できないため、バッチ処理で全サブスクリプションの有効期限を更新し、延長します。
import logging
import azure.functions as func
from azure.identity import ClientSecretCredential

app = func.FunctionApp()

@app.schedule(schedule="0 35 2 * * *", arg_name="myTimer", run_on_startup=True,
              use_monitor=False)
def update_subscription(myTimer: func.TimerRequest) -> None:
    if myTimer.past_due:
        logging.info('The timer is past due!')

    import requests
    import os
    from datetime import datetime, timedelta, timezone

    logging.info("全サブスクリプションの有効期限を更新します。")

    try:
        # 環境変数から認証情報を取得
        tenant_id = os.getenv('AZURE_TENANT_ID')
        client_id = os.getenv('AZURE_CLIENT_ID')
        client_secret = os.getenv('AZURE_CLIENT_SECRET')

        # Microsoft Graph APIに認証
        credential = ClientSecretCredential(tenant_id, client_id, client_secret)
        token = credential.get_token("https://graph.microsoft.com/.default")

        # リクエストヘッダーを設定
        headers = {
            'Authorization': f'Bearer {token.token}',
            'Content-Type': 'application/json'
        }

        # サブスクリプションリストの取得
        subscriptions_url = f"https://graph.microsoft.com/v1.0/subscriptions"
        subscriptions_response = requests.get(subscriptions_url, headers=headers)

        if subscriptions_response.status_code == 200:
            subscriptions = subscriptions_response.json().get('value', [])

            if subscriptions:
                # サブスクリプションを一つずつ処理
                for subscription in subscriptions:
                    subscription_id = subscription.get('id')
                    if subscription_id:
                        try:
                            # APIリクエストを送信してサブスクリプションの有効期限を更新する
                            subscription_update_url = f"https://graph.microsoft.com/v1.0/subscriptions/{subscription_id}"
                            # 現在時刻から2日後の日時を計算し、ISO 8601 UTCフォーマットに変換
                            two_days_later = (datetime.now(timezone.utc) + timedelta(days=2)).isoformat()

                            # リクエストペイロード (expirationDateTimeを2日後に設定)
                            payload = {
                                "expirationDateTime": two_days_later
                            }

                            subscription_update_response = requests.patch(subscription_update_url, headers=headers, json=payload)

                            if subscription_update_response.status_code == 200:
                                logging.info(f"サブスクリプションID {subscription_id} の更新が正常に完了しました。")
                            else:
                                logging.error(f"サブスクリプションID {subscription_id} の更新に失敗しました: {subscription_update_response.status_code} - {subscription_update_response.text}")
                        except Exception as e:
                            logging.error(f"サブスクリプションID {subscription_id} の処理中にエラーが発生しました: {e}")
                    else:
                        logging.warning("無効なサブスクリプションIDが検出されました。")

            logging.info("全てのサブスクリプションの更新処理が完了しました。")
        else:
            logging.error(f"サブスクリプションの取得に失敗しました: {subscriptions_response.status_code} - {subscriptions_response.text}")

    except Exception as e:
        logging.error(f"エラーが発生しました: {e}")

@app.route(route="create_subscription")
def create_subscription(req: func.HttpRequest) -> func.HttpResponse:
    import requests
    import os
    import urllib.parse
    from datetime import datetime, timedelta, timezone

    try:
        req_body = req.get_json()
    except ValueError:
        logging.error("無効なリクエストボディです。")
        return func.HttpResponse(
            "無効なリクエストボディです",
            status_code=400
        )

    # 受信したリクエストのclientStateをチェック
    received_client_state = req_body.get('clientState')
    expected_client_state = os.getenv('AUTH_KEY') # サブスクリプション作成時に設定した値

    if received_client_state != expected_client_state:
        logging.error("無効なclientStateが受信されました。")
        return func.HttpResponse(
            "認証エラーです",
            status_code=403
        )

    logging.info("Teams会議で、トランスクリプト作成を自動通知するためのサブスクリプション作成用リクエストです。")

    try:
        # 環境変数から認証情報を取得
        tenant_id = os.getenv('AZURE_TENANT_ID')
        client_id = os.getenv('AZURE_CLIENT_ID')
        client_secret = os.getenv('AZURE_CLIENT_SECRET')

        # Microsoft Graph APIに認証
        credential = ClientSecretCredential(tenant_id, client_id, client_secret)
        token = credential.get_token("https://graph.microsoft.com/.default")

        # リクエストヘッダーを設定
        headers = {
            'Authorization': f'Bearer {token.token}',
            'Content-Type': 'application/json'
        }

        # リクエストボディからスレッドIDを取得
        thread_id = req_body.get('threadId')
        # ここに対象のスレッドIDを入力し、会議の管理者IDと会議呼び出しURLを取得
        chat_entity_url = f'https://graph.microsoft.com/v1.0/chats/{thread_id}'

        response = requests.get(chat_entity_url, headers=headers)

        if response.status_code == 200:
            chat_entity = response.json()
            # 会議の管理者IDと会議呼び出しURLを取得
            organizer_id = chat_entity.get('onlineMeetingInfo', {}).get('organizer', {}).get('id')
            # 会議呼び出しの URLを取得
            join_web_url = chat_entity.get('onlineMeetingInfo', {}).get('joinWebUrl')

            if not join_web_url or not organizer_id:
                logging.error("会議呼び出しの URL(joinWebUrl)または会議の管理者ID(organizerId)が見つかりませんでした。")
                return func.HttpResponse(
                    "会議呼び出しの URL(joinWebUrl)または会議の管理者ID(organizerId)が見つかりませんでした。",
                    status_code=404
                )

            # ここに対象の会議呼び出しの URLを入力し、会議IDを取得
            meeting_url = f"https://graph.microsoft.com/v1.0/users/{organizer_id}/onlineMeetings?$filter=JoinWebUrl%20eq%20'{join_web_url}'"
            response = requests.get(meeting_url, headers=headers)

            if response.status_code == 200:
                meeting = response.json()
                meeting_id = meeting.get('value', [])[0].get('id')

                if not meeting_id:
                    logging.error("会議IDが見つかりませんでした。")
                    return func.HttpResponse(
                        "会議IDが見つかりませんでした。",
                        status_code=404
                    )

                # リクエストボディからチャットURLを取得
                chat_url = req_body.get('chatUrl')
                encoded_chat_url = urllib.parse.quote(chat_url)

                # リクエストペイロード
                payload = {
                    "changeType": "created",
                    "notificationUrl": f"https://labkuses.azurewebsites.net/api/main?code=KXT_IslcvB2fi8lqhqElhvtnyXLB4Q5jsAZBFMs7t2lVAzFuf-M-lQ%3D%3D&threadId={thread_id}&chatUrl={encoded_chat_url}",
                    "lifecycleNotificationUrl": "https://labkuses.azurewebsites.net/api/lifecycle?code=KXT_IslcvB2fi8lqhqElhvtnyXLB4Q5jsAZBFMs7t2lVAzFuf-M-lQ%3D%3D",
                    "resource": f"communications/onlineMeetings/{meeting_id}/transcripts",
                    "expirationDateTime": (datetime.now(timezone.utc) + timedelta(days=2)).isoformat(),
                    "clientState": os.getenv('AUTH_KEY')
                }

                # APIリクエストを送信してサブスクリプションを作成
                subscription_url = "https://graph.microsoft.com/v1.0/subscriptions"
                subscription_response = requests.post(subscription_url, headers=headers, json=payload)

                if subscription_response.status_code == 201:
                    subscription = response.json()
                    subscription_id = subscription.get('id')
                    logging.info("サブスクリプションが正常に作成されました。")
                    return func.HttpResponse(
                        f"サブスクリプションが正常に作成されました。サブスクリプションID: {subscription_id}",
                        status_code=200
                    )
                else:
                    logging.error(f"サブスクリプションの作成に失敗しました: {subscription_response.status_code} - {subscription_response.text}")
                    return func.HttpResponse(
                        f"サブスクリプションの作成に失敗しました: {subscription_response.status_code} - {subscription_response.text}",
                        status_code=subscription_response.status_code
                    )
            else:
                logging.error(f"会議情報の取得に失敗しました: {response.status_code} - {response.text}")
                return func.HttpResponse(
                    f"会議情報の取得に失敗しました: {response.status_code} - {response.text}",
                    status_code=response.status_code
                )
        else:
            logging.error(f"チャット情報の取得に失敗しました: {response.status_code} - {response.text}")
            return func.HttpResponse(
                f"チャット情報の取得に失敗しました: {response.status_code} - {response.text}",
                status_code=response.status_code
            )
    except Exception as e:
        logging.error(f"エラーが発生しました: {e}")
        return func.HttpResponse(
            "エラーが発生しました",
            status_code=500
        )

会議ごとの初期設定

初回のみ以下の設定が必要です:

  1. Teamsカレンダーで会議URLを取得
    • 必ずTeamsカレンダーから会議を作成します(Googleカレンダーで作成された会議は対象外です)。
  2. Teams会議URLからチャットスレッドIDを取得
  3. Teamsワークフローの作成
    • Teamsワークフローのテンプレート「Webhook要求を受信したらチャットに投稿する」を活用して、会議チャットに自動で要約メッセージを投稿する設定を行います。
    • 完了後、表示されるWebhook URLをコピーし次の手順で利用します。
  4. Azure Functionsの設定
    • Azure FunctionsのMicrosoft Graph APIサブスクリプションを作成専用に作成していたアプリのcreate_subscription関数を開き、以下のjsonリクエスト内容でPOSTリクエストを実行します。
{
    "clientState": "{環境変数に設定した認証キーの値}",
    "threadId": "{チャットスレッドID}",
    "chatUrl": "{TeamsのWebhookURL}"
}

困ったこと

1. Graph APIのサブスクリプションが時々機能しない

現象:
Graph APIでトランスクリプト作成を検知するサブスクリプションを設定しましたが、通知が届かず、Azure Functionsはトリガーされないことがあります。

解決策:
現在、調査中


2. Azure Functionsのタイムアウト

現象:
処理が重い場合、HTTP 200レスポンスが遅れ、無限にトリガーされる問題が発生しました。

解決策:

  • 処理を非同期化し、即座にHTTP 200レスポンスを返す設計に変更
  • 処理終了後Teamsに結果を投稿する方式に改良
    これにより、タイムアウトや無限トリガーの問題を解消しました。

3. Azure Functionsのインポート競合問題

現象:
import azure.functions as func と import os を同時に使うと、関数が正常に作成されず、新しく関数を作成することもできなくなるという問題が発生しました。

解決策:
os モジュールを関数内で動的にインポートする方法に変更しました。これにより、azure.functions と os が競合せず、正常にデプロイ・実行されるようになりました。

おわりに

本記事では、Microsoft Teams会議後の文字起こしを自動要約し、Teamsチャットに投稿する仕組みを構築する方法を解説しました。この自動化により、議事録作成が大幅に効率化され、会議後の迅速な情報共有が可能になります。

今後も改善を続け、より安定した運用を目指します。

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?