ServiceNowとChatGPTをREST APIで繋げてみる

Vancouverリリースから追加されたNow Assistのいくつかの機能[^1]に触れる機会があったのですが、Now Assist利用で発生する追加コスト[^2]の負担がちょっと厳しいなと思いました。
そこでOpenAIやAzure OpenAIサービスのどちらもREST APIに対応していますし自分で作ったほうがいいかなと思い、ServiceNow→ChatGPTへの処理を行う仕組みを作ってみました。

また、将来的なメンテナンス性や拡張性を考慮してChatGPTへのプロンプトの内容をスクリプト内にハードコートはせず、ノーコードで追加・更新・削除できるような形での作成を行っていきます。

# 全体図
全体図としては以下のコンポーネントで構成されたものになります。

1\. 拡張テーブル：REST API接続の各種パラメーターを格納・管理する
2\. Script Include：REST API接続を行う
3\. Service Catalog（Catalog item）：エンドユーザーが利用する

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3748053/c412cc54-3b67-dba2-a2e1-6eaea524ac67.png)


# ChatGPTの準備
まずはともあれChatGPT側の環境が準備されていないことには何もできないのでREST API接続できる環境を準備します。
（多分他の記事でたくさん書かれているので）詳細は省きますが、個人でまず試してみたいという方は準備のハードルが低い[^3]OpenAIのサービスを利用されるのが良いかと思います。
※この記事ではAzure OpenAIサービスを使用しています。

# ServiceNowの実装
全体図に示した通り各コンポーネント作成していきます。
なお、使用したServiceNowの環境はVancouverのPDIです。

## 1.REST API用プロファイル格納用テーブルの作成
REST API接続に使用するパラメーターの内、必須のものと、まあまあ使いそうなものをプロファイルとして保存する拡張テーブルを作成します。

[Mainセクション]
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3748053/73ed0515-f40f-d94d-e077-7c6c426c08f3.png)

[Controlsセクション]
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3748053/cbddb8ff-bb50-8f0c-f834-b7f58ed4669c.png)

[Application Accessセクション]
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3748053/226a034a-7372-4fda-5544-4e3d2e3bb02e.png)

作成した拡張テーブルに情報を管理するための以下フィールドを作成していきます。
※表以外の設定値はスクリーンショットを参照してください。


|Column label|Type|Max length|
|:-:|:-:|:-:|
|Name|String|40|
|Endpoint|URL|1024|
|API Key|Password (2 Way Encrypted)|255|
|Model|String|40|
|Max tokens|Integer|5|
|Temperature|Floating Point Number|40|
|TOP_P|Floating Point Number|40|
|Presence penalty|Floating Point Number|40|
|Frequency penalty|Floating Point Number|40|
|System text|String|1000|

[Dictionary一覧]
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3748053/840f3001-15f2-3f5f-460d-6b18395eb60c.png)

フォームレイアウトは自動で追加されたレイアウトから少しだけ修正しました。

[拡張テーブルのフォーム]
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3748053/7ce25c8f-60f7-593a-844b-96d79525ea21.png)


## 2.Script Includeの作成と動作確認
「GenAIconnector」という名前で以下のコードを記載したScript Includeを作成します。

```javascript:GenAIconnector
var GenAIconnector = Class.create();
GenAIconnector.prototype = {

    AUTO_INIT_PARAMETER: true,

    initialize: function(profileID) {
        if (!gs.nil(profileID))
            this.setProfile(profileID);
    },

    setProfile: function(profileID) {
        if (typeof(profileID) != 'string')
            return false;

        profileID = profileID.trim();
        if (gs.nil(profileID))
            return false;

        var gr = new GlideRecord('u_gen_ai_connect_profile');
        gr.addQuery('u_name', profileID).addOrCondition('sys_id', profileID);
        gr.query();

        if (gr.next()) {
            this.PROFILE_NAME = gr.getValue('u_name');
            this.PROFILE_ID = gr.getValue('sys_id');
            this.SERVICE = gr.getValue('u_service');

            return true;
        } else {
            return false;
        }
    },

    autoInitParameter: function(bool) {
        if (typeof(bool) == 'boolean')
            this.AUTO_INIT_PARAMETER = bool;

        return this.AUTO_INIT_PARAMETER;
    },

    initParameter: function() {
        var gr = new GlideRecord('u_gen_ai_connect_profile');
        if (gr.get(this.PROFILE_ID)) {
            // init common parameter
            this.MESSAGES = [];
            this.RESPONSE = null;
            this.HTTP_STATUS = 0;

            // init REST API paramter
            this.ENDPOINT = gr.getValue('u_endpoint');
            this.API_KEY = gr.u_api_key.getDecryptedValue();
            this.MODEL = gr.u_model.nil() ? null : gr.getValue('u_model');
            this.MAX_TOKENS = gr.u_max_tokens.nil() ? null : Number(gr.getValue('u_max_tokens'));
            this.TEMPERATURE = gr.u_temperature.nil() ? null : Number(gr.getValue('u_temperature'));
            this.TOP_P = gr.u_top_p.nil() ? null : Number(gr.getValue('u_top_p'));
            this.PRESENCE_PENALTY = gr.u_presence_penalty.nil() ? null : Number(gr.getValue('u_presence_penalty'));
            this.FREQUENCY_PENALTY = gr.u_frequency_penalty.nil() ? null : Number(gr.getValue('u_frequency_penalty'));

            if (!gr.u_system_text.nil())
                this.setSystemMessage(gr.getValue('u_system_text'));

            return true;
        } else {
            return false;
        }
    },

    chat: function(prompt, systemText) {
        if (typeof(prompt) != 'string')
            return;

        prompt = prompt.trim();
        if (gs.nil(prompt))
            return;

        if (this.autoInitParameter()) {
            if (gs.nil(this.PROFILE_ID))
                return;

            if (!this.initParameter())
                return;
        }

        this.setSystemMessage(systemText.trim(), true);
        this.setUserMessage(prompt);

        var request = new sn_ws.RESTMessageV2();
        request.setEndpoint(this.ENDPOINT);
        request.setHttpMethod('POST');
        if (this.SERVICE == 'azure')
            request.setRequestHeader('api-key', this.API_KEY);
        else
            request.setRequestHeader('Authorization', 'Bearer ' + this.API_KEY);
        request.setRequestHeader('Content-type', 'application/json');

        var body = {};
        body.messages = this.MESSAGES;
        body.model = (this.SERVICE == 'openai') ? this.MODEL : null;
        body.max_tokens = this.MAX_TOKENS;
        body.temperature = this.TEMPERATURE;
        body.top_p = this.TOP_P;
        body.frequency_penalty = this.FREQUENCY_PENALTY;
        body.presence_penalty = this.PRESENCE_PENALTY;

        for (var key in body) {
            if (body[key] === null)
                delete body[key];
        }

        request.setRequestBody(JSON.stringify(body));
        var response = request.execute();
        this.RESPONSE = JSON.parse(response.getBody());
        this.HTTP_STATUS = response.getStatusCode();

        return this.RESPONSE.choices[0].message.content.trim();
    },

    setSystemMessage: function(content, override) {
        return this._setMessage('system', content, override);
    },

    setAssistantMessage: function(content, override) {
        return this._setMessage('assistant', content, override);
    },

    setUserMessage: function(content, override) {
        return this._setMessage('user', content, override);
    },

    _setMessage: function(role, content, override) {
        var roleList = ['system', 'assistant', 'user'];

        if (gs.nil(role) || gs.nil(content))
            return false;

        if (roleList.indexOf(role) < 0)
            return false;

        if (override && typeof(override) == 'boolean') {
            for (var i = 0; i < this.MESSAGES.length;) {
                if (this.MESSAGES[i].role == role)
                    this.MESSAGES.splice(i, 1);
                else
                    i++;
            }
        }

        this.MESSAGES.push({
            'role': role,
            'content': content
        });

        return true;
    },

    type: 'GenAIconnector'
};
```


Script Include作成後はスクリプト単体での動作確認のため、以下の最小のコード量で動作確認をします。

```javascript:最小実行コード
new GenAIconnector("プロファイル名 or sys_id").chat("ユーザープロンプト");
```

[実行結果]
![Screenshot 2024-03-19 22.12.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3748053/a01e15a5-e721-1511-1942-a36153166a82.png)

うまく動作したようです。Google検索結果から回答内容自体も正しいようです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3748053/d255242e-25c5-96f6-9ac8-52589643d618.png)


## 3.Service Catalog（Catalog item）の実装と動作確認
これまで作成したコンポーネントを組み込んだCatalog itemを作成します。

[Catalog item]
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3748053/119188c6-6aba-f9a7-bec6-53e78caa7baa.png)

Variableは以下の3つを作成します。

1\. プロファイル選択用
2\. 選択したプロファイルの説明表示用
3\. ユーザー文章入力用

[Variables]
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3748053/4bf30573-d2d1-7885-a031-5e077f169f2f.png)

Catalog itemで使用するFlowを作成します。
ChatGPTへ処理を投げる部分は再利用やノーコードでの活用を考慮して新たなActionとして作成しても良かったのですが、 ~~めんどくさい~~ ライセンスの観点[^4]からあえてFlowの中で直接コードを書いています。

[Flow designer]
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3748053/0d164638-6147-b656-56ce-86a3f954039d.png)

動作確認のため、作成したCatalog itemをポータルから申請してみます。

[Catalog item申請画面]
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3748053/4bc3b89e-3077-9c84-86af-596a72751c64.png)

生成されたJavaScriptコードは若干想像していたものと違いましたが内容としては問題なさそうです。

[Request item処理結果]
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3748053/5825626b-0897-faac-3af2-3099d1c72cda.png)

[コンソールでの実行結果]
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3748053/1d23cb7e-af68-a998-40aa-4e8e4f4c7722.png)

これで用途に応じたプロファイルを作成すればServiceNow上でノーコードに様々な処理を実現できると思います。

# 作ったもの
需要なさそうですがせっかく作ったのでUpdate setのXMLファイルをGitHubに置いておきます。

https://github.com/you0321/Gen-AI-connector



[^1]:インシデントの要約、解決メモの生成、コード生成、Flow自動生成など
[^2]:ITSMの場合、Pro以上かつ追加SKUで1Fulfillerあたりざっくり2倍ぐらい
[^3]:Azure OpenAIのような申請も不要で3ヶ月間有効な無料クレジットあり
[^4]:Action内のScript stepで実行される外部へのREST API接続はIntegration Hubのトランザクションとして計上されるっぽい