Faxで届く在庫照会をAIで自動化する

# 概要
私が所属する会社の業界では、まだまだFaxが使われていて、営業部にはかなりの頻度で在庫照会のFaxが届くそうです。
担当者が社内システムで在庫を調べ、またFaxで送り返すのですが、繁忙期には派遣社員を雇わなければいけなくなるほどで、かなりの負担になっているとのこと。
なんとか効率化できないかと相談を受けた(最初の相談は検索機能の改善だった)ので、表題の機能を提案してみました。

システムで自動化するにあたって難しいのは、Faxのフォーマットが、取引先ごとにバラバラであること。ヘタすると、手書きで送ってきたりするので厄介です。
そこはまるっとAIにやらせてしまい、ややこしい処理を実装する事なく、簡単に実現させる事ができました。

# 全体像
全体像を簡単に説明すると、受信したFaxはPDFに変換してクラウドストレージに保存。OCRした結果をAIに読ませて、商品一覧を抽出します。あとは社内のデータベースで在庫を照会し、結果を元のPDFに書き込んで終了。
照会結果をFaxで送り返すところは、今まで通り人の手でやってもらいます。たぶん、送り返すところまで含めて自動化できるとは思うのですが、正確性が重視される業界的のため、間違った情報を返す事は信用問題になりかねないので、自動生成した照会結果は、必ず担当者がチェックしてから手作業で返信してもらう事にしました。

```mermaid
sequenceDiagram
    participant F as コピー機
    participant B as Box
    participant A as Azure Functions
    participant N as 社内システム
    participant C as Azure Computer Vision
    participant O as Azure OpenAI Service
    participant P as Pincone

    F->>B: upload
    B->>A: Webhook
    A->>N: Web API Request
    N->>C: OCR Request
    C->>N: Response
    N->>O: normalisation
    O ->> N: Title list
    N->>P: Query
    P->>N: Product Code
    N->>N:在庫数取得
    N->>B: update
```

以下に各ステップごとの詳細を説明します。

# 詳細
## コピー機 → Box
コピー機(複合機)にはFaxの機能もついているので、Faxはここで受信します。受信したFaxは、PDFにしてメールやFTPで送信できるので、クラウドストレージに送信するように設定します。
弊社ではクラウドストレージとして、[Box](https://www.box.com/ja-jp/home)を使用しているので、[メール](https://support.box.com/hc/ja/articles/360043697534-%E3%83%A1%E3%83%BC%E3%83%AB%E3%82%92%E4%BD%BF%E7%94%A8%E3%81%97%E3%81%9FBox%E3%81%B8%E3%81%AE%E3%82%A2%E3%83%83%E3%83%97%E3%83%AD%E3%83%BC%E3%83%89)でも[FTP](https://support.box.com/hc/ja/articles/360043697414-FTPS%E3%81%AB%E3%82%88%E3%82%8BBox%E3%81%AE%E4%BD%BF%E7%94%A8)でもアップロードする事ができます。

ここまでは、コロナ禍で在宅勤務が主流になった時に仕組みを作ってあったので、今回はそのまま利用しました。

## Box → (Azure Functions) → 社内システム
受信したFaxがBoxにアップロードされたら、それを社内システムに知らせます。
以後、この社内システムが処理を進めていきます。
間にAzure Functionsが挟まっていますが、特別な事はしていません。BoxからはWebhookを飛ばし、Azure FunctionsでWebhookの中から必要な情報だけを取り出したうえで、社内システムのWebAPIをリクエストしています。

BoxのWebhookは、「開発者コンソール」で設定します。
Webhookを飛ばすトリガーとなるイベントはたくさん用意されていますので、「File Uploaded」を選びましょう。ファイルが新規にアップロードされた時や、他のフォルダから移動された時なんかに発動します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/230422/2c3b267a-8c0e-4ec8-9c1b-707188cf883e.png)

※「File Moved」というトリガーもありますが、これはファイルを他のフォルダに移動「した」時に発動します。今回は、他のフォルダから移動「された」時に発動したいので、「File Uploaded」でOKです。

BoxのWebhookについては、公式から多くのドキュメントが公開されていますので、そちらをご覧ください。
[Box APIを実践！使ってみたいけど、なにをすればいいの？ – Box Support](https://support.box.com/hc/ja/articles/23244017627155-Box-API%E3%82%92%E5%AE%9F%E8%B7%B5-%E4%BD%BF%E3%81%A3%E3%81%A6%E3%81%BF%E3%81%9F%E3%81%84%E3%81%91%E3%81%A9-%E3%81%AA%E3%81%AB%E3%82%92%E3%81%99%E3%82%8C%E3%81%B0%E3%81%84%E3%81%84%E3%81%AE)
[APIリファレンス - Box Developerドキュメント](https://ja.developer.box.com/reference/)

## OCR(Azure Computer Vision)
画像のOCRには、[Azure AI Vision](https://azure.microsoft.com/ja-jp/products/ai-services/ai-vision)を使います。
Azure AI Visionは、様々な画像分析の機能が提供されており、OCRもその1つです。使用方法の詳細は、下記などのドキュメントを参照してください。
[Azure AI Vision v3.2 GA Read API を呼び出す - Azure AI services | Microsoft Learn](https://learn.microsoft.com/ja-jp/azure/ai-services/computer-vision/how-to/call-read-api)

Boxに保存されているファイルを、直接Azure AI Visionに読み込ませる事はできないので、まずはBoxからファイルをダウンロードします。
ダウンロード処理は、[Box Java SDK](https://github.com/box/box-java-sdk)を使うのが手軽でした。
Box Java SDKについては、下記の記事で解説していますので、よかったら参考にしてみてください。
[Box Java SDKで、Box APIを試してみる #BOX - Qiita](https://qiita.com/suzuq/items/ab1df48a598fe42c77ca)

```Java
byte[] targetFileByte;
try(ByteArrayOutputStream baos = new ByteArrayOutputStream();)
{
    BoxFile file = new BoxFile(api, fileId);
    file.download(baos);
    targetFileByte = baos.toByteArray();
}
```

ファイルのデータがバイト配列形式で取得できたので、Azure AI VisionのWeb APIを使って解析させます。

```Java
Response analyzeResponse = ClientBuilder.newClient()
        .target(destination))
        .path("vision")
        .path("v3.2")
        .path("read")
        .path("analyze")
        .request()
        .header("Ocp-Apim-Subscription-Key", subscriptionKey))
        .header("Content-Type", "application/octet-stream")
        .post(Entity.entity(targetFileByte, MediaType.APPLICATION_OCTET_STREAM))
        ;
```

上記リクエストは、画像の解析をキックするだけのもので、正常終了(Status202)のレスポンスが返ってきても、解析の依頼を受け付けたというだけにすぎません。
では、解析結果をどうやって確認するかというと、結果取得用のAPIに再度アクセスする必要があります。そのURIは、返ってきたレスポンスの`Operation-Location`というヘッダに格納されています。

解析結果は即座に出るわけではなく、時間がかかる事もあります。結果取得用APIにアクセスしても、まだ解析中という事がありますので、その場合は少し時間を置いて再度アクセスしましょう。
解析が終了しているかどうかは、レスポンスのBody部分(Json形式)の`status`という項目で判定できます。`succeeded`という値になっていれば、解析が完了しています。`readResults`オブジェクト内に、解析結果が示されますので、これを利用します。

```Java
boolean poll = true;
while(poll)
{
    Response resultResponse = ClientBuilder.newClient()
            .target(analyzeResponse.getHeaderString("Operation-Location"))
            .request()
            .header("Ocp-Apim-Subscription-Key", subscriptionKey)
            .get()
            ;
    //中略
    if(StringUtils.equalsAny(analyzeResult.status, "failed", "succeeded"))
    {
        //成功または失敗 -> ループを抜ける
        break;
    }
    else
    {
        //処理中(notStarted or running) -> 少し待ってもう一度取得する
        Log.info("analyze result retry : " + analyzeResult.status);
        TimeUnit.SECONDS.sleep(10);
    }
}
```

※レスポンスBodyのにサンプル
https://learn.microsoft.com/ja-jp/azure/ai-services/computer-vision/how-to/call-read-api#sample-json-output

## 照会内容の分析(Azure OpenAI Service)
OCRができたら、ここから対象の商品や数量を抽出するわけですが、自前でやるととんでもなく大変なので、AIに丸投げします。
Azure OpenAI Serviceにデプロイしたgpt-4oでやってみます。
ここはプログラムのサンプルよりプロンプトが重要なので、プロンプトのサンプルを示します。

```Json
{
  "model": "gpt-4o",
  "messages": [{"role": "user", "content": "これからComputer vision APIでOCRした結果のJSONを提示します。顧客が発注予定の商品について、必要な分の在庫があるかどうかの問い合わせになります。その中から、「商品コード」・「商品名」・「数量」を抜き出して、JSON形式にしてください。数量は、「予定数量」・「必要数」・「数」などど表記されている場合もありますし、記載されていない事もあります。「本体価格」や「NO」等にも数字が入りますので、数量と間違えないようにしてください。JSONの要素名は商品コード=productCode, 商品名=title, 数量=quantityとし、左記要素を1オブジェクトとした配列としてください。記載されていない要素はnullとして良いので、オブジェクトは配列に含めてください。回答にはJSONのみ含めてください。\\n========\\n"＜ここにOCR結果を追加する＞}]
}
```

上記プロンプトは一例に過ぎません。
送られてくるFaxの特徴や、欲しいOutputの形式等、可能な限り細かく教えてあげると精度が上がります。
最後(`＜ここにOCR結果を追加する＞`の部分)に、OCR結果のJSONを追加してリクエストします。

ここで注意したいのが、トークン数です。Azure Computer Visionのレスポンス内容をそのままリクエストに加えると、かなりのトークン数を使ってしまいます。gpt-4oであれば、最大トークン数が16384なので、この上限は簡単に超えてしまいます。必要な要素のみ取り出して、リクエスト加えるのが良いでしょう。
絶対に必要な要素は、OCRした結果の文字列が入っている`text`要素です。読み取ったテキストの位置を示す`boundingBox`も加えると精度が上がりました。
(今回対象にしたFaxは、商品名や数量をテーブル形式で表記しているものが多かったため、テキストの位置を教えてあげる事で、精度が上がったのだと思われます。)

うまく解析できれば、以下のようなJsonを返してくれます。これを元に、社内DBを検索して在庫数を取得します。

```Json
[
    {
        "productCode": 111111,
        "title": "商品1",
        "quantity": 2
    },
    {
        "productCode": null,
        "title": "商品2",
        "quantity": 1
    }
]
```

※必要に応じて、クォータ(Rate Limit)の引き上げも検討してください。初期値は割と低めに設定されている印象です。

## 商品コードの検索(Pinecone)
ここまでで、対象商品のリストが取得できましたが、社内DBから確実に在庫数を検索するためには、商品コードを使いたいところです。商品名だと、表記ゆれが多くデータベースの検索には向きません。
商品コードまでしっかり記載してくれる取引先は少なく、商品名のみ指定されている場合が多いため、そんな場合でも商品コードを取得できるようにしておきたいところです。

この課題を解決するため、ベクトルデータベースのPineconeを使う事にしました。
Pineconeはフルマネージドなベクトルデータベースで、アカウントさえ開設すれば、すぐに使い始める事ができます。

ここでは、Pinecone自体の設定方法やデータの格納方法は省略します。既に商品名をベクトル化したデータベースがある前提で話を進めます。

まずは、Pinconeに検索クエリを投げるために、商品名をベクトル化します。Azure OpenAI Serviceにベクトル化のAPIがありますので、これを使用します。今回は、`text-embedding-3-large`モデルを使いました。

```Java
Response embeddingsResponse = ClientBuilder.newClient()
        .target(destination)
        .path("text-embedding-3-large")
        .path("embeddings")
        .queryParam("api-version", "2023-05-15")
        .request()
        .header("api-key", "apiKey")
        .header("Content-Type", "application/json")
        .post(Entity.entity(entity, MediaType.APPLICATION_JSON))
        ;
```

リクエストボディである`entity`には、↓のようなシンプルなJsonを設定します。

```Json
{
    "input": "商品名",
    "model": "text-embedding-3-large"
}
```

結果として、floatの配列が得られますので、これをPineconeの検索APIに投げます。

```Java
Response pineconeQueryResponse = ClientBuilder.newClient()
        .target(destination)
        .path("query")
        .request()
        .header("Api-Key", apiKey))
        .header("accept", "application/json")
        .header("Content-Type", "application/json")
        .post(Entity.entity(entity, MediaType.APPLICATION_JSON))
        ;
```

結果として、下記のようなJsonが得られます。
`score`が高いほど類似度が高いので、ある程度高いスコアのうち、一番高いものを使う事にしましょう。

```Json
{
  "results": [],
  "matches": [
    {
      "id": "1111111",
      "score": 1.00036156,
      "values": [],
    },
    {
      "id": "2222222",
      "score": 0.876674533,
      "values": [],
    },
    {
      "id": "3333333",
      "score": 0.831323504,
      "values": [],
    }
  ],
  "namespace": "",
  "usage": {
    "readUnits": 6
  }
}
```

## 照会結果の更新
こうして得られた商品コードの一覧を使って、社内DBから在庫数を取得できたら、いよいよ照会結果を出力します。
照会結果は、もともとのFax(PDF)にページ追加して出力する事にしました。
PDFの編集には、iTextライブラリを使いました。

```Java
//元ファイル更新用
Document doc = new Document(PageSize.A4.rotate());
ByteArrayOutputStream outStream = new ByteArrayOutputStream();
PdfWriter newFilewriter = PdfWriter.getInstance(doc, outStream);

//Box Java SDKでダウンロードした、元ファイルのByteArrayを元にPdfReaderを作成
PdfReader originalFileReader = new PdfReader(targetFileByte);

doc.open();
doc.addLanguage("ja-jp");
//元ファイルにあったページは全てコピー
for(int p = 1;p <= originalFileReader.getNumberOfPages();p++)
{
    doc.newPage();
    //元ファイルのページの向きを再現する
    int originalRotation = originalFileReader.getPageRotation(p);
    PdfDictionary dict = newFilewriter.getPageDictEntries();
    dict.put(PdfName.ROTATE, new PdfNumber(originalRotation));
    PdfImportedPage page = newFilewriter.getImportedPage(originalFileReader, p);
    PdfContentByte cb = newFilewriter.getDirectContent();
    cb.addTemplate(page, 0, -1f, 1f, 0, 0, originalFileReader.getPageSizeWithRotation(p).getWidth());
}

doc.newPage();

// 在庫照会結果を書き込み
// 【中略】
// ※省略していますが、open済の各種リソースはcloseしましょう

try(ByteArrayInputStream boxFileInput = new ByteArrayInputStream(outStream.toByteArray());)
{
    BoxFile file = new BoxFile(api, targetFileId);
    file.uploadNewVersion(boxFileInput);
}
```

ここでの注意点は、元ファイルを更新したタイミングで、BoxのWebhookが反応してしまう事です。
ファイルの更新は、Boxの仕様上「新バージョンのアップロード」という解釈になるので、「File Uploaded」トリガーが発動してしまうのですね。
これを避けるため、別ファイルにアップロードしても良いですが、今回はWebhookを受けるAzure Functionsにて「APIユーザの更新によるWebhookは無視する」事にして凌ぎました。

# まとめ
今回は在庫照会の返信を自動化しましたが、ここから注文データを作成する等様々なケースに応用できそうです。
また、Faxに限らず、社内に眠るあらゆる非構造化データを使って何かできそうなポテンシャルも感じています。引き続き、新たな活用方法も模索していきたいと思います。