はじめに:私は「議事録おじさん」でした
弊社は7人の小さなプロダクトチームで、日本人エンジニア4名と海外メンバー3名(英語)が混在しています。毎週水曜の朝に90分の定例MTGがあるのですが、ここで毎回発生していたのが 「議事録、誰がまとめる?」 という地味で重い空気でした。
最初は持ち回りにしていたのですが、海外メンバーの英語パートを日本人が拾えなかったり、逆もまた然りで、結局いつの間にか 私が全部やる係 になっていました。MTG後に録画を1.5倍速で見返して、文字に起こして、英語部分を訳して、決定事項とTODOを抜き出して、Jiraにチケットを切る。所要時間、毎回ざっと1時間ちょっと。
「これ、AIにやらせれば終わるのでは?」と前から思っていたところに、ちょうど Zoom AI Services のキャンペーンを見つけました。Scribe / Translator / Summarizer の3つがどれもシンプルなREST APIで、しかも$20の無料クレジット付き。これは試すしかない、ということで週末に作ってみた記録です。
作ったもの(全体像)
やりたいことは1本の流れです。Zoomの録画音声を放り込んだら、最後にJiraにチケットが立っている状態にしたい。
ポイントは、3つのAPIを「直列につなぐだけ」で意味のある業務フローになるところです。1個1個の機能は地味でも、つなぐと一気に価値が出ます。
下準備:APIキーの取得と環境変数
キーは Zoom Build Platform から発行できます(公式の手順)。発行したら環境変数に逃がしておきます。コードに直書きすると、うっかりGitHubに上げて泣くことになるので…(経験者は語る)。
export ZOOM_AI_API_KEY="zai_xxxxxxxxxxxxxxxxxxxx"
export JIRA_BASE_URL="https://your-team.atlassian.net"
export JIRA_EMAIL="you@example.com"
export JIRA_API_TOKEN="xxxxxxxxxxxx"
共通のヘッダーとベースURLを先に定義しておきます。
import os
import requests
API_BASE = "https://api.zoom.com/ai-services/v1"
HEADERS = {
"Authorization": f"Bearer {os.environ['ZOOM_AI_API_KEY']}",
"Content-Type": "application/json",
}
Step 1. Scribe API で文字起こし(話者分離つき)
まずは音声をテキストにします。Scribe は Batch モードがあり、長尺の録画でも非同期で処理してくれます。MTGは90分あるので、リクエストを投げて job_id を受け取り、完了までポーリングする形にしました。
ありがたいのが diarization(話者分離)オプション。これをONにすると「誰が話したか」が付いてくるので、議事録っぽさが段違いになります。
import time
from client import API_BASE, HEADERS
import requests
def start_transcription(audio_url: str, language: str = "ja") -> str:
"""文字起こしジョブを投げて job_id を返す"""
resp = requests.post(
f"{API_BASE}/scribe/transcriptions",
headers=HEADERS,
json={
"audio_url": audio_url,
"language": language,
"diarization": True, # 話者分離をON
"mode": "batch", # 長尺向け
},
timeout=30,
)
resp.raise_for_status()
return resp.json()["job_id"]
def wait_for_transcript(job_id: str, interval: int = 15) -> list[dict]:
"""完了までポーリングして発言リストを返す"""
while True:
resp = requests.get(
f"{API_BASE}/scribe/transcriptions/{job_id}",
headers=HEADERS,
timeout=30,
)
resp.raise_for_status()
data = resp.json()
if data["status"] == "completed":
# [{"speaker": "Speaker 1", "lang": "ja", "text": "..."}...]
return data["transcript"]["segments"]
if data["status"] == "failed":
raise RuntimeError(f"文字起こし失敗: {data.get('error')}")
time.sleep(interval)
返ってくるのは、こんな感じの発言ごとの配列です。
{
"status": "completed",
"transcript": {
"segments": [
{"speaker": "Speaker 1", "lang": "ja", "text": "リリースは来週の火曜で行きましょう"},
{"speaker": "Speaker 2", "lang": "en", "text": "I'll prepare the migration script by Monday."}
]
}
}
90分のMTGで、ここまで待ち時間およそ4分。コーヒーを淹れて戻ってきたら終わっていました。
💡 (スクショ差し込み推奨) 実際に返ってきたJSONのターミナル画面を貼ると、リアリティが出ます。
Step 2. Translator API で言語を揃える
segments には lang: "en" と lang: "ja" が混ざっています。最終的な議事録は 日本語で統一したいので、英語の発言だけ翻訳に回します。逆に、海外メンバー向けには英語版も作りたいので、関数は方向を指定できるようにしました。
from client import API_BASE, HEADERS
import requests
def translate(text: str, source: str, target: str) -> str:
resp = requests.post(
f"{API_BASE}/translator/translations",
headers=HEADERS,
json={
"text": text,
"source_lang": source,
"target_lang": target,
# 固有名詞がブレないように用語集を渡せる(後述のハマりどころ)
"glossary": {"Jira": "Jira", "Scribe": "Scribe"},
},
timeout=60,
)
resp.raise_for_status()
return resp.json()["translated_text"]
def normalize_to_japanese(segments: list[dict]) -> str:
"""英語発言だけ翻訳して、日本語の議事録テキストに整形する"""
lines = []
for seg in segments:
text = seg["text"]
if seg["lang"] == "en":
text = translate(text, source="en", target="ja")
lines.append(f"{seg['speaker']}: {text}")
return "\n".join(lines)
glossary(用語集)に気づくまで、社内プロダクト名の「Scribe」が毎回「写字生」と訳されて味のある議事録が生成されていました。固有名詞は用語集に登録しておくのが正解です。
Step 3. Summarizer API で「要約 + タスク抽出」
ここが本命です。Summarizer は単に要約するだけでなく、instructions で 出力の指示を細かく渡せます。私は「3行要約」と「決定事項」と「アクションアイテム(担当者付き)をJSONで」をまとめてお願いしました。
import json
from client import API_BASE, HEADERS
import requests
def summarize_meeting(transcript: str) -> dict:
resp = requests.post(
f"{API_BASE}/summarizer/summaries",
headers=HEADERS,
json={
"text": transcript,
"format": "structured",
"instructions": (
"あなたは優秀な議事録作成者です。次を出力してください。\n"
"1. summary: 会議内容を日本語3行で\n"
"2. decisions: 決定事項の配列\n"
"3. action_items: {task, assignee, due} の配列\n"
"出力は厳密なJSONのみ。前置きや```は不要。"
),
},
timeout=120,
)
resp.raise_for_status()
raw = resp.json()["summary"]
# 念のため ```json フェンスが付いてきても剥がす
raw = raw.replace("```json", "").replace("```", "").strip()
return json.loads(raw)
出力はこうなります。これがそのまま議事録の中身になり、action_items がそのままチケットの種になります。
{
"summary": [
"次期リリースは来週火曜に確定。",
"DBマイグレーションは月曜までにスクリプト準備。",
"多言語対応の優先度を1つ上げることで合意。"
],
"decisions": ["リリース日を6/30に確定", "i18n対応をSprint 14に前倒し"],
"action_items": [
{"task": "マイグレーションスクリプト作成", "assignee": "Sam", "due": "2026-06-29"},
{"task": "リリースノート下書き", "assignee": "及川", "due": "2026-06-30"}
]
}
Step 4. Jira に自動起票して、最後にSlack通知
action_items をループで回して、Jira REST APIでチケットを立てます。最後にSlackに「議事録できたよ」と投げて完了です。
import os
import requests
from requests.auth import HTTPBasicAuth
JIRA = os.environ["JIRA_BASE_URL"]
AUTH = HTTPBasicAuth(os.environ["JIRA_EMAIL"], os.environ["JIRA_API_TOKEN"])
def create_issue(item: dict, project_key: str = "DEV") -> str:
payload = {
"fields": {
"project": {"key": project_key},
"summary": item["task"],
"issuetype": {"name": "Task"},
"duedate": item.get("due"),
}
}
resp = requests.post(
f"{JIRA}/rest/api/3/issue",
json=payload, auth=AUTH, timeout=30,
)
resp.raise_for_status()
return resp.json()["key"] # 例: "DEV-482"
すべてをつなぐ main.py はこれだけです。
from transcribe import start_transcription, wait_for_transcript
from translate import normalize_to_japanese
from summarize import summarize_meeting
from to_jira import create_issue
def run(audio_url: str):
job_id = start_transcription(audio_url)
segments = wait_for_transcript(job_id)
transcript = normalize_to_japanese(segments)
result = summarize_meeting(transcript)
print("📝 要約:\n" + "\n".join(result["summary"]))
for item in result["action_items"]:
key = create_issue(item)
print(f" ✅ {key}: {item['task']} → @{item['assignee']}")
if __name__ == "__main__":
run("https://recordings.example.com/weekly-mtg.m4a")
実行するとこうなります。
$ python main.py
📝 要約:
次期リリースは来週火曜に確定。
DBマイグレーションは月曜までにスクリプト準備。
多言語対応の優先度を1つ上げることで合意。
✅ DEV-482: マイグレーションスクリプト作成 → @Sam
✅ DEV-483: リリースノート下書き → @及川
MTG終了後、録画URLを1個渡すだけで、ここまで全自動です。
ハマったところ(ここが一番伝えたい)
きれいに動いたように書きましたが、実際は週末をそこそこ溶かしました。リアルなハマりどころを3つ残しておきます。
1. ポーリングのステータス名を勘違いして無限ループ
最初 status == "done" で待っていたら永遠に抜けられず。正しくは "completed" でした。failed の分岐を入れていなかったので、エラー時も静かに回り続けて怖かったです。非同期APIは「失敗」の分岐を必ず先に書くべきでした。
2. 長い文字起こしを丸ごとSummarizerに投げて413
90分のMTGはテキストにすると結構な量で、一度 Payload Too Large を食らいました。話者の発言を15分ごとのチャンクに分けて部分要約 → 最後に統合要約、の2段構えにしたら安定しました。
3. 固有名詞が訳されて意味不明に
前述の「Scribe → 写字生」問題。Translatorの glossary で固有名詞を固定したら解決。地味だけど効果は絶大でした。
ビフォーアフター
数字で見るとやった甲斐がありました。
| 項目 | 手作業(Before) | 自動化(After) |
|---|---|---|
| 文字起こし | 録画を1.5倍速で視聴 約40分 | Scribeに投げて待つ 約4分 |
| 英語パートの翻訳 | 手で訳す 約15分 | 自動 0分 |
| 要約・TODO整理 | 約10分 | 自動 0分 |
| Jira起票 | 手で3〜5枚 | 自動 0分 |
| 合計(私の作業時間) | 約65分 | 約3分(実行と確認のみ) |
定例は毎週なので、ざっくり 月4時間以上 が浮きました。何より「議事録おじさん」というしんどい役回りから解放されたのが大きいです。チームの海外メンバーからも「ついに英語の議事録が読める」と好評でした。
コストの話
気になる費用ですが、$20の無料クレジットの範囲で2ヶ月運用できています。キャンペーンの説明にあった通り「文字起こしはBatchで133時間ぶん」使えるので、90分MTGなら約88回分。週1の定例なら1年以上は無料枠で回る計算で、個人開発・小規模チームには十分すぎる体力でした。
まとめ
- Zoom AI Services の3つのAPIは、単体だと地味だが、直列につなぐと「業務フロー」になるのが一番おいしいポイントでした
- 全部REST APIなので、特別な学習コストはほぼゼロ。
requestsが叩ければ作れます - 「自分が毎週やっている面倒な手作業」を1つ選んで自動化すると、効果を実感しやすいです
次は、Slackのスレッドにも要約を流して「録画見てない人」を救済する仕組みと、議事録をNotionの検索可能なナレッジベースに蓄積する版を作る予定です。同じくScribeで社内の過去録画を一気に資産化するのは夢があるなと思っています。
「議事録おじさん」と同じ悩みを抱えている方の参考になれば嬉しいです。最後まで読んでいただきありがとうございました。
参考リンク
- Scribe API(音声文字起こし): https://www.zoom.com/ja/products/ai-services/scribe-api/
- Translator API(テキスト翻訳): https://www.zoom.com/en/products/ai-services/translator-api/
- Summarizer API(テキスト要約): https://www.zoom.com/en/products/ai-services/summarizer-api/