開発情報・ナレッジ

投稿者: 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予測や感情分析を活用することで、マーケティング施策にも活かせます。
不具合やご質問がある場合は、下記の「コンテンツに関しての要望はこちら」からご連絡ください。
解決しない場合はこちら コンテンツに関しての
要望はこちら