SPIRAL v1の一覧表ページにAIチャットパネルを追加し、日本語で話しかけるだけでDBを検索・集計できる「自然言語検索フォーム」のサンプルプログラムです。
OpenAI GPTのFunction Callingを使って自然言語を検索条件に変換し、SPIRAL外部APIでデータを取得します。コードを配置するだけで、既存の一覧表にAI検索機能を追加できます。
注意点
・ OpenAI APIの利用にはAPIキーが必要です。利用料金が発生しますのであらかじめご確認ください。
・ SPIRAL外部APIの利用には、管理画面「開発>スパイラルAPI」で発行したAPIトークンとシークレットが必要です。
・ SPIRAL外部APIの利用には、管理画面「開発>スパイラルAPI」で発行したAPIトークンとシークレットが必要です。
実装の概要
以下の流れで処理を行います。
・ ユーザーがチャットに日本語でメッセージを入力
・ JavaScriptがBase64エンコードして
・
・ OpenAI GPT(Function Calling)が自然言語を解析し、3つのモードのいずれかを選択
・ 検索・集計モードはSPIRAL外部APIでデータを取得・計算し、結果をJSONで返す
・ チャットUIに一覧カードまたはレポートカードを表示
【動作モード一覧】
・ ユーザーがチャットに日本語でメッセージを入力
・ JavaScriptがBase64エンコードして
バックエンド PHP(SPIRALダミーフォームのPHP)へPOST
・
バックエンド PHPがSPIRAL内部APIでフィールド構成を取得
・ OpenAI GPT(Function Calling)が自然言語を解析し、3つのモードのいずれかを選択
・ 検索・集計モードはSPIRAL外部APIでデータを取得・計算し、結果をJSONで返す
・ チャットUIに一覧カードまたはレポートカードを表示
【動作モード一覧】
| モード | 入力例 | 返答形式 |
|---|---|---|
| 一覧検索 | 「佐藤さんの売上を見せて」「雑貨カテゴリのデータ一覧」 | レコードカード一覧 + 「もっと見る」リンク |
| 集計・レポート | 「2025年の売上合計は?」「カテゴリ別の件数を教えて」 | 集計値レポートカード(グループ別テーブル) |
| 会話応答 | 「使い方は?」「こんにちは」 | テキスト回答(DB 参照なし) |
事前準備
| 必要なもの | 取得場所・設定場所 |
|---|---|
| OpenAI APIキー | OpenAI プラットフォームで発行 |
| SPIRAL外部APIトークン・シークレット | SPIRAL管理画面「開発>スパイラルAPI」で発行 |
| SPIRAL DB(売上履歴) | 管理画面「DB作成」で作成済みであること |
| SPIRAL一覧表 + 検索フォーム | 管理画面「Webコンポーネント」から一覧表を作成し、デフォルトで生成される一覧表のソース編集HTMLに一覧表・検索フォームを設置(一般公開での利用を想定) |
SPIRAL DB(売上履歴)のDB構成例
売上履歴DBは、以下サンプル構成での動作を前提とします。フィールド構成を変更する場合は、バックエンドPHPの設定を変更してください。
| フィールド名 | フィールドタイプ | 差替キーワード | 必須 | 備考(検索フォーム用途など) |
|---|---|---|---|---|
| 売上ID | 数字記号アルファベット(32 bytes) | sales_id | 〇 | 主キー |
| 顧客ID | 数字記号アルファベット(32 bytes) | customer_id | 〇 | |
| 顧客名 | テキスト(128 bytes) | customer_name | 〇 | 検索項目:キーワード検索対象 |
| カテゴリ | セレクト | category | × | 検索項目:プルダウン等の完全一致検索用(例:家電、衣類、食品、雑貨、書籍等) |
| 商品ID | 数字記号アルファベット(32 bytes) | product_id | 〇 | |
| 商品名 | テキスト(128 bytes) | product_name | 〇 | 検索項目:キーワード検索対象 |
| 単価 | 整数 | unit_price | 〇 | |
| 数量 | 整数 | quantity | 〇 | |
| 売上金額 | 整数 | total_amount | 〇 | 検索項目:金額の範囲検索(以上/以下) |
| 売上日 | 日付 | sales_date | 〇 | 検索項目:期間検索用(開始日〜終了日) |
| 登録日時 | 登録日時 | registDate | - | - |
| 更新日時 | 更新日時 | updateDate | - | - |
設定方法
① バックエンドPHPの上部設定値の定数を設定する
後述のバックエンドPHPの冒頭にある以下の定数を、環境に合わせて変更してください。
後述のバックエンドPHPの冒頭にある以下の定数を、環境に合わせて変更してください。
コピー
// ===== 設定(ここを書き換えてください)=====
define('OPENAI_API_KEY', 'YOUR_OPENAI_API_KEY_HERE');
define('OPENAI_MODEL', 'gpt-5.2');
define('SPIRAL_DB_TITLE', 'YOUR_DB_TITLE'); // DBタイトル(英字)
// SPIRAL 外部API認証情報(管理画面「開発>スパイラルAPI」で発行)
define('SPIRAL_API_TOKEN', 'YOUR_SPIRAL_API_TOKEN_HERE');
define('SPIRAL_API_SECRET', 'YOUR_SPIRAL_API_SECRET_HERE');
// ロケータ URL(変更不要)
define('SPIRAL_LOCATOR_URL', 'https://www.pi-pe.co.jp/api/locator');
// 一覧表の「もっと見る」リンク先URL
define('SPIRAL_TABLE_URL', 'https://reg18.smp.ne.jp/area/table/xxxxx/yyyyyy/M?S=zzzzzzz'); //一覧表のURL
define('RESULTS_LIMIT', 10);
// 一覧表検索フォームのID(管理画面の検索フォームID)
//一覧表の検索フォーム管理画面のURL→https://ctr18.smp.ne.jp/spiral/servlet/member.parts.SettingSearchForm?_application_id=50&_act=MakeSearchForm&_search_form_id=xxxxx
define('SPIRAL_FORM_ID', 'xxxxx');
// フィールドコードマップ: api_name => f00XXXXXXX の数値部
// SPIRAL管理画面のフィールド一覧「差替キーワード(コード)」の ( ) 内の数値
define('SPIRAL_FIELD_CODES', [
'sales_id' => 'XXXXXXX',
'customer_id' => 'XXXXXXX',
'customer_name' => 'XXXXXXX',
'category' => 'XXXXXXX',
'product_id' => 'XXXXXXX',
'product_name' => 'XXXXXXX',
'unit_price' => 'XXXXXXX',
'quantity' => 'XXXXXXX',
'total_amount' => 'XXXXXXX',
'sales_date' => 'XXXXXXX',
'registDate' => 'XXXXXXX',
'updateDate' => 'XXXXXXX',
]);
② 同じバックエンドPHP内の SPIRAL_FIELD_CODES を設定する
①と同じファイル(バックエンドPHP)内にある
「もっと見る」リンクのURL生成に使用するため、SPIRAL管理画面「フィールド一覧」の差替キーワード(コード)欄にある括弧内の数値を登録してください。
③ バックエンドPHPをダミーフォームのソース編集HTMLに設置する
管理画面「Webコンポーネント」からダミーフォームを作成し、ソース編集を選択します。
入力フォームのまま公開するとフォーム機能が残り悪用される可能性があるため、わざと締切状態にして締切ページを活用します。
URLにアクセスすると締切ページが表示されるように設定したうえで、後述のバックエンドPHPを締切ページのソース編集HTMLに貼り付けます。作成後、ダミーフォーム(締切ページ)のURLを控えておきます。
④ 一覧表のソース編集HTMLのCHAT_API_URLを設定する
後述の一覧表のソース編集HTML(フロントエンド HTML)内、JavaScriptの設定欄にある
①と同じファイル(バックエンドPHP)内にある
SPIRAL_FIELD_CODESを編集します。
「もっと見る」リンクのURL生成に使用するため、SPIRAL管理画面「フィールド一覧」の差替キーワード(コード)欄にある括弧内の数値を登録してください。
③ バックエンドPHPをダミーフォームのソース編集HTMLに設置する
管理画面「Webコンポーネント」からダミーフォームを作成し、ソース編集を選択します。
入力フォームのまま公開するとフォーム機能が残り悪用される可能性があるため、わざと締切状態にして締切ページを活用します。
URLにアクセスすると締切ページが表示されるように設定したうえで、後述のバックエンドPHPを締切ページのソース編集HTMLに貼り付けます。作成後、ダミーフォーム(締切ページ)のURLを控えておきます。
④ 一覧表のソース編集HTMLのCHAT_API_URLを設定する
後述の一覧表のソース編集HTML(フロントエンド HTML)内、JavaScriptの設定欄にある
CHAT_API_URLに、③で控えたダミーフォームのURLを設定します。
実行結果
画面左側にSPIRAL標準一覧表、右側にAIチャットパネルが表示されます。チャットに自然言語で入力すると、意図に応じてモードを自動判別して結果を返します。
・一覧検索:条件に合うレコードをカード形式で最大10
件表示。「もっと見る」リンクからSPIRALの一覧表(検索条件付き)に遷移できます。
・集計・レポート:合計・平均・件数・最大・最小・グループ別件数・グループ別合計に対応。結果をレポートカードで強調表示します。
・会話応答:使い方の質問や挨拶にはDB を参照せずテキストで回答します。
・集計・レポート:合計・平均・件数・最大・最小・グループ別件数・グループ別合計に対応。結果をレポートカードで強調表示します。
・会話応答:使い方の質問や挨拶にはDB を参照せずテキストで回答します。
サンプルコード全文
バックエンド PHP
③で作成したダミーフォームの締切ページのソース編集HTMLに貼り付けます。冒頭の設定値(APIキー・DBタイトル等)を環境に合わせて変更してください。
③で作成したダミーフォームの締切ページのソース編集HTMLに貼り付けます。冒頭の設定値(APIキー・DBタイトル等)を環境に合わせて変更してください。
コピー
<?//<!-- SMP_DYNAMIC_PAGE DISPLAY_ERRORS=OFF NAME=XXX -->?>
<?php
/**
* 自然言語検索チャットAPI
*
* - OpenAI GPT + Function Calling で自然言語 → 検索条件に変換
* - SPIRAL 内部API(SpiralApiCommunicator)でフィールド構成取得
* - SPIRAL 外部API(curl + JSON)でデータ検索
*
*/
// ===== 設定(ここを書き換えてください)=====
define('OPENAI_API_KEY', 'YOUR_OPENAI_API_KEY_HERE');
define('OPENAI_MODEL', 'gpt-5.2');
define('SPIRAL_DB_TITLE', 'YOUR_DB_TITLE'); // DBタイトル(英字)
// SPIRAL 外部API認証情報(管理画面「開発>スパイラルAPI」で発行)
define('SPIRAL_API_TOKEN', 'YOUR_SPIRAL_API_TOKEN_HERE');
define('SPIRAL_API_SECRET', 'YOUR_SPIRAL_API_SECRET_HERE');
// ロケータ URL(変更不要)
define('SPIRAL_LOCATOR_URL', 'https://www.pi-pe.co.jp/api/locator');
// 一覧表の「もっと見る」リンク先URL
define('SPIRAL_TABLE_URL', 'https://reg18.smp.ne.jp/area/table/xxxxx/yyyyyy/M?S=zzzzzzz'); //一覧表のURL
define('RESULTS_LIMIT', 10);
// 一覧表検索フォームのID(管理画面の検索フォームID)
//一覧表の検索フォーム管理画面のURL→https://ctr18.smp.ne.jp/spiral/servlet/member.parts.SettingSearchForm?_application_id=50&_act=MakeSearchForm&_search_form_id=xxxxx
define('SPIRAL_FORM_ID', 'xxxxx');
// フィールドコードマップ: api_name => f00XXXXXXX の数値部
// SPIRAL管理画面のフィールド一覧「差替キーワード(コード)」の ( ) 内の数値
define('SPIRAL_FIELD_CODES', [
'sales_id' => 'XXXXXXX',
'customer_id' => 'XXXXXXX',
'customer_name' => 'XXXXXXX',
'category' => 'XXXXXXX',
'product_id' => 'XXXXXXX',
'product_name' => 'XXXXXXX',
'unit_price' => 'XXXXXXX',
'quantity' => 'XXXXXXX',
'total_amount' => 'XXXXXXX',
'sales_date' => 'XXXXXXX',
'registDate' => 'XXXXXXX',
'updateDate' => 'XXXXXXX',
]);
// レスポンスはマーカー(<div id="api-json-response">〜</div>)でbase64抽出する
if (empty($_POST['message'])) {
echo '<div id="api-json-response">' . base64_encode(json_encode(['error' => 'message_empty'], JSON_UNESCAPED_UNICODE)) . '</div>';
exit;
}
// フロント側でBase64エンコードして送信しているためデコードする
// (SPIRAL環境のSJIS変換による文字化け対策)
$raw = trim($_POST['message']);
$decoded = base64_decode($raw, true);
// base64デコードに成功した場合はそれを使用、失敗した場合は従来のSJIS→UTF-8変換にフォールバック
if ($decoded !== false) {
$message = $decoded;
}
else {
$enc = mb_detect_encoding($raw, ['UTF-8', 'SJIS-win', 'EUC-JP'], true);
$message = ($enc && $enc !== 'UTF-8') ? mb_convert_encoding($raw, 'UTF-8', $enc) : $raw;
}
// ─────────────────────────────────────────────
// メイン処理
// ─────────────────────────────────────────────
// デバッグ用: $intent と $fields をcatchでも参照できるようスコープを広げる
$intent = null;
$fields = [];
try {
// 1. SPIRAL 内部API database/get でフィールド構成を取得
$fields = getSpiralFields();
// 2. OpenAI Function Calling で意図を判定(検索 or 雑談)
$intent = interpretQueryWithOpenAI($message, $fields);
if ($intent['_function'] === 'general_reply') {
// 雑談・挨拶などはDB検索せずそのまま返す
$json = json_encode([
'success' => true,
'is_search' => false,
'is_report' => false,
'reply' => isset($intent['message']) ? $intent['message'] : '',
'headers' => [],
'results' => [],
'total' => 0,
'search_url' => '',
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
echo '<div id="api-json-response">' . base64_encode($json) . '</div>';
exit;
}
if ($intent['_function'] === 'analyze_sales_records') {
// レポート・集計モード: 全件取得してPHP側で集計
$analysisResult = analyzeData($intent, $fields);
$json = json_encode([
'success' => true,
'is_search' => false,
'is_report' => true,
'reply' => $analysisResult['reply'],
'report_rows' => $analysisResult['report_rows'],
'headers' => [],
'results' => [],
'total' => $analysisResult['total'],
'search_url' => buildTableUrl($analysisResult['conditions']),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
echo '<div id="api-json-response">' . base64_encode($json) . '</div>';
exit;
}
// 3. SPIRAL 内部API database/select でデータを検索
$results = searchSpiralData($intent, $fields);
$json = json_encode([
'success' => true,
'is_search' => true,
'reply' => isset($intent['summary']) ? $intent['summary'] : '検索完了',
'headers' => $results['headers'],
'results' => $results['rows'],
'total' => $results['total'],
'search_url' => buildTableUrl($results['conditions']),
// ===== デバッグ用(実装確認後に不要であれば削除してください) =====
'_debug_intent' => $intent,
'_debug_fields' => array_map(function ($f) {
return $f['api_name'];
}, $fields),
'_debug_conditions' => $results['conditions'],
'_debug_raw_header' => $results['raw_header'],
'_debug_raw_data_row0' => isset($results['raw_data'][0]) ? $results['raw_data'][0] : null,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
echo '<div id="api-json-response">' . base64_encode($json) . '</div>';
}
catch (Exception $e) {
// ===== デバッグ用(実装確認後に不要であれば削除してください): エラー時も $intent と $fields の内容を確認 =====
$debugFields = [];
foreach ($fields as $title => $info) {
$debugFields[] = [
'title' => $title,
'api_name' => $info['api_name'],
'kind' => $info['kind'],
];
}
$json = json_encode([
'error' => $e->getMessage(),
'_debug_intent' => $intent,
'_debug_fields' => $debugFields,
], JSON_UNESCAPED_UNICODE);
echo '<div id="api-json-response">' . base64_encode($json) . '</div>';
}
// ─────────────────────────────────────────────
// SpiralApiRequest::put() 配列展開ヘルパー
// put() はスカラー値のみ受け付けるため、配列を
// key[0], key[0][name] ... のインデックス形式に再帰展開する
// ─────────────────────────────────────────────
function spiralPut($request, string $key, $value)
{
if (is_array($value)) {
foreach ($value as $k => $v) {
spiralPut($request, $key . '[' . $k . ']', $v);
}
}
else {
$request->put($key, $value);
}
}
// ─────────────────────────────────────────────
// SPIRAL 内部API 共通呼び出し
// $SPIRAL->getSpiralApiCommunicator() を使用(外部認証不要)
// ─────────────────────────────────────────────
function callSpiralInternalAPI(string $module, string $method, array $params)
{
global $SPIRAL;
$communicator = $SPIRAL->getSpiralApiCommunicator();
$request = new SpiralApiRequest();
foreach ($params as $key => $value) {
spiralPut($request, (string)$key, $value);
}
$response = $communicator->request($module, $method, $request);
$code = $response->get('code');
if ($code != 0) {
throw new Exception(
$module . '/' . $method . ' エラー(code:' . $code . '): ' . $response->get('message')
);
}
return $response;
}
// ─────────────────────────────────────────────
// SPIRAL database/get でフィールド構成を取得
// ─────────────────────────────────────────────
function getSpiralFields(): array
{
$response = callSpiralInternalAPI('database', 'get', ['db_title' => SPIRAL_DB_TITLE]);
// SpiralApiResponse::get() は stdClass を返す場合があるので配列に変換する
$schema = json_decode(json_encode($response->get('schema')), true);
$fieldList = isset($schema['fieldList']) ? $schema['fieldList'] : [];
if (empty($fieldList)) {
throw new Exception('フィールド情報が取得できませんでした(fieldList が空)');
}
$fields = [];
foreach ($fieldList as $f) {
// name = 日本語の表示名 → OpenAI に見せるフィールド名($fieldsのキー)
// title = 英字のAPI識別子(customer_name, category 等) → SPIRAL 検索条件に使う
$displayName = isset($f['name']) ? $f['name'] : '';
$apiName = isset($f['title']) ? $f['title'] : '';
$type = isset($f['type']) ? $f['type'] : '';
if ($displayName === '' || $apiName === '')
continue;
// mm_alternative(セレクト)はラベルIDで検索するため ラベル名 → ID のマップを作成
// SPIRAL API の label 構造: keywordAry=["雑貨","家電"...], idAry=["3","0"...] の並列配列
$options = [];
if (!empty($f['label']['keywordAry']) && !empty($f['label']['idAry'])) {
$keywords = $f['label']['keywordAry'];
$ids = $f['label']['idAry'];
foreach ($keywords as $i => $label) {
if (isset($ids[$i])) {
$options[$label] = $ids[$i]; // 例: ["雑貨" => "3", "家電" => "0", ...]
}
}
}
$fields[$displayName] = [
'api_name' => $apiName,
'raw_type' => $type,
'kind' => resolveKind($type),
'options' => $options,
];
}
return $fields;
}
/**
* SPIRAL フィールドタイプ → 大まかな種別に分類
*/
function resolveKind(string $type): string
{
if (preg_match('/integer|real|currency|numeric/', $type))
return 'number';
if (preg_match('/date|time/', $type))
return 'date';
if (preg_match('/alternative|multiple|select/', $type))
return 'select';
if (preg_match('/text|name|area|email|tel|zip/', $type))
return 'text';
return 'other';
}
// ─────────────────────────────────────────────
// OpenAI Function Calling で自然言語 → 検索条件に変換
// ─────────────────────────────────────────────
function interpretQueryWithOpenAI(string $message, array $fields): array
{
if (OPENAI_API_KEY === 'YOUR_OPENAI_API_KEY_HERE') {
throw new Exception('OpenAI APIキーが未設定です。');
}
$today = date('Y-m-d');
$lines = [];
foreach ($fields as $title => $info) {
$line = '・' . $title . '(種別: ' . $info['kind'] . ')';
if (!empty($info['options'])) {
// optionsはラベル名→IDのマップのためキー(ラベル名)のみOpenAIに伝える
$line .= ' 選択肢: ' . implode(', ', array_keys($info['options']));
}
$lines[] = $line;
}
$fieldList = implode("\n", $lines);
$tools = [
[
'type' => 'function',
'function' => [
'name' => 'search_sales_records',
'description' => '売上履歴DBをユーザーの条件で検索する。検索・絞り込み・一覧取得など、データ取得を求める場合に呼び出す。',
'parameters' => [
'type' => 'object',
'properties' => [
'conditions' => [
'type' => 'array',
'description' => '検索条件リスト(複数条件はAND結合)',
'items' => [
'type' => 'object',
'properties' => [
'field_title' => ['type' => 'string', 'description' => '検索対象フィールドのタイトル(フィールドリストと完全一致)'],
'operator' => [
'type' => 'string',
'enum' => ['contains', 'equals', 'gte', 'lte', 'gt', 'lt', 'date_range'],
'description' => 'contains=部分一致, equals=完全一致, gte=以上, lte=以下, gt=超える, lt=未満, date_range=期間(value=開始日 value2=終了日)',
],
'value' => ['type' => 'string', 'description' => '検索値(日付はYYYY-MM-DD形式)'],
'value2' => ['type' => 'string', 'description' => 'date_range の終了日'],
],
'required' => ['field_title', 'operator', 'value'],
],
],
'summary' => ['type' => 'string', 'description' => '実行する検索の日本語説明'],
],
'required' => ['conditions', 'summary'],
],
],
],
[
'type' => 'function',
'function' => [
'name' => 'analyze_sales_records',
'description' => '売上データを集計・分析する。合計・平均・件数・最大・最小・グループ別集計など、数値の計算や要約を求める場合に呼び出す。',
'parameters' => [
'type' => 'object',
'properties' => [
'conditions' => [
'type' => 'array',
'description' => '絞り込み条件(search_sales_recordsと同じ形式)',
'items' => [
'type' => 'object',
'properties' => [
'field_title' => ['type' => 'string'],
'operator' => ['type' => 'string', 'enum' => ['contains', 'equals', 'gte', 'lte', 'gt', 'lt', 'date_range']],
'value' => ['type' => 'string'],
'value2' => ['type' => 'string'],
],
'required' => ['field_title', 'operator', 'value'],
],
],
'analysis_type' => [
'type' => 'string',
'enum' => ['sum', 'avg', 'count', 'max', 'min', 'group_count', 'group_sum'],
'description' => 'sum=合計, avg=平均, count=件数, max=最大値, min=最小値, group_count=グループ別件数, group_sum=グループ別合計(target_fieldも指定)',
],
'target_field' => ['type' => 'string', 'description' => '集計対象フィールドのタイトル(sum/avg/max/min時に必須)'],
'group_by_field' => ['type' => 'string', 'description' => 'グループ化フィールドのタイトル(group_count時に必須)'],
'summary' => ['type' => 'string', 'description' => '実行する分析の日本語説明'],
],
'required' => ['conditions', 'analysis_type', 'summary'],
],
],
],
[
'type' => 'function',
'function' => [
'name' => 'general_reply',
'description' => '挨拶・質問・使い方確認など、DB検索を必要としない会話に回答する。',
'parameters' => [
'type' => 'object',
'properties' => [
'message' => ['type' => 'string', 'description' => 'ユーザーへの返答テキスト'],
],
'required' => ['message'],
],
],
],
];
$payload = [
'model' => OPENAI_MODEL,
'messages' => [
[
'role' => 'system',
'content' =>
'あなたは販売管理システムの自然言語検索アシスタントです。' . "\n" .
'ユーザーの意図を判断し、以下のルールで関数を呼び分けてください。' . "\n\n" .
'【関数の使い分け】' . "\n" .
'- search_sales_records : レコード一覧が欲しいとき(「〜を探して」「〜の一覧」「〜を見せて」など)' . "\n" .
'- analyze_sales_records: 数値の計算・集計・ランキングが欲しいとき(「合計」「平均」「最大」「最小」「件数」「カテゴリ別」「月別」「ランキング」など)' . "\n" .
'- general_reply : 挨拶・雑談・使い方確認など、DB操作を必要としない会話' . "\n\n" .
'利用可能なフィールド:' . "\n" . $fieldList . "\n\n" .
'今日の日付: ' . $today . "\n\n" .
'注意:' . "\n" .
'- 「今日」「本日」は ' . $today . ' として処理する' . "\n" .
'- 「今月」は月初〜月末として date_range で処理する' . "\n" .
'- 「1万円」→10000 のように数値に変換する' . "\n" .
'- フィールドが特定できない場合は conditions を空配列にして search_sales_records を呼ぶ' . "\n" .
'- group_sum は group_by_field と target_field の両方を必ず指定する',
],
['role' => 'user', 'content' => $message],
],
'tools' => $tools,
'tool_choice' => 'auto',
];
$res = callOpenAI($payload);
$toolCall = isset($res['choices'][0]['message']['tool_calls'][0])
? $res['choices'][0]['message']['tool_calls'][0] : null;
if (!$toolCall) {
throw new Exception('GPT からのFunction Callingレスポンスが取得できませんでした。');
}
$args = json_decode($toolCall['function']['arguments'], true);
if (!$args) {
throw new Exception('Function Calling の引数パースに失敗しました。');
}
// 呼ばれた関数名を結果に付加して呼び出し元で分岐できるようにする
$args['_function'] = $toolCall['function']['name'];
return $args;
}
// ─────────────────────────────────────────────
// SPIRAL database/select でデータを検索
// ─────────────────────────────────────────────
function searchSpiralData(array $searchIntent, array $fields): array
{
$conditions = buildSearchConditions($searchIntent, $fields);
// 取得するカラムを指定
$selectColumns = array_values(array_map(function ($info) {
return $info['api_name'];
}, $fields));
// 外部API(curl + JSON)で database/select を実行
$response = callSpiralExternalAPI('database/select', [
'db_title' => SPIRAL_DB_TITLE,
'lines_per_page' => RESULTS_LIMIT,
'page' => 1,
'select_columns' => $selectColumns,
'labels_target' => 'all',
'search_condition' => !empty($conditions) ? $conditions : [],
]);
// SPIRAL database/select レスポンス:
// header: [日本語フィールド名, ...] カラム名配列
// data: [[val, val, ...], ...] 2D行配列
// label: [{labelId: labelName}] ラベルID→名前マップ(selectフィールド用)
$rawHeader = isset($response['header']) ? $response['header'] : [];
$rawData = isset($response['data']) ? $response['data'] : [];
$rawLabel = isset($response['label']) ? $response['label'] : [];
if (!is_array($rawData))
$rawData = [];
$total = count($rawData);
if (empty($rawData)) {
return ['headers' => [], 'rows' => [], 'total' => 0, 'conditions' => $conditions];
}
// api_name → 日本語表示名 変換マップ
$apiNameToTitle = [];
foreach ($fields as $displayName => $info) {
$apiNameToTitle[$info['api_name']] = $displayName;
}
// カラム情報を構築(表示名 + ラベルマップ)
$columnTitles = [];
$columnLabels = [];
if (is_array($rawHeader) && !empty($rawHeader)) {
// 2D配列形式: headerでカラム位置を特定
foreach ($rawHeader as $i => $apiName) {
$columnTitles[$i] = isset($apiNameToTitle[$apiName]) ? $apiNameToTitle[$apiName] : $apiName;
// label は [{labelId: labelName}] の配列 (カラムごと)
$columnLabels[$i] = (is_array($rawLabel) && isset($rawLabel[$i]) && is_array($rawLabel[$i]))
? $rawLabel[$i] : [];
}
$headers = array_values($columnTitles);
$rows = [];
foreach ($rawData as $rawRow) {
$row = is_array($rawRow) ? array_values($rawRow) : [];
foreach ($row as $i => &$val) {
// ラベルIDを表示名に解決(select フィールド)
if (!empty($columnLabels[$i]) && isset($columnLabels[$i][(string)$val])) {
$val = $columnLabels[$i][(string)$val];
}
}
unset($val);
$rows[] = $row;
}
}
else {
// フォールバック: 連想配列形式(api_nameがキー)
$firstRow = $rawData[0];
$headers = [];
foreach (array_keys($firstRow) as $k) {
$headers[] = isset($apiNameToTitle[$k]) ? $apiNameToTitle[$k] : $k;
}
$rows = [];
foreach ($rawData as $row) {
$rows[] = array_values($row);
}
}
return [
'headers' => $headers,
'rows' => $rows,
'total' => $total,
'conditions' => $conditions,
'raw_header' => $rawHeader, // デバッグ用(実装確認後に不要であれば削除してください)
'raw_data' => $rawData, // デバッグ用(実装確認後に不要であれば削除してください)
];
}
// ─────────────────────────────────────────────
// GPT の conditions 配列 → SPIRAL search_condition 配列に変換
// searchSpiralData と analyzeData の両方から使用
// ─────────────────────────────────────────────
function buildSearchConditions(array $intent, array $fields): array
{
$conditions = [];
$intentConditions = isset($intent['conditions']) ? $intent['conditions'] : [];
foreach ($intentConditions as $cond) {
$title = isset($cond['field_title']) ? $cond['field_title'] : '';
$operator = isset($cond['operator']) ? $cond['operator'] : 'equals';
$value = isset($cond['value']) ? $cond['value'] : '';
$value2 = isset($cond['value2']) ? $cond['value2'] : '';
if (!isset($fields[$title]))
continue;
$apiName = $fields[$title]['api_name'];
$kind = $fields[$title]['kind'];
// select フィールドはラベル名 → ID に変換
if ($kind === 'select' && !empty($fields[$title]['options'])) {
$optionMap = $fields[$title]['options'];
if (isset($optionMap[$value])) {
$value = $optionMap[$value];
}
}
// 外部API の search_condition は operator 文字列形式(>=, <=, LIKE 等)を使用。
// 日付は YYYY-MM-DD → YYYY/MM/DD に変換が必要。
// 参考: https://support.smp.ne.jp/programs/api-programs/select1/
$toSpiralDate = function ($v) {
return preg_replace('/^(\d{4})-(\d{2})-(\d{2})$/', '$1/$2/$3', $v);
};
switch ($operator) {
case 'contains':
$conditions[] = ['name' => $apiName, 'value' => '%' . $value . '%', 'operator' => 'LIKE'];
break;
case 'equals':
$conditions[] = ['name' => $apiName, 'value' => $value, 'operator' => '='];
break;
case 'gte':
$conditions[] = ['name' => $apiName, 'value' => $toSpiralDate($value), 'operator' => '>='];
break;
case 'gt':
$conditions[] = ['name' => $apiName, 'value' => $toSpiralDate($value), 'operator' => '>'];
break;
case 'lte':
$conditions[] = ['name' => $apiName, 'value' => $toSpiralDate($value), 'operator' => '<='];
break;
case 'lt':
$conditions[] = ['name' => $apiName, 'value' => $toSpiralDate($value), 'operator' => '<'];
break;
case 'date_range':
// 開始日(>=) と 終了日(<=) を個別条件として追加
if ($value)
$conditions[] = ['name' => $apiName, 'value' => $toSpiralDate($value), 'operator' => '>='];
if ($value2)
$conditions[] = ['name' => $apiName, 'value' => $toSpiralDate($value2), 'operator' => '<='];
break;
}
}
return $conditions;
}
// ─────────────────────────────────────────────
// analyze_sales_records: データを取得してPHP側で集計
// analysis_type: sum / avg / count / max / min / group_count / group_sum
// ─────────────────────────────────────────────
function analyzeData(array $intent, array $fields): array
{
$analysisType = isset($intent['analysis_type']) ? $intent['analysis_type'] : 'count';
$targetTitle = isset($intent['target_field']) ? $intent['target_field'] : '';
$groupTitle = isset($intent['group_by_field']) ? $intent['group_by_field'] : '';
$summary = isset($intent['summary']) ? $intent['summary'] : '';
$conditions = buildSearchConditions($intent, $fields);
$selectColumns = array_values(array_map(function ($info) {
return $info['api_name'];
}, $fields));
$response = callSpiralExternalAPI('database/select', [
'db_title' => SPIRAL_DB_TITLE,
'lines_per_page' => 500,
'page' => 1,
'select_columns' => $selectColumns,
'labels_target' => 'all',
'search_condition' => !empty($conditions) ? $conditions : [],
]);
$rawHeader = isset($response['header']) ? $response['header'] : [];
$rawData = isset($response['data']) ? $response['data'] : [];
$rawLabel = isset($response['label']) ? $response['label'] : [];
if (!is_array($rawData))
$rawData = [];
$total = count($rawData);
// 外部APIの header は日本語表示名のインデックス配列(例: ["売上ID","顧客ID",...])
// 表示名 → 列インデックス マップ
$colIndex = [];
if (is_array($rawHeader)) {
foreach ($rawHeader as $i => $displayName) {
$colIndex[$displayName] = (int)$i;
}
}
// api_name → 列インデックス マップ(fields の表示名キーを経由して解決)
$apiToIdx = [];
foreach ($fields as $title => $info) {
if (isset($colIndex[$title])) {
$apiToIdx[$info['api_name']] = $colIndex[$title];
}
}
// api_name → 行の値を取得
$getCellVal = function ($row, $apiName) use ($apiToIdx) {
if ($apiName === '' || !isset($apiToIdx[$apiName]))
return null;
$rowArr = array_values($row);
$idx = $apiToIdx[$apiName];
return isset($rowArr[$idx]) ? $rowArr[$idx] : null;
};
// ラベルID → 表示名 解決
$resolveLabel = function ($apiName, $val) use ($rawLabel, $apiToIdx) {
if (!isset($apiToIdx[$apiName]))
return $val;
$idx = $apiToIdx[$apiName];
if (is_array($rawLabel) && isset($rawLabel[$idx][(string)$val])) {
return $rawLabel[$idx][(string)$val];
}
return $val;
};
// 指定フィールドの数値一覧を取得
$getNumVals = function ($apiName) use ($rawData, $getCellVal) {
if ($apiName === '')
return [];
$vals = [];
foreach ($rawData as $row) {
$v = $getCellVal($row, $apiName);
if ($v !== null && $v !== '') {
$vals[] = floatval(str_replace(',', '', (string)$v));
}
}
return $vals;
};
// ── count ──
if ($analysisType === 'count') {
return [
'reply' => $summary . "\n件数: " . number_format($total) . ' 件',
'report_rows' => [],
'total' => $total,
'conditions' => $conditions,
];
}
// ── sum / avg / max / min ──
if (in_array($analysisType, ['sum', 'avg', 'max', 'min'], true)) {
$targetApiName = isset($fields[$targetTitle]) ? $fields[$targetTitle]['api_name'] : '';
$vals = $getNumVals($targetApiName);
if (empty($vals)) {
return [
'reply' => $summary . "\n集計対象のデータが見つかりませんでした",
'report_rows' => [],
'total' => $total,
'conditions' => $conditions,
];
}
$labelMap = ['sum' => '合計', 'avg' => '平均', 'max' => '最大値', 'min' => '最小値'];
switch ($analysisType) {
case 'sum':
$result = array_sum($vals);
break;
case 'avg':
$result = array_sum($vals) / count($vals);
break;
case 'max':
$result = max($vals);
break;
case 'min':
$result = min($vals);
break;
}
return [
'reply' => $summary . "\n" . ($targetTitle ? $targetTitle . ' ' : '') . $labelMap[$analysisType] . ': ' . number_format($result),
'report_rows' => [],
'total' => $total,
'conditions' => $conditions,
];
}
// ── group_count / group_sum ──
if (in_array($analysisType, ['group_count', 'group_sum'], true)) {
$groupApiName = isset($fields[$groupTitle]) ? $fields[$groupTitle]['api_name'] : '';
$targetApiName = isset($fields[$targetTitle]) ? $fields[$targetTitle]['api_name'] : '';
$groups = [];
foreach ($rawData as $row) {
$groupVal = $getCellVal($row, $groupApiName);
if ($groupVal === null)
$groupVal = '(未設定)';
$groupVal = $resolveLabel($groupApiName, $groupVal);
if (!isset($groups[$groupVal])) {
$groups[$groupVal] = ['count' => 0, 'sum' => 0.0];
}
$groups[$groupVal]['count']++;
$tv = $getCellVal($row, $targetApiName);
if ($tv !== null && $tv !== '') {
$groups[$groupVal]['sum'] += floatval(str_replace(',', '', (string)$tv));
}
}
// 多い順にソート
$sortKey = ($analysisType === 'group_sum') ? 'sum' : 'count';
uasort($groups, function ($a, $b) use ($sortKey) {
return $b[$sortKey] <=> $a[$sortKey];
});
if ($analysisType === 'group_sum' && $targetTitle) {
$reportRows = [[$groupTitle, '件数', $targetTitle . ' 合計']];
foreach ($groups as $k => $v) {
$reportRows[] = [$k, number_format($v['count']), number_format($v['sum'])];
}
}
else {
$reportRows = [[$groupTitle, '件数']];
foreach ($groups as $k => $v) {
$reportRows[] = [$k, number_format($v['count'])];
}
}
return [
'reply' => $summary,
'report_rows' => $reportRows,
'total' => $total,
'conditions' => $conditions,
];
}
// フォールバック
return [
'reply' => $summary . "\n総件数: " . number_format($total) . ' 件',
'report_rows' => [],
'total' => $total,
'conditions' => $conditions,
];
}
// ─────────────────────────────────────────────
// SPIRAL 一覧表の検索付きURLを構築
// search_condition 配列からフィールドコードを引き、
// SJIS URL エンコードしたクエリを付加して返す
// ─────────────────────────────────────────────
function buildTableUrl(array $conditions): string
{
$codeMap = SPIRAL_FIELD_CODES;
$formId = SPIRAL_FORM_ID;
// SPIRAL_TABLE_URL からベースURLとセッションキーを分離
$tableUrl = SPIRAL_TABLE_URL;
$sessionKey = '';
if (preg_match('/[?&]S=([^&]+)/', $tableUrl, $m)) {
$sessionKey = $m[1];
}
$baseUrl = strtok($tableUrl, '?');
// conditions から api_name => {start, end} マップを構築
// date_range は >= を start、<= を end に格納
$condMap = [];
foreach ($conditions as $cond) {
$name = isset($cond['name']) ? $cond['name'] : '';
if ($name === '' || !isset($codeMap[$name]))
continue;
$op = isset($cond['operator']) ? $cond['operator'] : '';
$val = isset($cond['value']) ? trim($cond['value'], '%') : '';
if (!isset($condMap[$name])) {
$condMap[$name] = ['start' => '', 'end' => ''];
}
if ($op === '>=') {
$condMap[$name]['start'] = $val;
}
elseif ($op === '<=') {
$condMap[$name]['end'] = $val;
}
else {
$condMap[$name]['start'] = $val;
}
}
// 値エンコード: 日付(YYYY/MM/DD → YYYY-MM-DD)はそのまま、数値はそのまま、文字列はSJIS
$encVal = function ($v) {
if ($v === '')
return '';
$v = preg_replace('/^(\d{4})\/(\d{2})\/(\d{2})$/', '$1-$2-$3', $v);
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $v))
return $v;
if (is_numeric($v))
return $v;
return rawurlencode(mb_convert_encoding($v, 'SJIS-win', 'UTF-8'));
};
$params = [];
$params[] = 'detect=' . rawurlencode(mb_convert_encoding('判定', 'SJIS-win', 'UTF-8'));
if ($sessionKey !== '') {
$params[] = 'S=' . $sessionKey;
}
// 全フィールドを定義順に列挙(条件なしは空値)
// 日付範囲は _1=開始日 & _2=終了日 の2パラメータ
foreach ($codeMap as $apiName => $fieldCode) {
$baseKey = $formId . '_' . $fieldCode;
$start = isset($condMap[$apiName]) ? $condMap[$apiName]['start'] : '';
$end = isset($condMap[$apiName]) ? $condMap[$apiName]['end'] : '';
$params[] = $baseKey . '_1=' . $encVal($start);
if ($end !== '') {
$params[] = $baseKey . '_2=' . $encVal($end);
}
}
$params[] = 'smp_sf_button_' . $formId . '='
. rawurlencode(mb_convert_encoding('検索', 'SJIS-win', 'UTF-8'));
return $baseUrl . '?' . implode('&', $params);
}
// ─────────────────────────────────────────────
// SPIRAL 外部API 呼び出し(curl + JSON + HMAC-SHA1署名)
// $endpoint : 'database/select' など X-SPIRAL-API ヘッダの / 前までの部分
// $params : リクエストボディ(配列)→ 自動でトークン・署名を付加
// 成功時はレスポンスの配列を返す。失敗時は例外を投げる。
// ─────────────────────────────────────────────
function callSpiralExternalAPI(string $endpoint, array $params): array
{
// 1. ロケータからサービスURLを取得
$ch = curl_init(SPIRAL_LOCATOR_URL);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode(['spiral_api_token' => SPIRAL_API_TOKEN]),
CURLOPT_HTTPHEADER => [
'Content-Type: application/json; charset=UTF-8',
'X-SPIRAL-API: locator/apiserver/request',
],
CURLOPT_TIMEOUT => 10,
CURLOPT_SSL_VERIFYPEER => true,
]);
$locatorBody = curl_exec($ch);
$locatorErr = curl_errno($ch) ? curl_error($ch) : null;
curl_close($ch);
if ($locatorErr) {
throw new Exception('SPIRAL ロケータ通信エラー: ' . $locatorErr);
}
$locatorData = json_decode($locatorBody, true);
if (empty($locatorData['location'])) {
throw new Exception('SPIRAL ロケータレスポンス異常: ' . $locatorBody);
}
$apiUrl = $locatorData['location'];
// 2. リクエストパラメータにトークン・パスキー・署名を付加
$passkey = time();
$params['spiral_api_token'] = SPIRAL_API_TOKEN;
$params['passkey'] = $passkey;
$params['signature'] = hash_hmac(
'sha1',
SPIRAL_API_TOKEN . '&' . $passkey,
SPIRAL_API_SECRET,
false
);
// 3. database/select を呼び出す
$ch = curl_init($apiUrl);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($params, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
CURLOPT_HTTPHEADER => [
'Content-Type: application/json; charset=UTF-8',
'X-SPIRAL-API: ' . $endpoint . '/request',
],
CURLOPT_TIMEOUT => 30,
CURLOPT_SSL_VERIFYPEER => true,
]);
$body = curl_exec($ch);
$errno = curl_errno($ch);
$err = curl_error($ch);
curl_close($ch);
if ($errno) {
throw new Exception('SPIRAL 外部API 通信エラー: ' . $err);
}
$data = json_decode($body, true);
if (!is_array($data)) {
throw new Exception('SPIRAL 外部API レスポンスのパース失敗: ' . $body);
}
if (isset($data['code']) && $data['code'] != 0) {
throw new Exception('SPIRAL 外部API エラー(code:' . $data['code'] . '): ' . ($data['message'] ?? ''));
}
return $data;
}
// ─────────────────────────────────────────────
// OpenAI API 呼び出し(curl使用)
// ─────────────────────────────────────────────
function callOpenAI(array $payload): array
{
$ch = curl_init('https://api.openai.com/v1/chat/completions');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . OPENAI_API_KEY,
],
CURLOPT_TIMEOUT => 30,
CURLOPT_SSL_VERIFYPEER => true,
]);
$body = curl_exec($ch);
$errno = curl_errno($ch);
$err = curl_error($ch);
curl_close($ch);
if ($errno) {
throw new Exception('OpenAI API 通信エラー: ' . $err);
}
$data = json_decode($body, true);
if (isset($data['error'])) {
throw new Exception('OpenAI API エラー: ' . $data['error']['message']);
}
return $data;
}
一覧表のソース編集HTML(フロントエンド HTML)
事前準備で作成した一覧表のソース編集HTMLに貼り付けます。
また、コード内の一覧表・検索フォーム用の差替キーワード(
事前準備で作成した一覧表のソース編集HTMLに貼り付けます。
CHAT_API_URLを③で設置したダミーフォームのURLに変更してください。
また、コード内の一覧表・検索フォーム用の差替キーワード(
%sf:usr:search9%等)は、ご自身の一覧表で設定している差替キーワードに合わせて変更してください。
コピー
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
<html lang="ja">
<head>
<meta http-equiv="Content-Style-Type" content="text/css">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>AI売上一覧</title>
<style type="text/css">
<!--
/* ===== リセット ===== */
* {
box-sizing: border-box;
}
body {
padding: 0;
margin: 0;
background-color: #E7E6DA;
font-family: 'Hiragino Kaku Gothic ProN', 'Meiryo', sans-serif;
display: flex;
height: 100vh;
overflow: hidden;
}
form {
margin: 0;
padding: 0;
}
/* ===== 左側:SPIRALテーブルエリア ===== */
#spiral-area {
flex: 1;
overflow-y: auto;
background-color: #E7E6DA;
display: flex;
justify-content: center;
padding: 20px 10px 40px 10px;
}
.mainBody {
width: 800px;
max-width: 100%;
background-color: #FFFFFF;
padding: 20px 50px 50px 50px;
border-width: 0 1px 0 1px;
border-style: solid;
border-color: #F0F0F0;
overflow: hidden;
}
.smp-search-form {
padding: 20px 20px 5px 20px;
}
.smp-search-form div {
padding: 1px;
}
.smp-search-form-table {}
.smp-pager a {
color: #8080FF;
font-weight: bold;
padding: 1px 5px;
text-decoration: none;
}
.smp-table {
margin: 10px 0 0 0;
}
.smp-page {
background-color: #FFFFFF;
text-align: center;
width: 15px;
padding: 0;
margin: 0;
}
.smp-current-page {
color: #202020;
font-weight: bold;
padding: 0 2px;
}
.smp-page a {
border: 1px solid #aaaaaa;
color: #606060;
height: 1.4em;
font-size: 90%;
}
.smp-page a:hover {
background-color: #909090;
color: #FFFFFF;
border: 1px solid #333333;
font-size: 90%;
}
.smp-page-space {
border: 0;
}
#smp-table-update-button {
width: 70px;
}
#smp-table-reset-button {
width: 70px;
}
tr.smp-be-operate td.smp-cell-data {
background-color: #CCFFCC !important;
}
tr.smp-valid-err-row td.smp-cell-data {
background-color: #FFF099;
}
.smp-valid-err-input {
background-color: #FF9663;
}
/* ===== 右側:AIチャットパネル ===== */
#ai-chat-panel {
width: 380px;
flex-shrink: 0;
background: #fff;
border-left: 1px solid #dde1e7;
display: flex;
flex-direction: column;
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.12);
}
#chat-panel-header {
padding: 14px 16px;
background: linear-gradient(135deg, #2c3e50 0%, #3498db 100%);
color: #fff;
font-size: 14px;
font-weight: bold;
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
#chat-messages {
flex: 1;
overflow-y: auto;
padding: 14px;
display: flex;
flex-direction: column;
gap: 10px;
background: #f8f9fb;
}
#chat-messages::-webkit-scrollbar {
width: 4px;
}
#chat-messages::-webkit-scrollbar-track {
background: transparent;
}
#chat-messages::-webkit-scrollbar-thumb {
background: #ccc;
border-radius: 2px;
}
.ai-msg {
display: flex;
flex-direction: column;
}
.ai-msg.user {
align-items: flex-end;
}
.ai-msg.bot {
align-items: flex-start;
}
.ai-msg-label {
font-size: 10px;
color: #aaa;
margin-bottom: 3px;
padding: 0 4px;
}
.ai-msg-bubble {
max-width: 90%;
padding: 9px 13px;
border-radius: 14px;
font-size: 12px;
line-height: 1.6;
word-break: break-word;
}
.ai-msg.user .ai-msg-bubble {
background: linear-gradient(135deg, #2c3e50, #3498db);
color: #fff;
border-bottom-right-radius: 3px;
}
.ai-msg.bot .ai-msg-bubble {
background: #fff;
color: #333;
border: 1px solid #e0e4ea;
border-bottom-left-radius: 3px;
}
/* 検索結果テーブル */
.ai-result-summary {
font-size: 11px;
color: #555;
margin-bottom: 6px;
}
.ai-table-wrap {
overflow-x: auto;
border-radius: 5px;
border: 1px solid #dde1e7;
margin-top: 6px;
}
.ai-result-table {
width: 100%;
border-collapse: collapse;
font-size: 10px;
white-space: nowrap;
}
.ai-result-table thead th {
background: #2c3e50;
color: #fff;
padding: 6px 8px;
text-align: left;
font-weight: normal;
}
.ai-result-table tbody td {
padding: 5px 8px;
border-bottom: 1px solid #eee;
}
.ai-result-table tbody tr:last-child td {
border-bottom: none;
}
.ai-result-table tbody tr:hover td {
background: #f0f7ff;
}
.ai-more-link {
display: inline-flex;
align-items: center;
gap: 4px;
margin-top: 8px;
color: #3498db;
text-decoration: none;
font-size: 11px;
padding: 5px 10px;
border: 1px solid #3498db;
border-radius: 4px;
}
.ai-more-link:hover {
background: #3498db;
color: #fff;
}
.ai-no-result {
color: #888;
font-size: 11px;
font-style: italic;
}
.ai-error {
color: #c0392b;
font-size: 11px;
background: #fdf2f2;
padding: 6px 10px;
border-radius: 4px;
border-left: 3px solid #e74c3c;
}
/* ===== レポートカード ===== */
.ai-report-card {
background: linear-gradient(135deg, #eaf4ff 0%, #f0f7ff 100%);
border: 1px solid #bdd7f5;
border-left: 4px solid #3498db;
border-radius: 6px;
padding: 10px 14px;
margin-top: 6px;
}
.ai-report-label {
font-size: 10px;
color: #3498db;
font-weight: bold;
letter-spacing: 0.5px;
margin-bottom: 4px;
}
.ai-report-value {
font-size: 22px;
font-weight: bold;
color: #2c3e50;
line-height: 1.3;
word-break: break-all;
}
.ai-report-sub {
font-size: 10px;
color: #888;
margin-top: 3px;
}
.ai-report-table {
width: 100%;
border-collapse: collapse;
font-size: 11px;
margin-top: 8px;
}
.ai-report-table th {
background: #2c3e50;
color: #fff;
padding: 4px 8px;
text-align: left;
font-weight: normal;
}
.ai-report-table td {
padding: 4px 8px;
border-bottom: 1px solid #dde1e7;
}
.ai-report-table tr:last-child td {
border-bottom: none;
}
.ai-report-table td:last-child {
text-align: right;
font-weight: bold;
color: #2c3e50;
}
/* ===== 検索結果カード ===== */
.ai-result-count {
font-size: 11px;
color: #3498db;
font-weight: bold;
margin-bottom: 6px;
}
.ai-record-card {
background: #fff;
border: 1px solid #e0e4ea;
border-radius: 6px;
padding: 8px 10px;
margin-bottom: 6px;
font-size: 11px;
}
.ai-record-card:last-of-type {
margin-bottom: 0;
}
.ai-record-card table {
width: 100%;
border-collapse: collapse;
}
.ai-record-card td {
padding: 2px 4px;
vertical-align: top;
line-height: 1.5;
}
.ai-record-card td.ai-field-name {
color: #888;
white-space: nowrap;
width: 6em;
font-size: 10px;
}
.ai-record-card td.ai-field-value {
color: #222;
word-break: break-all;
}
.ai-record-num {
font-size: 10px;
color: #aaa;
margin-bottom: 4px;
}
/* ローディング */
.ai-loading {
display: flex;
gap: 4px;
padding: 2px;
}
.ai-loading span {
width: 5px;
height: 5px;
background: #3498db;
border-radius: 50%;
animation: aiBounce 1.2s infinite;
}
.ai-loading span:nth-child(2) {
animation-delay: .2s;
}
.ai-loading span:nth-child(3) {
animation-delay: .4s;
}
@keyframes aiBounce {
0%,
80%,
100% {
transform: scale(.6);
opacity: .4;
}
40% {
transform: scale(1);
opacity: 1;
}
}
/* 入力エリア */
#chat-input-area {
padding: 10px 12px;
border-top: 1px solid #eee;
background: #fff;
display: flex;
gap: 7px;
align-items: flex-end;
flex-shrink: 0;
}
#ai-chat-input {
flex: 1;
padding: 8px 12px;
border: 1.5px solid #dde1e7;
border-radius: 18px;
outline: none;
font-size: 12px;
font-family: inherit;
resize: none;
line-height: 1.5;
max-height: 80px;
overflow-y: auto;
transition: border-color .2s;
}
#ai-chat-input:focus {
border-color: #3498db;
}
#ai-send-btn {
width: 34px;
height: 34px;
background: linear-gradient(135deg, #2c3e50, #3498db);
color: #fff;
border: none;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: opacity .2s, transform .1s;
}
#ai-send-btn:hover {
opacity: .9;
transform: scale(1.05);
}
#ai-send-btn:active {
transform: scale(.95);
}
#ai-send-btn:disabled {
opacity: .4;
cursor: not-allowed;
transform: none;
}
-->
</style>
</head>
<body>
<!-- ===== 左側:SPIRALテーブル ===== -->
<div id="spiral-area">
<div class="mainBody" align="center">
<!-- ===== ご自身の一覧表の差し替えキーワードに変更してください ===== -->
%sf:usr:search9%
<div style="margin-top:15px;" align="center">
<table style="width:550px;margin:0;background-color:#ffffff;border:1px solid #cccccc">
<tr>
<td style="font-size:11px;padding:5px;">このページは、当社が契約する<a
href="https://www.spiral-platform.co.jp/" target="_blank"
title="スパイラル株式会社">スパイラル株式会社</a>の情報管理システム「スパイラル バージョン1」が表示しています。
</td>
<td style="padding:5px;">
<script type="text/javascript"
src="https://reg18.smp.ne.jp/spiral/servlet/seal.Seal?_act=GetJS&version=2&sid=kfL_Fdpbsj&type=page&size=m&lang=ja"></script>
</td>
</tr>
</table>
</div>
</div>
</div>
<!-- ===== 右側:AIチャットパネル ===== -->
<div id="ai-chat-panel">
<div id="chat-panel-header">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
AI自然言語検索
</div>
<div id="chat-messages"></div>
<div id="chat-input-area">
<textarea id="ai-chat-input" placeholder="検索内容を自由に入力..." rows="1"></textarea>
<button id="ai-send-btn" title="送信">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<line x1="22" y1="2" x2="11" y2="13" />
<polygon points="22 2 15 22 11 13 2 9 22 2" />
</svg>
</button>
</div>
</div>
<script type="text/javascript">
// ===== 設定 =====
var CHAT_API_URL = 'https://reg18.smp.ne.jp/regist/is?SMPFORM=xxxxxxxxxxxxxxxxxxxxxxxx';
//PHPを設置するダミーフォームのURLを指定してください。
// ===== DOM =====
var chatMessages = document.getElementById('chat-messages');
var chatInput = document.getElementById('ai-chat-input');
var sendBtn = document.getElementById('ai-send-btn');
// テキストエリア自動リサイズ
chatInput.addEventListener('input', function () {
chatInput.style.height = 'auto';
chatInput.style.height = Math.min(chatInput.scrollHeight, 80) + 'px';
});
// ===== メッセージ追加 =====
function addMsg(role, content) {
var wrap = document.createElement('div');
wrap.className = 'ai-msg ' + role;
var label = document.createElement('div');
label.className = 'ai-msg-label';
label.textContent = role === 'user' ? 'あなた' : 'AI検索';
wrap.appendChild(label);
var bubble = document.createElement('div');
bubble.className = 'ai-msg-bubble';
if (content === '__loading__') {
var dots = document.createElement('div');
dots.className = 'ai-loading';
dots.innerHTML = '<span></span><span></span><span></span>';
bubble.appendChild(dots);
} else if (typeof content === 'string') {
bubble.textContent = content;
} else {
bubble.appendChild(content);
}
wrap.appendChild(bubble);
chatMessages.appendChild(wrap);
chatMessages.scrollTop = chatMessages.scrollHeight;
return { wrap: wrap, bubble: bubble };
}
// ===== 検索結果HTML構築(カードレイアウト) =====
function buildResult(data) {
var container = document.createElement('div');
// ===== レポートモード =====
if (data.is_report) {
// reply を改行で分割してカード表示
var lines = (data.reply || '').split('\n').filter(function (l) { return l.trim() !== ''; });
// 1行目=説明テキスト、以降=内容
var labelText = lines.length > 1 ? lines[0] : '分析結果';
var valueLines = lines.length > 1 ? lines.slice(1) : lines;
var s = document.createElement('div');
s.className = 'ai-result-summary';
s.textContent = labelText;
container.appendChild(s);
var card = document.createElement('div');
card.className = 'ai-report-card';
if (valueLines.length === 1 && !data.report_rows.length) {
// 単一値(合計・平均・最大・最小・件数)
var lbl = document.createElement('div');
lbl.className = 'ai-report-label';
lbl.textContent = '集計結果';
card.appendChild(lbl);
var val = document.createElement('div');
val.className = 'ai-report-value';
val.textContent = valueLines[0].replace(/^\s*\S+?:\s*/, '').trim() || valueLines[0];
card.appendChild(val);
var sub = document.createElement('div');
sub.className = 'ai-report-sub';
sub.textContent = '対象: ' + (data.total || 0) + '件';
card.appendChild(sub);
} else if (data.report_rows && data.report_rows.length > 0) {
// グループ別テーブル
var lbl2 = document.createElement('div');
lbl2.className = 'ai-report-label';
lbl2.textContent = 'グループ別集計(全' + (data.total || 0) + '件)';
card.appendChild(lbl2);
var tbl = document.createElement('table');
tbl.className = 'ai-report-table';
data.report_rows.forEach(function (row, i) {
var tr = document.createElement('tr');
row.forEach(function (cell, j) {
var td = i === 0 ? document.createElement('th') : document.createElement('td');
td.textContent = cell;
tr.appendChild(td);
});
tbl.appendChild(tr);
});
card.appendChild(tbl);
} else {
// 複数行テキスト
valueLines.forEach(function (l) {
var p = document.createElement('div');
p.className = 'ai-report-value';
p.style.fontSize = '13px';
p.textContent = l;
card.appendChild(p);
});
}
container.appendChild(card);
// 「詳細を見る」リンク
if (data.search_url) {
var link = document.createElement('a');
link.href = data.search_url;
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.className = 'ai-more-link';
link.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>元データを見る(全' + (data.total || 0) + '件)';
container.appendChild(link);
}
return container;
}
// 概要テキスト
if (data.reply) {
var s = document.createElement('div');
s.className = 'ai-result-summary';
s.textContent = data.reply;
container.appendChild(s);
}
// 件数表示
if (data.results && data.results.length > 0) {
var cnt = document.createElement('div');
cnt.className = 'ai-result-count';
cnt.textContent = '上位 ' + data.results.length + ' 件' +
(data.total && data.total > data.results.length ? '(全 ' + data.total + ' 件中)' : '');
container.appendChild(cnt);
}
// 結果なし
if (!data.results || data.results.length === 0) {
var nr = document.createElement('div');
nr.className = 'ai-no-result';
nr.textContent = '該当するデータが見つかりませんでした。';
container.appendChild(nr);
return container;
}
// 各レコードをカードで表示
data.results.forEach(function (row, rowIdx) {
var card = document.createElement('div');
card.className = 'ai-record-card';
var num = document.createElement('div');
num.className = 'ai-record-num';
num.textContent = '# ' + (rowIdx + 1);
card.appendChild(num);
var tbl = document.createElement('table');
var values = Array.isArray(row) ? row : Object.values(row);
values.forEach(function (cell, colIdx) {
var colName = (data.headers && data.headers[colIdx]) ? data.headers[colIdx] : ('列' + (colIdx + 1));
var val = (cell !== null && cell !== undefined && cell !== '') ? String(cell) : '—';
var tr = document.createElement('tr');
var tdLabel = document.createElement('td');
tdLabel.className = 'ai-field-name';
tdLabel.textContent = colName;
var tdVal = document.createElement('td');
tdVal.className = 'ai-field-value';
tdVal.textContent = val;
tr.appendChild(tdLabel);
tr.appendChild(tdVal);
tbl.appendChild(tr);
});
card.appendChild(tbl);
container.appendChild(card);
});
// もっと見るリンク
if (data.search_url) {
var link = document.createElement('a');
link.href = data.search_url;
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.className = 'ai-more-link';
link.innerHTML =
'<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">' +
'<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>' +
'<polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>' +
'もっと見たい場合はコチラ' + (data.total ? '(全 ' + data.total + ' 件)' : '');
container.appendChild(link);
}
return container;
}
// ===== 送信処理 =====
function sendMessage() {
var message = chatInput.value.trim();
if (!message || sendBtn.disabled) return;
chatInput.value = '';
chatInput.style.height = 'auto';
sendBtn.disabled = true;
addMsg('user', message);
var loading = addMsg('bot', '__loading__');
// 日本語文字化け対策: UTF-8 → Base64 エンコードして送信
var b64message = btoa(unescape(encodeURIComponent(message)));
fetch(CHAT_API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'message=' + encodeURIComponent(b64message)
})
.then(function (res) {
if (!res.ok) throw new Error('HTTP ' + res.status);
return res.text();
})
.then(function (text) {
// <div id="api-json-response">base64</div> を抽出してデコード
var match = text.match(/<div id="api-json-response">([\s\S]*?)<\/div>/);
if (!match) {
// フォールバック: レスポンス全文をそのままJSONとして試みる
throw new Error('レスポンスの形式が不正です(マーカーなし)。サーバーログを確認してください。');
}
var b64 = match[1].trim();
var json = decodeURIComponent(escape(atob(b64)));
return JSON.parse(json);
})
.then(function (data) {
loading.wrap.remove();
if (data.error) {
var err = document.createElement('div');
err.className = 'ai-error';
err.textContent = 'エラー: ' + data.error;
addMsg('bot', err);
} else {
addMsg('bot', buildResult(data));
}
})
.catch(function (e) {
loading.wrap.remove();
var err = document.createElement('div');
err.className = 'ai-error';
err.textContent = '通信エラー: ' + e.message;
addMsg('bot', err);
})
.finally(function () {
sendBtn.disabled = false;
chatInput.focus();
});
}
sendBtn.addEventListener('click', sendMessage);
chatInput.addEventListener('keydown', function (e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
// 初期メッセージ
addMsg('bot', 'こんにちは!左の一覧を自然言語で絞り込めます。\n例:「名前に田中が含む」「今日の売上は?」');
</script>
</body>
</html>
まとめ
本サンプルを活用することで、既存の SPIRAL 一覧表に AI 自然言語検索機能を追加できます。
DBの構成はフィールド構成取得APIが自動で取得するため、フィールド名やカテゴリ選択肢の変更もコードを書き直す必要がありません。
OpenAI APIキーとSPIRAL外部API認証情報を用意して、ぜひお試しください。
DBの構成はフィールド構成取得APIが自動で取得するため、フィールド名やカテゴリ選択肢の変更もコードを書き直す必要がありません。
OpenAI APIキーとSPIRAL外部API認証情報を用意して、ぜひお試しください。