NetSuiteで5分で計算、文書参照ボット

**はじめに**
「NetSuiteで5分」シリーズ、4本目です。過去の[「5分で作るチッャトボット」](https://qiita.com/fujitak/items/7c8c472ccfba9c253e31)、[「5分で遊ぶ分析ボット」](https://qiita.com/fujitak/items/ff489dcdd59bb85d9d20)、[「5分で受注するショッピングボット」](https://qiita.com/fujitak/items/ad2a4e069b1ddc661ed0)では、それぞれN/llm、N/search、N/record（生成AI、レコード検索、作成）モジュールを活用して、実務的なシナリオをもとにした実験的なアプリご紹介して参りました。今回はちょっと趣向を変え、社内規定や製品情報を参照しながらブラックジョークも交えて回答するボットを作ってみました。

このボットは、冗談で作成した架空の旅費精算規程に潜む、異常に複雑なルールをしっかり解釈して、正確に回答してくれます（かなり）。「ギリギリ」とは、きわどいブラックジョークを表現していますが、実は「ぎりぎりと」ルールを細部まで読み込んでくれる、という意味も含めています。さらに、トークンも無料枠ギリギリ目一杯使えるように、動的にプロンプトの構成を最適化しています。

したがって、参照している文書を実務用のものに入れ替えるだけで、実際の運用に耐えうるソースコードとなっております。また、外部URL（API）から得た情報をもとに回答する機能も、実際に動かして頂けます。ご参考になりましたら幸いです。
<br>

**こんなシーン、ちょっと古いですかね？**
- 12日間でアメリカとヨーロッパを周遊出張。宿泊費はいくらまで認めてもらえるの？
- 東京-大阪日帰り出張でグリーン車に乗っちゃったけど、旅費満額出してもらえるかなぁ？
- 北海道に18日間出張するけど、日当も含めて、いくら前払いしてもらえるかな？

異常に複雑な架空の旅費精算規程をもとに、ギリギリボットがユーモラスに、でもかなり正確に回答してくれます。

また、外部URLを参照する機能では、2025年3月より日本でも利用可能となったサブスクリプション管理の「NetSuite契約更新」という製品について、回答するシナリオを用意致しました。
<br>

**当ボットの特徴**
**1. 内部文書でも外部URLでも対応可能**
ファイルキャビネットに格納された社内文書（今回は「.txt」ファイル）、あるいは外部URLから得た情報を参照して、NetSuiteのN/llmモジュールが適切な回答を生成します。

**2. ジョークはサンプル文書のみ（ソースコードはまじめ）**
当デモでは、「株式会社ネットマニュファクチャリング 出張旅費精算規程」を使用しております。この規程はかなり複雑な計算を要求しますが、N/llmモジュールはしっかりそのルールをもとに、計算と説明を提供してくれます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3431564/95707f64-176a-4522-9748-402a6a5f472a.png)


おふざけで、「海外旅行保険料は、出張国数が偶数の場合は会社負担、奇数の場合は自己負担」と規程してみました。

例1：「3カ国にわたり合計12日間の海外出張をします。アメリカ5日間、ドイツ4日間、フランス日間の滞在予定です。宿泊費と、海外旅行保険について教えてください。」

回答：「3ヵ国の奇数であり、海外旅行保険は自己負担です。」（シュールです）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3431564/3f8340c4-d4ff-4621-b6d5-058728b9933c.png)

例2：「東京から大阪へ日帰り出張しました（行きは新幹線グリーン車 19,590円、帰りは普通車14,720円）。今回支給される交通費と日当の総額を教えてください。」

回答：「行きの交通費の精算率90%、帰りは100%。日当は、基本額 × 職位係数 × 出張タイプ係数 × 滞在期間係数。交通費と日当の合計額は 35,601円」
（グリーン車と普通車の精算率を正しく認識した他、職位から適切な日当の係数を4つ正しくルックアップして日数を掛け、交通費と日当の合計額を求めました。緻密です。）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3431564/4dcd5e9a-90eb-429c-bf41-5d9f6db2d4a2.png)

**3. 製品情報も適確に**
当デモ用の外部URLは「Webhook.site」という無料のサービスで作った、シンプルに製品説明のテキストを返すAPIです。N/httpsモジュールで取得したその製品説明文を、N/llmモジュールへのプロンプトに組み込んでいます。そのデモ製品説明文は、日本で利用可能となったNetSuite契約更新（サブスクリプション管理）についてです。加えて、NetSuite契約更新が対応していない機能である「従量課金」をカバーし、かつNetSuiteとのコネクターが使用できる（有償）3rd Partyソリューションとして、「Stripe Billing」との比較情報も簡単に入れておきました。

例：「従量課金の機能が必要ですが、NetSuite契約更新に加え、Stripe社の製品の情報も比較して教えてください。」
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3431564/94290c7e-35c6-487f-8501-49e0f62db02d.png)
<br>

**技術的な解説**
**a. モジュールの役割**
N/file：NetSuiteファイルキャビネットから、ファイルIDを使用して簡単にその内容を取得
```
const internalFile = file.load({ id: 12345 });
const fileContent = internalFile.getContents();
```
N/https： 外部APIにアクセスしてレスポンスを取得
```
const response = https.post({
  url: "https://api.example.com/data",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ query: "data" })
});
```
N/runtime： 実行中のスクリプト情報を取得し、動的な処理を実現（内部／外部の切り替えや新規会話のスタート）
```
const scriptId = runtime.getCurrentScript().id;
const deploymentId = runtime.getCurrentScript().deploymentId;
```
<br>

**b. 動的なトークン管理**
N/llmの無料トークン枠を意識して、動的にプロンプトに含める会話履歴の長さなどを調整しています。「出張に同行する部長が使える宿泊費枠は？」など、先行する会話に続いた質問にも、規程に準拠しつつ文脈に沿った回答をすることが目的です。この処理を行うコードの例は以下の通りです。
```
const maxTotalTokens = 4096;
const reservedForResponse = 100;
const staticPromptTokens = estimateTokenCount(staticPrompt);
const chatHistoryTokens = estimateTokenCount(chatHistoryPrompt);
const suffixTokens = estimateTokenCount(suffix);

let availableTokensForSource =
  maxTotalTokens - (staticPromptTokens + chatHistoryTokens + suffixTokens + reservedForResponse);
if (availableTokensForSource < 0) {
  availableTokensForSource = 0;
}

const maxCharsForSource = availableTokensForSource * 4;
const trimmedSourceInfo = sourceInfo.substring(0, maxCharsForSource);
```
[※「Chatbot with intenal and external source reference.zip（ソースコードとデモシナリオ）」（© 2025 Takusuke Fujii）は、CC BY 4.0（原作者名の表記が必要）で自由に共有・改変・配布可能ですが、無保証につき著作者は一切責任を負いません。](https://drive.google.com/file/d/1MAFOcPVdJW2NVMyqOqtqyS0JAvk0vI8v/view?usp=sharing)
<br>



**c. 技術的なポイントのまとめ**
• 標準モジュールとSuiteletを活用して、テンポよくプロトタイプを作成
• 内部・外部情報の切り替え可能なインターフェース
• N/llmのトークン無料枠を意識したプロンプト生成の最適化
<br>

**最後に**
NetSuiteの様々な標準APIやモジュールを活用して、安心、安全、かつ実用的な機能を「サクッと」実現することが可能です。これは、NetSuiteが、ガバメントクラウド（政府・自治体向けクラウド）としても採用されている、OCI(Oracle Cloud Infrastructure)上で稼働しているからとも言えます。デジタル署名のような高度なセキュリティが求められる外部システム連携についても、NetSuiteは追加費用なく標準APIを使用して対応可能です。ぜひ、このインフラの活用を楽しみたいものです。
<br>

**参考リンク**
[政府・自治体向け OCI (Oracle Cloud Infrastructure)](https://www.oracle.com/jp/cloud/government/)
[NetSuite ヘルプセンター > N/search Module](https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_4345764122.html#subsect_87180423808)
[JWT認証を用いたNetSuiteとデジタル署名サービスの連携](https://qiita.com/fujitak/items/c5563b62d3ac3b1a58a6)
[Webhook.site（テスト用APIを無料で簡単に作成可能）](https://webhook.site/)
<br>

**セミナー動画と資料**

https://videohub.oracle.com/id/1_wp2qe25h?width=400&height=285&playerId=54927922

[NetSuite生成AI機能の活用例のご紹介.pdf](https://drive.google.com/file/d/1J0rtiGj9PfNU49X2tBCeIYVSJ4At-PDT/view?usp=sharing)

<br>

**ソースコード**
```
/**
 * @NApiVersion 2.1
 * @NScriptType Suitelet
 */
define(
  [
    "N/ui/serverWidget",
    "N/llm",
    "N/log",
    "N/https",
    "N/file",
    "N/runtime"
  ],
  (serverWidget, llm, log, https, file, runtime) => {

    const onRequest = (context) => {
      const form = serverWidget.createForm({
        title: "文書参照ボット： 出張規定（社内用） or 製品情報（社外URL）"
      });
      const fieldGroup = form.addFieldGroup({
        id: "chat_group",
        label: "チャット履歴"
      });
      fieldGroup.isSingleColumn = true;

      // 情報ソース選択用のフィールド追加
      const sourceField = form.addField({
        id: "custpage_source_type",
        type: serverWidget.FieldType.SELECT,
        label: "情報ソース",
        container: "chat_group"
      });
      sourceField.addSelectOption({
        value: "external",
        text: "NetSuite 契約更新（外部URL）",
        isSelected: context.request.parameters.custpage_source_type === "external"
      });
      sourceField.addSelectOption({
        value: "internal",
        text: "出張規定（ファイルキャビネット）",
        isSelected:
          context.request.parameters.custpage_source_type === "internal" ||
          !context.request.parameters.custpage_source_type
      });

      // チャットメッセージ数を隠しフィールドで管理
      let historySize = parseInt(context.request.parameters.custpage_num_chats || "0");
      const numChats = form.addField({
        id: "custpage_num_chats",
        type: serverWidget.FieldType.INTEGER,
        label: "メッセージ数",
        container: "chat_group"
      });
      numChats.updateDisplayType({
        displayType: serverWidget.FieldDisplayType.HIDDEN
      });
      numChats.defaultValue = historySize;

      // 前回の情報ソースを保持する隠しフィールドを追加
      const prevSourceField = form.addField({
        id: "custpage_prev_source",
        type: serverWidget.FieldType.TEXT,
        label: "前回情報ソース",
        container: "chat_group"
      });
      prevSourceField.updateDisplayType({
        displayType: serverWidget.FieldDisplayType.HIDDEN
      });
      // 初期の場合は「internal」をデフォルトとする
      prevSourceField.defaultValue = context.request.parameters.custpage_source_type || "internal";

      // POST時は新しい入力を処理
      if (context.request.method === "POST") {
        const userInput = context.request.parameters.custpage_input || "";
        const currentSource = context.request.parameters.custpage_source_type || "external";
        const previousSource = context.request.parameters.custpage_prev_source || "internal";

        // 既存のチャット履歴があり、情報ソースが切り替えられている場合、フォームを初期化する
        if (historySize > 0 && currentSource !== previousSource) {
          historySize = 0;
        }

        // 過去のメッセージを読み込み、必要ならば初期化後は何も読み込まれない
        const chatHistory = loadHistory(context, form, historySize);

        if (userInput.trim()) {
          // ユーザー発言を画面に表示
          addMessageField(form, "custpage_hist" + historySize, "あなた", userInput);
          chatHistory.push({ role: llm.ChatRole.USER, text: userInput });

          // LLMからの回答を取得
          const responseText = generateResponse(chatHistory, currentSource);
          addMessageField(form, "custpage_hist" + (historySize + 1), "チャットボット", responseText);

          // ヒストリー件数を2増加（ユーザー＋チャットボット）
          historySize += 2;
          numChats.defaultValue = historySize;
        }
      }

      // 次回の質問入力欄
      const inputField = form.addField({
        id: "custpage_input",
        type: serverWidget.FieldType.TEXTAREA,
        label: "質問を入力してください",
        container: "chat_group"
      });
      inputField.setHelpText({
        help: "AI（NetSuiteのN/llモジュール）による回答を生成するため、正確性は100%は保証されません。"
      });

      // 新規チャットを開始 button with appropriate spacing.
      const scriptId = runtime.getCurrentScript().id;
      const deploymentId = runtime.getCurrentScript().deploymentId;

      const buttonsHtml = `
        <div style="margin-top: 15px;">
          <input type="submit" value="送信" 
                 style="padding: 8px 16px; background-color: #0066cc; color: white; 
                        border: none; border-radius: 4px; cursor: pointer; font-size: 14px; margin-bottom: 10px;">
          <br>
          <a href="/app/site/hosting/scriptlet.nl?script=${scriptId}&deploy=${deploymentId}" 
             style="padding: 8px 16px; background-color: #0066cc; color: white; 
                    border: none; border-radius: 4px; cursor: pointer; font-size: 14px; text-decoration: none;">
             新規チャットを開始
          </a>
        </div>`;

      form.addField({
        id: "custpage_buttons",
        type: serverWidget.FieldType.INLINEHTML,
        label: " ",
        container: "chat_group"
      }).defaultValue = buttonsHtml;

      context.response.writePage(form);
    };

    /**
     * 過去のメッセージを読み込み・フォームに表示
     */
    const loadHistory = (context, form, historySize) => {
      const chatHistory = [];
      for (let i = 0; i < historySize; i++) {
        const text = context.request.parameters["custpage_hist" + i] || "";
        const label = i % 2 === 0 ? "あなた" : "チャットボット";
        addMessageField(form, "custpage_hist" + i, label, text);
        chatHistory.push({
          role: i % 2 === 0 ? llm.ChatRole.USER : llm.ChatRole.CHATBOT,
          text: text
        });
      }
      return chatHistory;
    };

    /**
     * フィールド追加用のヘルパー
     */
    const addMessageField = (form, id, label, text) => {
      const fld = form.addField({
        id: id,
        type: serverWidget.FieldType.TEXTAREA,
        label: label,
        container: "chat_group"
      });
      fld.defaultValue = text;
      fld.updateDisplayType({
        displayType: serverWidget.FieldDisplayType.INLINE
      });
    };

    /**
     * LLMモジュールを使って回答を生成
     */
    const generateResponse = (chatHistory, sourceType) => {
      try {
        // ユーザー選択に基づいて情報ソースを取得
        let sourceInfo;
        if (sourceType === "internal") {
          sourceInfo = fetchInternalInfo();
        } else {
          sourceInfo = fetchExternalInfo();
        }

        // 定数設定
        const maxTotalTokens = 4096; // 全体トークン制限（プロンプト＋応答）
        const reservedForResponse = 100; // 応答用に最低限確保するトークン数

        // 静的なプロンプト部分の構築（固定の案内文）
        let staticPrompt = "回答は必ず日本語で行ってください。\n\n";
        if (sourceType === "internal") {
          staticPrompt += 
          `以下は内部ドキュメントから取得した社内規定の情報です。
          表形式の情報がある場合は、先ずは条件が一致する列を特定、続いて条件が一致する行を特定し、その対応するセルの値を用いて回答してください。
          文書に情報が存在しない要素については、ハルシネーションを避け、正直にその部分の情報が存在しないことをユーザーに伝え、その他の情報が存在する要素について回答してください。
          ただし、質問の内容が文書の情報と関連性がない場合は、回答を避けてください。:\n"`; 
          
        } else {
          staticPrompt += 
          `以下は外部サービスから取得した製品の情報です。 
           その情報を主な根拠として回答し、その情報に含まれない要素については「確認が必要です」と名言してください。質問の内容がその情報と関連性がない場合は、回答を避けてください。:\n"`; 
        }

        // 会話履歴部分の構築
        let chatHistoryPrompt = "";
        for (let i = 0; i < chatHistory.length; i++) {
          const role = chatHistory[i].role === llm.ChatRole.USER ? "ユーザー" : "チャットボット";
          chatHistoryPrompt += `${role}: ${chatHistory[i].text}\n\n`;
        }

        // 応答開始を促すサフィックス
        const suffix = "チャットボット: ";

        // トークン数の推定
        const staticPromptTokens = estimateTokenCount(staticPrompt);
        const chatHistoryTokens = estimateTokenCount(chatHistoryPrompt);
        const suffixTokens = estimateTokenCount(suffix);

        // 利用可能な外部情報のトークン数を計算
        let availableTokensForSource =
          maxTotalTokens - (staticPromptTokens + chatHistoryTokens + suffixTokens + reservedForResponse);
        if (availableTokensForSource < 0) {
          availableTokensForSource = 0;
        }

        // 情報ソースをトークン数に合わせて文字数でトリミング (1 token ≒ 4文字)
        const maxCharsForSource = availableTokensForSource * 4;
        const trimmedSourceInfo = sourceInfo.substring(0, maxCharsForSource);

        // 最終的なプロンプトの構築
        const finalPrompt = staticPrompt + trimmedSourceInfo + "\n\n" + chatHistoryPrompt + suffix;

        // 再計算: 最終プロンプトのトークン数に応じた応答トークン数
        const finalPromptTokenCount = estimateTokenCount(finalPrompt);
        const maxResponseTokens = Math.max(100, maxTotalTokens - finalPromptTokenCount);

        // generateText 呼び出し (LLM) with dynamic maxTokens
        const result = llm.generateText({
          prompt: finalPrompt,
          modelParameters: {
            maxTokens: maxResponseTokens,
            temperature: 0.2,
            topK: 3,
            topP: 0.7,
            frequencyPenalty: 0.4,
            presencePenalty: 0
          }
        });

        return result.text || "";
      } catch (e) {
        log.error({
          title: "LLM Error",
          details: e
        });
        return "エラーが発生しました。再度お試しください。";
      }
    };

    /**
     * 簡易トークン数推定関数
     */
    const estimateTokenCount = (text) => {
      return Math.ceil(text.length / 4);
    };

    /**
     * 外部サービスへPOSTしてレスポンスを取得する例
     */
    const fetchExternalInfo = () => {
      const url = "https://webhook.site/651bde22-84a9-4716-b45c-6e7e1fea20a7";
      const requestBody = { sampleKey: "sampleValue" };

      try {
        const response = https.post({
          url: url,
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify(requestBody)
        });
        log.debug("Response Code", response.code);
        log.debug("Response Body", response.body);
        return response.body;
      } catch (e) {
        log.error({
          title: "Error fetching external info",
          details: e
        });
        return "（外部情報を取得できませんでした。）";
      }
    };

    /**
     * 内部ドキュメント（.txt形式）の内容を取得する関数
     * この関数はファイルURLではなく直接ファイルID (12522) を使用してファイルをロードします。
     */
    const fetchInternalInfo = () => {
      try {
        // 直接ファイルIDを指定してファイルをロードする
        const fileId = 12522;
        const fileObj = file.load({ id: fileId });

        // ファイル存在のチェックと形式確認（テキストファイルのみ対応）
        if (fileObj.fileType !== file.Type.PLAINTEXT) {
          throw new Error("Unsupported file format. Only text files are supported.");
        }

        // テキストファイルの場合は内容を取得する
        const txtContent = fileObj.getContents();
        log.debug("Text File Content Extracted", {
          name: fileObj.name,
          size: fileObj.size,
          textLength: txtContent.length
        });

        return txtContent;
      } catch (e) {
        log.error({
          title: "Error fetching internal info",
          details: e
        });
        return "（内部ドキュメントを取得できませんでした。）";
      }
    };

    return { onRequest };
  }
);

```

© 2025 Takusuke Fujii
本記事は CC BY 4.0（原作者名の表記が必要）で自由に共有・改変・配布できますが、無保証につき著作者は一切責任を負いません。