開発情報・ナレッジ

投稿者: ShiningStar株式会社 2026年4月9日 (木)

AIでお問い合わせを自動分類!感情分析・LTV予測・担当者アサインをSPIRAL×OpenAIで実現

お問い合わせフォームから届く内容を人手で分類・振り分けしていませんか?
本記事では、OpenAI APIを活用してお問い合わせの自動分類・ルーティングを実現する方法を紹介します。
カテゴリ分類、緊急度判定、感情分析、LTV予測、エスカレーション判定まで、
AIが自動で処理することで、対応の初動を大幅に短縮できます。

注意点

・ OpenAI APIキーが必要です(有料)
・ APIコストが発生します(1件あたり約0.01〜0.05円程度)
・ 個人情報をAPIに送信する場合はプライバシーポリシーの確認が必要です
・ AI判定は100%正確ではないため、重要な判断は人間が確認してください

実装の概要

今回のコードでは、以下の流れで処理を行います。

1. お問い合わせフォームからDBに登録
2. フォーム完了画面のPHPでOpenAI APIを呼び出し、内容を分析
3. 分類結果に基づいて担当者を自動決定(負荷分散対応)
4. エスカレーション判定を実行

事前準備

1. お問い合わせDB、担当者マスタDBを作成します
2. 担当者マスタにデータを登録します
3. フォームの完了画面にPHPコードを設置します
4. OpenAI APIキーを取得・設定します

お問い合わせDB
フィールド名 識別名 型 説明
ID contactId 数字・記号・アルファベット(32 bytes) 自動採番 主キー
お問い合わせ日時 regist_date 登録日時 日付・時刻
お名前 name テキスト 問い合わせ者名
会社名 company テキスト 会社名
メールアドレス email メールアドレス 連絡先
お問い合わせ内容 content テキストエリア 本文
カテゴリ category セレクト 技術/営業/クレーム/その他
緊急度 urgency セレクト 高/中/低
感情 sentiment セレクト ポジティブ/ニュートラル/ネガティブ
LTVスコア ltv_score 整数 0-100
担当部署 department テキスト ルーティング先
担当者 assignee テキスト 担当者名
エスカレーション escalation セレクト 要/不要
AI解析結果 ai_analysis テキストエリア JSON形式
ステータス status セレクト 未対応/対応中/完了
担当者マスタDB
フィールド名 識別名 型 説明
担当者ID staff_id 数字・記号・アルファベット(32 bytes) 主キー
担当者名 staff_name テキスト 氏名
メールアドレス email メールアドレス 通知先
部署 department セレクト 技術/営業/カスタマーサポート
対応カテゴリ categories マルチセレクト 技術/営業/クレーム/その他
役職 role セレクト 一般/リーダー/マネージャー

本番用コード(完全版)

以下のコードをフォームの完了画面のPHPにそのまま設置してください。
冒頭の設定部分(APIキー、DB名等)を環境に合わせて変更するだけで動作します。

登録されたデータは$SPIRAL->getParam()で取得します。

コピー
<?//<!-- SMP_DYNAMIC_PAGE DISPLAY_ERRORS=ON NAME=XXX -->?>
<?php
/**
 * AI問い合わせ自動分類・ルーティングシステム
 * 
 * お問い合わせフォームから登録されたデータをAIで分析し、
 * カテゴリ分類、緊急度判定、感情分析、LTV予測、エスカレーション判定を行い、
 * 適切な担当者に自動ルーティングするシステム
 * 
 * SPIRAL ver.1用 - フォーム完了画面のPHPとして設置
 */

// ============================================================
// 設定
// ============================================================

define('OPENAI_API_KEY', 'sk-xxxxxxxxxxxxxxxxxxxxxxxx'); // OpenAI APIキー
define('OPENAI_MODEL', 'gpt-5.2'); // OpenAI モデル名
define('INQUIRY_DB_NAME', 'inquiry_db'); // お問い合わせDB名
define('STAFF_DB_NAME', 'staff_master'); // 担当者マスタDB名

// ============================================================
// メイン処理
// ============================================================

// フォームから登録されたデータを取得(ver.1形式)
$recordId = $SPIRAL->getContextByFieldTitle('contactId');
$content = $SPIRAL->getParam('content');
$customerName = $SPIRAL->getParam('name');
$companyName = $SPIRAL->getParam('company');
$email = $SPIRAL->getParam('email');

// 内容が空の場合はスキップ
if (empty(trim($content))) {
    return;
}

// 過去のお問い合わせ履歴を取得(同一メールアドレス)
$customerHistory = [];
if (!empty($email)) {
    $customerHistory = getCustomerHistory($email, $recordId);
}

// AI分析を実行
$analysisResult = analyzeInquiry($content, $customerName, $companyName, $customerHistory);

// ルーティング(担当者決定)- 負荷分散機能付き
$routing = routeWithLoadBalancing(
    $analysisResult['category'],
    $analysisResult['urgency'],
    $analysisResult['ltv_score']
);

// エスカレーション判定
$escalation = checkEscalation(
    $analysisResult['category'],
    $analysisResult['urgency'],
    $analysisResult['sentiment'],
    $analysisResult['ltv_score'],
    $analysisResult['churn_risk'],
    $analysisResult['escalation_reason']
);

// SELECTフィールドのIDマッピング(SPIRALのDB設定に合わせて各種ID値を変更してください)
$selectMap = [
    'category' => ['技術' => '1', '営業' => '2', 'クレーム' => '3', 'その他' => '4'],
    'urgency' => ['高' => '1', '中' => '2', '低' => '3'],
    'sentiment' => ['ポジティブ' => '1', 'ニュートラル' => '2', 'ネガティブ' => '3'],
    'escalation' => ['要' => '1', '不要' => '2'],
    'status' => ['未対応' => '1', '対応中' => '2', '完了' => '3']
];

// DB更新用データを準備(セレクトフィールドは文字列ではなくID値として格納)
$updateData = [
    'category' => $selectMap['category'][$analysisResult['category']] ?? '4',
    'urgency' => $selectMap['urgency'][$analysisResult['urgency']] ?? '2',
    'sentiment' => $selectMap['sentiment'][$analysisResult['sentiment']] ?? '2',
    'ltv_score' => $analysisResult['ltv_score'],
    'escalation' => $selectMap['escalation'][$escalation['required'] ? '要' : '不要'] ?? '2',
    'department' => $routing['department'],
    'assignee' => $routing['assignee_name'],
    'ai_analysis' => json_encode([
        'summary' => $analysisResult['summary'],
        'suggested_response_points' => $analysisResult['suggested_response_points'],
        'keywords' => $analysisResult['keywords'],
        'churn_risk' => $analysisResult['churn_risk'],
        'upsell_opportunity' => $analysisResult['upsell_opportunity'],
        'escalation_reason' => $escalation['reason'],
        'escalation_level' => $escalation['level'],
        'routing_reason' => $routing['reason'],
        'analyzed_at' => date('Y-m-d H:i:s')
    ], JSON_UNESCAPED_UNICODE),
    'status' => $selectMap['status']['未対応'] ?? '1'
];

// SPIRAL APIでレコード更新
updateRecord($recordId, $updateData);

// ============================================================
// AI分析機能
// ============================================================

/**
 * お問い合わせ内容をAIで分析
 */
function analyzeInquiry($content, $customerName = '', $companyName = '', $customerHistory = [])
{
    $prompt = buildAnalysisPrompt($content, $customerName, $companyName, $customerHistory);
    $response = callOpenAI($prompt);

    if ($response === false) {
        return getDefaultAnalysisResult();
    }

    return parseAnalysisResponse($response);
}

/**
 * 分析用プロンプトを構築
 */
function buildAnalysisPrompt($content, $customerName, $companyName, $customerHistory)
{
    $systemPrompt = <<<EOT




あなたはお問い合わせ分析AIです。以下の情報を分析し、JSON形式で結果を返してください。

## 分析項目

1. category(カテゴリ): 以下から1つ選択
   - "技術": 技術的な質問、バグ報告、使い方の質問
   - "営業": 見積もり依頼、導入相談、デモ依頼
   - "クレーム": 不満、苦情、解約示唆
   - "その他": 上記に該当しないもの

2. urgency(緊急度): 以下から1つ選択
   - "高": 本番障害、業務停止、即時対応必要
   - "中": 当日〜翌日対応が望ましい
   - "低": 通常対応で問題なし

3. sentiment(感情): 以下から1つ選択
   - "ポジティブ": 好意的、感謝、期待
   - "ニュートラル": 中立的、事務的
   - "ネガティブ": 不満、怒り、失望

4. ltv_score(LTV予測スコア): 0-100の整数
5. escalation_reason(エスカレーション理由): 必要な場合はその理由
6. summary(要約): 50文字以内
7. suggested_response_points(回答のポイント): 箇条書き
8. keywords(キーワード): 最大5つ
9. churn_risk(解約リスク): 0-100の整数
10. upsell_opportunity(アップセル機会): 0-100の整数

## 出力形式(JSON)
{
  "category": "技術",
  "urgency": "中",
  "sentiment": "ニュートラル",
  "ltv_score": 50,
  "escalation_reason": "",
  "summary": "APIの認証方法についての質問",
  "suggested_response_points": ["認証フローの説明"],
  "keywords": ["API", "認証"],
  "churn_risk": 10,
  "upsell_opportunity": 30
}
EOT;

    $userPrompt = "## お問い合わせ情報\n\n";
    if ($customerName)
        $userPrompt .= "お客様名: {$customerName}\n";
    if ($companyName)
        $userPrompt .= "会社名: {$companyName}\n";

    if (!empty($customerHistory)) {
        $userPrompt .= "\n## 過去のお問い合わせ履歴(直近3件)\n";
        foreach (array_slice($customerHistory, 0, 3) as $history) {
            $userPrompt .= "- [{$history['date']}] {$history['category']}: {$history['summary']}\n";
        }
    }

    $userPrompt .= "\n## 今回のお問い合わせ内容\n{$content}";

    return ['system' => $systemPrompt, 'user' => $userPrompt];
}

/**
 * OpenAI APIを呼び出し
 */
function callOpenAI($prompt)
{
    $data = [
        'model' => OPENAI_MODEL,
        'messages' => [
            ['role' => 'system', 'content' => $prompt['system']],
            ['role' => 'user', 'content' => $prompt['user']]
        ],
        'response_format' => ['type' => 'json_object']
    ];

    $ch = curl_init('https://api.openai.com/v1/chat/completions');
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_POST => true,
        CURLOPT_HTTPHEADER => [
            'Content-Type: application/json',
            'Authorization: Bearer ' . OPENAI_API_KEY
        ],
        CURLOPT_POSTFIELDS => json_encode($data),
        CURLOPT_TIMEOUT => 30
    ]);

    $response = curl_exec($ch);
    $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    if ($httpCode !== 200) {
        return false;
    }
    $result = json_decode($response, true);
    return $result['choices'][0]['message']['content'] ?? false;
}

/**
 * AI分析レスポンスをパース
 */
function parseAnalysisResponse($response)
{
    $data = json_decode($response, true);
    if (json_last_error() !== JSON_ERROR_NONE) {
        return getDefaultAnalysisResult();
    }

    return [
        'category' => $data['category'] ?? 'その他',
        'urgency' => $data['urgency'] ?? '中',
        'sentiment' => $data['sentiment'] ?? 'ニュートラル',
        'ltv_score' => max(0, min(100, intval($data['ltv_score'] ?? 50))),
        'escalation_reason' => $data['escalation_reason'] ?? '',
        'summary' => mb_substr($data['summary'] ?? '', 0, 100),
        'suggested_response_points' => $data['suggested_response_points'] ?? [],
        'keywords' => $data['keywords'] ?? [],
        'churn_risk' => max(0, min(100, intval($data['churn_risk'] ?? 0))),
        'upsell_opportunity' => max(0, min(100, intval($data['upsell_opportunity'] ?? 0)))
    ];
}

/**
 * デフォルト分析結果(API失敗時)
 */
function getDefaultAnalysisResult()
{
    return [
        'category' => 'その他', 'urgency' => '中', 'sentiment' => 'ニュートラル',
        'ltv_score' => 50, 'escalation_reason' => '', 'summary' => 'AI分析に失敗しました',
        'suggested_response_points' => [], 'keywords' => [],
        'churn_risk' => 0, 'upsell_opportunity' => 0
    ];
}

// ============================================================
// ルーティング機能
// ============================================================

/**
 * 負荷分散を考慮したルーティング
 */
function routeWithLoadBalancing($category, $urgency, $ltvScore)
{
    $departmentMap = [
        '技術' => 'テクニカルサポート',
        '営業' => '営業部',
        'クレーム' => 'カスタマーサポート',
        'その他' => 'カスタマーサポート'
    ];
    $department = $departmentMap[$category] ?? $departmentMap['その他'];
    $vipThreshold = 70;

    // 担当者マスタから候補を取得
    $candidates = getRoutingCandidates($category, $urgency, $ltvScore, $vipThreshold);

    if (empty($candidates)) {
        return [
            'department' => $department,
            'assignee_id' => '',
            'assignee_name' => '未割当',
            'assignee_email' => '',
            'reason' => "カテゴリ「{$category}」に対応可能な担当者が見つかりませんでした"
        ];
    }

    // 最初の担当者を選択(ver.1では負荷情報がないため)
    $selectedStaff = $candidates[0];

    $reasons = ["カテゴリ「{$category}」"];
    if ($ltvScore >= $vipThreshold)
        $reasons[] = "VIP顧客(LTVスコア: {$ltvScore})";
    if ($urgency === '高')
        $reasons[] = "緊急度「高」";

    return [
        'department' => $department,
        'assignee_id' => $selectedStaff['staff_id'] ?? '',
        'assignee_name' => $selectedStaff['staff_name'] ?? '未割当',
        'assignee_email' => $selectedStaff['email'] ?? '',
        'reason' => implode('、', $reasons) . " → {$selectedStaff['staff_name']}を選定"
    ];
}

/**
 * ルーティング候補を取得(ver.1 API)
 */
function getRoutingCandidates($category, $urgency, $ltvScore, $vipThreshold)
{
    global $SPIRAL;

    // SELECTフィールドのIDマッピング
    $selectMap = [
        'category' => ['技術' => '1', '営業' => '2', 'クレーム' => '3', 'その他' => '4']
    ];
    $categoryId = $selectMap['category'][$category] ?? '4';

    // 担当者マスタから対応可能カテゴリ(マルチセレクト)に指定IDが含まれる担当者を検索
    $db = $SPIRAL->getDataBase(STAFF_DB_NAME);
    $db->addSelectFields('staff_id', 'staff_name', 'email', 'department', 'categories', 'role');
    $db->addLikeCondition('categories', '%' . $categoryId . '%');
    $db->setLinesPerPage(50);

    $result = $db->doSelect();

    if (!$result || empty($result['data'])) {
        return [];
    }

    // VIPまたは緊急の場合はマネージャー/リーダーを優先
    $candidates = [];
    foreach ($result['data'] as $staff) {
        if (($ltvScore >= $vipThreshold || $urgency === '高') &&
        in_array($staff['role'] ?? '', ['マネージャー', 'リーダー'])) {
            array_unshift($candidates, $staff);
        }
        else {
            $candidates[] = $staff;
        }
    }

    return $candidates;
}

// ============================================================
// エスカレーション判定機能
// ============================================================

/**
 * エスカレーション判定を実行
 */
function checkEscalation($category, $urgency, $sentiment, $ltvScore, $churnRisk, $aiEscalationReason = '')
{
    $thresholds = [
        'ltv_vip' => 80,
        'ltv_high_value' => 60,
        'churn_risk_high' => 70,
        'churn_risk_medium' => 40
    ];

    $reasons = [];
    $level = 0;

    // レベル3(最高緊急度)
    if ($ltvScore >= $thresholds['ltv_vip'] && $category === 'クレーム' && $urgency === '高') {
        $reasons[] = 'VIP顧客の緊急クレーム';
        $level = max($level, 3);
    }
    if ($ltvScore >= $thresholds['ltv_vip'] && $churnRisk >= $thresholds['churn_risk_high']) {
        $reasons[] = 'VIP顧客の解約リスク高';
        $level = max($level, 3);
    }

    // レベル2
    if ($category === 'クレーム' && $urgency === '高') {
        $reasons[] = '緊急クレーム';
        $level = max($level, 2);
    }
    if ($ltvScore >= $thresholds['ltv_vip'] && $urgency === '高') {
        $reasons[] = 'VIP顧客の緊急案件';
        $level = max($level, 2);
    }
    if ($ltvScore >= $thresholds['ltv_vip'] && $category === 'クレーム') {
        $reasons[] = 'VIP顧客からのクレーム';
        $level = max($level, 2);
    }
    if ($churnRisk >= $thresholds['churn_risk_high'] && $sentiment === 'ネガティブ') {
        $reasons[] = '解約リスク高+ネガティブ';
        $level = max($level, 2);
    }

    // レベル1
    if ($category === 'クレーム' && $sentiment === 'ネガティブ') {
        $reasons[] = 'ネガティブなクレーム';
        $level = max($level, 1);
    }
    if ($ltvScore >= $thresholds['ltv_high_value'] && $sentiment === 'ネガティブ') {
        $reasons[] = '高価値顧客の不満';
        $level = max($level, 1);
    }
    if ($category === '営業' && $ltvScore >= $thresholds['ltv_vip']) {
        $reasons[] = 'VIP商談機会';
        $level = max($level, 1);
    }
    if ($churnRisk >= $thresholds['churn_risk_medium'] && $churnRisk < $thresholds['churn_risk_high']) {
        $reasons[] = '解約リスク中程度';
        $level = max($level, 1);
    }
    if (!empty($aiEscalationReason)) {
        $reasons[] = "AI判定: {$aiEscalationReason}";
        $level = max($level, 1);
    }

    $levelDescriptions = [
        0 => '通常対応',
        1 => '部門責任者へエスカレーション',
        2 => '事業部長へ緊急エスカレーション',
        3 => '経営層へ最優先エスカレーション'
    ];

    return [
        'required' => $level > 0,
        'level' => $level,
        'level_description' => $levelDescriptions[$level] ?? '不明',
        'reason' => implode('、', $reasons)
    ];
}

// ============================================================
// SPIRAL API関連機能(ver.1)
// ============================================================

/**
 * 顧客の過去のお問い合わせ履歴を取得
 */
function getCustomerHistory($email, $excludeRecordId)
{
    global $SPIRAL;

    $db = $SPIRAL->getDataBase(INQUIRY_DB_NAME);
    $db->addSelectFields('contactId', 'category', 'ai_analysis', 'regist_date');
    $db->addEqualCondition('email', $email);
    $db->addSortField('regist_date', false);
    $db->setLinesPerPage(4);

    $result = $db->doSelect();

    if (!$result || empty($result['data'])) {
        return [];
    }

    $history = [];
    foreach ($result['data'] as $item) {
        if (isset($item['contactId']) && $item['contactId'] == $excludeRecordId)
            continue;
        if (count($history) >= 3)
            break;

        $aiAnalysisStr = (isset($item['ai_analysis']) && is_string($item['ai_analysis'])) ? $item['ai_analysis'] : '{}';
        $aiAnalysis = json_decode($aiAnalysisStr, true) ?: [];
        $history[] = [
            'date' => date('Y-m-d', strtotime($item['regist_date'] ?? 'now')),
            'category' => (isset($item['category']) && is_string($item['category'])) ? $item['category'] : 'その他',
            'summary' => $aiAnalysis['summary'] ?? ''
        ];
    }

    return $history;
}

/**
 * レコードを更新
 */
function updateRecord($recordId, $data)
{
    global $SPIRAL;

    $db = $SPIRAL->getDataBase(INQUIRY_DB_NAME);
    $db->addEqualCondition("contactId", $recordId);

    return $db->doUpdate($data);
}
?>
            

コード詳細解説

上記の本番用コードを機能ごとに分割して解説します。
各機能の仕組みを理解することで、カスタマイズが容易になります。

1. AI分析機能

OpenAI APIを呼び出してお問い合わせ内容を分析する機能です。
analyzeInquiry()関数がエントリーポイントとなり、以下の処理を行います:

・ プロンプト構築:顧客情報と過去履歴を含めた分析用プロンプトを生成
・ API呼び出し:OpenAI APIにリクエストを送信
・ レスポンス解析:JSON形式の結果をパースし、バリデーション
コピー
// ============================================================
// AI分析機能
// ============================================================

/**
 * お問い合わせ内容をAIで分析
 */
function analyzeInquiry($content, $customerName = '', $companyName = '', $customerHistory = [])
{
    $prompt = buildAnalysisPrompt($content, $customerName, $companyName, $customerHistory);
    $response = callOpenAI($prompt);

    if ($response === false) {
        return getDefaultAnalysisResult();
    }

    return parseAnalysisResponse($response);
}

/**
 * 分析用プロンプトを構築
 */
function buildAnalysisPrompt($content, $customerName, $companyName, $customerHistory)
{
    $systemPrompt = <<<EOT


あなたはお問い合わせ分析AIです。以下の情報を分析し、JSON形式で結果を返してください。

## 分析項目

1. category(カテゴリ): 以下から1つ選択
   - "技術": 技術的な質問、バグ報告、使い方の質問
   - "営業": 見積もり依頼、導入相談、デモ依頼
   - "クレーム": 不満、苦情、解約示唆
   - "その他": 上記に該当しないもの

2. urgency(緊急度): 以下から1つ選択
   - "高": 本番障害、業務停止、即時対応必要
   - "中": 当日〜翌日対応が望ましい
   - "低": 通常対応で問題なし

3. sentiment(感情): 以下から1つ選択
   - "ポジティブ": 好意的、感謝、期待
   - "ニュートラル": 中立的、事務的
   - "ネガティブ": 不満、怒り、失望

4. ltv_score(LTV予測スコア): 0-100の整数
5. escalation_reason(エスカレーション理由): 必要な場合はその理由
6. summary(要約): 50文字以内
7. suggested_response_points(回答のポイント): 箇条書き
8. keywords(キーワード): 最大5つ
9. churn_risk(解約リスク): 0-100の整数
10. upsell_opportunity(アップセル機会): 0-100の整数

## 出力形式(JSON)
{
  "category": "技術",
  "urgency": "中",
  "sentiment": "ニュートラル",
  "ltv_score": 50,
  "escalation_reason": "",
  "summary": "APIの認証方法についての質問",
  "suggested_response_points": ["認証フローの説明"],
  "keywords": ["API", "認証"],
  "churn_risk": 10,
  "upsell_opportunity": 30
}
EOT;

    $userPrompt = "## お問い合わせ情報\n\n";
    if ($customerName)
        $userPrompt .= "お客様名: {$customerName}\n";
    if ($companyName)
        $userPrompt .= "会社名: {$companyName}\n";

    if (!empty($customerHistory)) {
        $userPrompt .= "\n## 過去のお問い合わせ履歴(直近3件)\n";
        foreach (array_slice($customerHistory, 0, 3) as $history) {
            $userPrompt .= "- [{$history['date']}] {$history['category']}: {$history['summary']}\n";
        }
    }

    $userPrompt .= "\n## 今回のお問い合わせ内容\n{$content}";

    return ['system' => $systemPrompt, 'user' => $userPrompt];
}

/**
 * OpenAI APIを呼び出し
 */
function callOpenAI($prompt)
{
    $data = [
        'model' => OPENAI_MODEL,
        'messages' => [
            ['role' => 'system', 'content' => $prompt['system']],
            ['role' => 'user', 'content' => $prompt['user']]
        ],
        'response_format' => ['type' => 'json_object']
    ];

    $ch = curl_init('https://api.openai.com/v1/chat/completions');
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_POST => true,
        CURLOPT_HTTPHEADER => [
            'Content-Type: application/json',
            'Authorization: Bearer ' . OPENAI_API_KEY
        ],
        CURLOPT_POSTFIELDS => json_encode($data),
        CURLOPT_TIMEOUT => 30
    ]);

    $response = curl_exec($ch);
    $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    if ($httpCode !== 200) {
        return false;
    }
    $result = json_decode($response, true);
    return $result['choices'][0]['message']['content'] ?? false;
}

/**
 * AI分析レスポンスをパース
 */
function parseAnalysisResponse($response)
{
    $data = json_decode($response, true);
    if (json_last_error() !== JSON_ERROR_NONE) {
        return getDefaultAnalysisResult();
    }

    return [
        'category' => $data['category'] ?? 'その他',
        'urgency' => $data['urgency'] ?? '中',
        'sentiment' => $data['sentiment'] ?? 'ニュートラル',
        'ltv_score' => max(0, min(100, intval($data['ltv_score'] ?? 50))),
        'escalation_reason' => $data['escalation_reason'] ?? '',
        'summary' => mb_substr($data['summary'] ?? '', 0, 100),
        'suggested_response_points' => $data['suggested_response_points'] ?? [],
        'keywords' => $data['keywords'] ?? [],
        'churn_risk' => max(0, min(100, intval($data['churn_risk'] ?? 0))),
        'upsell_opportunity' => max(0, min(100, intval($data['upsell_opportunity'] ?? 0)))
    ];
}

/**
 * デフォルト分析結果(API失敗時)
 */
function getDefaultAnalysisResult()
{
    return [
        'category' => 'その他', 'urgency' => '中', 'sentiment' => 'ニュートラル',
        'ltv_score' => 50, 'escalation_reason' => '', 'summary' => 'AI分析に失敗しました',
        'suggested_response_points' => [], 'keywords' => [],
        'churn_risk' => 0, 'upsell_opportunity' => 0
    ];
}
            
2. ルーティング機能

AI分析結果に基づいて適切な担当者を自動決定する機能です。
routeWithLoadBalancing()関数が以下のロジックで担当者を選定します:

・ カテゴリ→部署マッピング:技術→テクニカルサポート、営業→営業部など
・ VIP顧客判定:LTVスコア70以上はマネージャー/リーダーを優先
・ 担当者マスタから対応可能カテゴリでフィルタリング
コピー
// ============================================================
// ルーティング機能
// ============================================================

/**
 * 負荷分散を考慮したルーティング
 */
function routeWithLoadBalancing($category, $urgency, $ltvScore)
{
    $departmentMap = [
        '技術' => 'テクニカルサポート',
        '営業' => '営業部',
        'クレーム' => 'カスタマーサポート',
        'その他' => 'カスタマーサポート'
    ];
    $department = $departmentMap[$category] ?? $departmentMap['その他'];
    $vipThreshold = 70;

    // 担当者マスタから候補を取得
    $candidates = getRoutingCandidates($category, $urgency, $ltvScore, $vipThreshold);

    if (empty($candidates)) {
        return [
            'department' => $department,
            'assignee_id' => '',
            'assignee_name' => '未割当',
            'assignee_email' => '',
            'reason' => "カテゴリ「{$category}」に対応可能な担当者が見つかりませんでした"
        ];
    }

    // 最初の担当者を選択(ver.1では負荷情報がないため)
    $selectedStaff = $candidates[0];

    $reasons = ["カテゴリ「{$category}」"];
    if ($ltvScore >= $vipThreshold)
        $reasons[] = "VIP顧客(LTVスコア: {$ltvScore})";
    if ($urgency === '高')
        $reasons[] = "緊急度「高」";

    return [
        'department' => $department,
        'assignee_id' => $selectedStaff['staff_id'] ?? '',
        'assignee_name' => $selectedStaff['staff_name'] ?? '未割当',
        'assignee_email' => $selectedStaff['email'] ?? '',
        'reason' => implode('、', $reasons) . " → {$selectedStaff['staff_name']}を選定"
    ];
}

/**
 * ルーティング候補を取得(ver.1 API)
 */
function getRoutingCandidates($category, $urgency, $ltvScore, $vipThreshold)
{
    global $SPIRAL;

    // SELECTフィールドのIDマッピング
    $selectMap = [
        'category' => ['技術' => '1', '営業' => '2', 'クレーム' => '3', 'その他' => '4']
    ];
    $categoryId = $selectMap['category'][$category] ?? '4';

    // 担当者マスタから対応可能カテゴリ(マルチセレクト)に指定IDが含まれる担当者を検索
    $db = $SPIRAL->getDataBase(STAFF_DB_NAME);
    $db->addSelectFields('staff_id', 'staff_name', 'email', 'department', 'categories', 'role');
    $db->addLikeCondition('categories', '%' . $categoryId . '%');
    $db->setLinesPerPage(50);

    $result = $db->doSelect();

    if (!$result || empty($result['data'])) {
        return [];
    }

    // VIPまたは緊急の場合はマネージャー/リーダーを優先
    $candidates = [];
    foreach ($result['data'] as $staff) {
        if (($ltvScore >= $vipThreshold || $urgency === '高') &&
        in_array($staff['role'] ?? '', ['マネージャー', 'リーダー'])) {
            array_unshift($candidates, $staff);
        }
        else {
            $candidates[] = $staff;
        }
    }

    return $candidates;
}
            
3. エスカレーション判定機能

複数の条件を組み合わせてエスカレーションレベル(0〜3)を判定します。
checkEscalation()関数の判定ロジック:

・ レベル3(経営層):VIP顧客の緊急クレーム、VIP顧客の解約リスク高
・ レベル2(事業部長):緊急クレーム、VIP顧客の緊急案件
・ レベル1(部門責任者):ネガティブなクレーム、高価値顧客の不満
コピー
// ============================================================
// エスカレーション判定機能
// ============================================================

/**
 * エスカレーション判定を実行
 */
function checkEscalation($category, $urgency, $sentiment, $ltvScore, $churnRisk, $aiEscalationReason = '')
{
    $thresholds = [
        'ltv_vip' => 80,
        'ltv_high_value' => 60,
        'churn_risk_high' => 70,
        'churn_risk_medium' => 40
    ];

    $reasons = [];
    $level = 0;

    // レベル3(最高緊急度)
    if ($ltvScore >= $thresholds['ltv_vip'] && $category === 'クレーム' && $urgency === '高') {
        $reasons[] = 'VIP顧客の緊急クレーム';
        $level = max($level, 3);
    }
    if ($ltvScore >= $thresholds['ltv_vip'] && $churnRisk >= $thresholds['churn_risk_high']) {
        $reasons[] = 'VIP顧客の解約リスク高';
        $level = max($level, 3);
    }

    // レベル2
    if ($category === 'クレーム' && $urgency === '高') {
        $reasons[] = '緊急クレーム';
        $level = max($level, 2);
    }
    if ($ltvScore >= $thresholds['ltv_vip'] && $urgency === '高') {
        $reasons[] = 'VIP顧客の緊急案件';
        $level = max($level, 2);
    }
    if ($ltvScore >= $thresholds['ltv_vip'] && $category === 'クレーム') {
        $reasons[] = 'VIP顧客からのクレーム';
        $level = max($level, 2);
    }
    if ($churnRisk >= $thresholds['churn_risk_high'] && $sentiment === 'ネガティブ') {
        $reasons[] = '解約リスク高+ネガティブ';
        $level = max($level, 2);
    }

    // レベル1
    if ($category === 'クレーム' && $sentiment === 'ネガティブ') {
        $reasons[] = 'ネガティブなクレーム';
        $level = max($level, 1);
    }
    if ($ltvScore >= $thresholds['ltv_high_value'] && $sentiment === 'ネガティブ') {
        $reasons[] = '高価値顧客の不満';
        $level = max($level, 1);
    }
    if ($category === '営業' && $ltvScore >= $thresholds['ltv_vip']) {
        $reasons[] = 'VIP商談機会';
        $level = max($level, 1);
    }
    if ($churnRisk >= $thresholds['churn_risk_medium'] && $churnRisk < $thresholds['churn_risk_high']) {
        $reasons[] = '解約リスク中程度';
        $level = max($level, 1);
    }
    if (!empty($aiEscalationReason)) {
        $reasons[] = "AI判定: {$aiEscalationReason}";
        $level = max($level, 1);
    }

    $levelDescriptions = [
        0 => '通常対応',
        1 => '部門責任者へエスカレーション',
        2 => '事業部長へ緊急エスカレーション',
        3 => '経営層へ最優先エスカレーション'
    ];

    return [
        'required' => $level > 0,
        'level' => $level,
        'level_description' => $levelDescriptions[$level] ?? '不明',
        'reason' => implode('、', $reasons)
    ];
}
            
4. SPIRAL API関連機能

SPIRAL ver.1のAPIを呼び出すための共通関数群です。
レコードの取得・更新を行います。

・ getCustomerHistory():同一メールアドレスの過去問い合わせを取得
・ updateRecord():分析結果でレコードを更新
コピー
// ============================================================
// SPIRAL API関連機能ver.1
// ============================================================

/**
 * 顧客の過去のお問い合わせ履歴を取得
 */
function getCustomerHistory($email, $excludeRecordId)
{
    global $SPIRAL;

    $db = $SPIRAL->getDataBase(INQUIRY_DB_NAME);
    $db->addSelectFields('contactId', 'category', 'ai_analysis', 'regist_date');
    $db->addEqualCondition('email', $email);
    $db->addSortField('regist_date', false);
    $db->setLinesPerPage(4);

    $result = $db->doSelect();

    if (!$result || empty($result['data'])) {
        return [];
    }

    $history = [];
    foreach ($result['data'] as $item) {
        if (isset($item['contactId']) && $item['contactId'] == $excludeRecordId)
            continue;
        if (count($history) >= 3)
            break;

        $aiAnalysisStr = (isset($item['ai_analysis']) && is_string($item['ai_analysis'])) ? $item['ai_analysis'] : '{}';
        $aiAnalysis = json_decode($aiAnalysisStr, true) ?: [];
        $history[] = [
            'date' => date('Y-m-d', strtotime($item['regist_date'] ?? 'now')),
            'category' => (isset($item['category']) && is_string($item['category'])) ? $item['category'] : 'その他',
            'summary' => $aiAnalysis['summary'] ?? ''
        ];
    }

    return $history;
}

/**
 * レコードを更新
 */
function updateRecord($recordId, $data)
{
    global $SPIRAL;

    $db = $SPIRAL->getDataBase(INQUIRY_DB_NAME);
    $db->addEqualCondition("contactId", $recordId);

    return $db->doUpdate($data);
}
            

動作確認

テストケース1:技術的な質問(通常)
お問い合わせ内容:
「APIの認証方法について教えてください。ドキュメントを見ましたが、トークンの取得方法がわかりません。」

期待される結果:
カテゴリ: 技術 / 緊急度: 低 / 感情: ニュートラル / エスカレーション: 不要
テストケース2:クレーム(緊急)
お問い合わせ内容:
「先週から何度も問い合わせていますが、一向に返信がありません。このままでは契約を解除せざるを得ません。至急対応してください。」

期待される結果:
カテゴリ: クレーム / 緊急度: 高 / 感情: ネガティブ / エスカレーション: 要(マネージャーに通知)
テストケース3:営業案件(高LTV)
お問い合わせ内容:
「弊社は従業員5000名規模の企業です。全社的なシステム導入を検討しており、年間予算として1000万円程度を想定しています。デモをお願いできますか。」

期待される結果:
カテゴリ: 営業 / 緊急度: 中 / 感情: ポジティブ / LTVスコア: 85以上 / エスカレーション: 要(VIP案件)

マーケティング活用

LTVスコアの活用
・ VIP顧客(80点以上):専任担当者をアサイン、優先対応
・ 高価値顧客(60-79点):アップセル/クロスセル提案の対象
・ 標準顧客(30-59点):通常対応
・ 低価値顧客(30点未満):自動応答やFAQ誘導を検討
感情分析の活用
・ ネガティブ傾向の検出:解約予兆として早期フォロー
・ ポジティブ傾向の検出:アップセル提案のタイミング
・ トレンド分析:製品改善のフィードバック

コスト試算

月間お問い合わせ数 推定APIコスト
100件 約50〜100円
1,000件 約500〜1,000円
10,000件 約5,000〜10,000円
※GPT-5.2を使用した場合の概算。実際のコストは内容の長さにより変動します。

まとめ

本記事では、AIによるお問い合わせの自動分類・ルーティングの実装方法を紹介しました。
特にクレームの早期検出やVIP顧客の優先対応は、顧客満足度向上に直結します。
さらにLTV予測や感情分析を活用することで、マーケティング施策にも活かせます。
不具合やご質問がある場合は、下記の「コンテンツに関しての要望はこちら」からご連絡ください。
解決しない場合はこちら コンテンツに関しての
要望はこちら