はじめに
議事録作成の手間を減らし、作業を自動化するため、Microsoft Graph APIやAzureのサービスをを活用した仕組みを構築しました。
この仕組みでは以下を実現しています:
-
会議後の文字起こしデータを自動取得
Microsoft Graph APIを使ってTeams会議の文字起こしを取得します。 -
文字起こしの要約
Azure OpenAIを活用し、文字起こしを要約して簡潔にまとめます。 -
要約の自動共有
生成した要約を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 (アプリケーション) - 管理者同意が必要
自動化の流れ
-
Teams会議で文字起こしを行う
Teamsの文字起こし機能を有効化し、会議内容を自動で記録します。 -
文字起こしデータの作成
会議終了後、Teams内にトランスクリプトデータが生成されます。 -
Graph APIで更新を検知
Graph APIを使用して、トランスクリプトデータの更新をリアルタイムで検知します。 -
Azure Functionsでデータを取得
Microsoft Graph APIを通じて、最新の文字起こしデータを取得します。 -
Azure OpenAIで要約
トランスクリプトデータをAzure OpenAIを用いて要約します。 -
Teamsチャットに投稿
要約された内容を指定されたTeamsチャットに1分以内に投稿します。
構築手順
1. Azure Microsoft Entra IDでアプリ登録
- Azureポータルにサインイン
- 「Microsoft Entra ID」で新規アプリケーションを登録
- クライアントIDやテナントIDを取得し、メモします
2. Microsoft Graph APIの権限設定
- 登録したアプリに以下のAPI権限を追加:
- OnlineMeetings.Read.All
- OnlineMeetingTranscript.Read.All
- 「管理者同意」を行い、権限を有効化
3. Azure Functionsで関数アプリを作成
- Azureポータルで新規「関数アプリ」を作成
- 関数内で以下の処理を実装:
- トランスクリプトデータの取得
- 要約処理(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
)
会議ごとの初期設定
初回のみ以下の設定が必要です:
- Teamsカレンダーで会議URLを取得
- 必ずTeamsカレンダーから会議を作成します(Googleカレンダーで作成された会議は対象外です)。
- Teams会議URLからチャットスレッドIDを取得
- 会議URLの中にある「チャットスレッドID」を取得します。例として、以下の形式のURLから「19%3Ameeting_xxxx%40thread.v2」の部分がチャットスレッドIDです。
https://teams.microsoft.com/l/meetup-join/19%3Ameeting_xxxx%40thread.v2/0?context=xxxx
- 会議URLの中にある「チャットスレッドID」を取得します。例として、以下の形式のURLから「19%3Ameeting_xxxx%40thread.v2」の部分がチャットスレッドIDです。
- Teamsワークフローの作成
- Teamsワークフローのテンプレート「Webhook要求を受信したらチャットに投稿する」を活用して、会議チャットに自動で要約メッセージを投稿する設定を行います。
- 完了後、表示されるWebhook URLをコピーし次の手順で利用します。
- 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チャットに投稿する仕組みを構築する方法を解説しました。この自動化により、議事録作成が大幅に効率化され、会議後の迅速な情報共有が可能になります。
今後も改善を続け、より安定した運用を目指します。