🎧

AIエージェントでPodcastを自動生成する

2024/12/15に公開

はじめに

こんにちは。株式会社アイデミーデータサイエンティストの中沢(X/Bsky)です。

私は現職ではフルリモートで勤務していますが、色々あってオフィスに出社する日も稀にあります。
この移動時間を何かしらに使わないともったいないと思う一方、オフィスが大手町にあるため行きも帰りも激混み、その上最近はコートを手で持ったりもするため、もはや本を開く余裕すらありません。こんな状況で自由に使えるのは耳だけです。

耳からのインプットツールとして候補に挙がるものの一つがPodcastです。ここ数年で様々なジャンルの番組が増えました。しかし、今自分が興味を持っているものをピンポイントで取り扱う番組があるとは限りません。情報が洪水のように流れていく今の時代、自分の気になる情報をまとめてザッと聞く方法があると嬉しいですよね。

そこで本稿では LangChainLangGraph で実装したAIエージェントを用い、最近公開された論文を紹介する音声ファイルを作る方法をご紹介します。

Python/パッケージのバージョンの確認

本稿では以下のバージョンのPython/パッケージを用いています。

python==3.10.12
semanticscholar==0.9.0
langchain-core==0.3.0
langchain-openai==0.2.0
langgraph==0.2.22
openai==1.56.2
python-dotenv==1.0.1

構成を考える

構成は非常にシンプルです。Semantic Scholar から論文情報を取得 → OpenAI の GPT-4o-mini で要約&原稿を作成 → text-to-speech で音声ファイル化します。
この要約&原稿作成においてAIエージェントを用います。

論文情報を取得する

Semantic Scholar API Tutorial を参考に論文情報を取得します[1]。今回はタイトルと要旨のみから原稿を生成してみましょう。

import requests
import json
import os
from datetime import datetime, timedelta

keyword = "Machine Learning" # 検索キーワード
days = 7 # 過去何日間分の情報を集めるか

url = 'https://api.semanticscholar.org/graph/v1/paper/search'
# 何の情報を集めるか
fields = ('title', 'abstract', 'paperId')

# 集める期間の設定
start_date = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
end_date = datetime.now().strftime("%Y-%m-%d")
date_range = f"{start_date}:{end_date}"

query_params = {
    'query': keyword,
    'fields': ','.join(fields),
    'publicationDateOrYear': date_range,
    'sort': 'publicationDate',
    'limit': 5 # 多すぎるとこのあとのOpenAI APIの利用コストが怖いので、いったん5件に制限
}

response = requests.get(url, params=query_params)

if response.status_code == 200:
    response_data = response.json()
    total = response_data['total']
    print(f'Total search result: {total}')
    papers = response_data['data']
    print(papers)
else:
    print(f'Error: {response.status_code}')
    print(response.text)

良さそうですね。「オープンアクセスでない論文は除く」「論文URLも取得する」など、目的に応じて適当にご調整ください。

Podcastの原稿を生成する

次に、集めた論文情報からPodcastの原稿を生成するAIエージェントを構築します。構成はシンプルに以下のようにしてみましょう。

ライターが原稿を生成→レビュワーがクオリティチェック&評価文を生成→クオリティが低いと判断されていれば評価文を添えてライターに差し戻し、十分なクオリティと判断されていれば終了となります。

AIエージェントの実装は LangChainとLangGraphによるRAG・AIエージェント[実践]入門を参考にさせていただきました。

環境変数の設定

ここからは OpenAI API を使います。APIキーを発行後、.env ファイルを作成し記載してください。

OPENAI_API_KEY = "your api key"

それを読み込んで使います。

from dotenv import load_dotenv
load_dotenv()

ステートの定義とモデルの初期化

今回はエージェントに

  • 論文情報
  • Podcast原稿
  • 原稿のクオリティ
  • 原稿に対する評価文

という情報を持たせたいため、それらを保存するステート[2]を定義します。

from langchain_openai import ChatOpenAI
from langchain_core.runnables import ConfigurableField
from pydantic import BaseModel, Field

# ステートの定義
class State(BaseModel):
    papers: list = Field(
        ..., description="入力された論文情報"
    )
    podcast_script: str = Field(
        default="", description="生成されたPodcastの原稿"
    )
    quality_check: bool = Field(
        default=False, description="原稿の品質が基準を満たしているかどうか"
    )
    quality_feedback: str = Field(
        default="", description="品質チェックの結果理由またはフィードバック"
    )

# モデルの初期化
llm = ChatOpenAI(model="gpt-4o-mini")
llm = llm.configurable_fields(max_tokens=ConfigurableField(id='max_tokens'))

ノードの定義

次に writer と reviewer という二つのノードを定義します。

from typing import Any
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser

def writer_node(state: State) -> dict[str, Any]:
    papers = state.papers
    quality_feedback = state.quality_feedback
    # デバッグ出力
    print("Writer Node: paper_data:", papers)
    print("Writer Node: quality_feedback:", quality_feedback)

    prompt = ChatPromptTemplate.from_template(
        """以下の論文情報を基にPodcast用の原稿を作成してください。

論文情報:
{papers}

論文ごとに、以下の内容を盛り込んでください。
1. 論文の英語タイトル
2. タイトルの日本語訳
3. 主な研究内容
4. 結果と意義

以下の点に注意して原稿を書いてください。
- 論文の内容を正確に伝えること
- 聞き手が理解しやすい言葉で書くこと
- 耳で聞いて分かりやすい表現・内容を心掛けること
- Podcastの原稿であるため、箇条書きはせず、自然な話し言葉でつなぐこと
- 冒頭の挨拶と結尾の締めの言葉は定型文を別に加えるため、現時点では含めないこと
- {quality_feedback}

原稿:""".strip()
    )
    chain = prompt | llm | StrOutputParser()
    podcast_script = chain.invoke({"paper_data": papers, "quality_feedback": quality_feedback})
    return {"podcast_script": podcast_script}


# 構造化された品質チェック結果のモデル
class ReviewResult(BaseModel):
    quality_check: bool = Field(default=False, description="原稿の品質チェック結果 (True/False)")
    feedback: str = Field(default="", description="品質に関するフィードバックや改善案")


# Reviewerノード
def reviewer_node(state: State) -> dict[str, Any]:
    script = state.podcast_script
    # デバッグ出力
    print("Reviewer Node: script:", script)
    
    prompt = ChatPromptTemplate.from_template(
        """以下のPodcast原稿の品質を評価してください。

Podcast原稿:
{script}

評価基準:
1. 内容の正確性
2. 聴きやすさ
3. 全体の構成
4. 改善点の提案
5. 論文ごとに、内容に重複や冗長な部分がないか

出力は次の形式で返してください:
- quality_check: True (品質が十分であれば) または False (問題があれば)
- feedback: 品質に関する詳細なフィードバックや改善案。markdownの箇条書き形式。""".strip()
    )
    chain = prompt | llm.with_structured_output(ReviewResult)
    result: ReviewResult = chain.invoke({"script": script})

    return {
        "quality_check": result.quality_check,
        "quality_feedback": result.feedback
    }

ここのプロンプトエンジニアリングをこだわったり、複雑にしたりしていくことで、色々と発展させていくことができそうです。

グラフの作成

先のmermaidで書いたグラフを作成します。

from langgraph.graph import StateGraph
from langgraph.graph import END

workflow = StateGraph(State)
workflow.add_node("writer", writer_node)
workflow.add_node("reviewer", reviewer_node)
workflow.set_entry_point("writer") # writerノードから処理を開始
workflow.add_edge("writer", "reviewer") # ノードの接続
# state.quality_checkの値がTrueならENDノードへ、Falseならwriterノードへ
workflow.add_conditional_edges(
    "reviewer",
    lambda state: state.quality_check,
    {True: END, False: "writer"}
)

compiled = workflow.compile()

エージェントの実行

作成したエージェントを実行します。

initial_state = State(papers=papers, quality_feedback="")
result = compiled.invoke(initial_state)

結果を表示してみます。

print(result["podcast_script"])

色々と改善したい点もありますが、一旦は良さそうです。

原稿に手を加える

Podcastっぽくするために、少し手を加えます。全てを生成AIに行わせるのではなく、定型部はルールベースで定めることもコストを抑える一つのポイントです[3]

# 冒頭と終わりに定型文を挿入
intro = f"こんにちは。 Aidemy radio の時間です。今回もこの1週間で投稿された {keyword} 関連の新着論文の紹介を行っていきます。早速始めていきましょう。"

outro = "これで今週の Aidemy radio は終了です。次回もお楽しみに。"

podcast_script = intro + "\n\n" + result["podcast_script"] + "\n\n" + outro

print(podcast_script)

こちらを原稿の完成版とします。

原稿から音声ファイルを生成する

最後に原稿から音声ファイルを生成します。OpenAI公式ドキュメントに従い text-to-speech を実行します。

from openai import OpenAI

client = OpenAI()

response = client.audio.speech.create(
    model="tts-1",
    voice="alloy",
    input=podcast_script
)

response.stream_to_file(output_file)

音声ファイルが生成されました。
ここまで出来たら後はご自身のスマホに音声ファイルを送るだけ。自家製AIポッドキャストの完成です!上記の実装だと5論文でコストは $0.05 (約7.5円) 程度、時間にして約7分のファイルが生成されましたので、通勤2時間で80報は拾えますね[4]

おわりに

本稿ではAIエージェントを用いて自家製Podcastを生成する方法をご紹介しました。

今回程度のタスク・生成物であればAIエージェントを作る必要は実のところありません。しかし、今の生成物には明確に不満があり、まだまだできていないこと・改善したいことがたくさんあります。例えば、

  • 今は要旨しか見ていないが、PDF本文まで読んでから内容をまとめて欲しい
  • 重要そうな論文とそうでない論文に傾斜をつけて欲しい
  • キーワード検索で引っかかってはきたが明確に関係のなさそうな論文は省いて欲しい
  • 同じような文章の繰り返しを避けて欲しい
  • 全体で何分(何文字)以内に収めて欲しい
  • ハルシネーション対策

などが挙げられます。
こうした点に対応するにはプロンプトの工夫はもちろん、エージェント構成の工夫も必要と考えられます。複雑な系を組めば組むほど、AIエージェントとして実装する価値が出てくるでしょう。さらに将来的には、対話形式で出力して欲しい、抑揚をつけて話して欲しい、BGMも付けて欲しい、なんてところまで対応できるようになってくると面白いですね。

本稿では論文を題材としましたが、情報収集が自動化できれば、本稿の手法は何にでも適用できます。例えば気になる分野のニュースを夜中に集め音声化しておく→翌朝の通勤電車で聞く、みたいな使い方もできるでしょう。その中で特に気になった論文やニュースはメモしておき、後から深堀りPodcastを作るなんてこともできれば更にインプットが捗るのではないでしょうか[5]

今年も残すところあと半月です。みなさま、体調にお気をつけて良いお年をお迎えください!

脚注
  1. バルクでの情報取得であればAPIキーを発行せずとも使えますが、論文ごとに細かい情報を取得するなど高頻度なアクセスが必要であればAPIキーが必要となります。https://www.semanticscholar.org/product/api ↩︎

  2. ワークフローの各ノードで更新された値を保存するための仕組み ↩︎

  3. さらに言うと、完全な定型文であれば毎回音声生成させる必要もありません。別に作っておいた音声を使いまわせば毎回のコストを抑えられます。実際に使ってみるとこういった細かいノウハウが結構あることに気づきます。 ↩︎

  4. なんて簡単に言っていますが、実際は text-to-speech の入力に字数制限があるので、たくさんの論文を一気に音声化するには一工夫必要です。 ↩︎

  5. 本稿の手法は情報収集から音声ファイル生成まで自動で行える点に強みがあります。特定の論文やニュースの深堀りをしたいという場合には Google NotebookLM のPodcast生成機能や東大 苗村研のpaperwaveなども利用できます。 ↩︎

Aidemy Tech Blog

Discussion