NetSuiteで5分で遊んでみる、プライベート分析ボット [ハルシネーション修正済]

前回は、[NetSuite上で簡単なプライベートチャットボットを作る](https://qiita.com/fujitak/items/7c8c472ccfba9c253e31) 例をご紹介しました。そこでご紹介した「N/llmモジュール」に加えて、今回はそれに「N/searchモジュール」を組み合わせて、NetSuite上の実データを参照する「分析ボット」を動かして遊んでみましょう。本記事下部にあるSuiteletのスクリプトをコピペするだけで、チャット形式で「今四半期の売上上位5社を教えてください」といった質問に答えさせることができます。
<br>

**全体概要**
• チャット形式のUI
　フォーム上にユーザーの入力履歴とボットの回答を表示し、やり取りを継続できます。
• N/search で NetSuiteデータを取得
　ユーザーの質問に応じてトランザクションレコードを検索し、売上高と件数を取得します。
• N/llm で自然言語のインサイトを生成
　検索したデータをLLMに渡して解釈させ、自然言語での考察コメントを返します。
<br>

**1. 前回ディプロイ済のSuiteletファイルに、今回のスクリプトをコピペする**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3431564/a3358c96-8ac5-4be6-a6ee-fca99b7941d7.png)
<br>

**2. 前回のディプロイメントのURLをクリックして、分析ボットアプリを起動する**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3431564/aad42df2-ccdc-41ac-a8e9-52f7eaa44ab6.png)
<br>

**3. 「今年の主要顧客の売上を教えてください？」と質問してみた（成功）**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3431564/f9e6ebbc-1fd6-4743-a2cd-3ae5e47dc60d.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3431564/18978dc3-1ba7-4b25-8314-7b57f5229bc9.png)
※サンプルスクリプトの分析結果の精度は保証しておりませんので、ご了承ください。本スクリーンショットの実行環境は、日本の製造企業の成長を支援する[SuiteSuccess Manufacturing Edition](https://www.oracle.com/jp/news/announcement/netsuite-to-help-manufacturers-grow-and-adapt-to-change-2024-07-17/) のデモ環境を使用しております。
<br>

**4. 続けて、「今期一年間の粗利率を教えてください？」と質問してみた（失敗）**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3431564/78af044d-c9ea-4736-b777-d0c7d4ac5d63.png)
※今回のスクリプトは検索の対象を顧客と売上のみに絞っているため、「売上原価のデータがなく、粗利率を計算できない」と、いちおう「適切」な回答ではありましたが、非常に回りくどいものでした。
<br>

**まとめ**
この記事では、N/llm と N/search を組み合わせて、リアルタイム分析ができるチャットボットをSuiteletの1ファイルのみで実装する例をご紹介しました。

ソースコードのポイントは下記の通りです。
•	チャット形式のUI（フォーム）で会話を継続
•	N/search 用のパラメーター設定を、古典的なテキスト分析で現実的に実装 (createSearchConfig)
•	実際にN/searchでデータを取得し、LLMがその結果を解説 (generateInsights fucntion)

「ちょっとした売上レポートを会話形式で見られると便利」「エンドユーザーでも親しみやすいUIで分析したい」というニーズへ応えるための、たたき台になれば幸いです。プロンプトを工夫すれば、「在庫切れリスクのある商品をリストアップ」、「請求書ステータスの確認」なども自然言語で行える小さな「AIアシスタント」を、御社でも作ることができるかもしれません。
<br>

※ 当初は、「どんなレコードを検索すべきかをLLMに判断させる」という夢のようなアプローチをとりましたが、ハルシネーションがひどく、今回は古典的なテキスト分析のアプローチに切り替えました。次回は夢のアプローチで動くコソースコードを完成させたいです。
<br>

**参考リンク**
N/llmは無料で使用を開始できます。有料モードの設定をしない限り追加料金は発生しませんので、安心してチャットボットをお試し下さい。使用モード（無料、オンデマンド、専用AIクラスタ）やその他のAI関連機能について、以下のコミュニティ記事を参考にされて下さい。
[NetSuite サポートコミュニティ› AI関連機能› SuiteScript 2.x 生成AI API](https://community.oracle.com/netsuite/japanese/discussion/4503315/suitescript-2-x-%E7%94%9F%E6%88%90ai-api#latest)
[NetSuite サポートコミュニティ› AI関連機能](https://community.oracle.com/netsuite/japanese/categories/ai%E9%96%A2%E9%80%A3%E6%A9%9F%E8%83%BD)
[NetSuite サポートコミュニティ› [動画解説] リリース2025.1主要なアップデート](https://community.oracle.com/netsuite/japanese/discussion/4504569/%E3%83%AA%E3%83%AA%E3%83%BC%E3%82%B92025-1%E4%B8%BB%E8%A6%81%E3%81%AA%E3%82%A2%E3%83%83%E3%83%97%E3%83%87%E3%83%BC%E3%83%88#latest)  

N/llmと、N/search のさらなる詳細につきましては、ヘルプセンターをご参照ください。
[Oracle NetSuite Help Center > N/llm Module](https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/article_9123730083.html#subsect_1014032311)
[Oracle NetSuite Help Center > N/search Module](https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_4345764122.html#subsect_87180423808)

※コミュニティのアカウントをお持ちでない方は、ぜひコミュニティのアカウントを作成下さい。
<br>

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

        /**
         * メインSuiteletのエントリーポイント（チャットボット形式）
         */
        const onRequest = (context) => {

            // Suiteletフォーム
            const form = serverWidget.createForm({
                title: "顧客売上分析ボット"
            });

            // タイトル下の説明を追加
            const descriptionField = form.addField({
                id: "custpage_description",
                type: serverWidget.FieldType.INLINEHTML,
                label: " " // 空のラベル
            });

            descriptionField.defaultValue = `
  <div style="margin-bottom:15px; padding:10px; background-color:#f8f8f8; border-left:4px solid #4b71a1; border-radius:3px; font-size:13px;">
    <p style="margin:5px 0;"><strong>📅 使用可能な期間キーワード:</strong> 今年/本年, 昨年/前年, 今四半期/今期, 前四半期/前期, 今月, 先月/前月</p>
    <p style="margin:5px 0;"><strong>🔢 表示件数の指定方法:</strong> 「上位○社」と入力してください（例: 上位5社）</p>
  </div>
        `;

            // チャット履歴グループ（左側）
            const chatGroup = form.addFieldGroup({
                id: "chat_group",
                label: "チャット履歴"
            });
            chatGroup.isSingleColumn = true;

            // データテーブルグループ（右側）
            const dataGroup = form.addFieldGroup({
                id: "data_group",
                label: "データテーブル"
            });
            dataGroup.isSingleColumn = true;

            // チャットメッセージ数を隠しフィールドで管理
            const 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 dataCache = form.addField({
                id: "custpage_data_cache",
                type: serverWidget.FieldType.LONGTEXT,
                label: "データキャッシュ",
                container: "data_group"
            });
            dataCache.updateDisplayType({ displayType: serverWidget.FieldDisplayType.HIDDEN });
            dataCache.defaultValue = context.request.parameters.custpage_data_cache || "[]";

            // クエリ分析結果のキャッシュ用の隠しフィールド
            const queryAnalysisCache = form.addField({
                id: "custpage_query_analysis_cache",
                type: serverWidget.FieldType.LONGTEXT,
                label: "クエリ分析キャッシュ",
                container: "data_group"
            });
            queryAnalysisCache.updateDisplayType({ displayType: serverWidget.FieldDisplayType.HIDDEN });
            queryAnalysisCache.defaultValue = context.request.parameters.custpage_query_analysis_cache || "{}";

            // 初回 or 2回目以降を判定するための隠しパラメータ
            const visitedParam = context.request.parameters.custpage_visited;
            const isFirstTime = (visitedParam !== "T");  // "T" でなければ初回アクセスとみなす

            // 既存のチャット履歴をフォームに再表示
            const chatHistory = loadHistory(context, form, historySize);

            // クエリ分析結果表示フィールド (右上に配置)
            const queryAnalysisField = form.addField({
                id: "custpage_query_analysis",
                type: serverWidget.FieldType.INLINEHTML,
                label: "クエリ設定",
                container: "data_group"
            });

            // データテーブル表示用のフィールド
            const dataTableField = form.addField({
                id: "custpage_data_table",
                type: serverWidget.FieldType.INLINEHTML,
                label: "検索結果データ",
                container: "data_group"
            });

            let customerData = [];
            let tableHtml = "";
            let queryAnalysisHtml = "";
            let queryConfig = {};

            // POSTリクエスト(ユーザーが入力を送信)の場合
            if (context.request.method === "POST") {
                const userInput = context.request.parameters.custpage_input || "";
                if (userInput.trim()) {
                    // 1) ユーザーの質問をチャット履歴に追加
                    addMessageField(form, "custpage_hist" + historySize, "あなた", userInput);
                    chatHistory.push({ role: "user", text: userInput });

                    try {
                        // 2) 質問を解析して回答を生成
                        // LLMを使わず、直接検索設定を構築
                        queryConfig = createSearchConfig(userInput);

                        // クエリ設定をキャッシュに保存
                        queryAnalysisCache.defaultValue = JSON.stringify(queryConfig);

                        // クエリ設定のHTMLを生成
                        queryAnalysisHtml = generateQueryAnalysisHtml(queryConfig);

                        // 実際の顧客売上データを取得
                        customerData = fetchCustomerSalesData(queryConfig);

                        // データキャッシュに保存
                        dataCache.defaultValue = JSON.stringify(customerData);

                        // テーブルHTMLを生成 (フィールド名を含む)
                        tableHtml = generateTableHtml(customerData, true);

                        // LLMを使用してインサイトを生成 (これは保持)
                        const responseText = generateInsights(userInput, customerData);
                        addMessageField(form, "custpage_hist" + (historySize + 1), "チャットボット", responseText);
                        chatHistory.push({ role: "bot", text: responseText });

                        // 3) ヒストリー件数を2増加（ユーザー＋チャットボット）
                        numChats.defaultValue = historySize + 2;
                    } catch (e) {
                        log.error({ title: "ChatBot Error", details: e });
                        const errorMsg = "申し訳ございません。リクエストを処理中にエラーが発生しました: " + e.message;
                        addMessageField(form, "custpage_hist" + (historySize + 1), "チャットボット", errorMsg);
                        chatHistory.push({ role: "bot", text: errorMsg });
                        numChats.defaultValue = historySize + 2;
                    }
                }
            } else if (!isFirstTime) {
                // 既存データがある場合は、キャッシュから復元して各HTMLを再生成
                try {
                    // クエリ設定を復元
                    const cachedQueryConfig = context.request.parameters.custpage_query_analysis_cache || "{}";
                    queryConfig = JSON.parse(cachedQueryConfig);
                    if (queryConfig && Object.keys(queryConfig).length > 0) {
                        queryAnalysisHtml = generateQueryAnalysisHtml(queryConfig);
                    }

                    // 顧客データを復元
                    const cachedData = context.request.parameters.custpage_data_cache || "[]";
                    customerData = JSON.parse(cachedData);
                    if (customerData && customerData.length > 0) {
                        // 過去の質問で取得したデータがあれば、テーブルHTMLを再生成
                        tableHtml = generateTableHtml(customerData, true);
                    }
                } catch (e) {
                    log.error({ title: "データキャッシュ解析エラー", details: e });
                }
            }

            // クエリ設定HTMLをセット
            queryAnalysisField.defaultValue = queryAnalysisHtml;

            // データテーブルHTMLをセット
            dataTableField.defaultValue = tableHtml;

            // 次の質問を入力するフィールド
            const inputField = form.addField({
                id: "custpage_input",
                type: serverWidget.FieldType.TEXTAREA,
                label: "質問を入力してください",
                container: "chat_group"
            });

            // 初回アクセスなら固定のサンプル質問を、2回目以降ならLLM生成のサンプル質問を設定
            if (isFirstTime) {
                inputField.defaultValue = "今四半期の売上上位5社を教えてください。";
            } else {
                inputField.defaultValue = generateShortSampleQuestion();
            }

            inputField.setHelpText({
                help: "NetSuiteの検索やAIによる応答をチャット形式で行います。検索結果は右側のデータテーブルに表示されます。"
            });

            // 「visited」を保持する隠しフィールド => 2回目以降は常に "T"
            const visitedField = form.addField({
                id: "custpage_visited",
                type: serverWidget.FieldType.TEXT,
                label: "visited param",
                container: "chat_group"
            });
            visitedField.updateDisplayType({ displayType: serverWidget.FieldDisplayType.HIDDEN });
            visitedField.defaultValue = "T";

            // 送信ボタン
            form.addSubmitButton({ label: "送信" });

            // フォーム出力
            context.response.writePage(form);
        };

        /**
         * ユーザーの質問からクエリ設定を生成（LLMを使わない直接実装）
         */
        const createSearchConfig = (query) => {
            // 質問内容に関わらず、固定の検索設定を返す
            // 顧客ごとの売上データを取得する標準設定
            return {
                recordTypes: ['transaction'],
                timeFrame: getTimeFrameFromQuery(query),
                transactionTypes: ['CustInvc', 'CashSale'], // 請求書と現金売上
                limit: getLimitFromQuery(query)
            };
        };

        /**
         * 質問から期間情報を抽出
         */
        const getTimeFrameFromQuery = (query) => {
            const lowerQuery = query.toLowerCase();

            // 期間を特定するキーワードを検索
            if (lowerQuery.includes('今年') || lowerQuery.includes('本年')) {
                return 'thisYear';
            } else if (lowerQuery.includes('昨年') || lowerQuery.includes('前年')) {
                return 'lastYear';
            } else if (lowerQuery.includes('今四半期') || lowerQuery.includes('今期')) {
                return 'thisQuarter';
            } else if (lowerQuery.includes('前四半期') || lowerQuery.includes('前期')) {
                return 'lastQuarter';
            } else if (lowerQuery.includes('今月')) {
                return 'thisMonth';
            } else if (lowerQuery.includes('先月') || lowerQuery.includes('前月')) {
                return 'lastMonth';
            }

            // デフォルトは今年
            return 'thisYear';
        };

        /**
         * 質問から件数制限を抽出
         */
        const getLimitFromQuery = (query) => {
            // "上位N社" といった表現から数値を抽出
            const matches = query.match(/上位(\d+)社/);
            if (matches && matches[1]) {
                return parseInt(matches[1]);
            }

            // デフォルトは10件
            return 10;
        };

        /**
         * クエリ設定のHTMLを生成
         */
        const generateQueryAnalysisHtml = (queryConfig) => {
            if (!queryConfig || Object.keys(queryConfig).length === 0) {
                return "<div style='text-align:center; padding:10px;'>クエリ設定がありません</div>";
            }

            // JSONを見やすく表示するHTMLを生成
            let html = `
  <div style="width:100%; margin-bottom:20px; border:1px solid #ddd; border-radius:5px; overflow:hidden;">
    <div style="background-color:#f5f5f5; padding:8px; border-bottom:1px solid #ddd; font-weight:bold;">
      検索設定
    </div>
    <div style="padding:10px; background-color:#fff; overflow:auto; max-height:200px; font-family:monospace; font-size:12px;">
        `;

            // 各プロパティを表で表示
            html += `<table style="width:100%; border-collapse:collapse;">`;

            // 対象レコード
            html += `
      <tr>
        <td style="padding:4px; border-bottom:1px solid #eee; color:#666; width:120px; vertical-align:top;">対象レコード:</td>
        <td style="padding:4px; border-bottom:1px solid #eee; word-break:break-word;">
          ${queryConfig.recordTypes.join(', ')}
        </td>
      </tr>`;

            // 期間
            let timeFrameText = '今年';
            switch (queryConfig.timeFrame) {
                case 'thisYear': timeFrameText = '今年'; break;
                case 'lastYear': timeFrameText = '昨年'; break;
                case 'thisQuarter': timeFrameText = '今四半期'; break;
                case 'lastQuarter': timeFrameText = '前四半期'; break;
                case 'thisMonth': timeFrameText = '今月'; break;
                case 'lastMonth': timeFrameText = '先月'; break;
            }

            html += `
      <tr>
        <td style="padding:4px; border-bottom:1px solid #eee; color:#666; width:120px; vertical-align:top;">対象期間:</td>
        <td style="padding:4px; border-bottom:1px solid #eee; word-break:break-word;">
          ${timeFrameText}
        </td>
      </tr>`;

            // トランザクションタイプ
            html += `
      <tr>
        <td style="padding:4px; border-bottom:1px solid #eee; color:#666; width:120px; vertical-align:top;">対象取引:</td>
        <td style="padding:4px; border-bottom:1px solid #eee; word-break:break-word;">
          ${queryConfig.transactionTypes.map(type => type === 'CustInvc' ? '請求書' : '現金売上').join(', ')}
        </td>
      </tr>`;

            // 件数制限
            html += `
      <tr>
        <td style="padding:4px; border-bottom:1px solid #eee; color:#666; width:120px; vertical-align:top;">表示件数:</td>
        <td style="padding:4px; border-bottom:1px solid #eee; word-break:break-word;">
          上位 ${queryConfig.limit} 件
        </td>
      </tr>`;

            html += `</table>
    </div>
  </div>
        `;

            return html;
        };

        /**
         * JSON値を適切にフォーマット
         */
        const formatJsonValue = (value) => {
            if (Array.isArray(value)) {
                if (value.length === 0) {
                    return "[]";
                }

                // 配列の中身が単純な値かオブジェクトかで表示方法を変える
                if (typeof value[0] === 'object' && value[0] !== null) {
                    // オブジェクトの配列
                    let result = "[<br>";
                    value.forEach((item, index) => {
                        result += "&nbsp;&nbsp;&nbsp;&nbsp;{";
                        const entries = Object.entries(item);
                        entries.forEach(([k, v], i) => {
                            result += `${k}: ${JSON.stringify(v)}`;
                            if (i < entries.length - 1) {
                                result += ", ";
                            }
                        });
                        result += "}";
                        if (index < value.length - 1) {
                            result += ",";
                        }
                        result += "<br>";
                    });
                    result += "]";
                    return result;
                } else {
                    // プリミティブ値の配列
                    return JSON.stringify(value)
                        .replace(/^\[/, "")
                        .replace(/\]$/, "")
                        .split(",")
                        .map(item => item.trim())
                        .join(", ");
                }
            } else if (typeof value === 'object' && value !== null) {
                // オブジェクト
                let result = "{<br>";
                const entries = Object.entries(value);
                entries.forEach(([k, v], i) => {
                    result += `&nbsp;&nbsp;&nbsp;&nbsp;${k}: ${JSON.stringify(v)}`;
                    if (i < entries.length - 1) {
                        result += ",";
                    }
                    result += "<br>";
                });
                result += "}";
                return result;
            } else {
                // 単純な値
                return JSON.stringify(value);
            }
        };

        /**
         * 2回目以降のアクセス時に、LLMで「30文字以内の短いサンプル質問」を生成
         */
        const generateShortSampleQuestion = () => {
            try {
                const prompt = `
  あなたはNetSuiteを使用する会社の経営幹部です。日本語50文字以内で、シンプルな質問を1つ生成してください。
  
  以下の条件を必ず守ってください:
  1. 必ず以下のいずれかの期間キーワードを含めること:
     - 今年または本年
     - 昨年または前年
     - 今四半期または今期
     - 前四半期または前期
     - 今月
     - 先月または前月
  
  2. 必ず「上位○社」という表現を含めること（○は数字）
  
  質問例：「今四半期の売上上位5社を教えてください。」「昨年の取引上位10社はどこですか？」
          `;

                const result = llm.generateText({
                    prompt: prompt,
                    model: "gpt-3.5-turbo",
                    temperature: 0.7,
                    maxTokens: 60
                });
                return (result.text || "").trim(); // 切り捨てはしない
            } catch (e) {
                log.error({ title: "generateShortSampleQuestion Error", details: e });
                // 失敗した場合のfallback
                return "今四半期の売上上位10社を教えてください。";
            }
        };

        /**
         * 過去のメッセージを読み込み、フォームに表示
         */
        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 ? "user" : "bot",
                    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 });
        };

        /**
         * テーブルHTMLを生成 - フィールド名付きでデータを表示
         */
        const generateTableHtml = (data, showHeaders = false) => {
            if (!data || data.length === 0) {
                return "<div style='text-align:center; padding:20px;'>データがありません</div>";
            }

            // テーブルのHTMLを生成
            let tableHtml = `
  <div style="width:100%; overflow-x:auto;">
  <table style="width:100%; border-collapse:collapse; font-size:12px;">
      `;

            // ヘッダー行（表示する場合）
            if (showHeaders && data.length > 0) {
                const headers = Object.keys(data[0]);
                tableHtml += `<tr style="background-color:#f0f0f0;">`;
                headers.forEach(header => {
                    const displayHeader = formatHeader(header);
                    tableHtml += `<th style="padding:8px; border:1px solid #ddd; text-align:center; font-weight:bold;">${displayHeader}</th>`;
                });
                tableHtml += `</tr>`;
            }

            // テーブル本体
            data.forEach((row, rowIndex) => {
                const bgColor = rowIndex % 2 === 0 ? "#ffffff" : "#f9f9f9";
                tableHtml += `<tr style="background-color:${bgColor};">`;

                // 各行のデータを追加
                Object.entries(row).forEach(([key, value]) => {
                    // 数値データは右寄せに、顧客データは常に左寄せに
                    const valueStr = String(value || "");

                    // 顧客フィールドの場合は常に左寄せ
                    let alignment = "left";

                    // 顧客フィールド以外で数値の場合は右寄せ
                    if (key !== "entity" && key !== "entityid" && key !== "entityId" && key !== "companyname") {
                        const isNumeric = !isNaN(valueStr.replace(/[^\d.-]/g, "")) &&
                            (valueStr.includes('JPY') || !isNaN(parseFloat(valueStr)));
                        if (isNumeric) {
                            alignment = "right";
                        }
                    }

                    tableHtml += `<td style="padding:6px; border:1px solid #ddd; text-align:${alignment};">${valueStr}</td>`;
                });

                tableHtml += `</tr>`;
            });

            tableHtml += `
  </table>
  </div>
      `;

            return tableHtml;
        };

        /**
         * ヘッダー名を表示用にフォーマット
         */
        const formatHeader = (header) => {
            // フィールド名を人間が読みやすい形式に変換
            const headerMap = {
                'entityid': '顧客ID',
                'companyname': '会社名',
                'entity': '顧客',
                'entityId': '顧客ID',
                'amount': '売上金額',
                'transactionCount': '取引数',
                'category': 'カテゴリ',
                'datecreated': '作成日'
            };

            return headerMap[header] || header;
        };

        /**
         * 期間フィルターを構築
         */
        const buildDateFilter = (timeFrame) => {
            const now = new Date();
            const currentYear = now.getFullYear();
            const currentMonth = now.getMonth() + 1; // 0-indexed to 1-indexed
            const currentQuarter = Math.ceil(currentMonth / 3);

            let fromDate, toDate;

            switch (timeFrame) {
                case 'thisYear':
                    fromDate = new Date(currentYear, 0, 1);
                    toDate = new Date(currentYear, 11, 31);
                    break;
                case 'lastYear':
                    fromDate = new Date(currentYear - 1, 0, 1);
                    toDate = new Date(currentYear - 1, 11, 31);
                    break;
                case 'thisQuarter':
                    const quarterStartMonth = (currentQuarter - 1) * 3;
                    fromDate = new Date(currentYear, quarterStartMonth, 1);
                    toDate = new Date(currentYear, quarterStartMonth + 3, 0);
                    break;
                case 'lastQuarter':
                    const lastQuarter = currentQuarter === 1 ? 4 : currentQuarter - 1;
                    const lastQuarterYear = currentQuarter === 1 ? currentYear - 1 : currentYear;
                    const lastQuarterStartMonth = (lastQuarter - 1) * 3;
                    fromDate = new Date(lastQuarterYear, lastQuarterStartMonth, 1);
                    toDate = new Date(lastQuarterYear, lastQuarterStartMonth + 3, 0);
                    break;
                case 'thisMonth':
                    fromDate = new Date(currentYear, currentMonth - 1, 1);
                    toDate = new Date(currentYear, currentMonth, 0);
                    break;
                case 'lastMonth':
                    const lastMonth = currentMonth === 1 ? 12 : currentMonth - 1;
                    const lastMonthYear = currentMonth === 1 ? currentYear - 1 : currentYear;
                    fromDate = new Date(lastMonthYear, lastMonth - 1, 1);
                    toDate = new Date(lastMonthYear, lastMonth, 0);
                    break;
                default:
                    // デフォルトは今年
                    fromDate = new Date(currentYear, 0, 1);
                    toDate = new Date(currentYear, 11, 31);
            }

            // NetSuite形式の日付文字列に変換
            const formatDate = (date) => {
                const year = date.getFullYear();
                const month = String(date.getMonth() + 1).padStart(2, '0');
                const day = String(date.getDate()).padStart(2, '0');
                return `${month}/${day}/${year}`;
            };

            return [
                search.createFilter({
                    name: 'trandate',
                    operator: search.Operator.ONORAFTER,
                    values: formatDate(fromDate)
                }),
                search.createFilter({
                    name: 'trandate',
                    operator: search.Operator.ONORBEFORE,
                    values: formatDate(toDate)
                })
            ];
        };

        /**
         * 実際の顧客売上データを取得（改善版）
         */
        const fetchCustomerSalesData = (queryConfig) => {
            try {
                // 顧客ごとの売上合計を取得
                const transactionSearch = search.create({
                    type: search.Type.TRANSACTION,
                    filters: [
                        // トランザクションタイプフィルター（請求書、現金売上など）
                        search.createFilter({
                            name: 'type',
                            operator: search.Operator.ANYOF,
                            values: queryConfig.transactionTypes
                        }),
                        // メインラインのみを取得
                        search.createFilter({
                            name: 'mainline',
                            operator: search.Operator.IS,
                            values: 'T'
                        }),
                        // 期間フィルター
                        ...buildDateFilter(queryConfig.timeFrame)
                    ],
                    columns: [
                        // 顧客名
                        search.createColumn({
                            name: 'entity',
                            summary: search.Summary.GROUP,
                            label: 'entity'
                        }),
                        // 売上合計
                        search.createColumn({
                            name: 'amount',
                            summary: search.Summary.SUM,
                            sort: search.Sort.DESC,
                            label: 'amount'
                        }),
                        // トランザクション数
                        search.createColumn({
                            name: 'internalid',
                            summary: search.Summary.COUNT,
                            label: 'transactionCount'
                        })
                    ]
                });

                const resultSet = transactionSearch.run();
                const results = resultSet.getRange({ start: 0, end: queryConfig.limit }) || [];

                const customerSales = [];

                results.forEach(result => {
                    const customerName = result.getText({ name: 'entity', summary: search.Summary.GROUP }) || '不明';
                    const customerId = result.getValue({ name: 'entity', summary: search.Summary.GROUP }) || '';

                    // 売上金額を取得して数値変換
                    let salesAmount = result.getValue({ name: 'amount', summary: search.Summary.SUM }) || '0';
                    salesAmount = parseFloat(salesAmount) || 0;

                    // トランザクション数
                    const transCount = result.getValue({ name: 'internalid', summary: search.Summary.COUNT }) || '0';

                    customerSales.push({
                        entity: customerName,
                        entityId: customerId,
                        amount: "JPY" + salesAmount.toLocaleString("ja-JP", { minimumFractionDigits: 0 }),
                        transactionCount: transCount
                    });
                });

                return customerSales;

            } catch (e) {
                log.error({ title: "fetchCustomerSalesData Error", details: e });
                return [];
            }
        };

        /**
         * 取得したデータをLLMにわかりやすいJSON形式で提示し、自然言語インサイトを生成
         */
        const generateInsights = (query, customerData) => {
            if (!customerData || customerData.length === 0) {
                return "該当するデータが見つかりませんでした。検索条件を変更して再度お試しください。";
            }

            const prompt = `
  あなたはNetSuiteのビジネスアナリストです。以下のデータに基づいて、
  ユーザーの質問に対する洞察を日本語で詳しく解説してください。
  
  ユーザーの質問:
  ${query}
  
  検索結果データ(JSON):
  ${JSON.stringify(customerData, null, 2)}
  
  注意点:
  - 日付: ${new Date().toISOString().split("T")[0]}
  - 通貨: JPY
  - 金額のフィールド（amount）はすでにJPY付きでフォーマットされています
  - 必要に応じて売上上位や件数などの具体的な数字を示してください。
  - 実際のデータに基づいた分析を行い、架空の情報を加えないでください。
        `;

            const insightsObject = llm.generateText({
                prompt: prompt,
                model: "gpt-3.5-turbo",
                temperature: 0.5,
                maxTokens: 1500
            });

            return insightsObject.text || "データの分析ができませんでした。";
        };

        return { onRequest };
    }
);
```
※動作確認は、Shift-JIS文字コードでのみ行っております。