1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

DatabricksでPowerPointを作成するChatAgentを作る

Posted at

こちらの精神的続編です。

導入

Model Context Protocol(MCP)を使ったエージェントの第3弾です。

タイトルの通り、公開されているMCP Serverを活用してPowerPointを作成します。

日本企業の多くで資料にPowerPointなどのスライドを利用することが多いのではないかと思います。これをちょっとでも楽にできるエージェントがあると便利ですよね。

というわけで、今回は以下のリポジトリで公開されているPowerPoint MCP Serverを使ってPowerPointを作成するエージェントをDatabricks上で作成します。

PowerPoint操作のMCPは他にも公開されているものがあります。
お好みで別のMCP Serverも使ってみてください。

進め方は前回記事以下とほとんど同じですが、今回はModel Servingでのデプロイはしません。(ファイル作成と取得がやりづらいので)
また、前回利用したMarkitdown-MCPを今回も利用しています。

実行はDatabricks on AWS、ノートブックはサーバレスクラスタを利用しました。

コード飛ばして、実行結果だけ確認したい方はStep3から確認ください。

Step1. MCPを使うエージェントを定義

まずはMCPサーバをツールとして利用するエージェントを定義します。
ノートブックを作成し、必要なパッケージをインストール。

%pip install langchain-mcp-adapters langgraph databricks-langchain databricks-agents rich nest-asyncio mcp-simple-timeserver markitdown-mcp ffmpeg-python python-pptx
%pip install mlflow

%restart_python

前回同様、MCPサーバと連携するためのモジュールとしてLangChain MCP Adaptersを利用しています。

次にこちらのリポジトリをgit cloneで取得。/tmp配下にクローンします。

!cd /tmp && git clone https://github.com/GongRzhe/Office-PowerPoint-MCP-Server.git powerpoint-mcp
!chmod +x /tmp/powerpoint-mcp/ppt_mcp_server.py

では、MCPを使うエージェントを定義します。
MLflowのChatAgentインターフェースを備えたカスタムクラスとして定義しています。
前回記事との違いは、Markitdown-MCPなど、複数のMCP Serverと連携するように処理を組んでいることです。

%%writefile powerpoint_mcp_agent.py

from typing import Literal, Generator, List, Optional, Any, Dict, Mapping, Union
import uuid
import asyncio
import sys
import os

import mlflow
from databricks_langchain import ChatDatabricks
from langgraph.prebuilt import create_react_agent

from langchain_core.messages import BaseMessage, convert_to_openai_messages
from mlflow.pyfunc import ChatAgent
from mlflow.types.agent import (
    ChatAgentChunk,
    ChatAgentMessage,
    ChatAgentResponse,
    ChatContext,
)
from langchain_mcp_adapters.client import MultiServerMCPClient
from langchain_mcp_adapters.tools import load_mcp_tools
from functools import reduce

# 環境変数
PPT_OUTPUT_DIR = os.environ.get("PPT_OUTPUT_DIR", os.getcwd())

# Tracingの有効化
mlflow.langchain.autolog()


class PowerPointMCPChatAgent(ChatAgent):
    def __init__(self, model):
        """LangGraphのグラフを指定して初期化"""
        self.model = model
        self.mcp_connections = {}

    def load_context(self, context):
        # artifactからPowerPoint-MCP Serverのリポジトリパスを取得
        powerpoint_mcp_path = context.artifacts["powerpoint_mcp"]
        self.mcp_connections = {
            "markitdown": {
                "command": "markitdown-mcp",
                "args": [],
                "transport": "stdio",
            },
            "powerpoint": {
                "command": "python",
                "args": [
                    os.path.join(powerpoint_mcp_path, "ppt_mcp_server.py")
                ],
                "transport": "stdio",
            },
            "simple-timeserver": {
                "command": "python",
                "args": ["-m", "mcp_simple_timeserver"],
                "transport": "stdio",
            },
        }
        print("loaded contexts.")
        print(self.mcp_connections)

    def predict(
        self,
        messages: list[ChatAgentMessage],
        context: Optional[ChatContext] = None,
        custom_inputs: Optional[dict[str, Any]] = None,
    ) -> ChatAgentResponse:
        """
        指定されたチャットメッセージリストを使用して回答を生成する

        Args:
            messages (list[ChatAgentMessage]): チャットエージェントメッセージのリスト。
            context (Optional[ChatContext]): オプションのチャットコンテキスト。
            custom_inputs (Optional[dict[str, Any]]): カスタム入力のオプション辞書。

        Returns:
            ChatAgentResponse: 予測結果を含むChatAgentResponseオブジェクト。
        """

        gen_messages = []
        usages = []
        for c in self.predict_stream(messages, context, custom_inputs):
            gen_messages.append(c.delta)
            usages.append(c.usage.model_dump(exclude_none=True))

        return ChatAgentResponse(messages=gen_messages, usage=self._sum_usages(usages))

    def predict_stream(
        self,
        messages: list[ChatAgentMessage],
        context: Optional[ChatContext] = None,
        custom_inputs: Optional[dict[str, Any]] = None,
    ) -> Generator[ChatAgentChunk, None, None]:
        """
        指定されたチャットメッセージリストを使用して、非同期的にエージェントを呼び出し、結果を取得します。

        Args:
            messages (list[ChatAgentMessage]): チャットエージェントメッセージのリスト。
            context (Optional[ChatContext]): オプションのチャットコンテキスト。
            custom_inputs (Optional[dict[str, Any]]): カスタム入力のオプション辞書。

        Returns:
            ChatAgentResponse: 予測結果を含むChatAgentResponseオブジェクト。
        """

        async def stream_with_mcp_tools(model, connections, messages: list):
            """MCP Serverと通信して回答を取得する"""

            async with MultiServerMCPClient(connections) as client:
                # エージェントを作成して実行
                agent = create_react_agent(model, client.get_tools())
                async for event in agent.astream(
                    {"messages": messages},
                    config={"recursion_limit": 100},
                    stream_mode="updates",
                ):
                    for v in event.values():
                        messages = v.get("messages", [])
                        for msg in messages:
                            yield ChatAgentChunk(
                                delta=self._convert_lc_message_to_chat_message(msg),
                                usage=msg.response_metadata.get("usage", {}),
                            )
                await client.exit_stack.aclose()

        streamer = stream_with_mcp_tools(
            self.model,
            self.mcp_connections,
            self._convert_messages_to_dict(messages),
        )

        loop = asyncio.get_event_loop()
        while True:
            try:
                yield loop.run_until_complete(streamer.__anext__())

            except StopAsyncIteration:
                break
            except Exception as e:
                # for "Attempted to exit cancel scope in a different task than it was entered in" Exception.
                break

    def _convert_lc_message_to_chat_message(
        self, lc_message: BaseMessage
    ) -> ChatAgentMessage:
        """LangChainメッセージをChatAgentMessageに変換する。"""
        msg = convert_to_openai_messages(lc_message)
        if not "id" in msg:
            msg.update({"id": str(uuid.uuid4())})

        return ChatAgentMessage(**msg)

    def _sum_usages(self, usages: list[dict]) -> dict:
        """使用量のリストから使用量を合計する。"""

        def add_usages(a: dict, b: dict) -> dict:
            pt = "prompt_tokens"
            ct = "completion_tokens"
            tt = "total_tokens"
            return {
                pt: a.get(pt, 0) + b.get(pt, 0),
                ct: a.get(ct, 0) + b.get(ct, 0),
                tt: a.get(tt, 0) + b.get(tt, 0),
            }

        return reduce(add_usages, usages)

    def _convert_lc_message_to_chat_message(
        self, lc_message: BaseMessage
    ) -> ChatAgentMessage:
        """LangChainメッセージをChatAgentMessageに変換する。"""
        msg = convert_to_openai_messages(lc_message)
        if not "id" in msg:
            msg.update({"id": str(uuid.uuid4())})

        return ChatAgentMessage(**msg)

    def _sum_usages(self, usages: list[dict]) -> dict:
        """使用量のリストから使用量を合計する。"""

        def add_usages(a: dict, b: dict) -> dict:
            pt = "prompt_tokens"
            ct = "completion_tokens"
            tt = "total_tokens"
            return {
                pt: a.get(pt, 0) + b.get(pt, 0),
                ct: a.get(ct, 0) + b.get(ct, 0),
                tt: a.get(tt, 0) + b.get(tt, 0),
            }

        return reduce(add_usages, usages, {})


# DatabricksネイティブのClaude 3.7 SonnetをLLMとして利用
LLM_ENDPOINT_NAME = "databricks-claude-3-7-sonnet"
llm = ChatDatabricks(model=LLM_ENDPOINT_NAME)

# エージェントを作成
AGENT = PowerPointMCPChatAgent(model=llm)
mlflow.models.set_model(AGENT)

これで準備は終わりました。

Step2. エージェントをロギング

作成したエージェントをMLflow上にロギング(保管)します。

まずnest_asyncioパッケージを使って、async.ioをノートブック内で利用可能にします。

import nest_asyncio

nest_asyncio.apply()

次にMLflowのlog_modelを実行して、Step1で定義したカスタムChatAgentクラスを保管します。

import mlflow
from powerpoint_mcp_agent import LLM_ENDPOINT_NAME
from mlflow.models.resources import DatabricksServingEndpoint

resources = [DatabricksServingEndpoint(endpoint_name=LLM_ENDPOINT_NAME)]

input_example = {
    "messages": [
        {
            "role": "user",
            "content": "今何時?",
        }
    ]
}

artifacts = {
    "powerpoint_mcp": "/tmp/powerpoint-mcp/", # git cloneしたPowerPoint-MCPリポジトリのパス
}

with mlflow.start_run():
    logged_agent_info = mlflow.pyfunc.log_model(
        artifact_path ="powerpoint_mcp_agent",
        artifacts=artifacts,
        python_model="powerpoint_mcp_agent.py",
        input_example=input_example,
        pip_requirements=[
            "mlflow",
            "langgraph==0.4.0",
            "langchain-mcp-adapters==0.0.9",
            "databricks-langchain==0.4.2",
            "markitdown-mcp==0.0.1a3",
            "ffmpeg-python==0.2.0",
            "mcp-simple-timeserver==1.0.6",
            "python-pptx==1.0.2",
        ],
        resources=resources,
    )

これで作成したエージェントが利用可能になりました。

では、次のStepから実際に使ってみます。

Step3. エージェントを使ってみる

Step2でロギングしたエージェントをロードします。

import mlflow

agent = mlflow.pyfunc.load_model(logged_agent_info.model_uri)

では、このエージェントに対していろいろ指示してみましょう。

agent.predict(
    {
        "messages": [
            {
                "role": "user",
                "content": "LLMの概要解説を文字だけの単純なPowerPoint1枚で作成して",
            }
        ]
    }
)

MLflow Tracingの結果は以下のようになります。

image.png

最終出力内容のように、ノートブックと同じフォルダに「LLM概要解説.pptx」というファイルが作られます。
ダウンロードして開くと、以下のようなPowerPointファイルとなっています。

image.png

文字情報だけですが、コピペでPowerPoint作るよりは楽かな。

さらに、以下のようなクエリも実行してみます。

agent.predict(
    {
        "messages": [
            {
                "role": "user",
                "content": (
                    "次のURLの内容から、Qwen3に関する特徴をPowerPoint3枚でまとめてください。" 
                    "https://qiita.com/isanakamishiro2/items/1104e65c7ebeb5b30c02"
                ),
            }
        ]
    }
)

指定したURLは以下の記事になります。

実行後のTracing結果は以下の通り。

まず、該当のURLをMarkitdown-MCPでMarkdownに変換して取得する処理が実行されます。

image.png

その後、PowerPoint作成のツールが諸所呼び出されたのち、今度は「Qwen3の特徴.pptx」というファイルが作られました。

image.png

PowerPointファイルを開くと以下のような内容でした。

image.png

このように、他のMCP Serverのツールと連携してPowerPointを作ることが出来ます。
特定のサイトやファイルを使ってレポートを作る際などに重宝しそう。

今回の例では出てきませんでしたが、指示次第で画像の利用や図形・表を使ったPowerPointファイルの作成も可能です。

おわりに

PowerPointを作成するエージェントを作成してみました。
今回のようにMCPを呼び出すだけだと、実用性は正直まだ低いかなという感想です。
レイアウトも崩れやすいので。

実際にはスライドを前提としたコンテンツの作成や、レイアウト指示の作り込みを行うエージェントシステムを作る必要があり、その最終段階としてPowerPoint形式でファイル出力する、というフローを組む必要があるかなと考えています。
また、入力トークン数がかなり大きくなるので、セルフホストしたLLMを利用するなどコストに対する工夫も必要になりそう。

実用性のあるエージェントシステムを作り込むことができたら、多くの日本企業にとっては救いになる・・・かもしれませんね。

1
1
0

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
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?