21
22

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

編集可能なパワーポイントを作成するAIエージェントを作成してみた

Posted at

はじめに

生成AIを活用して、編集可能なパワーポイントを自動作成する仕組みを構築しました。
本記事では、その実現方法や得られた知見を共有します。

↓ 以下は、生成したパワーポイントの例です。人の手による修正は一切行っていません。
Pythonについて_20250117_061646.gif

本記事は2024/1/16に公開されたMarp CLI v4.1.0 に新しく追加された「編集可能な PowerPointファイルの出力」は利用しておらずPython-pptxを用いてパワーポイント作成の実装をしています。

参考) Marp CLI v4.1.0についての公式リリースおよび参考にさせていただいた記事
https://github.com/marp-team/marp-cli/releases/tag/v4.1.0
https://qiita.com/youtoy/items/e7168d762d3fe909d278

背景

業務の中でパワーポイントを作成する機会が多く、作成を支援する仕組みが欲しいと思っていました。
これまでも生成AIが出てくる以前よりMarkdownからプレゼンテーションを作成できるMarpを利用していましたが、出力したファイルが編集できないという弱点がありました。
そのため、Marpを扱わないメンバーと共同して作業することが困難で個人で作成する資料での利用にとどまっていました。
(最近、Marpでも編集可能な形式で出力できるようになったとのことなのでまた試してみたいです。)

編集不可という課題を解決するために、今回は以下のアプローチを採用しました:

  1. Python-pptxライブラリを使用
    Python-pptxはPythonでパワーポイントを操作できるライブラリです。これを使ってパワーポイントファイルを直接編集可能な形式で生成しました

参考

  1. Langgraphを用いたワークフロー
    LanggraphはAIエージェントを作成できるフレームワークです。今回はLanggraphのReActエージェントを多段で組み合わせて要件 -> アウトライン -> 個々スライドの流れでコンテンツを生成するようにしました

参考

全体構成とパワーポイント生成の流れ

以下が今回のシステムの全体像です。
Langgraphを用いたワークフローを構築することで安定してパワーポイントが作成できるようになりました。

image.png

資料作成は以下の流れで進行します(構成図の数字と対応しています。):

  1. チャット画面から依頼
    利用者はフロントエンドのチャット画面を通じて、PowerPoint作成を依頼します。

  2. 資料作成要件の生成
    入力内容をもとに、所定のフォーマットに従いLLMが資料作成要件を生成します。

  3. 要件の確認と修正
    利用者はLLMの回答を確認し、要件の追加や修正を行います。その後、作成を依頼します。

  4. アウトラインの生成
    作成許可が出ると、LLMは要件を基に資料のアウトラインをJSON形式で生成します。

  5. 各ページ内容の生成
    アウトラインが完成すると、LLMが各ページごとの詳細な内容をJSON形式で生成します。

  6. PowerPointへの配置
    各ページごとのJSONデータを基に、Python-pptxを使用してPowerPointファイルにコンテンツを配置します。

  7. 完成した資料の提示
    最終的に完成したPowerPointファイルを利用者に提示します。

背景でも述べていますが、LLMの性能を考えて一度に成果物を作成させるのではなく、課題を小さく分割してLLMに与えることStructured Outputを使って出力形式を固定して各タスク間を接続するの2点がポイントとなります

(仕事においても課題に対し粒度を細かくしてタスクの落とし込んだり、後続の業務プロセスに落とし込む際、成果物の定義を明確にしておくことが大事なのでLLMに仕事を頼む際も同じなんだなと実装していて改めて思いました。)

以下では、各ステップについて詳細に説明します。

チャット画面から依頼

当社では内製でAIチャットアプリを構築しており、以下のUIを提供しています:

  1. Webアプリ
  2. Microsoft Teamsアプリ

パワーポイント生成機能は、これら両方のUIから利用できるよう設計されています。

パワーポイント作成のトリガーについては当初はTool Callingを用いて、ユーザーのチャット内容を元に資料作成へ移る方式も検討しましたが、安定性を考え明示的に指定した際に作成する方式としました。

image.png

資料作成要件の生成

ユーザーからの入力を受け取って以下のプロンプトを用いて資料作成にあたり最低限必要な情報を収集、生成します。ここでは特にStructured Outputは使わず、要件定義のフォーマットだけプロンプトで定義して渡すようにしています。

<参考> プロンプトれい
ユーザーからパワーポイントの作成依頼を受けるため要件をヒアリングして、必要な情報を特定します。
デザインや納期に関する質問は不要です。質の高いパワーポイントを作成するために、以下の情報を収集してください。
内容についておまかせやサンプル作成など委任された場合はヒアリングは不要であなたが項目を埋めるようにすること

# ヒアリング項目

- **タイトル**
  - パワーポイントのタイトルを教えてください。(例: 〇〇について)

- **目的**
  - パワーポイントの目的や使用するシーンを教えてください。(例: プレゼンテーション、教育、報告)

- **主要メッセージ**
  - 資料を通じて伝えたい主要なメッセージやポイントを教えてください。

- **コンテンツ**
  - 含めたい具体的な内容やトピックをリストしてください。(例: 製品の特徴、過去の業績データ)

- **スライドの数**
  - 希望するスライドの枚数や、各スライドにどの程度の情報を含めたいか教えてください。(最大でも10程度。おまかせなら5枚程度)

- **既存資料の利用**
  - 参考にしたい、または使用したい既存の資料やデータがあれば教えてください。(ファイル添付による読込も可)

# 再ヒアリングの指示

- **回答の確認**
  - 各質問に対するユーザーの回答を確認し、不足している情報があれば指摘してください。
  - ユーザーとの会話に基づいて回答案のアドバイスをしてください。

- **追加質問**
  - 不足している情報を補うための追加質問を行ってください。
  - 内容確認の場合は詳細出力は不要でスライドを作成する旨のみ回答すること。

# 出力形式
- 情報が不足している場合はユーザーの入力を元に内容を考えて作成すること。回答後に確認を促すこと。
- ユーザーが解答しやすいようにヒアリング用の回答フォーマットを提示してください。(フォーマットは下記のものを使用すること)
- ユーザーへの内容確認の結果の回答に対し許可をされた場合は、スライドを作成する旨のみ回答すること。

## 回答フォーマット
\`\`\`markdown
タイトル: 
目的: 
主要メッセージ: 
コンテンツ: 
スライドの数: 
既存資料の利用:
\`\`\`

note: スライドのアウトライン以外の内容についてはコードブロック外に書いてください

# 例

**入力例**

タイトル: 環境保護について
目的: 教育用プレゼンテーション
ターゲットオーディエンス: 高校生
主要メッセージ: 環境保護の重要性
コンテンツ: 環境問題の現状、持続可能な解決策
スライドの数: 5枚
既存資料の利用: 2023年の環境報告書

# 備考

- 質問の順序や内容は、ユーザーがスムーズに回答できるように工夫してください。
- 資料内容に関する最新のデータや資料がある場合は、それを利用することを推奨します。
- 今日の日付: {today_str}

要件の確認と修正

上記のプロンプトを利用して会話を重ねる中で並行して、会話が終わり資料作成に入ってよいかを会話履歴を元にLLMで判定させています。下記コードのcheck_node内でstructured_outputを用いてTrue/Falseで出力させることにより後続の処理に行かせるか判断をさせています。

class Judgement(BaseModel):
    judge: bool = Field(default=False, description="判定結果")
    reason: str = Field(default="", description="判定理由")


def create_requirements_definition_workflow(llm):
    def answering_node(state: State) -> dict[str, Any]:
        query = state["messages"]
        prompt = ChatPromptTemplate.from_template(
            """
            入力: {query}

            回答:""".strip()
        )
        chain = prompt | llm
        answer = chain.invoke({"query": query})
        return {"messages": [answer]}

    def check_node(state: State) -> dict[str, Any]:
        query = state["messages"][-2:]

        logger.debug(query)
        prompt = ChatPromptTemplate.from_template(
            # REQUIREMENTS_DEFINITION_CHECK_PROMPT
            """
            以下の会話履歴にてユーザーがアシスタントが出してきた案に対しスライドを作成していいと言っている場合はTrue, 
            それ以外の場合はFalseを返すこと
            
            # 例
            作成してください, 問題無し, お願いします, Yes, はい, 〇〇を追加して作成, よろしく, よろ: True
            

            # 会話履歴
            {query}
            """.strip()
        )
        chain = prompt | llm.with_structured_output(Judgement)
        result: Judgement = chain.invoke({"query": query})

        return {"judgement_result": result.judge, "judgement_reason": result.reason}

以下略

アウトラインの生成

資料作成が可能とLLMが判断した場合、要件を元に資料作成プロセスへ進みます。

当初はいきなりここから最終成果物であるパワーポイント用の全コンテンツJsonを生成させていましたが、ページ数が多くなると生成結果が安定しなかったため、一度中間生成物としてアウトラインを作成するプロセスを導入しました。

アウトラインは以下のようなPydanticのモデルで定義をしてそのStructured Outputで出力形式を固定して生成をさせています。

アウトラインについては資料の内容そのものについてはタイトルや概要程度にとどめています。
ポイントはtemplateでこの項目を用いて後続での処理でパワーポイントのテンプレートを利用するか判断をさせています。

from typing import Annotated, List, Literal, Union

from pydantic import BaseModel, Field

class Page(BaseModel):
    header: str = Field(..., description="スライドのヘッダー情報。ページのタイトル。")
    content: str = Field(..., description="スライドに含むコンテンツ概要")
    template: Literal[
        "text",
        "image",
        "table",
        "two_column",
        "three_images",
        "three_horizontal_flow",
        "three_vertical_flow",
    ] = Field(
        ...,
        description="スライドのコンテンツタイプ\n"
        "text: テキストのみのページ。複数セクションと複数個の箇条書きで構成\n"
        "image: テキストとイメージ画像のページ\n"
        "table: 表とキーメッセージが含まれるページ\n"
        "two_column: 2カラムで対比をするようなページ\n"
        "three_images: 3枚の画像とそれぞれに対するキーメッセージが含まれるページ\n"
        "three_horizontal_flow: 3つの要素を横向きでフローを表すページ\n"
        "three_vertical_flow: 3つの要素を縦向きでフローを表すページ",
    )
    policy: str = Field(
        ..., description="スライド作成にあたるデザインやコンテンツの作成方針を記載"
    )


class Outline(BaseModel):
    title: str = Field(..., description="プレゼンテーションのタイトル")
    pages: List[Page] = Field(..., description="プレゼンテーションの各ページのリスト")
<参考> 生成されたアウトラインの例
{
    "title": "Pythonについて",
    "pages": [
        {
            "header": "Pythonとは?",
            "content": "Pythonの概要について説明します。Pythonは汎用プログラミング言語であり、シンプルで読みやすい構文を持つため、初心者からプロフェッショナルまで幅広く利用されています。主な用途や特長も紹介します。",
            "template": "text",
            "policy": "Pythonの基本的な定義と概要をテキストでわかりやすく説明します。ポイントを箇条書きにして視覚的に整理します。"
        },
        {
            "header": "Pythonの特徴",
            "content": "Pythonの特徴として、簡潔で直感的な構文、豊富なライブラリ、大規模なコミュニティサポート、マルチプラットフォーム対応性などを解説します。",
            "template": "image",
            "policy": "Pythonの特徴を視覚的に印象付けるため、関連するイメージ(例: コード例やPythonロゴ)を含めます。"
        },
        {
            "header": "Pythonの基本構文",
            "content": "Pythonの基本構文を簡潔に説明します。例として、変数の定義、制御構造(if文、ループ)、関数の定義などを示します。",
            "template": "text",
            "policy": "コード例を含む箇条書きで、初心者にもわかりやすい構成にします。"
        },
        {
            "header": "Pythonの活用例",
            "content": "Pythonが使用される主な分野(データサイエンス、Web開発、機械学習、ゲーム開発など)について具体例を挙げて説明します。",
            "template": "three_images",
            "policy": "各活用例に対応する画像を3つ選び、短い説明を添えて視覚的に理解しやすくします。"
        },
        {
            "header": "他言語との比較",
            "content": "Pythonと他のプログラミング言語(例: Java、C++、JavaScript)との違いを表にまとめます。特徴や用途、学習難易度などを比較します。",
            "template": "table",
            "policy": "比較のポイントを明確にするため、シンプルで見やすい表を作成します。"
        },
        {
            "header": "Pythonの学習法",
            "content": "Pythonを学ぶための具体的な方法を提案します。オンラインリソース、書籍、実践プロジェクトなどを挙げて、効率的な学習ルートを示します。",
            "template": "two_column",
            "policy": "学習リソースを2つの列に分けて、種類別に整理します。初心者向けと上級者向けの情報を提供します。"
        },
        {
            "header": "まとめと次のステップ",
            "content": "Python学習の重要性と可能性を再確認し、次のステップとしてプロジェクト作成やコミュニティ参加を促します。",
            "template": "three_horizontal_flow",
            "policy": "3つの次のステップを横向きのフローで示し、行動を促す構成にします。"
        }
    ]
}

また、アウトライン作成についてはLanggraphのプリビルドノードのToolNodeを用いたReActエージェントを利用しています。ツールとしてWeb検索やRAGなどの情報検索系のツールと上記のPydanticモデルをツールとして渡すことで情報検索後やアウトライン作成後の最終出力形式を固定させることができます。(以下のリンクのOption 1)

from langgraph.graph import END, MessagesState, StateGraph
from langgraph.prebuilt import ToolNode

# アウトライン生成以外でも流用できるように汎用化
class DynamicToolAgentState(MessagesState):
    final_response: any


def create_page_gen_workflow(llm, tools, response_class):
    model_with_response_tool = llm.bind_tools(
        tools, parallel_tool_calls=False, tool_choice="any"
    )

    def call_model(state: DynamicToolAgentState):
        response = model_with_response_tool.invoke(state["messages"])
        return {"messages": [response]}

    def respond(state: DynamicToolAgentState):
        response = response_class(**state["messages"][-1].tool_calls[0]["args"])
        return {"final_response": response}

    def should_continue(state: DynamicToolAgentState):
        messages = state["messages"]
        last_message = messages[-1]
        if (
            len(last_message.tool_calls) == 1
            and last_message.tool_calls[0]["name"] == response_class.__name__
        ):
            return "respond"
        else:
            return "continue"

    workflow = StateGraph(DynamicToolAgentState)

    workflow.add_node("agent", call_model)
    workflow.add_node("respond", respond)
    workflow.add_node("tools", ToolNode(tools))

    workflow.set_entry_point("agent")

    workflow.add_conditional_edges(
        "agent",
        should_continue,
        {
            "continue": "tools",
            "respond": "respond",
        },
    )

    workflow.add_edge("tools", "agent")
    workflow.add_edge("respond", END)

    return workflow.compile()

# 利用例
outline_prompt = ChatPromptTemplate.from_messages(
    [
        ("system", OUTLINE_PROMPT),
        MessagesPlaceholder(variable_name="chat_history"),
        human_message_template,
    ]
)
outline_agent = create_page_gen_workflow(
    llm,
    tools=self.tools + [Outline],
    response_class=Outline,
)
outline_chain = outline_prompt | outline_agent
outline = outline_chain.invoke(
    {
        "input": response["messages"][-1],
        "chat_history": response["messages"][:-1],
    }
)["final_response"]

各ページ内容の生成

スライド全体のアウトラインが完成したら、各ページのコンテンツを生成します。
ページのコンテンツの形式はアウトラインのtemplateに基づいて決定するようにしており、
templateごとにpydanticのモデルを用意してその型式で出力をさせています。

以下はテキストのみのページとテキストと画像が入ったページについて抜粋したコードです。
TextPageとImagePageがPydanticのモデルとなっており、アウトラインを作成したときと同様のLanggraphのReActエージェントを使って形式を指定して出力をさせています。

class Section(BaseModel):
    title: str = Field(..., description="セクションのタイトル")
    # content: List[str] = Field(..., description='セクションの内容。箇条書き形式')
    content: Annotated[
        list[str],
        Field(..., description="セクションの内容。箇条書き形式"),
    ]

# テキストのみが含まれるページ
class TextPage(Page):
max_length=5)
    sections: Annotated[
        list[Section],
        Field(..., description="スライドのコンテンツセクションのリスト"),
    ]

# 画像が含まれるページ
class ImagePage(Page):
    """Respond to the user with this"""

    image_path: str = Field(..., description="スライドに表示する画像のファイル名")
    sections: Annotated[
        list[Section],
        Field(
            ...,
            description="スライドのコンテンツセクションのリスト。各セクションの文字量は少な目",
            max_length=5,
        ),
    ]

# パワーポイント全体
class PowerPoint(BaseModel):
    title: str = Field(..., description="プレゼンテーションのタイトル")
    pages: List[
        Union[
            TextPage,
            TablePage,
            ImagePage,
            TwoColumnPage,
            ThreeImagesPage,
            ThreeHorizontalFlowPage,
            ThreeVerticalFlowPage,
        ]
    ] = Field(..., description="プレゼンテーションの各ページ情報のリスト")


# 各ページ用のReActエージェントを作成
image_agent = create_page_gen_workflow(
    llm,
    tools=self.tools
    + [
        StructuredTool.from_function(self.search_similar_file),
        ImagePage,
    ],
    response_class=ImagePage,
)
text_agent = create_page_gen_workflow(
    llm,
    tools=self.tools + [TextPage],
    response_class=TextPage,
)
# 以下別のテンプレートについて記載

# アウトラインを元にページを生成
page_list = []
total_pages = len(outline.pages)
for i, page in enumerate(outline.pages):
    g.send(f"({i + 1}/{total_pages}) 「{page.header}」 を作成中\n\n")
    common_messages = [
        (
            "human",
            "<history>\n" + self.dump_chat_history() + "\n</history>\n",
        ),
        (
            "human",
            "<user-input>\n" + user_input_string + "\n</user-input>\n",
        ),
        (
            "human",
            "<target-page-outline>\n"
            + page.model_dump_json()
            + "\n</target-page-outline>\n",
        ),
    ]

    if page.template == "text":
        slide = text_agent.invoke(
            input={
                "messages": [("human", TEXT_AGENT_PROMPT)] + common_messages
            }
        )["final_response"]
        page_list.append(slide)

    elif page.template == "image":
        slide = image_agent.invoke(
            input={
                "messages": [("human", IMAGE_AGENT_PROMPT)]
                + common_messages
            }
        )["final_response"]
        page_list.append(slide)

# 以下他のテンプレートについて記載
<参考> 生成されたページの例

TextPage(
	header='Pythonの特徴',
	content='Pythonはシンプルで読みやすい文法を持つプログラミング言語です。オープンソースであり、多数のライブラリやフレームワークが提供されています。データサイエンス、機械学習、Web開発など多岐にわたる分野で利用されています。', 
	template='text', policy='基本的な特徴を紹介し、Pythonの魅力を伝える。',
	sections=[
		Section(
			title='シンプルで読みやすい文法',
			content=['初心者にも理解しやすい文法', 'コードの可読性が高い', '保守性の向上に寄与', '迅速な開発が可能']
		),
		Section(
			title='オープンソースの利点',
			content=['誰でも無料で利用可能', '多数のコミュニティによるサポート', '豊富なライブラリとフレームワーク', '継続的な改善と更新']
		),
		Section(
			title='多様な応用分野',
			content=['データサイエンスと解析', '機械学習と人工知能', 'Web開発', '自動化スクリプト']
		)
	]
)

(参考)上記を元に作成されたスライド

image.png

また、画像を出力する部分については現状は予め設定した法人利用フリーの画像集に対し、
コンテンツの内容を元に最も似た画像をベクトル検索するツールをエージェント内で呼び出させて自動選択をさせるようにしています。実際試していてイメージと違う画像が選ばれるケースもあるため将来的には画像生成したものを配置させたりユーザーから受け取った画像を配置したり検討をしていきたいです。

    def search_similar_file(self, query: str) -> str:
        """
        queryの内容に最も近い画像ファイルのパスを返すtoolです。
        - queryは必ず英語にする必要があります
        - queryはひとつのみ受け取ることができます
        - queryの単語区切りは _ を利用すること
        """

        logger.info(query)
        result = self.image_retriever.invoke(query))
        return os.path.join(result[0].page_content)

PowerPointへの配置

各ページのコンテンツが完成したらその内容をPython-pptxを用いてパワーポイント形式に加工していきます。
Python-pptxで一からコンテンツを作っていくのは大変であるため、今回はあらかじめ自社のテンプレートのパワーポイントのスライドマスタに各ページごとテンプレート(TextPageなど)のスライドマスタを用意しておきページのコンテンツを当てはめていってページを作成しています。

  • TextPage
    image.png
class TextSlide(SlideBase):
    """テキストスライドのクラス"""

    def create_slide(self):
        """テキストスライドを作成する"""
        slide_layout = self.presentation.slide_layouts[CONTENT_SLIDE_LAYOUT]
        self.slide = self.presentation.slides.add_slide(slide_layout)
        self.set_title(self.page.header)

        content = self.slide.placeholders[1]
        text_frame = content.text_frame
        text_frame.clear()

        self.add_sections_to_slide(text_frame, self.page.sections)
  • ImagePage (右側に画像を配置する
    image.png
class ImageSlide(SlideBase):
    """画像スライドのクラス"""

    def _add_right_aligned_picture(self, slide, image_path, top, max_width, max_height):
        # 画像のサイズを取得
        with Image.open(image_path) as img:
            img_width, img_height = img.size

        # スライドのサイズを取得
        slide_width = self.presentation.slide_width
        slide_height = self.presentation.slide_height

        # 画像のアスペクト比を計算
        aspect_ratio = img_height / img_width

        # 画像の幅と高さをスライドの制約に合わせて調整
        if img_width > img_height:
            # 横長の場合、幅を基準に調整
            width = min(max_width, slide_width - Pt(40))
            height = width * aspect_ratio
        else:
            # 縦長の場合、高さを基準に調整
            height = min(max_height, slide_height - top - Pt(40))
            width = height / aspect_ratio

        # 画像を右側に配置するための座標を計算
        left = slide_width - width - Pt(40)  # 右端から20ポイントの余白を設定

        # 画像をスライドに追加
        slide.shapes.add_picture(image_path, left, top, width=width, height=height)

    def create_slide(self):
        """画像スライドを作成する"""
        slide_layout = self.presentation.slide_layouts[IMAGE_SLIDE_LAYOUT]
        self.slide = self.presentation.slides.add_slide(slide_layout)
        self.set_title(self.page.header)

        content = self.slide.placeholders[1]
        text_frame = content.text_frame
        text_frame.clear()

        self.add_sections_to_slide(text_frame, self.page.sections)

        # 画像を追加
        temp_image_path = self._download_image_from_blob(self.page.image_path)
        try:
            self._add_right_aligned_picture(
                self.slide,
                temp_image_path,
                Pt(170),
                max_width=Pt(360),
                max_height=Pt(300),
            )
        finally:
            self._delete_temp_file(temp_image_path)

完成した資料の提示

当社ではファイル共有についてBoxを利用しているため、
利便性を考えて作成した資料をBoxにアップロードしそのURLを渡すようにしています。

また、最終的な資料完成までに繰り返しテキスト生成を行う都合上、時間がかかってしまうため、ユーザーに対してはアウトラインやページ作成の進捗を表示させるよう工夫をしています。

image.png

今後について

まだまだ人間が作成した資料にはかなわない部分があると感じています。
今後はさらに複雑なテンプレートやMermaidなどを用いた図表を資料に追加するなどに取り組み全社の資料作成工数の削減に寄与できればと考えています。

その他参考にさせていただいた記事など

Python-pptxとAIエージェントを使ってパワーポイントを生成。
この記事を見てStructured Outputを使えばもっと複雑なデザインを表現できるのでは?と考えチャレンジしました

同じくPython-pptxを使ってパワーポイントを生成されている事例です。資料の完成度がすごく高い!

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
21
22

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?