パワポ資料自動作成AIエージェントを自作してみた
はじめに
皆さんこんにちは。株式会社アイデミー データサイエンティストの藤井(X | LinkedIn)です。
ここ最近、「AIエージェント」というワードを耳にする機会が急増しています。何をもってAIエージェントと定義するかは現状曖昧であり、人によって意見の分かれるところかとは思いますが、個人的には「ある特定のタスクに対して複数のLLMを接続したワークフローによって対処するシステム」をAIエージェントと呼ぶと認識しています。
この定義の元では比較的簡単にAIエージェントを実装することが可能です。そこで今回は、ユーザーがプレゼンしたい内容を入力するとパワポの資料を自動で作ってくれるAIエージェントを実際に作成してみました。
本記事の内容は、LangChainとLangGraphによるRAG・AIエージェント[実践]入門を大いに参考にしています。LangChainの基本的な使い方からAIエージェントの実装ハンズオンまで重要な技術が網羅された一冊です。このジャンルに興味のある方はぜひ一度読んでみてください!
ワークフロー
AIエージェントの構築にはLangChainおよびLangGraphを使用します。
今回は、最終的にpython-pptxを用いたPythonコードを出力させるよう設計しました。
Pythonコードを作成するためのワークフローは以下の通りです。各ノードでインプットに対してLLMによる処理が走り、次のノードにアウトプットを渡していきます。
- ユーザーの入力(プレゼン内容の説明テキスト)を受け取り、プレゼンのストーリーを作成する
- 作成したストーリーが十分か&適切かを判定(OK: 3へ/NG: 1へ)
- ストーリーをもとにプレゼンの内容を作成
- プレゼン内容をpptx化するPythonコードを生成
今回は最初ということもあり、かなりシンプルな構成にしています。
LangGraphを用いるとワークフロー構造を以下のような画像として出力することが可能です。
図1 ワークフローの構造
では、このワークフローを実装してみましょう。
実装
環境
- python: 3.10.14
- langchain-core==0.3.0
- langchain-openai==0.2.0
- langgraph==0.2.22
- python-pptx==1.0.2
データモデルの定義
AIエージェントは複数のLLM間でやり取りを行うため、意図しない型のデータが発生しないよう気をつけなければいけません。
ここでは、あらかじめPydantic
を使用してデータの構造を定義しています。
from langchain_core.pydantic_v1 import BaseModel, Field
# ストーリーの評価結果を表すデータモデル
class Judgement(BaseModel):
judge: bool = Field(default=False, description="ストーリーが十分かどうかの判定結果")
reason: str = Field(default="", description="ストーリーが十分かどうかの判定理由")
# ステートを表すデータモデル
class State(BaseModel):
user_request: str = Field(..., description="ユーザーからのリクエスト")
story: str = Field(default="", description="生成されたストーリー")
iteration: int = Field(default=0, description="ストーリー生成の反復回数")
current_judge: bool = Field(default=False, description="ストーリーが十分かどうかの判定結果")
judgement_reason: str = Field(default="", description="ストーリーが十分かどうかの判定理由")
slide_contents: str = Field(default="", description="スライドの内容")
slide_gen_code: str = Field(default="", description="スライド生成のコード")
また、こちらで同時にState
クラスを定義しています。このState
によってタスク全体の進捗が管理されます。ワークフローの各ノードでは、このState
を受け取り、各自の担当部分を更新して次のノードに渡していきます。
各ノードの処理の定義
ここでは各ノードでLLMに実行させる処理を定義していきます。
1. ストーリー作成ノード
ユーザーから入力されたテキストをもとに、プレゼンのストーリーを作成するノードです。
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI
class StoryGenerator:
def __init__(self, llm: ChatOpenAI):
self.llm = llm
def run(self, user_request: str) -> str:
# プロンプトを定義
prompt = ChatPromptTemplate.from_messages(
[
(
"system",
"あなたはプレゼンテーションのストーリーを作成する専門家です。"
),
(
"human",
"以下のユーザーリクエストに基づいて、プレゼンテーションのストーリーを作成してください。\n\n"
"ユーザーの意図を理解し、その意図がオーディエンスにしっかりと伝わることを重視してください。\n\n"
"ユーザーリクエスト:\n{user_request}"
)
]
)
# ストーリー作成のためのチェーンを作成
chain = prompt | self.llm | StrOutputParser()
# ストーリーを生成
return chain.invoke({"user_request": user_request})
2. ストーリー評価ノード
作成されたストーリーが十分か、そしてプレゼンとして適切かを評価するノードです。
ここでは、ChatOpenAI
のwith_structured_output()
を使用して、datamodel.py
で定義したJudgement
クラスで定義したデータ構造に合致するよう出力をコントロールしています。
Judgement.judge
に作成したストーリーが十分か否かが、Judgement.reason
にその判断理由が入ります。
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI
from datamodel import Judgement
class StoryEvaluator:
def __init__(self, llm: ChatOpenAI):
self.llm = llm.with_structured_output(Judgement)
def run(self, user_request: str, story: str) -> Judgement:
# プロンプトを定義
prompt = ChatPromptTemplate.from_messages(
[
(
"system",
"あなたはプレゼンテーションのストーリーの十分性および適切性を評価する専門家です。"
),
(
"human",
"以下のユーザーリクエストと生成されたストーリーから、良いプレゼンテーション資料を作成するために十分で適切な情報が記載されているかどうかを判断してください。\n\n"
"ユーザーリクエスト: {user_request}\n\n"
"ストーリー:\n{story}"
)
]
)
# ストーリーの十分性および適切性を評価するチェーンを作成
chain = prompt | self.llm
# 評価結果を返す
return chain.invoke({"user_request": user_request, "story": story})
3. スライド内容作成ノード
ストーリーをもとにスライドの構成内容を作成するノードです。
自由に生成させると存在しない画像のパスを出力してくることがあったので、プロンプトで使用可能オブジェクトをテキスト、図形、表のみに制限しています。
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI
class SlideContentsGenerator:
def __init__(self, llm: ChatOpenAI):
self.llm = llm
def run(self, user_request: str, story: str) -> str:
# プロンプトを定義
prompt = ChatPromptTemplate.from_messages(
[
(
"system",
"あなたは提供されたストーリーに基づいてプレゼンテーションの構成を作成する専門家です。"
),
(
"human",
"以下のユーザーリクエストと生成されたストーリーに基づいて、プレゼンテーションのスライドの内容を作成してください。\n\n"
"ユーザーリクエスト: {user_request}\n\n"
"ストーリー:\n{story}\n\n"
"ルール:\n"
"- スライドの内容は、テキストベースで作成してください。\n"
"- 使用して良いのはテキスト、図形、表のみです。\n"
"- テキスト以外の要素(図形および表)を使用する場合は、その旨を明記してください。\n"
"- スライド番号を進める際は、'---next---' と記述してください。\n\n"
)
]
)
# スライド内容を生成するチェーンを作成
chain = prompt | self.llm | StrOutputParser()
# スライド内容を生成
return chain.invoke({"user_request": user_request, "story": story})
4. Pythonコード作成ノード
作成したスライド構成内容をPythonコードに変換するノードです。
ここでも使用可能オブジェクトをテキスト、図形、表に限定する指示を入れています。
また、今回はパワーポイントのテンプレートファイルとしてtemplate.pptx(アイデミーのパワポテンプレートデザイン)を使用し、プロンプト中で使用するレイアウトおよび各レイアウト中に定義されているプレースホルダーの説明を入れています。このようにすることで、比較的安定したデザインのパワーポイント資料を作成することができます。
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI
class PPTXCodeGenerator:
def __init__(self, llm: ChatOpenAI):
self.llm = llm
def run(self, slide_contents: str) -> str:
# プロンプトを定義
prompt = ChatPromptTemplate.from_messages(
[
(
"system",
"python-pptxモジュールを用いてプレゼンテーション資料のスライドを自動生成する専門家です。"
),
(
"human",
"以下のスライドの内容を生成するためのpythonコードを生成してください。\n\n"
"スライドの内容:\n{slide_contents}\n\n"
"以下のpptxファイルを読み込み、テンプレートとして使用してください。\n"
"/workspace/input/template.pptx\n"
"テンプレートのレイアウト情報は以下を参照してください。記載にないレイアウト番号やプレースホルダー番号は決して使用しないでください。\n"
" タイトルスライド: slide_layouts[2]\n"
" placeholder_format.idx:\n"
" 0: 会社名など\n"
" 10: 発表タイトル\n"
" 11: サブタイトル・日付など\n"
" 12: 発表者名など\n"
" 一般スライド: slide_layouts[0]\n"
" placeholder_format.idx:\n"
" 0: スライドタイトル\n"
" 1: 内容\n"
"作成したパワーポイントは/workspace/output内に出力されるようにしてください。\n\n"
"ルール:\n"
"- 【重要】必ずpython-pptxモジュールを使用したpythonコードのみを出力してください。\n"
"- 使用が許可されているのは、テキスト、図形、表のみです。\n"
"- テキスト以外の要素(図形および表)を使用してほしい箇所には、その旨が明記されています。\n"
"- 画像や動画は使用できません。絶対に画像や動画を使用しないでください。\n"
"- '---next---' はスライド番号を進める合図です。このタイミングで新たなスライドを追加してください。\n\n"
)
]
)
# スライド生成のためのチェーンを作成
chain = prompt | self.llm | StrOutputParser()
# スライド生成のコードを生成
return chain.invoke({"slide_contents": slide_contents})
ワークフロー(グラフ)の定義
では、上で作成した4つの処理を繋いでワークフローを作成しましょう。
各ノードは関数の形で定義されており、いずれもdict形式のオブジェクトを返します。
このように記載しておけば、dictのkeyに該当するstateの要素を勝手に更新してくれて、次のノードへと渡してくれます。便利ですね。
from typing import Any
from IPython.display import Image
from langchain_openai import ChatOpenAI
from langgraph.graph import END, StateGraph
from datamodel import Judgement, State
from story_generator import StoryGenerator
from story_evaluator import StoryEvaluator
from slide_contents_generator import SlideContentsGenerator
from pptx_code_generator import PPTXCodeGenerator
class PPTXAgent:
def __init__(self, llm: ChatOpenAI):
# 各種ジェネレーターの初期化
self.story_generator = StoryGenerator(llm=llm)
self.story_evaluator = StoryEvaluator(llm=llm)
self.slide_contents_generator = SlideContentsGenerator(llm=llm)
self.pptx_code_generator = PPTXCodeGenerator(llm=llm)
# グラフの作成
self.graph = self._create_graph()
def _create_graph(self) -> StateGraph:
# グラフの初期化
workflow = StateGraph(State)
# 各ノードの追加
workflow.add_node("generate_story", self._generate_story)
workflow.add_node("evaluate_story", self._evaluate_story)
workflow.add_node("generate_slide_contents", self._generate_slide_contents)
workflow.add_node("generate_pptx_code", self._generate_pptx_code)
# エントリーポイントの設定
workflow.set_entry_point("generate_story")
# ノード間のエッジの追加
workflow.add_edge("generate_story", "evaluate_story")
workflow.add_conditional_edges(
"evaluate_story",
lambda state: not state.current_judge and state.iteration < 5,
{True: "generate_story", False: "generate_slide_contents"}
)
workflow.add_edge("generate_slide_contents", "generate_pptx_code")
workflow.add_edge("generate_pptx_code", END)
# グラフのコンパイル
return workflow.compile()
def _generate_story(self, state: State) -> dict[str, Any]:
# ストーリーの生成
new_story: str = self.story_generator.run(state.user_request)
return {
"story": new_story,
"iteration": state.iteration + 1
}
def _evaluate_story(self, state: State) -> dict[str, Any]:
# ストーリーの評価
judgement: Judgement = self.story_evaluator.run(state.user_request, state.story)
return {
"current_judge": judgement.judge,
"judgement_reason": judgement.reason
}
def _generate_slide_contents(self, state: State) -> dict[str, Any]:
# スライド内容の生成
slide_contents: str = self.slide_contents_generator.run(state.user_request, state.story)
return {"slide_contents": slide_contents}
def _generate_pptx_code(self, state: State) -> dict[str, Any]:
# Python-pptxコードの生成
pptx_code: str = self.pptx_code_generator.run(state.slide_contents)
return {"slide_gen_code": pptx_code}
def run(self, user_request: str) -> str:
# 初期状態の設定
initial_state = State(user_request=user_request)
# グラフ構造の可視化
graph_img = Image(self.graph.get_graph().draw_png())
with open("/workspace/output/graph.png", "wb") as f:
f.write(graph_img.data)
# グラフの実行
final_state = self.graph.invoke(initial_state)
# 最終的なPythonコードの取得
return final_state["slide_gen_code"]
実行ファイル
最後に実行ファイルを作成します。
コマンドライン引数--file
として作成したいスライド内容を説明したテキストファイルのパスを受け取り、出力されたPythonコードのテキストをcreate_pptx.py
として保存するようにしています。
得られたcreate_pptx.py
を実行すればパワーポイントファイルが出力されます!
import argparse
from langchain_openai import ChatOpenAI
from pptx_agent import PPTXAgent
def main():
# コマンドライン引数のパーサーを作成
parser = argparse.ArgumentParser(
description="ユーザーがインプットしたテキストを基にスライドを生成するPythonファイルを出力します"
)
# "file"引数を追加
parser.add_argument(
"--file",
type=str,
help="プレゼンテーションの元になるテキストファイルのパス(.txt/.docx/.md)"
)
# コマンドライン引数を解析
args = parser.parse_args()
# テキストの取得
filepath = args.file
if filepath.endswith((".txt", ".docx", ".md")):
with open(filepath, "r") as f:
user_request = f.read()
else:
raise ValueError("ファイル形式がサポートされていません")
# ChatOpenAIモデルを初期化
llm = ChatOpenAI(model="gpt-4o", temperature=0.0)
# PPTXAgentを初期化
agent = PPTXAgent(llm=llm)
# エージェントを実行して最終的な出力を取得
final_output = agent.run(user_request=user_request)
final_output = final_output.split("```python\n")[-1].split("```")[0]
# 出力をファイルに保存
with open("/workspace/output/create_pptx.py", "w") as f:
f.write(final_output)
print("DONE.")
if __name__ == "__main__":
main()
実行結果
今回はテストということで、大谷翔平選手のWikipediaの冒頭部分をお題として与えてみました。
得られたcreate_pptx.py
を実行した結果、以下のようなパワーポイントファイルが生成されました!
AIエージェントが作成したパワーポイントはこちら...!
はい。
極めてショボいパワーポイントが出来上がりました。
図形をタイトルに被せるな。
SpeakerDeckなどのオンラインサービスで共有するのも憚られる出来なので、画像で載せています。許して。。。
反省と改善案
成果物としては残念な結果に終わってしまいましたが、今回のトライによって巷に出てきているGammaなどのパワーポイント生成エージェントの仕組みが少し見えてきたような気がしています。
以下、クオリティ向上案を挙げていきます。
パワーポイントのテンプレートを使用する
今回は図形などの挿入判断をエージェントに委ねたので、ご覧の通りセンスの欠片もない適当な図形が全てのスライドに横たわるという悲惨な状況が発生しました。
これを改善するためには、テンプレートを用意し、エージェントにはあらかじめ配置されたプレースホルダーを埋めるためのコードのみを生成させるという方法が理にかなっていると考えられます。
また、テンプレートの中から各スライドの内容に応じて適切なレイアウトを選択するノードも用意する必要がありそうです。
Gammaなどの既存サービスは、いずれもこの方法でスライド生成しているものと思われます。
画像はプリセットから選択orAIで生成
python-pptxでは、パスを指定して画像を挿入することができます。上で述べた通り、この機能にも制限をかけておかないと架空のパスを吐き出したりすることがあります。対策としては、プリセットの画像を用意しておきそのパスと説明のペアをエージェントに渡してあげるか、もしくは画像が必要になった際に都度AIに生成させるかのどちらかが必要になりそうです。
巷のエージェントはおそらく後者のものが多いかなーという印象です。(Gammaは自分で用意した画像を使用することなども可能です。)
アイコンもプリセット?
アイコンをAIに生成させるのは難しいと思うので、これはプリセットにて対応していると予想されます。
アイコン画像のパスと説明文をLLMに渡して、必要に応じて適切なアイコンを選択する形かと思われます。
ワークフロー内で生成したコードを実行してエラーハンドリング
生成したPythonコードを実行してみるとエラーが出てしまうことも結構ありました。
もう少しちゃんとしたエージェントを作る場合は、ワークフロー内でPythonコードを実行して、もしエラーが発生した際にはコード生成ノードに戻る処理を追加すべきと考えます。
こちらの記事が参考になるかと思います。
さいごに
というわけで、今回の記事ではパワーポイント資料の自動生成AIエージェントを作成する方法を紹介しました。
ブラックボックスですごく難しいことをしているように思えていたAIエージェントも、実際に作ってみると幾分親しみやすい存在に感じることができました。
なにより、自作したエージェントが実際にちゃんと動いてくれたのがめちゃくちゃ嬉しかったです。
もしかしたら今後、案に挙げたようなエージェントの改善にもトライするかもしれないので、その時はまた記事にしたいと思います。(この記事がきっかけでGamma触ってみたんですが、サービスとしてのクオリティがあまりに高くてビビり散らかしている)
最後まで読んでいただきありがとうございました!
Discussion