0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Azure OpenAIのHosted ShellとSkillsを使ってPowerPoint生成AIを作り直してみた

0
Posted at

はじめに

1年半ほど前に、生成AIを使って編集可能なPowerPointを作成する仕組みを構築しました。

前回の記事はこちらです。

当時は、LLMへ一度にPowerPointを作らせると出力が安定しなかったため、要件整理、アウトライン作成、各スライドの生成を分け、最後にpython-pptxで固定テンプレートへ配置していました。

しばらく運用する中で、もう少し自由なレイアウトにしたい、既存のPowerPointをデザインとして使いたい、生成後に一部のページだけ直したい、といった要望が出てきました。
前回の構成でも対応はできますが、レイアウトを追加するたびにスキーマ、プロンプト、描画処理を増やす必要があります。

モデルやAPIも大きく変わったため、今回はPowerPoint生成の仕組みを一から見直しました。
Azure OpenAI Responses APIのHosted ShellとSkillsを使い、LLMが生成したJavaScriptをコンテナ内で実行する構成にしています。

↓ 以下は、新しい構成で生成したPowerPointの例です。
Claude_Fable_5_1_概要.pptx  -  読み取り専用.gif

前回の構成と課題

前回は、次の流れでPowerPointを作成していました。

  1. 利用者の依頼から資料作成の要件を整理
  2. 利用者が要件を確認して修正
  3. 資料全体のアウトラインをJSONで生成
  4. アウトラインを基に各スライドの内容をJSONで生成
  5. python-pptxで固定テンプレートへ配置

アウトラインには、ページごとに次のいずれかのテンプレートを指定します。

  • text
  • image
  • table
  • two_column
  • three_images
  • three_horizontal_flow
  • three_vertical_flow

テンプレートごとにPydanticモデル、プロンプト、python-pptxの描画クラスを用意していました。
LLMは内容とテンプレートを選び、座標や図形の配置はアプリケーション側で固定します。

この方式は、出力を安定させやすい点がメリットです。
定例報告や帳票のようにレイアウトをそろえたい資料では、現在でもこの方式が向いていると思います。

一方で、資料の内容に応じて構図を変えるのは苦手でした。
新しいレイアウトを追加する場合、Pydanticモデル、プロンプト、描画クラス、PowerPointテンプレートをまとめて修正する必要があります。

今回の構成

今回は、ページ内容を表すJSONではなく、PowerPointを生成するJavaScriptをサンドボックスで実行する構成に変更しました。

01_old_vs_new_architecture.png

コンテナ内には、次のファイルを生成します。

  • deck_spec.json:対象者、目的、根拠、全体構成、各スライドの役割
  • generate_document.cjs:PptxGenJSでPowerPointを作成するソースコード
  • final.pptx:利用者へ返すPowerPoint
  • quality_report.json:OOXMLとスライド内容の検査結果
  • generation_manifest.json:生成条件、警告、ダウンロード時のファイル名

deck_spec.jsonは資料全体の設計書として使います。
実際のPowerPointは、エージェントが作成したgenerate_document.cjsを実行して生成します。

検査で問題が見つかった場合は、同じJavaScriptを修正して再実行します。
生成コードを残すことで、レイアウトを七種類から選ぶだけでなく、資料の内容に合わせて構図を変えられるようになりました。

もちろん、すべてをLLMへ任せているわけではありません。
編集可能なテキストや図形を優先すること、画像の縦横比を維持すること、要素をページ外へ出さないことなどは、Skillと検査コードで制御しています。

メインエージェントからHosted Shellを利用

PowerPoint専用のエージェントを別に立てるのではなく、通常のチャットを処理するメインエージェントへHosted Shellを追加しました。

メインエージェントは、通常会話に加えてWeb検索、RAG、MCPも利用します。
PowerPointの生成や編集が必要な依頼の場合だけ、Hosted Shellを有効にします。

処理の流れは次の通りです。

  1. 利用者の依頼からPowerPointの生成または編集意図を検出
  2. 登録済みSkillのskill_idと入力ファイルを指定してHosted Containerを作成
  3. 作成したコンテナを参照するShell Toolをメインエージェントへ追加
  4. エージェントが設計、JavaScript生成、PowerPoint生成、検査、修正を実行
  5. final.pptxをコンテナから取得し、アプリケーション側でも検査して配信

検索結果や会話内容を別のPowerPoint専用エージェントへ渡す必要がないため、メインエージェントが調査と資料作成を続けて処理できます。

同じ会話ではコンテナIDを保存しておきます。
例えば、「3ページ目だけ修正して」と依頼された場合は、コンテナに残っているgenerate_document.cjsとfinal.pptxを再利用します。

Hosted Shellの設定

Hosted Shellは、Responses APIからシェルコマンドを実行できる管理対象のLinux環境です。1
公式ドキュメントでは、Debian 12をベースにPython 3.11を備え、/mnt/dataを成果物の保存場所として利用すると説明されています。

今回はコンテナを明示的に作成し、container_referenceで再利用しています。
実際のコードを簡略化すると、次のような形です。

container = await openai.containers.create(
    name=container_name,
    expires_after={
        "anchor": "last_active_at",
        "minutes": 20,
    },
    file_ids=uploaded_file_ids,
    memory_limit="4g",
    network_policy={"type": "disabled"},
    skills=[
        {
            "type": "skill_reference",
            "skill_id": slide_skill_id,
        }
    ],
)

shell = ShellTool(
    environment={
        "type": "container_reference",
        "container_id": container.id,
    }
)

コンテナの外向きネットワークは無効にしています。
Azureの資格情報、Blob Storageの接続情報、アプリケーションの環境変数なども渡していません。

02_control_plane_and_sandbox.png

Webアプリ側では、PowerPoint生成機能を利用できるかの判定、コンテナの作成と再利用、入力ファイルの受け渡し、成果物の検査と配信を行います。
モデルが生成したコマンドや一時ファイルはHosted Container内で扱います。

コンテナは最終利用から20分で失効します。
コンテナが失効した後に修正を依頼された場合は、直前のPowerPointを再度添付してもらうよう案内します。

PowerPoint生成用Skill

OpenAIが公開しているSlides Skillでは、PptxGenJSを使って編集可能なPowerPointを作成し、レンダリングと検査を繰り返しています。2
今回の実装も、このSkillを参考にしました。

Azure OpenAI Responses APIのSkillsは、SKILL.mdと関連ファイルを一つのZIPにまとめ、バージョン付きで登録できる仕組みです。3
指示文だけでなく、スクリプト、参照資料、ライブラリ、実行ファイルも同じZIPへ入れられます。

今回作成したSkillは、次のような構成です。

pptx-generation/
├── SKILL.md
├── references/
│   ├── design-rules.md
│   └── presentation-playbook.md
├── scripts/
│   ├── bootstrap_node.py
│   ├── slide_helpers.cjs
│   ├── analyze_reference_pptx.py
│   ├── apply_reference_template.py
│   ├── normalize_pptx.py
│   ├── validate_deck.py
│   └── validate_edit_scope.py
├── node_modules/
│   └── pptxgenjs 4.0.1と実行時依存
├── runtime/
│   ├── node.xz.part.00
│   ├── node.xz.part.01
│   └── node.sha256
└── licenses/

03_skill_packaging_and_injection.png

Node.jsもSkillへ同梱

実装中に困ったのが、Hosted Shellに任意のNode.js環境が用意されているとは限らない点です。
実行時にnpm installする方法もありますが、今回はコンテナの外向きネットワークを無効にしています。

そこで、Node.js 22.22.3の公式Linuxバイナリをビルド時に取得し、SHA-256を照合してからXZ形式で圧縮し、Skillへ同梱しました。

Skillsには、ZIP 50MB、1バージョン500ファイル、展開後の単一ファイル25MBという上限があります。3
Node.jsの実行ファイルをそのまま入れると単一ファイルの上限へ触れるため、20MiB単位に分割しています。
コンテナ内では、bootstrap_node.pyが分割ファイルを結合してNode.jsを復元します。

2026年9月3日時点のビルド結果は次の通りです。

項目 実値
ZIPサイズ 30,977,025 bytes(29.5 MiB)
ファイル数 322
ZIP内ファイルの展開後合計 36,299,165 bytes(34.6 MiB)
Node.js 22.22.3
PptxGenJS 4.0.1

依存ライブラリを削減

OpenAIのSlides Skillで利用している画像処理、SVG処理、整列、重なり検出のうち、今回必要な関数を依存の少ないCommonJSモジュールへ分けました。

skia-canvas、fontkit、linebreakを必要とする文字計測は持ち込まず、単純な高さ見積もりへ置き換えています。
npm ci --omit=dev --ignore-scriptsで実行時依存だけを取得し、利用しないimage-sizeも削除しました。

画像入力はPNG、JPEG、GIF、SVGに限定しています。
これで、コンテナから外部ネットワークへ接続せずにNode.jsの復元からPowerPoint生成まで実行できます。

PowerPointの生成と検査

PowerPointを作る前に、エージェントがdeck_spec.jsonを作成します。
ここには対象者、目的、使用する根拠、全体構成、デザイン方針、各スライドの役割を記録します。

その後、PptxGenJSを使ったgenerate_document.cjsを作成して実行します。
生成後の検査は次の流れです。

  1. JavaScript内のヘルパーでページ外の要素と意図しない重なりを検出
  2. normalize_pptx.pyで既知のOOXML互換性問題を補正
  3. validate_deck.pyでPowerPointの構造と内容を検査
  4. エラーや意図しない警告があれば、JavaScriptを修正して再生成

normalize_pptx.pyでは、負数になった図形サイズ、再利用された影オブジェクトの不正な値、存在しないパーツを指すContent Typesの定義を補正します。
PowerPointでファイルを開いた際に修復ダイアログが表示される原因になるため、先に機械的に直しています。

validate_deck.pyの主な検査内容は次の通りです。

  • ZIPの破損、サイズ、ファイル数、展開後容量、危険なパス
  • マクロ、ActiveX、埋め込みオブジェクト、外部Relationship
  • 存在しないRelationship参照先とContent Types参照先
  • 壊れたスライドXML、負の図形サイズ、不正な影の値
  • プレースホルダーテキスト、空スライド、650文字を超えるスライド、重複スライド
  • deck_spec.jsonと実際のスライド枚数の不一致

Skillでは、必要に応じて修正と再検査を最大二回繰り返すよう指示しています。

コンテナから取得したfinal.pptxは、そのまま利用者へ返しません。
アプリケーション側でも、64MiBのサイズ上限、ZIPの整合性、必須パーツ、マクロ、外部Relationshipを再検査します。

コンテナ内の検査はPowerPointの品質を改善するための処理です。
アプリケーション側の検査は、問題のあるファイルを配信しないための処理として分けています。

視覚的な検査は未実装

現時点で検査しているのは、OOXMLの構造、文字数、要素の座標、重なりです。
全スライドを画像化し、見た目を評価する処理までは実装できていません。

そのため、次のような問題は取りこぼす可能性があります。

  • 代替フォントによる改行位置の変化
  • 行間や余白の不自然さ
  • 色の組み合わせによる読みにくさ
  • 情報の優先順位や視線の流れ
  • PowerPointと別のレンダラーによる表示差

OpenAIのSlides Skillには、PowerPointの画像化、モンタージュ作成、フォント置換検出などのツールがあります。2
今回はSkillの容量と実行時依存を減らすため、この部分はまだ取り込んでいません。

既存PowerPointのデザインを利用

業務で利用する場合、既存のPowerPointや会社のテンプレートにデザインを合わせたいケースがあります。
調べてみると、単純にThemeを読み込むだけでは足りませんでした。

PowerPointのThemeで主に定義されるのは、色、フォント、効果です。4
タイトルや本文の位置、プレースホルダー、ロゴ、背景、共通図形は、Slide MasterやSlide Layoutにも含まれています。5

04_pptx_template_extraction.png

今回は、利用者の指示に応じて二つのモードを使い分けます。

モード 利用者の指示例 処理
見た目参照 「雰囲気を参考に」「色だけ使って」 抽出したデザイン情報を生成コードへ反映
厳密テンプレート 「このテンプレートで」「このデザインへ差し替えて」 生成後のPowerPointへTheme、Master、Layout、レイアウト内メディアを適用

見た目参照

analyze_reference_pptx.pyを使って、次の情報を抽出します。

  • スライド数、サイズ、縦横比
  • Theme名、配色、majorとminorのLatin、East Asian、日本語フォント
  • 先頭Masterの背景色とタイトル、本文、その他の既定書式
  • 最大16件のLayout名、種別、プレースホルダーの位置とサイズ、装飾要素
  • 最大8枚のスライドから取得した背景色、図形数、画像数、頻出色
  • ロゴ、フッター、端の装飾を避けるための推奨セーフエリア

参照PowerPointの本文テキストは、抽出結果へコピーしません。
配色だけでなく、タイトルの階層、余白、強調色の使い方を含むデザイン情報として生成コードへ渡します。

厳密テンプレート

厳密テンプレートモードでは、PptxGenJSで編集可能な本文を生成した後、apply_reference_template.pyでTheme、Master、Layout、レイアウト内の画像を適用します。

生成スライドが参照しているLayoutのRelationshipをたどるため、slideLayout1.xmlのような番号を決め打ちしていません。

ただし、参照PowerPointの本文スライドをそのまま複製する機能ではありません。
SmartArt、複雑なグループ図形、埋め込みオブジェクト、特殊なフォントまで完全に再現するものでもありません。

生成後のPowerPointを修正

生成コードをコンテナに残しているため、同じ会話の中でページ単位の修正も行えます。

generate_document.cjsとfinal.pptxが残っている場合、エージェントは変更前のファイルを/tmpへ退避し、指定されたスライドの処理だけを修正します。

再生成後は、validate_edit_scope.pyで変更対象外のスライドと関連パーツを比較します。
意図しない差分が見つかった場合は、そのPowerPointを配信せず、退避したソースへ戻して修正し直します。

資料用の画像生成も同じコンテナへ接続しました。
生成した画像はPNGへ変換してコンテナへ送り、PptxGenJSのソースから参照します。

会話内の既存画像は最大8枚、新たに資料用として生成する画像は最大4枚に制限しています。

現状では、コンテナが失効するとJavaScriptのソースも失われます。
長期間にわたって編集を続けるには、PowerPointだけでなく、生成ソースとマニフェストを永続化する仕組みが必要です。

Skillの登録と更新

PowerPoint生成用Skillは、アプリケーションの起動ごとに毎回アップロードしていません。
Blob Storage上のskills.jsonへ、Skill名、ZIPのBlob名、skill_id、登録時のETagを記録します。

ZIPのETagが変わっていなければ、既存のskill_idを再利用します。
更新されている場合は、新しいSkill Versionを既定版として登録し、skills.jsonを更新します。

複数のApp Serviceインスタンスから同時に登録されることを防ぐため、skills.jsonへ60秒のBlob Leaseを取得しています。

コンテナ作成時はskill_idだけを指定し、Skills APIのdefault_versionを利用します。
そのため、記事中のコードへversionを固定値として書いていません。

Skillはモデルの計画やコマンド実行に影響するため、利用者が自由にSkillを登録できる形には現状していません。
開発者が内容を確認したZIPだけを登録しています。3

まとめと今後の課題

前回は、LLMの処理を細かく分け、JSONと固定レイアウトで出力を安定させました。
今回は、Hosted ContainerにJavaScriptと生成物を残し、検査結果を見ながら同じソースを修正できる構成へ変更しました。

実際に作り直してみて、モデルを変えるだけでなく、モデルが作業する環境を用意することが大きなポイントだと感じました。
生成コードが残ることで、自由なレイアウトだけでなく、ページ単位の修正も扱いやすくなります。

一方で、まだ次の課題が残っています。

  • スライドを画像化した視覚的な検査
  • コンテナ失効後も編集を続けられる生成ソースの永続化

まずは視覚的な検査を追加し、PowerPointを開いて確認する作業をどこまで自動化できるか試していく予定です。

参考

  1. Microsoft Learn: Use the shell tool with the Azure OpenAI Responses API ↩

  2. OpenAI Skills: Slides(参照した版) ↩ ↩2

  3. Microsoft Learn: Use skills with the Azure OpenAI Responses API ↩ ↩2 ↩3

  4. Microsoft Learn: Apply a theme to a presentation ↩

  5. Microsoft Learn: Structure of a PresentationML document ↩

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?