Azure Functionsを使ってMarkItDownを試す

先日、[下記のつぶやき](https://x.com/daiki15036604/status/1867895974238331161)を拝見して心が躍りました。

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">マイクロソフトが開発した MarkItDown<br><br>PDFだけじゃなく、Word、Excel、PowerPointなどの多様なファイル形式をMarkdownに変換し、テキスト分析やインデックス作成を容易できるのよい！<a href="https://t.co/DgCxamEpTs">https://t.co/DgCxamEpTs</a></p>&mdash; daka | Microsoft | AI (@daiki15036604) <a href="https://twitter.com/daiki15036604/status/1867895974238331161?ref_src=twsrc%5Etfw">December 14, 2024</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

> マイクロソフトが開発した MarkItDown PDFだけじゃなく、Word、Excel、PowerPointなどの多様なファイル形式をMarkdownに変換し、テキスト分析やインデックス作成を容易できる

**非常に心が踊るフレーズです。**

さっそく`Power Apps`で試されている方もいらっしゃいます。

https://qiita.com/reire/items/bc2243e53dc5e0d502e0

この記事では`Azure Functions`を使って、この[MarkItDown](https://github.com/microsoft/markitdown)を試してみたいと思います。

https://github.com/microsoft/markitdown

# 作ったもの
1. `Power Apps` - `SharePoint サイト`のドキュメントライブラリを指定するインターフェース
1. `Azure Functions` - [MarkItDown](https://github.com/microsoft/markitdown)を関数用に加工したもの
1. `Power Automate` - (1)の`Power Apps`から、`ファイル識別子`を受け取り、[ファイル コンテンツを取得](https://learn.microsoft.com/ja-jp/connectors/sharepoint/#%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB-%E3%82%B3%E3%83%B3%E3%83%86%E3%83%B3%E3%83%84%E3%81%AE%E5%8F%96%E5%BE%97)、`Azure Functions`を起動

`Power Apps`はあくまで結果のプレビュー用に用いています。
キャプチャを載せると、ファイルに対して結果がある程度上手くかえってきていることがわかります。

**■ Word**

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2734332/e44aa61d-1885-dd44-b7a7-8a88e0630a9a.png)

**■ PDF**

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2734332/fb9e871a-ef64-a51f-0b58-bb2fffd38095.png)

**■ PowerPoint**

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2734332/6096d6e2-d445-890a-7431-0f6a67771f3b.png)

実際につかったところ、`PowerPoint`や`Word`、`PDF`のMarkdownへの変換は精度が高いです。

Power Appsでは、赤枠のギャラリー コントロールに`ドキュメント ライブラリ`の結果を出力し、`OnSelect`プロパティで結果を取得しています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2734332/4f3f4b21-8f5a-4dda-ace2-d0181fbe72b9.png)

```javascript:OnSelect
UpdateContext({Result: MarkdownConvert.Run(ThisItem.識別子).response});
```

結果は`テキスト入力`で表示しています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2734332/dde60c22-d40f-0fa0-b1da-7de5158298b7.png)

## 小ネタ: 環境変数
`Power Apps`、`Power Automate`でSharePointのサイトやリスト情報であったり、今回はAzure Functionsのエンドポイントを指定したりしますが、そのような値は**環境変数に設定しておくと管理が容易です。**

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2734332/16d647e3-13a9-54a3-8170-88e6ae99cf6c.png)

[Power Platform の概要のための環境変数](https://learn.microsoft.com/ja-jp/power-apps/maker/data-platform/environmentvariables)

https://learn.microsoft.com/ja-jp/power-apps/maker/data-platform/environmentvariables

ソリューションを作成し、環境変数を追加すると、`Power Apps`と`Power Automate`の共通の値(例: データソースの情報)、エンドポイントの情報をまとめて管理出来ます。
また開発に置ける環境戦略`開発者環境`、`テスト環境`、`本稼働環境`を分ける際にも非常に重宝する機能です。


今回は下記の値を`環境変数`として定義します。

| 項目名                 | 環境変数名              | 説明                                   |
|------------------------|------------------------|---------------------------------------|
| SharePoint サイト      | `SP_SITE_URL`          | SharePoint サイト |
| ドキュメント ライブラリ| `SP_DOCUMENT_LIBRARY`  | SharePoint ドキュメントライブラリ |
| Azure Function エンドポイント | `AZURE_FUNCTION_ENDPOINT` | Azure Functions URL |
| シークレット          | `AZURE_SECRET` | シークレット情報 |

# `Azure Functions` -  [MarkItDown](https://github.com/microsoft/markitdown)を関数用に加工したもの

[Visual Studio Code 用 Azure Functions 拡張機能](https://marketplace.visualstudio.com/items?itemName=ms-azuretools.vscode-azurefunctions)を使って作成を進めます。

https://marketplace.visualstudio.com/items?itemName=ms-azuretools.vscode-azurefunctions

https://learn.microsoft.com/ja-jp/azure/azure-functions/functions-develop-vs-code?tabs=node-v4%2Cpython-v2%2Cisolated-process%2Cquick-create&pivots=programming-language-python

私のような知識が少ないものでも簡単に作れる利便性の高いツールです。
作業するディレクトリを選択し、Azure Functions プロジェクトを作成します。

`HTTP トリガー関数テンプレートを選択`することで必要なファイルがそろいます。
Pythonの仮想環境は`venv`を使います。

```
markitdown-function/
├── .venv/
├── markitdown/
│   ├── __init__.py
│   ├── __about__.py
│   └── _markitdown.py
├── function_app.py
├── requirements.txt
├── host.json
└── local.settings.json
```

テンプレートの中に`markitdown`ディレクトリを設けて、[MarkItDown](https://github.com/microsoft/markitdown)の必要なファイルを組み込みます。
必要なファイルは下記の三つです。

- __init__.py
- __about__.py
- markitdown.py

## 依存関係
`azure-functions`以外は[MarkItDown](https://github.com/microsoft/markitdown)で使われるものです。

```plaintext:requirements.txt
azure-functions
beautifulsoup4>=4.9.3
markdownify>=0.11.6
mammoth>=1.5.1
pandas>=1.3.0
pdfminer.six>=20201018
python-pptx>=0.6.21
puremagic>=1.15
requests>=2.26.0
charset-normalizer>=2.0.0
```

## function_app.py
`エントリーポイント`である`function_app.py`は`Claude 3.5 Sonnet`に作成してもらいました。
`AI`様様です。

Power Automateから`base64`文字列で受け取るファイルをもとに`MarkItDown`を呼び出します。

```python:function_app.py
import azure.functions as func
import logging
import json
import base64
import tempfile
import os
from markitdown import MarkItDown, FileConversionException, UnsupportedFormatException

app = func.FunctionApp()

@app.route(route="convert", auth_level=func.AuthLevel.FUNCTION)
async def convert_to_markdown(req: func.HttpRequest) -> func.HttpResponse:
    logging.info('Python HTTP trigger function processed a request.')

    try:
        # Get request body
        req_body = req.get_json()
        
        if not req_body or 'file' not in req_body:
            return func.HttpResponse(
                json.dumps({
                    "error": "Please pass a base64 encoded file in the request body"
                }),
                mimetype="application/json",
                status_code=400
            )

        file_content = req_body.get('file')
        file_extension = req_body.get('extension', '')

        # Decode base64 content
        try:
            decoded_content = base64.b64decode(file_content)
        except Exception as e:
            return func.HttpResponse(
                json.dumps({
                    "error": f"Invalid base64 content: {str(e)}"
                }),
                mimetype="application/json",
                status_code=400
            )

        # Create a temporary file
        handle, temp_path = tempfile.mkstemp(suffix=file_extension)
        
        try:
            with os.fdopen(handle, 'wb') as temp_file:
                temp_file.write(decoded_content)
            
            # Initialize MarkItDown converter
            converter = MarkItDown()
            
            # Convert the file
            result = converter.convert_local(
                temp_path,
                file_extension=file_extension
            )

            # Prepare response
            response_data = {
                "title": result.title,
                "content": result.text_content
            }

            return func.HttpResponse(
                body=json.dumps(response_data),
                mimetype="application/json",
                status_code=200
            )

        except FileConversionException as e:
            return func.HttpResponse(
                json.dumps({
                    "error": f"File conversion error: {str(e)}"
                }),
                mimetype="application/json",
                status_code=422
            )
        except UnsupportedFormatException as e:
            return func.HttpResponse(
                json.dumps({
                    "error": f"Unsupported format: {str(e)}"
                }),
                mimetype="application/json",
                status_code=415
            )
        except Exception as e:
            logging.error(f"Error converting file: {str(e)}")
            return func.HttpResponse(
                json.dumps({
                    "error": f"Internal server error: {str(e)}"
                }),
                mimetype="application/json",
                status_code=500
            )
        
        finally:
            # Clean up temporary file
            try:
                os.unlink(temp_path)
            except:
                pass

    except Exception as e:
        logging.error(f"Error processing request: {str(e)}")
        return func.HttpResponse(
            json.dumps({
                "error": f"Internal server error: {str(e)}"
            }),
            mimetype="application/json",
            status_code=500
        )
```

認証は`Function`レベルとなっています。
こちらを`Azure Functions`としてデプロイすると、`Power Automate`経由で[MarkItDown](https://github.com/microsoft/markitdown)が使えます。

# Power Automate フロー

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2734332/409490b7-9378-c0c9-b79c-6755244f0242.png)


1. Power Apps がフローを呼び出したとき (V2) - SharePoint ドキュメントライブラリのファイル識別子を受け取る
1. ファイル コンテンツの取得
1. `作成`アクション - [base64関数](https://learn.microsoft.com/ja-jp/azure/logic-apps/workflow-definition-language-functions-reference#base64)で`ファイル コンテンツ`の文字列の base64 エンコード バージョンに変換
1. `HTTP` - Azure Functionsを実行
1. `Power Apps`に値を返す

前述のとおり、Power Appsからファイル識別子を受け取っています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2734332/78ac5de4-f3a4-3cfc-86e6-9db448a1357b.png)

こちらから`SharePoint`のファイル コンテンツを取得します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2734332/2d4b5db4-368b-e325-9ad6-07d37bdf319f.png)

[base64関数](https://learn.microsoft.com/ja-jp/azure/logic-apps/workflow-definition-language-functions-reference#base64)で取得したファイルコンテンツを、文字列の base64 エンコード バージョンに変換します。これにより、`Azure Functions`に渡す準備が整います。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2734332/7d1cb254-f162-12bc-55d8-8e56a69bfcb6.png)

```plaintext:入力
@{base64(outputs('ファイル_コンテンツの取得')?['body'])}
```

## HTTP - Azure Functionsを実行

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2734332/f2a06370-2e17-429e-0936-573cf8aae03f.png)

`URI`はデプロイしたリソースから取得します。
今回は`環境変数`で値を設定しています。

```plaintext:URI
@{parameters('AZURE_FUNCTION_ENDPOINT (gatsuo_AZURE_FUNCTION_ENDPOINT)')}?code=@{parameters('AZURE_SECRET (gatsuo_AZURE_SECRET)')}
```

Method `POST`

```json:Headers
{
  "Content-Type": "application/json"
}
```

```json:Body
{
  "file": "@{outputs('作成_base64')}"
}
```

設定から`入出力`をマスキングすることも可能です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2734332/ac0fa055-7be0-15d2-1ffc-e5dcc2783f07.png)

こちらを実行した結果が`content`として取得できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2734332/d5585904-e631-c0d4-e738-6be54dba6878.png)

```javascript:response
@{body('HTTP')?['content']}
```

# おわりに
構造化された文字列になると`AI`活用の幅が広がります。
「Copilot活用のためにAzure Functionsを使いたいです！」は市民開発の立ち位置から打ち手として首を傾げるところですが、こういった技術が普及することにより、**ファイルの形式関係なく`AI`との対話ができそうなことにワクワクしますね。**

今回もブログを読んでいただき、ありがとうございました！