2
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?

Azure OpenAI Service の GPT-3.5 を使って、 Dynamics 365 データに関して回答してくれるアプリを作ってみた (詳細編)

Last updated at Posted at 2023-11-30

はじめに

こんにちは。仕事で Dynamics 365 を担当している keijiinoue と申します。
Azure OpenAI Service の GPT-3.5 を使って、 Dynamics 365 データに関して回答してくれるアプリを作ってみました。この記事では、私がトライしたかったことと、そのために採用した技術や実装について紹介します。

完成したものを実行したイメージ動画:
AOAIGPTD365SidePaneHeroLoop.gif

完全なソースコードは載せていません。実装した方法の説明用として一部のソースコードのみを載せています。
なお、使用したライブラリはすべて MIT ライセンスのものです。

この記事で紹介しているものは、私が個人的に開発したものであり、以下の「トライしたかったこと」 のためだけに開発したものです。 Dynamics 365 において Azure OpenAI Service を利用するアプリを開発するところのベスト プラクティスを示すものではありません。

Dynamics 365 において 生成 AI 関連機能の恩恵を受けたい場合には、通常は Dynamics 365 の Copilot 機能群を利用いただくことになると思います。この記事は、私がコードを伴う開発をしたかったために、 Copilot 機能群を利用せずに開発したものです。

トライしたかったこと

  • Dynamics 365 上で普通にユーザーが操作したら何回か画面遷移しなければいけないようなデータについても、 GPT に 1 回質問したらサクッと回答してもらいたい
  • チャットのやり取りと、 Dynamics 365 フォームとが、必要に応じてインタラクティブに挙動して欲しい
  • なるべく Dynamics 365 におけるライトな開発手法を利用して、クラウド上の他のリソースを利用せずに、ブラウザ上で動作するようにしたい

実装を終えた感想

採用した技術や実装方法は後述しますが、先に実装を終えた感想を述べます。

  • GPT の Function calling (関数呼び出し) が素晴らしい!GPT の呼び出し回数が減り、コード量も減った!
  • チャットと連動する Dynamics 365 フォームとのインタラクティブな挙動は、見ていて楽しい!実装してみて良かった!
  • もし、今回のアプリを、実際のお客様プロジェクトにて開発したとしたら、 ROI はとっても低いんじゃないだろうか。もっとお客様のベネフィットに直結するようなユースケースを考えて、それを実現するためのアプリが開発されるべきだろう。

採用した技術

  • Dynamics 365 Sales アプリ上の サイド ペイン で動作させる Web リソース、およびそれを操作する Dataverse Client API
  • Dataverse Web API
  • Dataverse ソリューションの環境変数
  • チャンネルメッセージング API
  • Azure OpenAI Service の GPT-3.5 (モデル "gpt-35-turbo-16k")
  • GPT の Function calling (関数呼び出し)

採用した技術を実装した方法について

Dynamics 365 Sales アプリ上の サイド ペイン で動作させる Web リソース、および それを操作する Dataverse Client API

今回開発したものは大きく以下の2つです。どちらも Web リソースとして、 Dataverse のソリューションに含められるものです。

  • フォーム スクリプト

    ある (モデル駆動型アプリの) フォーム上の OnLoad イベントで動作し、次のサイド ペイン Web リソースを表示したり、また、サイド ペイン Web リソースからのイベントを受け取ったりします。
    単一の .js ファイルです。
    ある日同僚が、モデル駆動型アプリのサイド ペインを API で操作できることを言っているのを聞いて、調べてたところ、Dataverse Clinet API に記載がありました。

    以下のような function を OnLoad イベントで呼び出すことで、サイド ペイン Web リソースを表示します。

    // サイド ペインでレンダーする HTML Web リソース
    const SidePaneHTMLWebResource = 'keiji_/OpenAIGPTAssistedSidePane/openai-assisted-side-pane/index.html';
    
    // サイド ペイン用のイメージソース Web リソース
    const SidePaneImageWebResource = 'WebResources/keiji_/OpenAIGPTAssistedSidePane/Images/sidePane.svg';
    
    // Web リソースを指定して、サイド ペインを作成し、表示する。
    function createPane(_width = 700) {
        Xrm.App.sidePanes.createPane({
            title: "Azure OpenAI GPT Assisted Side Pane",
            imageSrc: SidePaneImageWebResource,
            paneId: PaneId,
            canClose: true,
            hideHeader: true,
            winth: _width, // ここで width の指定をしても、機能しない。固定の幅でのみ描画される。
        }).then((_pane) => {
            _pane.width = _width; // ここで指定したら、効果があった!
            currentWidthSetting = _width;
            _pane.navigate({
                pageType: "webresource",
                webresourceName: SidePaneHTMLWebResource,
                data: JSON.stringify({ width: currentWidthSetting }),
            });
            currentPane = _pane;
        });
    }
    

    このフォーム スクリプトは、サイド ペイン Web リソースと後述のチャネルメッセージング API を使って双方向で通信します。
    これを Web リソースとして、 Dynamics 365 Sales の環境内の今回用のソリューションにインポートします。

    さて、「トライしたかったこと」に以下がありました。

    チャットのやり取りと、 Dynamics 365 フォームとが、必要に応じてインタラクティブに挙動して欲しい

    本記事上部のイメージ動画でのチャットの 2 つ目 と 3 つ目では、 チャットの内容に応じて、フォーム上の表示タブや、タブ内のサブグリッドで表示するビューを動的に変更 (選択) しています。それらはこのフォーム スクリプトで実装しています。利用している API は以下のようなものです。


  • サイド ペイン Web リソース

    このサイド ペイン Web リソースは、 Dynamics 365 標準機能のサイド ペインと同じような感じで機能を利用できるのがよいです。
    今回、 Fluent UI React を使って開発しました。ビルド後に生成される重要なファイルは基本的に以下の 3 つです。

    • index.html (これが、上記 フォーム スクリプトから呼び出されます。)
    • main.js
    • main.css

    開発言語は TypeScript です。そのコードにおいて、後述の Dataverse Web API や Azure OpenAI Service の GPT-3.5 を呼び出しています。
    フォーム スクリプトとは、後述のチャネルメッセージング API を使って双方向で通信します。
    それらを Web リソースとして、 Dynamics 365 Sales の環境内の今回用のソリューションにインポートします。

    なお、このサイド ペイン内のチャットの UI として、以下の npm パッケージを利用しました。

    後から気付いたのですが、チャットの UI として、 Microsoft が提供している以下のサンプルを採用すればよかった、と今では思います。

Dataverse Web API

今回はチャット領域でユーザーが質問したことについて意図を理解し、 Dynamics 365 に格納されているデータに基いて回答すべきものの場合には、適切な回答を返すために、 Dataverse Web API を利用しました。以下のドキュメントを参照しました。

Web API をコールする実体は、以下のような Promise を返す function です。

private request = (apiUri: string, action: string, uri: string, data?: string, addHeader?: any): Promise<any> => {
  // ...(略)... //
  return new Promise(function (resolve, reject) {
    var request = new XMLHttpRequest();
    request.open(action, encodeURI(uri), true);
    request.setRequestHeader("OData-MaxVersion", "4.0");
    request.setRequestHeader("OData-Version", "4.0");
    request.setRequestHeader("Accept", "application/json");
    request.setRequestHeader("Content-Type", "application/json");
    request.setRequestHeader("If-None-Match", "null");
    request.setRequestHeader("Prefer", `odata.include-annotations="OData.Community.Display.V1.FormattedValue"`);
    if (addHeader) {
        request.setRequestHeader(addHeader.header, addHeader.value);
    }
    request.onreadystatechange = function () {
      if (this.readyState === 4) {
        request.onreadystatechange = null;
        switch (this.status) {
          case 200: // Operation success with content returned in response body.
          case 201: // Create success.
          case 204: // Operation success with no content returned in response body.
            resolve(JSON.parse(this.response));
            break;
          default: // All other statuses are unexpected so are treated like errors.
            var error;
            try {
              error = JSON.parse(request.response).error;
            } catch (e) {
              error = new Error("Unexpected Error");
            }
            reject(error);
            break;
        }
      }
    };
    request.send(data);
  });
}

上記で、 uri の例としましては、以下のようなものです。これは、ある取引先企業 (id が 'd46c1f35-e900-ed11-82e4-002248029768') の営業案件レコード群とその営業案件にぶら下がる提案製品レコード群をまとめて取得する例です。

https://xxx.crmx.dynamics.com/api/data/v9.2/opportunities?$select=name,estimatedvalue,estimatedclosedate,description,_owninguser_value&$filter=parentaccountid/accountid eq d46c1f35-e900-ed11-82e4-002248029768&$expand=customerid_account($select=name),owninguser($select=fullname),product_opportunities($select=productname,quantity,extendedamount)

なお、本記事上部のイメージ動画でのチャットの 1 つ目では、 Dataverse 検索 をしています。それにより、営業案件やサポート案件といったテーブル (エンティティ) を跨る検索が可能になり、そのコードも開発していて、 Web API の呼び出し方が上記 uri と異なるのですが、本記事ではその掲載を省略します。

さて、「トライしたかったこと」に以下がありました。

なるべく Dynamics 365 におけるライトな開発手法を利用して、クラウド上の他のリソースを利用せずに、ブラウザ上で動作するようにしたい

今回は Web リソースとして動作するスクリプトで、 Dataverse Web API を呼び出していますので、認証関連の実装が不要で済みました。ライトですね。
なお、この方法を採用すると、確実にそのユーザーが Dynamics 365 上で取得してよい、アクセスすることが許可されているレコード群だけが取得されることになります。

Dataverse ソリューションの環境変数

前述の Web リソース (TypeScript コード) から、後述の Azure OpenAI Service の GPT-3.5 を呼び出すのですが、そのためには、 Azure OpenAI Service の API キーAPI エンドポイント といった機密情報が必要です。それらをコード内には記述せず、 Dataverse ソリューションの環境変数に格納しました。

フォーム スクリプト (JavaScript コード) にて、その環境変数の値を取得する例として、以下の function を記載しておきます。name は環境変数の名前です。

async function getEnvVarPromise(name) {
    const result = await Xrm.WebApi.retrieveMultipleRecords('environmentvariabledefinition', `?$filter=schemaname eq '${name}'&$select=environmentvariabledefinitionid&$expand=environmentvariabledefinition_environmentvariablevalue($select=value)`);
    return new Promise((resolve, reject) => {
        try {
            const varValue = result.entities[0].environmentvariabledefinition_environmentvariablevalue[0].value;
            resolve(varValue);
        } catch (e) {
            reject(e);
        }
    });
}

後述のチャンネルメッセージング API を使って、サイド ペイン Web リソースに環境変数の値を渡しています。

チャンネルメッセージング API

前述の フォーム スクリプト および サイド ペイン Web リソース は、ブラウザ内では異なる IFrame として動作します。
それらが通信する方法として、今回は チャンネルメッセージング API を採用しました。

Azure OpenAI Service の GPT-3.5 (モデル "gpt-35-turbo-16k")

私がこのアプリを開発した時には (そしてひょっとしたら今もなお) ブラウザ上の JavaScript 用の Azure OpenAI Service の SDK は存在しませんでした。
そこで、今回以下の REST API をコールする実装としました。

そして、私は GPT からの返答を stream 形式で受け取りたかったので、ネットを検索したところ、以下の npm パッケージ @microsoft/fetch-event-source が利用できそうだと見出し、利用しています。

一部だけ特徴的なコードを抜き出すと以下のようなものです。後述の Function calling (関数呼び出し) に関するコードも含まれています。

await fetchEventSource(this.endpointURL, {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        'api-key': this.apiKeyOpenAI,
    },
    body: JSON.stringify((request.functions) ?
        {
            "messages": request.messages,
            "functions": request.functions,
            "function_call": "auto",
            "max_tokens": 4000,
            "temperature": _temperature,
            "frequency_penalty": 0,
            "presence_penalty": 0,
            "top_p": 0.95,
            "stream": true,
            "stop": null,
        } :
        {
            "messages": request.messages,
            "max_tokens": 4000,
            "temperature": _temperature,
            "frequency_penalty": 0,
            "presence_penalty": 0,
            "top_p": 0.95,
            "stream": true,
            "stop": null,
        }
    ),
    signal: ctrl.signal,
    async onopen(response) {
        if (response.ok && response.headers.get('content-type') === EventStreamContentType) {
            return; // everything's good
        } else if (response.status >= 400 && response.status < 500 && response.status !== 429) {
            // client-side errors are usually non-retriable:
            console.log('FatalError response', response);
            throw new FatalError();
        } else {
            console.log('RetriableError response', response);
            throw new RetriableError();
        }
    },
    onmessage(msg) {
        // if the server emits an error message, throw an exception
        // so it gets handled by the onerror callback below:
        if (msg.event === 'FatalError') {
            console.log('FatalError msg', msg);
            throw new FatalError(msg.data);
        }
        const msgData = JSON.parse(msg.data);
        if (msgData.object === 'chat.completion.chunk') {
            if (msgData.choices[0].finish_reason !== null) { // like 'stop'
                ctrl.abort();
                done();
                return;
            }
            const functionCall = msgData.choices[0].delta.function_call;
            if (functionCall) {
                if (functionCall.name) functionName = functionCall.name;
                if (functionCall.arguments) functionArguments += functionCall.arguments;
            }
            const chunkText = msgData.choices[0].delta.content;
            if (chunkText) {
                gotChunkText(chunkText);
            }
        }
    },
    onclose() {
        done();
    },
    onerror(err) {
        console.log('err', err);
        if (err instanceof FatalError) {
            // throw err; // rethrow to stop the operation
            exceeededMaxTokens();
        } else {
            // do nothing to automatically retry. You can also
            // return a specific retry interval here.
        }
    }
});

なお、今回は私は Azure OpenAI Service のモデル "gpt-35-turbo-16k" を採用しています。
以前は "gpt-35-turbo" を使っていたのですが、 Function calling (関数呼び出し) を利用するとプロンプトのトークンがオーバーしてしまうことがあったため、 "gpt-35-turbo-16k" にしました。

さて、「トライしたかったこと」に以下がありました。

Dynamics 365 上で普通にユーザーが操作したら何回か画面遷移しなければいけないようなデータについても、 GPT に 1 回質問したらサクッと回答してもらいたい

これを実装するために、前述の Dataverse Web API を呼び出して取得した JSON テキストを利用しています。JSON から余計な要素を取り除いてすっきりさせた文字列を GPT に渡すプロンプトに含めています。

例えば "このお客様企業で AR025 や TX083 に関してどんなデータがありますか?" というユーザーの質問に対して、以下のようなプロンプトを渡しています。system ロールのメッセージ内に JSON テキストを含めています。

{
  "messages": [
    {
      "role": "system",
      "content": "You are an AI assistant that helps people find information.\nAs a result of searching the database, you are getting several types of data related to a company, such as:\n[{\"@search.entityname\":\"incident\",\"@search.objectid\":\"12cf1749-8672-4942-99ea-8a06958f9a1c\",\"owneridname\":\"Hirayama Rei\",\"title\":\"AR025 異音がする\",\"statecode\":[\"アクティブ\"],\"createdon\":\"2023/05/08 15:17\",\"modifiedon\":\"2023/11/12 14:25\",\"prioritycode\":[\"\"],\"caseorigincode\":[\"電話\"],\"ticketnumber\":\"CAS-01046-X7N5D8\",\"customeridname\":\"港コンピュータ株式会社\"},{\"@search.entityname\":\"opportunity\",\"@search.objectid\":\"38ffcfab-bae8-ed11-a7c6-000d3a341b5a\",\"owneridname\":\"Hirayama Rei\",\"name\":\"港コンピュータ様 AR025を10台\",\"statecode\":[\"オープン\"],\"createdon\":\"2023/05/02 16:27\",\"modifiedon\":\"2023/05/10 15:31\",\"estimatedvalue\":35026500,\"customeridname\":\"港コンピュータ株式会社\",\"estimatedclosedate\":\"2023/06/28\"},{\"@search.entityname\":\"opportunity\",\"@search.objectid\":\"3db23897-582b-45d7-93e9-1ae95175abd5\",\"owneridname\":\"Inoue Keiji\",\"name\":\"海外部門 TX083 港コンピュータ様\",\"statecode\":[\"オープン\"],\"createdon\":\"2023/05/08 14:06\",\"modifiedon\":\"2023/05/10 15:32\",\"estimatedvalue\":10000000,\"customeridname\":\"港コンピュータ株式会社\",\"estimatedclosedate\":\"2023/08/31\"},{\"@search.entityname\":\"quote\",\"@search.objectid\":\"c512103b-00ee-ed11-8849-000d3a341e8f\",\"owneridname\":\"Inoue Keiji\",\"name\":\"AR025を10台 港コンピュータ様\",\"statecode\":[\"下書き\"],\"createdon\":\"2023/05/09 9:27\",\"modifiedon\":\"2023/05/09 9:28\",\"customeridname\":\"港コンピュータ株式会社\",\"totalamount\":5399900}]\n\nWhen displaying data, use bulleted lists with detailed information, not sentences.\nAlso, when answering, please add a URL link for each data. The format of the URL link is as follows:\n[{{name or title}}](https://japan1.crm.dynamics.com/main.aspx?appid=42d4ddb7-079b-ec11-b400-000d3a35cdc7&pagetype=entityrecord&etn={@search.entityname}&id={@search.objectid})"
    },
    {
      "role": "user",
      "content": "このお客様企業で AR025 や TX083 に関してどんなデータがありますか?"
    }
  ]
}

ここで、通常のユーザー操作であれば、複数のテーブル (エンティティ) に跨る検索ですので、 Dataverse 検索 機能を利用して検索し、特定の取引先企業でフィルターして、という操作が必要です。が、今回は GPT に任せてサクッと回答してもらっています。
これってきっと世間で言われている RAG (Retrieval Augmented Generation) グラウンディング (Grounding) なのだと思います。

GPT の Function calling (関数呼び出し)

最初にこのアプリを開発し始めたときには Function calling 機能は無く、 Few-shot の手法で、ユーザーの質問の意図を判別していました。
その後 Function calling (関数呼び出し) が利用できるようになってから、実装しました。
例えば、ユーザーがある質問をした際に GPT に渡しているプロンプトには、以下のように functions の記述があります。今回私は 5 個の function を定義しました。

{
  "messages": [
    {
      "role": "user",
      "content": "このお客様にはどんなサポート案件がありますか?Surface に関するものだけを抽出してください。"
    }
  ],
  "functions": [
    {
      "name": "provide_info_about_account",
      "description": "Provide information about data or product relating to a particular customer company. The customer company is usually an enterprise corporation, referred to as an 'account' or '企業' or '取引先企業' or 'お客様企業' in this system.",
      "parameters": {
        "type": "object",
        "properties": {
          "account_name": {
            "type": "string"
          },
          "product_name": {
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        },
        "required": [
          "account_name",
          "product_name"
        ]
      }
    },
    {
      "name": "provide_info_about_opportunities_for_account",
      "description": "Provide information about opportunities relating to a particular customer company. Do not provide information about the product. The customer company is usually an enterprise corporation, referred to as an 'account' or '企業' or '取引先企業' in this system. In this system, an 'opportunity' is also referred to as an '案件' or '営業案件'.",
      "parameters": {
        "type": "object",
        "properties": {
          "account_name": {
            "type": "string"
          }
        },
        "required": [
          "account_name"
        ]
      }
    },
    {
      "name": "provide_info_about_opportunities_and_products_for_account",
      "description": "Provide information about opportunities and those products relating to a particular customer company. The customer company is usually an enterprise corporation, referred to as an 'account' or '企業' or '取引先企業' in this system. In this system, an 'opportunity' is also referred to as an '案件' or '営業案件'.",
      "parameters": {
        "type": "object",
        "properties": {
          "account_name": {
            "type": "string"
          },
          "product_name": {
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        },
        "required": [
          "account_name",
          "product_name"
        ]
      }
    },
    {
      "name": "provide_info_about_incidents_for_account",
      "description": "Provide information about every incidents relating to a particular customer company. The customer company is usually an enterprise corporation, referred to as an 'account' or '企業' or '取引先企業' in this system. In this system, an 'incidents' is also referred to as an 'サポート案件' or 'チケット' or 'cases' or 'SR' or 'service requests'.",
      "parameters": {
        "type": "object",
        "properties": {
          "account_name": {
            "type": "string"
          }
        },
        "required": [
          "account_name"
        ]
      }
    },
    {
      "name": "provide_info_about_my_active_incidents_for_account",
      "description": "Provides information limited to my active incidents relating to a particular customer company. The customer company is usually an enterprise corporation, referred to as an 'account' or '企業' or '取引先企業' in this system. In this system, an 'incidents' is also referred to as an 'サポート案件' or 'チケット' or 'cases' or 'SR' or 'service requests'.",
      "parameters": {
        "type": "object",
        "properties": {
          "account_name": {
            "type": "string"
          }
        },
        "required": [
          "account_name"
        ]
      }
    }
  ]
}

その実装により、どのような質問に対してどの function が返されるか、の実例が以下です。

質問 返される function
「このお客様企業で AR025 や TX083 に関してどんなデータがありますか?」 provide_info_about_account
「この企業に関する営業案件の中で、提案製品として TX083 を含むものだけを表示してください。」 provide_info_about_opportunities_and_products_for_account
「このお客様にはどんなサポート案件がありますか?」 provide_info_about_incidents_for_account

返される function 毎に、どのように Dataverse Web API を呼び出すべきかが分岐されるようなコードを実装しました。
Function calling を使わずに、 Few-shot を利用していた時には、どのように Dataverse Web API を呼び出すべきかを判定した後に、検索対象となるレコードのキーワードを抽出するような 2 段階の GPT 呼び出しが必要でした。 Function calling を使うことで、それを 1 段階に減らすことができました。

なお、私が開発したアプリでは、内部的な GPT 呼び出しについても、ビジュアルにわかりやすくするために、チャットのやり取りとして (以下のイメージ動画の赤色吹き出し部分) 表示できるようにしました。これは完全に開発される方に説明するためのものです。

内部的な GPT 呼び出しイメージ動画:
AOAIGPTD365SidePaneInternalRequestLoop.gif

アプリで利用している npm パッケージ群

アプリで利用している npm パッケージのすべてをここに記載したいので、 package.json から抜粋します。

"@chatscope/chat-ui-kit-react": "^1.10.1",
"@chatscope/chat-ui-kit-styles": "^1.4.0",
"@fluentui/react": "^8.107.5",
"@fluentui/react-icons": "^2.0.200",
"@fluentui/react-icons-mdl2": "^1.3.37",
"@fortawesome/free-regular-svg-icons": "^6.4.0",
"@microsoft/fetch-event-source": "^2.0.1",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^12.1.5",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^28.1.8",
"@types/node": "^14.18.42",
"@types/react": "^17.0.58",
"@types/react-dom": "^17.0.19",
"axios": "^1.3.6",
"react": "^17.0.0",
"react-dom": "^17.0.0",
"react-scripts": "5.0.1",
"typescript": "^4.9.5",

最後に

本アプリをお披露目する機会があり、複数の方々に開発手法について興味を示していただきました。前述の「トライしたかったこと」に関心がある方にとって、いくらかでも参考になれば幸いです。
最後までご覧いただきましてありがとうございました。

2
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
2
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?