Zenn
🏛️

【個人開発】生成AI論文紹介PodCastをYoutubeではじめ...

2025/03/12に公開
2

ようとしているところです。

こんにちは!
はじめまして、ふっきーです。
興味をもっていただき、ありがとうございます。

春日部つむぎとずんだもんが、LLMの最新注目論文を毎日わかりやすく紹介してくれるYoutubePodcastを始めようとしています。
今回は、"何がやりたいか"と"どう実現したか"を紹介していきます。

内容より先に、現時点でのPodcastの出来栄えを知りたい方は、こちら からご覧ください。

背景

LLMの情報収集するにあたって、より深く知ろうとすると論文から情報収集する必要性をひしひしと感じてきます。
ただ、英語だし、内容は難しくて頭に入ってこないといったハードルの高さから、後回しにされがちな印象です。LLMによる要約で理解することもできますが、日々の情報のキャッチアップに組み込むには、少しカロリーが高いです。

自分自身そういった課題を抱えていたので、論文からの情報キャッチアップをより気軽に行うためにも、”自動で情報収集してくれて”、”わかりやすく要約してくれて”、”気軽に聞ける”そういったサービスが必要でした。

そこで、LLMアプリケーション開発の自己学習もかねて、

LLM関連の注目論文を毎日1つピックアップして、内容を紹介する動画をYoutubeに毎日自動で投稿するシステム

を作りました。

Web系の知識・経験が皆無なので、Youtubeの力を借りています。
※個人開発でYoutubeの自動投稿システムをすでに作っているというのもある

このPodcastは、論文の選定から、ナレーションの作成、動画の生成・投稿まで全自動です。
ナレーションの生成までは、LangChainを使ったエージェンティックな実装になっています。

早速、どういう実装になっているかを見ていきます。

実装

このLLMPodcastは、いわゆるワーキングフロー型のマルチエージェントシステムです。
以下のようなエージェントくんたちが頑張ってくれています。
各エージェントの役割と実装を紹介していきます。

BaseLLMクラス

LLMに役割を持たせるにあたって、

  • system prompt
  • tools ※今回使っていない
  • output_sturucture
    これらのパラメータを持たせます

それら以外の実装を共通化した基底クラスになります。

from pydantic import BaseModel
from langchain_core.prompts import ChatPromptTemplate


class BaseLLM:
    def __init__(self):
        self.llm = None
        self.system_prompt: str = ""
        self.output_structure: BaseModel = None

    @property
    def prompt(self):
        return ChatPromptTemplate.from_messages(
            [
                ("system", self.system_prompt),
                ("human", "{input}")
            ]
        )

    @property
    def chain(self):
        if self.output_structure:
            return self.prompt | self.llm.with_structured_output(self.output_structure)
        return self.prompt | self.llm

    def _invoke(self, text: str):
        res = self.chain.invoke({'input', text})
        return res

LLM論文選択エージェント

Podcastで紹介するLLM論文を選択してくれるエージェントです。

ロジックでLLMの論文を選択することは容易ですが、より注目度の高い論文だったり、自分自身が注目している分野の論文をピックアップして欲しかったので、エージェントに判断してもらうことにしました。

特に優先度を上げてもらっているジャンルは、以下の通りです。

  • 生成AIモデル
  • RAG
  • AIエージェント

余談ですが、このエージェントを別のエージェント(例えば医療系の論文を選択するエージェント)に変えるだけで、まったく別ジャンルのPodcastを生成できるようになっているので、応用の幅は広そうです。

実装ポイント

このエージェントのポイントはWebサイトをある程度スクレイピングしてから、LLMに情報を渡していることです。特定のサイトに特化しているため、汎用性は低いですが、ロジックで動作を担保しているため精度の高いLLMを必要としません。※GROK2レベルだと、ページ全体からの論文URL取得は精度が低かったです。

このようにプロジェクト全体を通して、LLMに処理を任せる範囲を、ロジックでは実現が難しい抽象度の高い処理に限るようにしています。

上記のようにお膳立てをしているため、無料クレジットが残っているGrok2をモデルとして採用しています。

コード

class Response(BaseModel):
    url: str = Field(..., description="Url start with 'https://arxiv.org/abs/'")
    title: str = Field(..., description="Title of the article")
    pdf_url: str = Field(..., description="Ignore param, set empty string")


class PickUpArticle(BaseLLM):
    def __init__(self):
        self.llm = GROK2_RATEST
        self.system_prompt = """
            生成AIに関する論文が掲載されているWebページから注目度の高い論文を1つピックアップしてください
            必要があれば、その論文に関する概要の情報を取得して判断してください。

            より優先度の高い論文のカテゴリは以下の通りです。
                - 生成AIモデル
                - 生成AIモデルの学習手法
                - AIエージェント
                - RAG
        """
        self.output_structure = Response
        self.picked_titles_file_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "processed_titles.txt")

    @property
    def processed_titles(self) -> list[str]:
        """選択済みの論文タイトルリスト"""
        with open(self.picked_titles_file_path, 'r') as file:
            titles = file.read().splitlines()
        return titles

    def pickup(self) -> str:
        url = "https://deeplearn.org/"
        content = self._extract_article_element(url, self.processed_titles)
        res: Response = self.chain.invoke({'input': content})
        res.pdf_url = res.url.replace('abs', 'pdf')
        self._append_picked_title(res.title)
        return res

    def _extract_article_element(self, url: str, ban_list: list[str]) -> str:
        response = requests.get(url)
        soup = BeautifulSoup(response.content, 'html.parser')
        elements = soup.find_all(class_="post")

        filtered_elements = []
        for element in elements:
            title = element.find(class_="title").text
            title = re.sub(r'^(.*): ', '', title)
            if not (title in ban_list):
                filtered_elements.append(element)
        return filtered_elements

    def _append_picked_title(self, title: str):
        """選択済み論文リストに追加"""
        title = re.sub(r'^(.*): ', '', title)
        with open(self.picked_titles_file_path, 'a') as file:
            file.write(f"\n{title}")

文字列抽出エージェント

論文の本旨に関連の高い文字列を章ごとに抽出してくれるエージェントです。

PDF内の表や数式内に含まれる数値がナレーション生成に不要なノイズとなります。また、引用文献や謝辞などの文字列も、PodCastに反映させたくないです。そういった論文の本筋とは関係の薄い情報を省き、本旨と関係のある文章を抽出してくれます。

実装ポイント

PDFからの文字列の抽出にLLMは使用せず、PyMuPDFを使っています。理由は前述のとおりです。

また、ナレーション作成時に論文全体の文字列を一気に渡すと、情報が大きく欠けてしまいます。それを防ぐために、"章ごとにナレーションを作成"→"ナレーションを結合"というフローにしています。
そのため、ここで章ごとの文字列のリストを出力させるようにしています。

抽出した文字列から不要な情報を削るだけなので、そこまで精度は必要ありません。なので、無料クレジ(略

Suumarizeではないよなぁとは思いつつ、名前はそのままです。

コード

class Section(BaseModel):
    title: str = Field(description="title of the section")
    text: str = Field(description="text of the section")


class Summary(BaseModel):
    sections: List[Section] = Field(description="sentences of each section")


class SummarizeAgent(BaseLLM):
    def __init__(self):
        self.llm = GROK2_RATEST
        self.system_prompt = """
            論文のPDFから抽出した文字列には多くのノイズが含まれていて、人間が読むことが難しいです。
            論文の情報が落ちないように、ノイズを除去して文章を整えてください。

            目的は論文で提案されている内容を理解し、要約することです。
            上記の目的に不要な情報(引用や謝辞、参照など)はすべて削除してください。

            # input
                - PDFから抽出された文字列

            # output
                - 章ごとの文章

            # 注意点
                - 情報の漏れのないように気をつけてください
                - 出力する文字数に制限はありません
                - 数式情報は含める必要はありません
                - 論文内の参照や引用の情報は不要です。
        """
        self.output_structure = Summary

    def summarize(self, article: Article):
        all_text = "".join([page.text for page in article.pages])

        summary = self._invoke(all_text)
        return summary

ナレーション生成エージェント

論文の内容から、Podcastで読み上げられるナレーション台本を生成してくれるエージェントです。

本Podcastでは、論文の内容を紹介・説明してくれるメインスピーカーと、視聴者の立場に立って質問したり、相槌を打つアシスタントスピーカーの二人の掛け合いにより、構成されています。
それぞれの役割にあったナレーションを作成してくれます。この段階では、ナレーションは標準語になっています。

実装ポイント

ここが本アプリケーションのコアになる部分で、精度が最も重要になります。
本当はClaude3.7を使いたいところですが、現状、OpenAI, Grok, DeepSeekとばらばらとクレジットを抱えている状態です。

精度を高めたいこれ以上サービス種類増やしたくない財布事情=o3mini 精度を高めたい - これ以上サービス種類増やしたくない*財布事情 = o3-mini

となりました。Grok3の様子をみて、最終結論を出したいと思っていますが、今はこれで妥協です。

また、システムプロンプトは試行錯誤の末、どんどん要求事項を追加していきました。隙あらば自問自答したり、自分がさも論文を書いたように喋り始めます。まだまだ、問題は潜在している気がします。

class NarrationText(BaseModel):
    speaker: str = Field(description="name of the speaker")
    text: str = Field(description="text of the narration")
    character_text: str = Field(description="Ignore this parameter, set empty string")


class NarrationFlow(BaseModel):
    flow: list[NarrationText]  = Field(description="flow of the narration")


class NarrationGenerator(BaseLLM):
    def __init__(self):
        self.llm = OPENAI_o3_MINI
        self.system_prompt = """
            論文をわかりやすく紹介するため、二人の登場人物を使った論文紹介動画のナレーションテキストを作成してください。
            メインスピーカーが論文の内容を順序立てて説明し、アシスタントスピーカーが質問を通して、内容をわかりやすくしてくれます。
            メインスピーカーとアシスタントスピーカーが交互に話す形でナレーションを構成させてください。

            入力した情報を欠落させないようもれなく情報をナレーションに盛り込んでください
            また、論文紹介動画はPodcast形式です。絶対にナレーション内で図や表の参照をしないでください。引用情報も参照しないでください。
            数式を読み上げられても理解できないので、数式は抽象化して、言葉でわかりやすく説明してください。
            
            # 登場人物
                ## メインスピーカー
                    - 名前は「つむぎ」
                    - 生成AIの専門家で論文について、内容に忠実に紹介してくれる
                    - 専門用語は使うが聞いている人にわかりやすく順序立てて、説明してくれる
                ## アシスタントスピーカー
                    - 名前は「ずんだもん」
                    - Podcast視聴者と同じ目線を持っている
                    - 生成AIに興味があるが、専門的な知識はもっていない
                    - わからないことがあれば、必ず質問する。
                    - 論文を理解するうえで必要な知識に関しては、必ず質問する
                    - あいずちは積極的にうつ、感心したり、復唱する:「なるほど」「そうなんですね」
                    - 聞いている人が理解しやすいように身近なもので例えたりします。(可能な場合に限る

            # 出力フォーマット ※内容はサンプルです。
                "つむぎ": "AAA",
                "ずんだもん": "BBB",
                "つむぎ": "CCCC",
                "ずんだもん": "DDD", ...

            # 注意点
                - 二人の掛け合いは聞いていて、テンポが良く聴き心地がよいようにしてください
                - 論文の内容が最も重要です。入力と齟齬がないようにナレーション文字列を生成してください
                - ナレーション文字列に制限はありません。論文全ての内容がわかるようにもれなくナレーション文字列を生成してください
                - 論文の要約が章ごとに渡されます。
                - 論文を紹介しているだけなので、一人称は使わないでください。
        """
        self.output_structure = NarrationFlow
        self.narration_texts = ""
        self.flows: list[NarrationText] = []

    def generate(self, summary) -> list[NarrationText]:
        # make each section of summary into narration text
        for section in summary.sections:
            query = f"""
                {section.title}
                ===
                {section.text}
            """
            narration_flow = self.invoke(query)
            self.flows += narration_flow.flow

        entire_narration_flow = self._convert_to_entire_narration()
        return entire_narration_flow

    def _convert_to_entire_narration(self):
        self.system_prompt = """
            各章で生成されたナレーションテキストを入力します。
            それらは、各章の流れを考慮せず作成されているので、各章ごとのナレーションテキストを
            論文全体を紹介する1つのナレーションに結合してください
            全体のナレーションの流れが不適切にならないよう細心の注意を払って下さい
            また、情報が欠落しないようにしてください

            # 出力フォーマット ※内容はサンプルです。
                "つむぎ": "今日はまるまるの論文について紹介します",
                "ずんだもん": "よろしくお願いします。",
                "つむぎ": "AはBであることがわかりました。",
                "ずんだもん": "なるほど、それはすごいですね。どうやってわかったんですか?", ...

            # 注意点
            - ナレーションテキスト間で重複している説明は、まとめて重複しないようにしてください。
            - ナレーションテキスト間で矛盾がある場合は、それを修正してください。
            - ナレーションテキスト間の結合している部分が自然な会話の流れになるようにしてください。
        """
        return self.invoke(self._convert_to_narration_text()).flow

    def _convert_to_narration_text(self):
        return "\n".join([f"{flow.speaker}: {flow.text}" for flow in self.flows])

キャラナレーション生成エージェント

標準語のナレーションから、キャラの特徴や口調を反映させたナレーションに変換してくれるエージェントです。

自作のYoutube用動画生成システムの都合上、VoiceVoxの音声を使うのが手っ取り早かったので、メインスピーカーを春日部つむぎに、アシスタントスピーカーをずんだもんにしていて、それぞれのキャラにあった口調にしてくれます。

実装ポイント

意外とここも精度が重要になってきます。つむぎの口調は問題ないのですが、ずんだもんの口調の再現が難しく、「~なのだね?」とか、「~なのだよ。」とか、急に髭の生えたおじさん口調になります。それを防ぐためのプロンプトになっているのがわかります。

コード

class CharactorNarratoinGenerator(BaseLLM):
    ZUNDAMON_FEATURE = """
        語尾に「〜のだ。」「〜なのだ。」を使う。
        敬語は使用しない
        硬い大人の人が使う言葉遣いは使用しない
        男性の言葉遣いはしない
        少年の言葉遣いはしない
    """

    THUMUGI_FEATURE = """
        語尾に「〜ね」「〜だよ」を使う。
        敬語を使用しない
        硬い大人の人が使う言葉遣いは使用しない
        男性の言葉遣いは使用しない
        少年の言葉遣いはしない
    """
    
    def __init__(self):
        self.llm = DEEPSEEK_CHAT
        self.output_structure = Response

    @property
    def character_feature(self):
        if self.speaker == "ずんだもん":
            return self.ZUNDAMON_FEATURE
        elif self.speaker == "つむぎ":
            return self.THUMUGI_FEATURE

    def _get_system_prompt(self):
        return f"""
            ナレーションテキストを読み上げるキャラクターに応じて、口調を反映させてください。
            反映させる際は、以下のルールを必ず守ってください。

            - キャラクターの口調を必ず反映させてください。
            - ナレーションの意図や情報はそのままにしてください。情報を変えたり、損失させないでください。
            - ナレーション内の質問には、自分で回答しないこと
            - キャラクターの意見は追加しないでください。
            - ナレーションが質問に含まれている場合は、その質問をキャラクターの口調に変更させるだけです

            あなたの役割は、入力されたナレーション文字列をキャラクターの口調に変更することだけです。
            口調は再現しつつ、不自然な喋り方にならないように気をつけてください。

            # キャラクターの特徴
                {self.character_feature}
        """

    def translation(self, narration_flow: list[NarrationText]):
        for narration in narration_flow:
            self.speaker = narration.speaker
            self.system_prompt = self._get_system_prompt()
            res = self.invoke(narration.text)
            narration.character_text = res.character_text

以上が、エージェンティックな実装の紹介でした。

超ざっくりフロー

今回作ったPodcast全自動投稿システムのフローをざっっくりと紹介します。

  1. 論文まとめサイトのhttps://deeplearn.org/ から論文選択エージェントが論文を1つ選択
  2. 選択した論文から、PyMuPDFと文字列抽出エージェントで文字列抽出
  3. ナレーション生成エージェントが、抽出文字列からナレーションを生成
  4. キャラナレーションエージェントがキャラの口調に変更
  5. TTSでナレーションの音声ファイルを生成
  6. 音声ファイルとナレーション文字列からASSファイル(字幕ファイル)を生成
  7. ASSファイル、音声ファイル、背景画像を組み合わせて、動画ファイルを生成
  8. 生成した動画をYoutubeDataAPIを使って、アップロード

上記の流れをEC2インスタンス上で毎日早朝に実行することで、朝の準備の時間や通勤時間を有効活用できるPodcastが実現します(予定)。

成果物

こんなかんじになりました。つっこみどころが多いですね。
動画の見た目としては寂しいですが、Podcast用動画として割り切っています。(つーかこれが限界)

https://youtu.be/pK6VGmv_wyo

課題

基本的には、動画の生成→アップロードの一連のフローの実装は完了している状況で、クオリティが低ければ、今段階でPodcastの運用が可能です。

ただ、

  • 話の流れや内容に突っ込みどころがある
  • 読みかたや口調が期待通りでない

など、ナレーション生成精度が低いです。お世辞にも内容がわかりやすいとは言えないです。
財布を痛めれば精度は上がるので、より良いモデルを使ってどれだけの精度になるかは、調べておく必要がありそうです。

今後の展望

品質的には改善の余地があるので、自分自身が満足して活用し続けたいと思えるような品質になるよう改善を進めていく予定です。ある程度、論文の内容が理解可能なレベルに達したら、本番運用を開始します。

また、今回はLLMの論文に特化したPodcastになっていますが、情報収集先としてニュースサイトや技術ブログなどいろいろあります。
これら全て包括して、LLMの情報収集はこのYoutubeチャンネルを聞いておけば問題ない、と言えるような状態にしていきたいなぁと考えています。

本当に価値のある動画を自動で生成するのは難しいと思いますが、がんばります。

最後に

最後まで読んでいただきありがとうございました!
ご意見や応援コメント、指摘等あれば、本当に嬉しいので、コメントください!

コメントください!!

また、Xをやっていますが、フォロワーが片手の指で数えられるほどしかいません。
ぜひ、フォローしてください!

フォローしてください!!

https://x.com/fky_create

2

Discussion

ログインするとコメントできます