できること
・お客様からメッセージに対して、AIエージェントから自動応答する。
・お客様から質問に対して、AIエージェントから回答生成する。
・会話メッセージの要約およびTODOの作成する。
・定期的に会話メッセージを分析し、顧客満足度や顧客ランク評価など任意情報抽出する。
・Agentforce・ChatGPT・Gemini・Claude・自社カスタムAIを接続可能です。
設定方法
準備① 必要なプロンプトを事前に登録し、以下各機能にて選択する。※利用箇所以下②以後にて参照する。
準備② リモートサイトの設定 必要なドメイン許可
Claude : https://api.anthropic.com
OpenAI : https://api.openai.com
DX-LINE→ホーム→設定・登録→すべて表示→プロンプト
選択する。

・プロンプト名:任意
・AIエージェント名(v2.11~):
デフォルトはOpenAI、OpenAIBot利用可能。
その他AIと接続する場合、拡張用Apexクラスの作成は必要です。※Apexのクラス名は、選択リストのAPI名と一致する必要です。サンプルコードはこちら
・有効化: 有効化のみ利用可能
・システム(共通):エージェントの役割を定義する文書。
・ユーザ: エージェントに具体的な指示する文書。
・メモ: 任意入力
・オプション:JSON形式で、そのままAPIに渡す。
・パラメータ(ChatGPT): ChatGPT関連のパラメータ詳細は各項目のヘルプを参照する。(デフォルトのままでよい)

準備 ② AIエージェントの接続情報をDX-LINEへ設定する
① DX-LINE設定にて、「生成AI利用」有効化し、「生成AI関連設定」タブへ遷移する

➁ 有効化後に、「認証情報」
Edit押下し、接続入力を入力する。
※ChatGPTキーの取得方法はこちらへ参照する
③要約生成機能有効化
有効することによって、チャット画面の要約生成ボタン利用可能になる。
登録済みのプロンプト選択して、TODO自動作成有効の場合、TODOのデフォルト件名、期日、紐付け先を指定する
④定期的にチャットメッセージから関心情報を分析
「定期処理有効」選択し、対象メッセージと友だち、実施頻度指定し、抽出した情報を友だち項目へ更新する。

活用① AIエージェントから自動応答する
以下の自動応答登録すれだけで、AIによる自動応答できる。
・マッチタイプ:
AIボット
・プロンプト:
作成済みプロンプト
・有効:選択

活用② AIエージェントから会話内容による回答を生成する
チャット画面のアインシュタインアインコをクリックし、AIアシスタント画面開く
ユーザの内容をここで編集しても利用可能です。
生成内容をダブルクリックしたらそのまま編集可能です。

活用③ AIエージェントでチャットの内容を要約生成し、TODOを自動作成も可能です。

Claude (Anthropic API) 連携拡張メソッド参考例:
/**
* Claude (Anthropic API) 実装クラス ── DX-LINE 用
*
* - FmlExternalIF インタフェースを実装
* - Anthropic Messages API ( https://api.anthropic.com/v1/messages ) を呼び出す
* - OpenAI (ChatGPT) 版との主な差異:
* 1. 認証ヘッダーが「x-api-key」(Bearer ではない)
* 2. 「anthropic-version」ヘッダーが必須
* 3. system プロンプトは messages 配列の外(トップレベル「system」)に置く
* 4. max_tokens が必須項目
* 5. レスポンス構造が異なる ( content[0].text / usage.input_tokens + output_tokens )
* 6. frequency_penalty / presence_penalty は未サポートのため送信しない
*
* ★ global: global interface FmlExternalIF を実装するクラスは global にする必要がある
* callAIApi メソッドも global で宣言する
* ★ bfml__FmlChatGPTLog__c にログを生成するには、環境によって、ゲストユーザに、項目の編集権限の付与が必要
*/
global with sharing class FmlAIClaude extends bfml.FmlExternalIF {
//'https://api.anthropic.com/v1/messages';
private static final String API_URL = 'https://api.anthropic.com/v1/messages';
//'sk-ant-api03-xxxxxxxxxxxxxxxxxxxxxxxx';
private static final String API_KEY = 'sk-XXXXXXXXX';
//'claude-opus-4-7' / 'claude-sonnet-4-6' など
private static final String API_MODEL = 'claude-opus-4-7';
//Anthropic API バージョン (固定値)
private static final String ANTHROPIC_VERSION = '2023-06-01';
//Claude では max_tokens が必須項目のためデフォルト値を用意
private static final Integer DEFAULT_MAX_TOKENS = 1024;
/**
* Anthropic Messages API を呼び出す
* @param bean リクエスト情報
* @return AI応答文字列
*/
override global String callAIApi(bfml.FmlExternalIF.IntentBean bean) {
if(null == bean) {
return 'Error:IntentBean null ERROR';
}
if(String.isBlank(bean.model)) {
bean.model = API_MODEL;
}
// HTTPリクエストの作成
HttpRequest req = new HttpRequest();
req.setEndpoint(FmlAIClaude.API_URL);
req.setMethod('POST');
req.setHeader('Content-Type', 'application/json');
req.setTimeout(50000);
// Anthropic 認証ヘッダー (Bearer ではない)
req.setHeader('x-api-key', FmlAIClaude.API_KEY);
//以下のように指定ログイン情報方式に変更をおすすめ
//例: req.setHeader('x-api-key', '{!$Credential.anthropic.apikey}');
req.setHeader('anthropic-version', FmlAIClaude.ANTHROPIC_VERSION);
// ──────────────────────────────────────────────
// Claude では system プロンプトを messages 配列内ではなく
// トップレベルの「system」フィールドに置く必要がある。
// OpenAI 形式の messages から system ロールを抽出して分離する。
// ──────────────────────────────────────────────
String systemPrompt = '';
List<Map<String, Object>> userMessages = new List<Map<String, Object>>();
if(bean.messages != null) {
for(bfml.FmlExternalIF.MessageBean msg : bean.messages) {
if(msg == null || String.isBlank(msg.role)) continue;
if('system'.equalsIgnoreCase(msg.role)) {
String sysText = (msg.content instanceof String)
? (String)msg.content
: JSON.serialize(msg.content);
systemPrompt += (String.isBlank(systemPrompt) ? '' : '\n') + sysText;
} else if('user'.equalsIgnoreCase(msg.role)) {
// ★ MessageBean を Map に詰め替える
userMessages.add(new Map<String, Object>{
'role' => msg.role,
'content' => msg.content
});
}
}
}
// max_tokens は必須。未指定 or 0 以下ならデフォルト値を使う
Integer maxTokens = (bean.max_tokens != null && bean.max_tokens > 0)
? (Integer)bean.max_tokens
: DEFAULT_MAX_TOKENS;
// リクエストボディの設定
// Claude は frequency_penalty / presence_penalty を受け付けないため送らない
Map<String, Object> body = new Map<String, Object>{
'model' => bean.model,
'stream' => bean.stream,
'messages' => userMessages,
'max_tokens' => maxTokens
};
if(String.isNotBlank(systemPrompt)) {
body.put('system', systemPrompt);
}
//オプション追加セットする (parameters で渡された値が優先される)
try {
if(String.isNotBlank(bean.parameters)) {
Map<String, Object> parameters =
(Map<String, Object>)JSON.deserializeUntyped(bean.parameters);
body.putAll(parameters);
}
} catch(Exception e) {
System.debug('parameters parse error');
}
String requestBody = JSON.serialize(body); // suppressApexObjectNulls = true]
req.setBody(requestBody);
// HTTPリクエストの送信
Http http = new Http();
HttpResponse res = http.send(req);
// 応答の処理
if (res.getStatusCode() == 200) {
Map<String, Object> resBody =
(Map<String, Object>) JSON.deserializeUntyped(res.getBody());
if (resBody.containsKey('content')) {
List<Object> contents = (List<Object>)resBody.get('content');
// content は複数ブロック返ることがある (text, tool_use 等)
// type=text のブロックを連結して回答とする
String retMsg = '';
for(Object c : contents) {
Map<String, Object> block = (Map<String, Object>)c;
String type = (String)block.get('type');
if('text'.equalsIgnoreCase(type)) {
retMsg += (String)block.get('text');
}
}
//Iuserkey は友だちの場合のみ保持する (ChatGPT 実装と同じロジック)
try {
String objName = Id.valueOf(bean.userkey).getsobjecttype()
.getDescribe().getName();
//友だち詳細ページの場合
if(!bfml__FmlLineMember__c.getSObjectType().getDescribe().getName().equals(objName)) {
bean.userkey = null;
}
} catch(Exception e) {
bean.userkey = null;
System.debug(e.getMessage());
}
try {
// usage の合計トークン数を計算
// Claude は input_tokens / output_tokens を別々に返す
Decimal totalTokens = 0;
if(resBody.containsKey('usage')) {
Map<String, Object> usage = (Map<String, Object>)resBody.get('usage');
Decimal inTok = (usage.get('input_tokens') != null)
? (Decimal)usage.get('input_tokens') : 0;
Decimal outTok = (usage.get('output_tokens') != null)
? (Decimal)usage.get('output_tokens') : 0;
totalTokens = inTok + outTok;
}
//ログ作成 (ChatGPT 用ログオブジェクトを流用)
bfml__FmlChatGPTLog__c log = new bfml__FmlChatGPTLog__c(
bfml__Model__c = bean.model,
bfml__Request__c = requestBody,
bfml__Response__c = res.getBody().toString(),
bfml__Total_tokens__c = totalTokens,
bfml__LineMemberID__c = bean.userkey
);
if (Schema.sObjectType.bfml__FmlChatGPTLog__c.isCreateable()) {
insert log;//ok
}
} catch(Exception e) {
system.debug(e.getMessage());
}
if(String.isBlank(retMsg)) {
// stop_reason が max_tokens の場合は枠が足りていない可能性が高い
String stopReason = (String)resBody.get('stop_reason');
if('max_tokens'.equalsIgnoreCase(stopReason)) {
return 'Error:プロンプトのパラメータ:「max_tokens」を拡大してみてください。';
}
return 'Error:応答が空でした。';
} else {
return retMsg;
}
}
} else {
// エラー応答 ( {"type":"error","error":{"type":"...","message":"..."}} )
try {
Map<String, Object> resBody =
(Map<String, Object>) JSON.deserializeUntyped(res.getBody());
Map<String, Object> errorObj = (Map<String, Object>) resBody.get('error');
String errorMessage = (String) errorObj.get('message');
return 'Error:' + errorMessage;
} catch(Exception e) {
return 'Error:HTTP ' + res.getStatusCode() + ' ' + res.getBody();
}
}
return 'Error: System Error Try Again';
}
}
//テストクラス
/**
* FmlAIClaude のテストクラス
*
* - Anthropic Messages API 呼び出しを HttpCalloutMock で擬似化し、callAIApi の各分岐を検証する
* - FmlAIClaude と同じく bfml 名前空間プレフィックス付きで参照し、サブスクライバ組織でも
* そのままデプロイできるようにしている
* - 検証ポイント (OpenAI 実装との差異):
* 1. system プロンプトが messages 配列の外(トップレベル system)へ分離されること
* 2. frequency_penalty / presence_penalty がリクエストボディに含まれないこと
* 3. max_tokens が必須として常にセットされること(未指定時はデフォルト 1024)
* 4. content[] の type=text ブロックのみ連結されること
* 5. usage.input_tokens + output_tokens でトークン数が算出されること
*/
@IsTest
private class FmlAIClaudeTest {
// 期待されるデフォルト値 (FmlAIClaude の private 定数と一致させる)
private static final String DEFAULT_MODEL = 'claude-opus-4-7';
private static final Integer DEFAULT_MAX_TOKENS = 1024;
// =========================================================
// 設定可能な HTTP コールアウトモック
// - ステータスコード / レスポンスボディを指定して任意の応答を返す
// - 送信されたリクエストボディ・主要ヘッダーを記録し、検証に使う
// =========================================================
private class ClaudeMock implements HttpCalloutMock {
private Integer statusCode;
private String responseBody;
public String capturedBody; // 送信ボディ(JSON)
public String capturedVersionHeader; // anthropic-version
public String capturedApiKeyHeader; // x-api-key
public String capturedContentType; // Content-Type
public ClaudeMock(Integer statusCode, String responseBody) {
this.statusCode = statusCode;
this.responseBody = responseBody;
}
public HttpResponse respond(HttpRequest req) {
this.capturedBody = req.getBody();
this.capturedVersionHeader = req.getHeader('anthropic-version');
this.capturedApiKeyHeader = req.getHeader('x-api-key');
this.capturedContentType = req.getHeader('Content-Type');
HttpResponse res = new HttpResponse();
res.setStatusCode(this.statusCode);
res.setBody(this.responseBody);
res.setHeader('Content-Type', 'application/json');
return res;
}
}
// =========================================================
// テストデータ生成ヘルパー
// =========================================================
/** system + user の標準メッセージを持つ IntentBean (model / max_tokens は未指定=デフォルト) */
private static bfml.FmlExternalIF.IntentBean baseBean() {
bfml.FmlExternalIF.IntentBean bean = new bfml.FmlExternalIF.IntentBean();
bean.stream = false;
bean.messages = new List<bfml.FmlExternalIF.MessageBean>{
new bfml.FmlExternalIF.MessageBean('system', 'あなたは親切なアシスタントです。'),
new bfml.FmlExternalIF.MessageBean('user', 'こんにちは')
};
return bean;
}
/** 正常レスポンス (text ブロック1つ・usage なし → ログは 0 トークンで作成される) */
private static String bodyText(String text) {
return JSON.serialize(new Map<String, Object>{
'id' => 'msg_001',
'type' => 'message',
'role' => 'assistant',
'content' => new List<Object>{
new Map<String, Object>{ 'type' => 'text', 'text' => text }
},
'stop_reason' => 'end_turn'
});
}
/** 正常レスポンス (text ブロック・usage あり) */
private static String bodyTextWithUsage(String text) {
return JSON.serialize(new Map<String, Object>{
'content' => new List<Object>{
new Map<String, Object>{ 'type' => 'text', 'text' => text }
},
'usage' => new Map<String, Object>{
'input_tokens' => 10,
'output_tokens' => 20
},
'stop_reason' => 'end_turn'
});
}
/** text 以外を含む複数ブロック (text ブロックのみ連結されることの検証用) */
private static String bodyMultiBlock() {
return JSON.serialize(new Map<String, Object>{
'content' => new List<Object>{
new Map<String, Object>{ 'type' => 'text', 'text' => '前半' },
new Map<String, Object>{ 'type' => 'tool_use', 'id' => 't1', 'name' => 'calc' },
new Map<String, Object>{ 'type' => 'text', 'text' => '後半' }
},
'stop_reason' => 'end_turn'
});
}
/** text ブロックが無い (retMsg が空になる) レスポンス */
private static String bodyEmptyText(String stopReason) {
return JSON.serialize(new Map<String, Object>{
'content' => new List<Object>{
new Map<String, Object>{ 'type' => 'tool_use', 'id' => 't1' }
},
'stop_reason' => stopReason
});
}
/** content キーを持たないレスポンス */
private static String bodyNoContent() {
return JSON.serialize(new Map<String, Object>{
'id' => 'msg_x',
'type' => 'message'
});
}
/** Anthropic エラー形式のレスポンス */
private static String bodyError(String message) {
return JSON.serialize(new Map<String, Object>{
'type' => 'error',
'error' => new Map<String, Object>{
'type' => 'authentication_error',
'message' => message
}
});
}
/** 送信されたリクエストボディを Map に復元する */
private static Map<String, Object> parseSent(ClaudeMock mock) {
return (Map<String, Object>) JSON.deserializeUntyped(mock.capturedBody);
}
// =========================================================
// 異常系: bean が null
// =========================================================
@IsTest
static void testBeanNull() {
Test.startTest();
String result = new FmlAIClaude().callAIApi(null);
Test.stopTest();
System.assertEquals('Error:IntentBean null ERROR', result, 'null bean は専用エラーを返す');
}
// =========================================================
// 正常系: デフォルトモデル / リクエスト整形 / ヘッダー
// - model 未指定 → デフォルトモデル
// - max_tokens 未指定 → デフォルト 1024
// - system がトップレベルへ分離
// - messages は user のみ
// - frequency_penalty / presence_penalty を送らない
// =========================================================
@IsTest
static void testSuccess_DefaultModel_RequestShape() {
ClaudeMock mock = new ClaudeMock(200, bodyText('はい、こんにちは。'));
Test.setMock(HttpCalloutMock.class, mock);
bfml.FmlExternalIF.IntentBean bean = baseBean(); // model / max_tokens 未指定
Test.startTest();
String result = new FmlAIClaude().callAIApi(bean);
Test.stopTest();
System.assertEquals('はい、こんにちは。', result, 'text ブロックの内容を返す');
// ── リクエストボディ検証 ──
Map<String, Object> sent = parseSent(mock);
System.assertEquals(DEFAULT_MODEL, (String) sent.get('model'), 'model 未指定時はデフォルトモデル');
System.assertEquals(DEFAULT_MAX_TOKENS, (Integer) sent.get('max_tokens'), 'max_tokens 未指定時はデフォルト 1024');
System.assertEquals('あなたは親切なアシスタントです。', (String) sent.get('system'), 'system はトップレベルへ分離される');
System.assertEquals(false, sent.containsKey('frequency_penalty'), 'frequency_penalty は送らない');
System.assertEquals(false, sent.containsKey('presence_penalty'), 'presence_penalty は送らない');
List<Object> sentMsgs = (List<Object>) sent.get('messages');
System.assertEquals(1, sentMsgs.size(), 'messages は user のみ');
System.assertEquals('user', (String) ((Map<String, Object>) sentMsgs[0]).get('role'), 'role は user');
// ── ヘッダー検証 ──
System.assertEquals('2023-06-01', mock.capturedVersionHeader, 'anthropic-version 必須ヘッダー');
System.assertEquals('application/json', mock.capturedContentType, 'Content-Type ヘッダー');
System.assertNotEquals(null, mock.capturedApiKeyHeader, 'x-api-key ヘッダーがセットされる');
System.assert(String.isNotBlank(mock.capturedApiKeyHeader), 'x-api-key ヘッダーは空でない');
}
// =========================================================
// 正常系: model / max_tokens を明示指定
// =========================================================
@IsTest
static void testSuccess_ProvidedModelAndMaxTokens() {
ClaudeMock mock = new ClaudeMock(200, bodyText('応答'));
Test.setMock(HttpCalloutMock.class, mock);
bfml.FmlExternalIF.IntentBean bean = baseBean();
bean.model = 'claude-sonnet-4-6';
bean.max_tokens = 2048;
Test.startTest();
String result = new FmlAIClaude().callAIApi(bean);
Test.stopTest();
System.assertEquals('応答', result);
Map<String, Object> sent = parseSent(mock);
System.assertEquals('claude-sonnet-4-6', (String) sent.get('model'), '指定モデルが使われる');
System.assertEquals(2048, (Integer) sent.get('max_tokens'), '指定 max_tokens が使われる');
}
// =========================================================
// 正常系: max_tokens = 0 はデフォルトへフォールバック
// =========================================================
@IsTest
static void testMaxTokensZero_UsesDefault() {
ClaudeMock mock = new ClaudeMock(200, bodyText('ok'));
Test.setMock(HttpCalloutMock.class, mock);
bfml.FmlExternalIF.IntentBean bean = baseBean();
bean.max_tokens = 0; // 0 以下はデフォルト扱い
Test.startTest();
new FmlAIClaude().callAIApi(bean);
Test.stopTest();
Map<String, Object> sent = parseSent(mock);
System.assertEquals(DEFAULT_MAX_TOKENS, (Integer) sent.get('max_tokens'), '0 指定時はデフォルト 1024');
}
// =========================================================
// 正常系: 複数ブロックのうち type=text のみ連結
// =========================================================
@IsTest
static void testMultipleTextBlocks_Concatenated() {
Test.setMock(HttpCalloutMock.class, new ClaudeMock(200, bodyMultiBlock()));
Test.startTest();
String result = new FmlAIClaude().callAIApi(baseBean());
Test.stopTest();
System.assertEquals('前半後半', result, 'tool_use を除き text ブロックのみ連結される');
}
// =========================================================
// 正常系: system メッセージが複数 → 改行で連結
// =========================================================
@IsTest
static void testMultipleSystemMessages_Joined() {
ClaudeMock mock = new ClaudeMock(200, bodyText('ok'));
Test.setMock(HttpCalloutMock.class, mock);
bfml.FmlExternalIF.IntentBean bean = new bfml.FmlExternalIF.IntentBean();
bean.stream = false;
bean.messages = new List<bfml.FmlExternalIF.MessageBean>{
new bfml.FmlExternalIF.MessageBean('system', 'sys1'),
new bfml.FmlExternalIF.MessageBean('system', 'sys2'),
new bfml.FmlExternalIF.MessageBean('user', 'hi')
};
Test.startTest();
new FmlAIClaude().callAIApi(bean);
Test.stopTest();
Map<String, Object> sent = parseSent(mock);
System.assertEquals('sys1\nsys2', (String) sent.get('system'), '複数 system は改行連結される');
}
// =========================================================
// 正常系: system の content が String 以外 → JSON 直列化される
// =========================================================
@IsTest
static void testSystemContentNonString_Serialized() {
ClaudeMock mock = new ClaudeMock(200, bodyText('ok'));
Test.setMock(HttpCalloutMock.class, mock);
bfml.FmlExternalIF.IntentBean bean = new bfml.FmlExternalIF.IntentBean();
bean.stream = false;
bean.messages = new List<bfml.FmlExternalIF.MessageBean>{
new bfml.FmlExternalIF.MessageBean('system', new Map<String, Object>{ 'k' => 'v' }),
new bfml.FmlExternalIF.MessageBean('user', 'hi')
};
Test.startTest();
new FmlAIClaude().callAIApi(bean);
Test.stopTest();
Map<String, Object> sent = parseSent(mock);
System.assertEquals('{"k":"v"}', (String) sent.get('system'), 'String 以外の content は JSON 直列化される');
}
// =========================================================
// 正常系: messages が null でも例外なく呼び出せる
// =========================================================
@IsTest
static void testMessagesNull() {
ClaudeMock mock = new ClaudeMock(200, bodyText('応答'));
Test.setMock(HttpCalloutMock.class, mock);
bfml.FmlExternalIF.IntentBean bean = new bfml.FmlExternalIF.IntentBean();
bean.stream = false;
bean.messages = null;
Test.startTest();
String result = new FmlAIClaude().callAIApi(bean);
Test.stopTest();
System.assertEquals('応答', result);
Map<String, Object> sent = parseSent(mock);
System.assertEquals(false, sent.containsKey('system'), 'system は付与されない');
System.assertEquals(0, ((List<Object>) sent.get('messages')).size(), 'messages は空');
}
// =========================================================
// 正常系: null 要素 / role 空白 / assistant は除外される
// =========================================================
@IsTest
static void testNullAndBlankRoleEntriesSkipped() {
ClaudeMock mock = new ClaudeMock(200, bodyText('ok'));
Test.setMock(HttpCalloutMock.class, mock);
bfml.FmlExternalIF.IntentBean bean = new bfml.FmlExternalIF.IntentBean();
bean.stream = false;
bean.messages = new List<bfml.FmlExternalIF.MessageBean>{
null, // null 要素 → skip
new bfml.FmlExternalIF.MessageBean('', 'blank role'), // role 空白 → skip
new bfml.FmlExternalIF.MessageBean('assistant', '無視'), // assistant → 対象外
new bfml.FmlExternalIF.MessageBean('user', 'hi') // user のみ採用
};
Test.startTest();
new FmlAIClaude().callAIApi(bean);
Test.stopTest();
Map<String, Object> sent = parseSent(mock);
List<Object> sentMsgs = (List<Object>) sent.get('messages');
System.assertEquals(1, sentMsgs.size(), 'user メッセージのみが残る');
System.assertEquals('user', (String) ((Map<String, Object>) sentMsgs[0]).get('role'));
}
// =========================================================
// 正常系: parameters が body へマージ(上書き)される
// =========================================================
@IsTest
static void testParametersOverride() {
ClaudeMock mock = new ClaudeMock(200, bodyText('ok'));
Test.setMock(HttpCalloutMock.class, mock);
bfml.FmlExternalIF.IntentBean bean = baseBean();
bean.max_tokens = 100;
bean.parameters = '{"temperature":0.3,"max_tokens":2000}';
Test.startTest();
new FmlAIClaude().callAIApi(bean);
Test.stopTest();
Map<String, Object> sent = parseSent(mock);
System.assertEquals(true, sent.containsKey('temperature'), 'parameters の値が追加される');
System.assertEquals(2000, (Integer) sent.get('max_tokens'), 'parameters の値が既存値を上書きする');
}
// =========================================================
// 正常系: parameters が不正 JSON でも握りつぶして継続
// =========================================================
@IsTest
static void testParametersInvalidJson() {
Test.setMock(HttpCalloutMock.class, new ClaudeMock(200, bodyText('応答')));
bfml.FmlExternalIF.IntentBean bean = baseBean();
bean.parameters = '{invalid json';
Test.startTest();
String result = new FmlAIClaude().callAIApi(bean);
Test.stopTest();
System.assertEquals('応答', result, '不正 parameters は無視され処理継続');
}
// =========================================================
// 異常系: 応答が空 + stop_reason=max_tokens
// =========================================================
@IsTest
static void testEmptyText_StopMaxTokens() {
Test.setMock(HttpCalloutMock.class, new ClaudeMock(200, bodyEmptyText('max_tokens')));
Test.startTest();
String result = new FmlAIClaude().callAIApi(baseBean());
Test.stopTest();
System.assertEquals('Error:プロンプトのパラメータ:「max_tokens」を拡大してみてください。', result);
}
// =========================================================
// 異常系: 応答が空 + stop_reason がそれ以外
// =========================================================
@IsTest
static void testEmptyText_StopOther() {
Test.setMock(HttpCalloutMock.class, new ClaudeMock(200, bodyEmptyText('end_turn')));
Test.startTest();
String result = new FmlAIClaude().callAIApi(baseBean());
Test.stopTest();
System.assertEquals('Error:応答が空でした。', result);
}
// =========================================================
// 異常系: 200 だが content キーが無い
// =========================================================
@IsTest
static void testNoContentKey() {
Test.setMock(HttpCalloutMock.class, new ClaudeMock(200, bodyNoContent()));
Test.startTest();
String result = new FmlAIClaude().callAIApi(baseBean());
Test.stopTest();
System.assertEquals('Error: System Error Try Again', result);
}
// =========================================================
// 異常系: 非 200 + error.message を解析
// =========================================================
@IsTest
static void testErrorResponse_WithMessage() {
Test.setMock(HttpCalloutMock.class, new ClaudeMock(401, bodyError('invalid x-api-key')));
Test.startTest();
String result = new FmlAIClaude().callAIApi(baseBean());
Test.stopTest();
System.assertEquals('Error:invalid x-api-key', result, 'error.message を返す');
}
// =========================================================
// 異常系: 非 200 + JSON 解析不能 → フォールバック
// =========================================================
@IsTest
static void testErrorResponse_Fallback() {
Test.setMock(HttpCalloutMock.class, new ClaudeMock(500, 'Internal Server Error'));
Test.startTest();
String result = new FmlAIClaude().callAIApi(baseBean());
Test.stopTest();
System.assertEquals('Error:HTTP 500 Internal Server Error', result, '解析不能時は HTTP ステータス+本文');
}
// =========================================================
// 正常系: userkey が友だち(FmlLineMember)→ 保持され、ログが作成される
// - usage を返さないため (Decimal) キャストを経由せず 0 トークンでログ作成される
// =========================================================
@IsTest
static void testUserkey_Friend_LogCreated() {
bfml__FmlLineMember__c lm = new bfml__FmlLineMember__c();
insert lm;
ClaudeMock mock = new ClaudeMock(200, bodyText('こんにちは'));
Test.setMock(HttpCalloutMock.class, mock);
bfml.FmlExternalIF.IntentBean bean = baseBean();
bean.userkey = lm.Id;
Test.startTest();
String result = new FmlAIClaude().callAIApi(bean);
Test.stopTest();
System.assertEquals('こんにちは', result);
List<bfml__FmlChatGPTLog__c> logs = [
SELECT Id, bfml__Model__c, bfml__Total_tokens__c, bfml__LineMemberID__c
FROM bfml__FmlChatGPTLog__c
];
System.assertEquals(1, logs.size(), '友だちの場合ログが1件作成される');
System.assertEquals(DEFAULT_MODEL, logs[0].bfml__Model__c, 'ログにモデルが記録される');
System.assert(logs[0].bfml__Total_tokens__c == 0, 'usage 無しのため 0 トークン');
System.assertEquals(
String.valueOf(lm.Id),
String.valueOf(logs[0].bfml__LineMemberID__c),
'友だちの ID が保持される'
);
}
// =========================================================
// 正常系: userkey が FmlLineMember 以外 → null 化される
// =========================================================
@IsTest
static void testUserkey_NotFriend_Nulled() {
ClaudeMock mock = new ClaudeMock(200, bodyText('応答'));
Test.setMock(HttpCalloutMock.class, mock);
bfml.FmlExternalIF.IntentBean bean = baseBean();
bean.userkey = UserInfo.getUserId(); // User の ID → 友だちではない
Test.startTest();
String result = new FmlAIClaude().callAIApi(bean);
Test.stopTest();
System.assertEquals('応答', result);
List<bfml__FmlChatGPTLog__c> logs = [
SELECT Id, bfml__LineMemberID__c FROM bfml__FmlChatGPTLog__c
];
System.assertEquals(1, logs.size(), 'ログは作成される');
System.assertEquals(null, logs[0].bfml__LineMemberID__c, '友だち以外は LineMemberID が null');
}
// =========================================================
// 正常系: userkey が ID 形式でない → 例外を握りつぶし null 化
// =========================================================
@IsTest
static void testUserkey_InvalidId() {
Test.setMock(HttpCalloutMock.class, new ClaudeMock(200, bodyText('応答')));
bfml.FmlExternalIF.IntentBean bean = baseBean();
bean.userkey = 'not-an-id';
Test.startTest();
String result = new FmlAIClaude().callAIApi(bean);
Test.stopTest();
System.assertEquals('応答', result, '不正 userkey でも処理継続');
}
// =========================================================
// 正常系: usage を含むレスポンスでも応答テキストを返す
// - usage.input_tokens/output_tokens の集計分岐を通す
// =========================================================
@IsTest
static void testUsagePresent_ReturnsText() {
Test.setMock(HttpCalloutMock.class, new ClaudeMock(200, bodyTextWithUsage('応答')));
Test.startTest();
String result = new FmlAIClaude().callAIApi(baseBean());
Test.stopTest();
System.assertEquals('応答', result);
}
}
AgentForce連携拡張メソッド参考例:
/**
* Salesforce Einstein Models API (aiplatform.ModelsAPI) 実装クラス
*
* - aiplatform.ModelsAPI.createGenerations を呼び出してLLM応答を取得
* - HTTPコールアウトを使わない(リモートサイト/Named Credential 不要)
* - Einstein Trust Layer (PIIマスキング/監査) 自動適用
* - ステートレスAPIのため、会話履歴はプロンプトに連結して送信
* - callAIApi() の戻り値はAI応答テキスト(エラー時は 'Error:...' 形式)
*
* 【前提条件】
* - 組織で Einstein Generative AI / Models API が有効
* - 実行ユーザーに必要な権限セット(Prompt Template User 等)が付与
*/
global with sharing class FmlAIAgentforce extends bfml.FmlExternalIF {
// カスタム設定から取得(デフォルトモデル)
private static final String DEFAULT_MODEL = 'sfdc_ai__DefaultGPT4Omni';//DX_LINE_SETTING__c.getOrgDefaults().AIPLATFORM_MODEL_NAME__c;
// =============================================
// callAIApi 実装
// =============================================
/**
* Models API を呼び出してAI応答を返す
*
* @param bean リクエスト情報
* @return AI応答テキスト(エラー時は 'Error:...' 形式)
*/
override global String callAIApi(bfml.FmlExternalIF.IntentBean bean) {
if (bean == null) {
return 'Error:IntentBean null ERROR';
}
String modelName = String.isBlank(bean.model) ? DEFAULT_MODEL : bean.model;
if (String.isBlank(modelName)) {
return 'Error:モデル名が未設定です';
}
// ── プロンプト構築(会話履歴を連結) ──────
String prompt = buildPrompt(bean);
if (String.isBlank(prompt)) {
return 'Error:プロンプトが空です';
}
String requestJson = '';
String responseJson = '';
try {
// ── リクエスト構築 ────────────────────────
aiplatform.ModelsAPI.createGenerations_Request request =
new aiplatform.ModelsAPI.createGenerations_Request();
request.modelName = modelName;
aiplatform.ModelsAPI_GenerationRequest body =
new aiplatform.ModelsAPI_GenerationRequest();
body.prompt = prompt;
// 追加パラメータ(bean.parameters 経由で上書き可能)
applyOptionalParameters(body, bean);
request.body = body;
// ログ用JSON(body + 元のbean設定を併記して可観測性を確保)
requestJson = JSON.serialize(new Map<String, Object> {
'modelName' => modelName,
'body' => body,
'beanParams' => new Map<String, Object> {
'temperature' => bean.temperature,
'max_tokens' => bean.max_tokens,
'top_p' => bean.top_p,
'frequency_penalty' => bean.frequency_penalty,
'presence_penalty' => bean.presence_penalty,
'stream' => bean.stream
}
});
// ── API呼び出し ───────────────────────────
aiplatform.ModelsAPI api = new aiplatform.ModelsAPI();
aiplatform.ModelsAPI.createGenerations_Response response =
api.createGenerations(request);
// ── レスポンス処理 ────────────────────────
String outputText = extractOutputText(response);
Decimal totalTokens = extractTokenCount(response);
responseJson = JSON.serialize(response.Code200);
// ── ログ記録 ──────────────────────────────
saveLog(bean, requestJson, responseJson, modelName, totalTokens);
if (String.isBlank(outputText)) {
return 'Error:応答テキストが空です';
}
return outputText;
} catch (Exception e) {
// Models API 例外 / その他例外をまとめて捕捉
String errMsg = e.getTypeName() + ': ' + e.getMessage();
saveLog(bean, requestJson, errMsg, modelName, 0);
return 'Error:' + errMsg;
}
}
// =============================================
// private: プロンプト構築
// =============================================
/**
* IntentBean からプロンプト文字列を組み立て
*
* 優先順位:
* 1) bean.messages (List<MessageBean>) が存在 → role別に整形連結
* 2) bean.systemtext / usertext / assistanttext から組み立て
*/
private String buildPrompt(bfml.FmlExternalIF.IntentBean bean) {
List<String> lines = new List<String>();
// ── (1) messages 優先 ────────────────────────
if (bean.messages != null && !bean.messages.isEmpty()) {
for (bfml.FmlExternalIF.MessageBean msg : bean.messages) {
if (msg == null) continue;
String text = contentToString(msg.content);
if (String.isBlank(text)) continue;
lines.add(formatRoleLine(msg.role, text));
}
} else {
// ── (2) systemtext / usertext / assistanttext フォールバック ──
if (String.isNotBlank(bean.systemtext)) {
lines.add(formatRoleLine('system', bean.systemtext));
}
if (String.isNotBlank(bean.assistanttext)) {
lines.add(formatRoleLine('assistant', bean.assistanttext));
}
if (String.isNotBlank(bean.usertext)) {
lines.add(formatRoleLine('user', bean.usertext));
}
}
if (lines.isEmpty()) return null;
// 最後にAssistantの応答を促す
lines.add('Assistant:');
return String.join(lines, '\n');
}
/**
* MessageBean.content (Object型) を安全に文字列化
* - String → そのまま
* - List/Map → JSON文字列
* - その他 → String.valueOf
*/
private String contentToString(Object content) {
if (content == null) return null;
if (content instanceof String) return (String) content;
try {
return JSON.serialize(content);
} catch (Exception e) {
return String.valueOf(content);
}
}
/**
* role に応じてプロンプト1行を整形
*/
private String formatRoleLine(String role, String text) {
if ('system'.equalsIgnoreCase(role)) {
return '[Instruction]\n' + text;
} else if ('assistant'.equalsIgnoreCase(role)) {
return 'Assistant: ' + text;
}
return 'User: ' + text;
}
/**
* bean.parameters (JSON文字列) から任意パラメータをbodyに反映
* 例: {"localization": {...}, "tags": {...}}
* ※ ModelsAPI_GenerationRequest が公開しているのは prompt / localization / tags のみ
*/
private void applyOptionalParameters(aiplatform.ModelsAPI_GenerationRequest body,
bfml.FmlExternalIF.IntentBean bean) {
if (String.isBlank(bean.parameters)) return;
try {
// ModelsAPI_Localization / ModelsAPI_Tags の構造はSDKに依存するため、
// JSON経由で安全にデシリアライズして反映
Map<String, Object> p = (Map<String, Object>) JSON.deserializeUntyped(bean.parameters);
if (p.containsKey('localization')) {
body.localization = (aiplatform.ModelsAPI_Localization)
JSON.deserialize(JSON.serialize(p.get('localization')),
aiplatform.ModelsAPI_Localization.class);
}
if (p.containsKey('tags')) {
body.tags = (aiplatform.ModelsAPI_Tags)
JSON.deserialize(JSON.serialize(p.get('tags')),
aiplatform.ModelsAPI_Tags.class);
}
} catch (Exception e) {
System.debug('applyOptionalParameters error: ' + e.getMessage());
}
}
// =============================================
// private: レスポンス処理
// =============================================
/**
* 生成結果テキストを取り出し
* response.Code200.generation.generatedText
*/
private String extractOutputText(aiplatform.ModelsAPI.createGenerations_Response response) {
if (response == null || response.Code200 == null) return null;
aiplatform.ModelsAPI_GenerationResponse res = response.Code200;
if (res.generation == null) return null;
return res.generation.generatedText;
}
/**
* トークン使用数を取得
* ※ ModelsAPI_GenerationResponse には標準でusage情報がない場合があるため、
* JSON経由で動的に取得を試みる
*/
private Decimal extractTokenCount(aiplatform.ModelsAPI.createGenerations_Response response) {
try {
if (response == null || response.Code200 == null) return 0;
String resJson = JSON.serialize(response.Code200);
Map<String, Object> m = (Map<String, Object>) JSON.deserializeUntyped(resJson);
Object gen = m.get('generation');
if (gen instanceof Map<String, Object>) {
Object usage = ((Map<String, Object>) gen).get('parameters');
if (usage instanceof Map<String, Object>) {
Object usageMap = ((Map<String, Object>) usage).get('usage');
if (usageMap instanceof Map<String, Object>) {
Map<String, Object> u = (Map<String, Object>) usageMap;
Object inp = u.get('prompt_tokens');
Object out = u.get('completion_tokens');
return (inp != null ? (Integer) inp : 0)
+ (out != null ? (Integer) out : 0);
}
}
}
} catch (Exception e) {
System.debug('extractTokenCount error: ' + e.getMessage());
}
return 0;
}
// =============================================
// private: ログ保存
// =============================================
private bfml__FmlChatGPTLog__c saveLog(bfml.FmlExternalIF.IntentBean bean, String requestJson,
String responseJson, String modelName, Decimal totalTokens) {
try {
// userkeyが友だち以外のレコードIDなら除去
try {
String objName = Id.valueOf(bean.userkey).getsobjecttype().getDescribe().getName();
if (!bfml__FmlLineMember__c.getSObjectType().getDescribe().getName().equals(objName)) {
bean.userkey = null;
}
} catch (Exception e) {
bean.userkey = null;
System.debug(e.getMessage());
}
bfml__FmlChatGPTLog__c log = new bfml__FmlChatGPTLog__c(
bfml__Model__c = 'ModelsAPI:' + modelName,
bfml__Request__c = requestJson,
bfml__Response__c = responseJson,
bfml__Total_tokens__c = totalTokens,
bfml__LineMemberID__c = bean.userkey
);
if (Schema.sObjectType.bfml__FmlChatGPTLog__c.isCreateable()) {
insert log;
return log;
}
} catch (Exception e) {
System.debug(e.getMessage());
return null;
}
return null;
}
}
Google Gemini 連携拡張メソッド参考例:
/**
* Google Gemini (Generative Language API) 実装クラス ── DX-LINE 用
*
* - FmlExternalIF インタフェースを実装
* - Gemini generateContent API
* ( https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent ) を呼び出す
* - Claude / OpenAI 版との主な差異:
* 1. モデル名をリクエストボディではなく URL パスに含める (.../models/{model}:generateContent)
* 2. 認証ヘッダーが「x-goog-api-key」(Bearer / x-api-key ではない)
* 3. system プロンプトはトップレベル「system_instruction.parts[].text」に置く
* 4. メッセージは「contents」配列。role は user / model、本文は parts[].text
* (アシスタントロールは「assistant」ではなく「model」と表現する)
* 5. 生成パラメータは「generationConfig」内 (maxOutputTokens など camelCase)
* 6. generateContent は非ストリーミングのため stream は送らない
* 7. レスポンスは candidates[0].content.parts[].text、トークンは usageMetadata
* (totalTokenCount をそのまま利用。無ければ prompt + candidates を合算)
*
* ★ global: global interface FmlExternalIF を実装するクラスは global にする必要がある
* callAIApi メソッドも global で宣言する
*/
global with sharing class FmlAIGemini extends bfml.FmlExternalIF {
//'https://generativelanguage.googleapis.com' を指す名前付き資格情報
private static final String API_BASE = 'callout:gemini';
//'AIzaSyXXXXXXXXXXXXXXXXXXXXXXXX';
private static final String API_KEY = 'AIza-XXXXXXXXX';
//'gemini-2.5-flash' / 'gemini-2.5-pro' など
private static final String API_MODEL = 'gemini-2.5-flash';
//Gemini も最大トークンは generationConfig.maxOutputTokens で指定するためデフォルト値を用意
private static final Integer DEFAULT_MAX_TOKENS = 1024;
/**
* Gemini generateContent API を呼び出す
* @param bean リクエスト情報
* @return AI応答文字列
*/
override global String callAIApi(bfml.FmlExternalIF.IntentBean bean) {
if(null == bean) {
return 'Error:IntentBean null ERROR';
}
if(String.isBlank(bean.model)) {
bean.model = API_MODEL;
}
// HTTPリクエストの作成
// ★ Claude / OpenAI と異なり、モデル名はボディではなく URL パスに含める
HttpRequest req = new HttpRequest();
// req.setEndpoint('https://generativelanguage.googleapis.com/v1beta/models/' + bean.model + ':generateContent');
req.setEndpoint(FmlAIGemini.API_BASE + '/v1beta/models/' + bean.model + ':generateContent');
req.setMethod('POST');
req.setHeader('Content-Type', 'application/json');
req.setTimeout(50000);
// Gemini 認証ヘッダー (Bearer / x-api-key ではない)
// req.setHeader('x-goog-api-key', FmlAIGemini.API_KEY); //Salesforceのcallout: 方式に変更をおすすめ
req.setHeader('x-goog-api-key', '{!$Credential.gemini.apikey}');
// ──────────────────────────────────────────────
// OpenAI 形式の messages を Gemini 形式へ変換する。
// - system ロール → トップレベル system_instruction へ分離
// - user ロール → contents (role=user)
// - assistant ロール → contents (role=model) ※Gemini は model と表現する
// ──────────────────────────────────────────────
String systemPrompt = '';
List<Map<String, Object>> contents = new List<Map<String, Object>>();
if(bean.messages != null) {
for(bfml.FmlExternalIF.MessageBean msg : bean.messages) {
if(msg == null || String.isBlank(msg.role)) continue;
String contentText = (msg.content instanceof String)
? (String)msg.content
: JSON.serialize(msg.content);
if('system'.equalsIgnoreCase(msg.role)) {
systemPrompt += (String.isBlank(systemPrompt) ? '' : '\n') + contentText;
} else if('user'.equalsIgnoreCase(msg.role)) {
contents.add(buildContent('user', contentText));
} else if('assistant'.equalsIgnoreCase(msg.role) || 'model'.equalsIgnoreCase(msg.role)) {
contents.add(buildContent('model', contentText));
}
}
}
// maxOutputTokens は未指定 or 0 以下ならデフォルト値を使う
Integer maxTokens = (bean.max_tokens != null && bean.max_tokens > 0)
? (Integer)bean.max_tokens
: DEFAULT_MAX_TOKENS;
// リクエストボディの設定
// model はパスに含めるためボディには入れない / generateContent は stream を受け付けない
Map<String, Object> body = new Map<String, Object>{
'contents' => contents,
'generationConfig' => new Map<String, Object>{
'maxOutputTokens' => maxTokens
}
};
if(String.isNotBlank(systemPrompt)) {
body.put('system_instruction', new Map<String, Object>{
'parts' => new List<Object>{
new Map<String, Object>{ 'text' => systemPrompt }
}
});
}
// オプション追加セットする (parameters で渡された値が優先される)
// ★ 生成パラメータを調整する場合は {"generationConfig":{...}} の形で渡す
try {
if(String.isNotBlank(bean.parameters)) {
Map<String, Object> parameters =
(Map<String, Object>)JSON.deserializeUntyped(bean.parameters);
body.putAll(parameters);
}
} catch(Exception e) {
System.debug('parameters parse error');
}
String requestBody = JSON.serialize(body);
req.setBody(requestBody);
// HTTPリクエストの送信
Http http = new Http();
HttpResponse res = http.send(req);
// 応答の処理
if (res.getStatusCode() == 200) {
Map<String, Object> resBody =
(Map<String, Object>) JSON.deserializeUntyped(res.getBody());
if (resBody.containsKey('candidates')) {
List<Object> candidates = (List<Object>)resBody.get('candidates');
// candidates[0].content.parts[] のうち text を連結して回答とする
// (functionCall 等 text を持たないパートは無視する)
String retMsg = '';
String finishReason = '';
if(!candidates.isEmpty()) {
Map<String, Object> firstCandidate = (Map<String, Object>)candidates[0];
finishReason = (String)firstCandidate.get('finishReason');
Map<String, Object> content = (Map<String, Object>)firstCandidate.get('content');
if(content != null && content.get('parts') != null) {
for(Object p : (List<Object>)content.get('parts')) {
Map<String, Object> part = (Map<String, Object>)p;
if(part.get('text') != null) {
retMsg += (String)part.get('text');
}
}
}
}
//Iuserkey は友だちの場合のみ保持する (ChatGPT 実装と同じロジック)
try {
String objName = Id.valueOf(bean.userkey).getsobjecttype()
.getDescribe().getName();
//友だち詳細ページの場合
if(!bfml__FmlLineMember__c.getSObjectType().getDescribe().getName().equals(objName)) {
bean.userkey = null;
}
} catch(Exception e) {
bean.userkey = null;
System.debug(e.getMessage());
}
try {
// usageMetadata からトークン数を取得
// Gemini は totalTokenCount を直接返す (無ければ prompt + candidates を合算)
// ※ JSON.deserializeUntyped の整数は Integer 型のため (Integer) でキャストする
Decimal totalTokens = 0;
if(resBody.containsKey('usageMetadata')) {
Map<String, Object> usage = (Map<String, Object>)resBody.get('usageMetadata');
if(usage.get('totalTokenCount') != null) {
totalTokens = (Integer)usage.get('totalTokenCount');
} else {
Integer promptTok = (usage.get('promptTokenCount') != null)
? (Integer)usage.get('promptTokenCount') : 0;
Integer candTok = (usage.get('candidatesTokenCount') != null)
? (Integer)usage.get('candidatesTokenCount') : 0;
totalTokens = promptTok + candTok;
}
}
//ログ作成 (ChatGPT 用ログオブジェクトを流用)
bfml__FmlChatGPTLog__c log = new bfml__FmlChatGPTLog__c(
bfml__Model__c = bean.model,
bfml__Request__c = requestBody,
bfml__Response__c = res.getBody().toString(),
bfml__Total_tokens__c = totalTokens,
bfml__LineMemberID__c = bean.userkey
);
if (Schema.sObjectType.bfml__FmlChatGPTLog__c.isCreateable()) {
insert log;//ok
}
} catch(Exception e) {
system.debug(e.getMessage());
}
if(String.isBlank(retMsg)) {
// finishReason が MAX_TOKENS の場合は枠が足りていない可能性が高い
if('MAX_TOKENS'.equalsIgnoreCase(finishReason)) {
return 'Error:プロンプトのパラメータ:「max_tokens」を拡大してみてください。';
}
return 'Error:応答が空でした。';
} else {
return retMsg;
}
}
} else {
// エラー応答 ( {"error":{"code":...,"message":"...","status":"..."}} )
try {
Map<String, Object> resBody =
(Map<String, Object>) JSON.deserializeUntyped(res.getBody());
Map<String, Object> errorObj = (Map<String, Object>) resBody.get('error');
String errorMessage = (String) errorObj.get('message');
return 'Error:' + errorMessage;
} catch(Exception e) {
return 'Error:HTTP ' + res.getStatusCode() + ' ' + res.getBody();
}
}
return 'Error: System Error Try Again';
}
/**
* Gemini contents 要素 (role + parts[].text) を生成する
* @param role メッセージロール (user / model)
* @param text 本文
*/
private Map<String, Object> buildContent(String role, String text) {
return new Map<String, Object>{
'role' => role,
'parts' => new List<Object>{
new Map<String, Object>{ 'text' => text }
}
};
}
}
//テストクラス
/**
* FmlAIGemini のテストクラス
*
* - Gemini generateContent API 呼び出しを HttpCalloutMock で擬似化し、callAIApi の各分岐を検証する
* - FmlAIGemini と同じく bfml 名前空間プレフィックス付きで参照し、サブスクライバ組織でも
* そのままデプロイできるようにしている
* - 検証ポイント (Claude / OpenAI 実装との差異):
* 1. モデル名が URL パス (.../models/{model}:generateContent) に含まれること
* 2. model / stream をリクエストボディに含めないこと
* 3. system プロンプトが system_instruction.parts[].text へ分離されること
* 4. assistant ロールが contents の role=model へ変換されること
* 5. generationConfig.maxOutputTokens がセットされること(未指定時はデフォルト 1024)
* 6. candidates[0].content.parts[] の text のみ連結されること
* 7. usageMetadata からトークン数が算出されること
*/
@IsTest
private class FmlAIGeminiTest {
// 期待されるデフォルト値 (FmlAIGemini の private 定数と一致させる)
private static final String DEFAULT_MODEL = 'gemini-2.5-flash';
private static final Integer DEFAULT_MAX_TOKENS = 1024;
// =========================================================
// 設定可能な HTTP コールアウトモック
// - ステータスコード / レスポンスボディを指定して任意の応答を返す
// - 送信されたエンドポイント・リクエストボディ・主要ヘッダーを記録し検証に使う
// =========================================================
private class GeminiMock implements HttpCalloutMock {
private Integer statusCode;
private String responseBody;
public String capturedEndpoint; // 送信先エンドポイント
public String capturedBody; // 送信ボディ(JSON)
public String capturedApiKeyHeader; // x-goog-api-key
public String capturedContentType; // Content-Type
public GeminiMock(Integer statusCode, String responseBody) {
this.statusCode = statusCode;
this.responseBody = responseBody;
}
public HttpResponse respond(HttpRequest req) {
this.capturedEndpoint = req.getEndpoint();
this.capturedBody = req.getBody();
this.capturedApiKeyHeader = req.getHeader('x-goog-api-key');
this.capturedContentType = req.getHeader('Content-Type');
HttpResponse res = new HttpResponse();
res.setStatusCode(this.statusCode);
res.setBody(this.responseBody);
res.setHeader('Content-Type', 'application/json');
return res;
}
}
// =========================================================
// テストデータ生成ヘルパー
// =========================================================
/** system + user の標準メッセージを持つ IntentBean (model / max_tokens は未指定=デフォルト) */
private static bfml.FmlExternalIF.IntentBean baseBean() {
bfml.FmlExternalIF.IntentBean bean = new bfml.FmlExternalIF.IntentBean();
bean.stream = false;
bean.messages = new List<bfml.FmlExternalIF.MessageBean>{
new bfml.FmlExternalIF.MessageBean('system', 'あなたは親切なアシスタントです。'),
new bfml.FmlExternalIF.MessageBean('user', 'こんにちは')
};
return bean;
}
/** 1つの text パートを返す candidate (usageMetadata なし → ログは 0 トークンで作成される) */
private static String bodyText(String text) {
return JSON.serialize(new Map<String, Object>{
'candidates' => new List<Object>{
new Map<String, Object>{
'content' => new Map<String, Object>{
'role' => 'model',
'parts' => new List<Object>{
new Map<String, Object>{ 'text' => text }
}
},
'finishReason' => 'STOP',
'index' => 0
}
},
'modelVersion' => 'gemini-2.5-flash'
});
}
/** text パート + usageMetadata (totalTokenCount あり) */
private static String bodyTextWithUsage(String text) {
return JSON.serialize(new Map<String, Object>{
'candidates' => new List<Object>{
new Map<String, Object>{
'content' => new Map<String, Object>{
'parts' => new List<Object>{
new Map<String, Object>{ 'text' => text }
}
},
'finishReason' => 'STOP'
}
},
'usageMetadata' => new Map<String, Object>{
'promptTokenCount' => 10,
'candidatesTokenCount' => 20,
'totalTokenCount' => 30
}
});
}
/** text パート + usageMetadata (totalTokenCount なし → prompt + candidates を合算) */
private static String bodyTextWithUsageNoTotal(String text) {
return JSON.serialize(new Map<String, Object>{
'candidates' => new List<Object>{
new Map<String, Object>{
'content' => new Map<String, Object>{
'parts' => new List<Object>{
new Map<String, Object>{ 'text' => text }
}
},
'finishReason' => 'STOP'
}
},
'usageMetadata' => new Map<String, Object>{
'promptTokenCount' => 7,
'candidatesTokenCount' => 5
}
});
}
/** text 以外(functionCall)を含む複数パート (text のみ連結されることの検証用) */
private static String bodyMultiParts() {
return JSON.serialize(new Map<String, Object>{
'candidates' => new List<Object>{
new Map<String, Object>{
'content' => new Map<String, Object>{
'parts' => new List<Object>{
new Map<String, Object>{ 'text' => '前半' },
new Map<String, Object>{ 'functionCall' => new Map<String, Object>{ 'name' => 'calc' } },
new Map<String, Object>{ 'text' => '後半' }
}
},
'finishReason' => 'STOP'
}
}
});
}
/** content を持たない candidate (retMsg が空になる) レスポンス */
private static String bodyEmptyText(String finishReason) {
return JSON.serialize(new Map<String, Object>{
'candidates' => new List<Object>{
new Map<String, Object>{ 'finishReason' => finishReason }
}
});
}
/** candidates が空配列のレスポンス */
private static String bodyEmptyCandidates() {
return JSON.serialize(new Map<String, Object>{
'candidates' => new List<Object>()
});
}
/** candidates キーを持たないレスポンス */
private static String bodyNoCandidates() {
return JSON.serialize(new Map<String, Object>{
'promptFeedback' => new Map<String, Object>{ 'blockReason' => 'SAFETY' }
});
}
/** Gemini エラー形式のレスポンス */
private static String bodyError(String message) {
return JSON.serialize(new Map<String, Object>{
'error' => new Map<String, Object>{
'code' => 400,
'message' => message,
'status' => 'INVALID_ARGUMENT'
}
});
}
/** 送信されたリクエストボディを Map に復元する */
private static Map<String, Object> parseSent(GeminiMock mock) {
return (Map<String, Object>) JSON.deserializeUntyped(mock.capturedBody);
}
// =========================================================
// 異常系: bean が null
// =========================================================
@IsTest
static void testBeanNull() {
Test.startTest();
String result = new FmlAIGemini().callAIApi(null);
Test.stopTest();
System.assertEquals('Error:IntentBean null ERROR', result, 'null bean は専用エラーを返す');
}
// =========================================================
// 正常系: デフォルトモデル / エンドポイント / リクエスト整形 / ヘッダー
// - model 未指定 → デフォルトモデルが URL パスに入る
// - max_tokens 未指定 → generationConfig.maxOutputTokens = 1024
// - system が system_instruction へ分離
// - contents は user のみ
// - model / stream はボディに含めない
// =========================================================
@IsTest
static void testSuccess_DefaultModel_RequestShape() {
GeminiMock mock = new GeminiMock(200, bodyText('はい、こんにちは。'));
Test.setMock(HttpCalloutMock.class, mock);
bfml.FmlExternalIF.IntentBean bean = baseBean(); // model / max_tokens 未指定
Test.startTest();
String result = new FmlAIGemini().callAIApi(bean);
Test.stopTest();
System.assertEquals('はい、こんにちは。', result, 'parts の text を返す');
// ── エンドポイント検証 (モデル名が URL パスに含まれる) ──
System.assert(
mock.capturedEndpoint.contains('/v1beta/models/' + DEFAULT_MODEL + ':generateContent'),
'モデル名が URL パスに含まれる: ' + mock.capturedEndpoint
);
// ── リクエストボディ検証 ──
Map<String, Object> sent = parseSent(mock);
System.assertEquals(false, sent.containsKey('model'), 'model はボディに含めない (URL パスに含める)');
System.assertEquals(false, sent.containsKey('stream'), 'stream はボディに含めない');
Map<String, Object> genConfig = (Map<String, Object>) sent.get('generationConfig');
System.assertEquals(DEFAULT_MAX_TOKENS, (Integer) genConfig.get('maxOutputTokens'), 'maxOutputTokens 未指定時はデフォルト 1024');
Map<String, Object> sysInst = (Map<String, Object>) sent.get('system_instruction');
List<Object> sysParts = (List<Object>) sysInst.get('parts');
System.assertEquals('あなたは親切なアシスタントです。', (String) ((Map<String, Object>) sysParts[0]).get('text'), 'system は system_instruction へ分離される');
List<Object> contents = (List<Object>) sent.get('contents');
System.assertEquals(1, contents.size(), 'contents は user のみ');
Map<String, Object> c0 = (Map<String, Object>) contents[0];
System.assertEquals('user', (String) c0.get('role'), 'role は user');
List<Object> c0Parts = (List<Object>) c0.get('parts');
System.assertEquals('こんにちは', (String) ((Map<String, Object>) c0Parts[0]).get('text'), 'parts[].text に本文が入る');
// ── ヘッダー検証 ──
System.assertEquals('application/json', mock.capturedContentType, 'Content-Type ヘッダー');
System.assert(String.isNotBlank(mock.capturedApiKeyHeader), 'x-goog-api-key ヘッダーがセットされる');
}
// =========================================================
// 正常系: model / max_tokens を明示指定
// =========================================================
@IsTest
static void testSuccess_ProvidedModelAndMaxTokens() {
GeminiMock mock = new GeminiMock(200, bodyText('応答'));
Test.setMock(HttpCalloutMock.class, mock);
bfml.FmlExternalIF.IntentBean bean = baseBean();
bean.model = 'gemini-2.5-pro';
bean.max_tokens = 2048;
Test.startTest();
String result = new FmlAIGemini().callAIApi(bean);
Test.stopTest();
System.assertEquals('応答', result);
System.assert(
mock.capturedEndpoint.contains('/v1beta/models/gemini-2.5-pro:generateContent'),
'指定モデルが URL パスに含まれる: ' + mock.capturedEndpoint
);
Map<String, Object> genConfig = (Map<String, Object>) parseSent(mock).get('generationConfig');
System.assertEquals(2048, (Integer) genConfig.get('maxOutputTokens'), '指定 max_tokens が使われる');
}
// =========================================================
// 正常系: max_tokens = 0 はデフォルトへフォールバック
// =========================================================
@IsTest
static void testMaxTokensZero_UsesDefault() {
GeminiMock mock = new GeminiMock(200, bodyText('ok'));
Test.setMock(HttpCalloutMock.class, mock);
bfml.FmlExternalIF.IntentBean bean = baseBean();
bean.max_tokens = 0; // 0 以下はデフォルト扱い
Test.startTest();
new FmlAIGemini().callAIApi(bean);
Test.stopTest();
Map<String, Object> genConfig = (Map<String, Object>) parseSent(mock).get('generationConfig');
System.assertEquals(DEFAULT_MAX_TOKENS, (Integer) genConfig.get('maxOutputTokens'), '0 指定時はデフォルト 1024');
}
// =========================================================
// 正常系: 複数パートのうち text のみ連結 (functionCall は無視)
// =========================================================
@IsTest
static void testMultipleTextParts_Concatenated() {
Test.setMock(HttpCalloutMock.class, new GeminiMock(200, bodyMultiParts()));
Test.startTest();
String result = new FmlAIGemini().callAIApi(baseBean());
Test.stopTest();
System.assertEquals('前半後半', result, 'functionCall を除き text パートのみ連結される');
}
// =========================================================
// 正常系: assistant ロールが contents の role=model へ変換される
// =========================================================
@IsTest
static void testAssistantRole_MappedToModel() {
GeminiMock mock = new GeminiMock(200, bodyText('ok'));
Test.setMock(HttpCalloutMock.class, mock);
bfml.FmlExternalIF.IntentBean bean = new bfml.FmlExternalIF.IntentBean();
bean.stream = false;
bean.messages = new List<bfml.FmlExternalIF.MessageBean>{
new bfml.FmlExternalIF.MessageBean('user', '質問'),
new bfml.FmlExternalIF.MessageBean('assistant', '前回の回答'),
new bfml.FmlExternalIF.MessageBean('user', '追加質問')
};
Test.startTest();
new FmlAIGemini().callAIApi(bean);
Test.stopTest();
List<Object> contents = (List<Object>) parseSent(mock).get('contents');
System.assertEquals(3, contents.size(), 'user / assistant / user の3件が contents に入る');
System.assertEquals('user', (String) ((Map<String, Object>) contents[0]).get('role'));
System.assertEquals('model', (String) ((Map<String, Object>) contents[1]).get('role'), 'assistant は model に変換される');
System.assertEquals('user', (String) ((Map<String, Object>) contents[2]).get('role'));
}
// =========================================================
// 正常系: system メッセージが複数 → 改行で連結
// =========================================================
@IsTest
static void testMultipleSystemMessages_Joined() {
GeminiMock mock = new GeminiMock(200, bodyText('ok'));
Test.setMock(HttpCalloutMock.class, mock);
bfml.FmlExternalIF.IntentBean bean = new bfml.FmlExternalIF.IntentBean();
bean.stream = false;
bean.messages = new List<bfml.FmlExternalIF.MessageBean>{
new bfml.FmlExternalIF.MessageBean('system', 'sys1'),
new bfml.FmlExternalIF.MessageBean('system', 'sys2'),
new bfml.FmlExternalIF.MessageBean('user', 'hi')
};
Test.startTest();
new FmlAIGemini().callAIApi(bean);
Test.stopTest();
Map<String, Object> sysInst = (Map<String, Object>) parseSent(mock).get('system_instruction');
List<Object> sysParts = (List<Object>) sysInst.get('parts');
System.assertEquals('sys1\nsys2', (String) ((Map<String, Object>) sysParts[0]).get('text'), '複数 system は改行連結される');
}
// =========================================================
// 正常系: system の content が String 以外 → JSON 直列化される
// =========================================================
@IsTest
static void testSystemContentNonString_Serialized() {
GeminiMock mock = new GeminiMock(200, bodyText('ok'));
Test.setMock(HttpCalloutMock.class, mock);
bfml.FmlExternalIF.IntentBean bean = new bfml.FmlExternalIF.IntentBean();
bean.stream = false;
bean.messages = new List<bfml.FmlExternalIF.MessageBean>{
new bfml.FmlExternalIF.MessageBean('system', new Map<String, Object>{ 'k' => 'v' }),
new bfml.FmlExternalIF.MessageBean('user', 'hi')
};
Test.startTest();
new FmlAIGemini().callAIApi(bean);
Test.stopTest();
Map<String, Object> sysInst = (Map<String, Object>) parseSent(mock).get('system_instruction');
List<Object> sysParts = (List<Object>) sysInst.get('parts');
System.assertEquals('{"k":"v"}', (String) ((Map<String, Object>) sysParts[0]).get('text'), 'String 以外の content は JSON 直列化される');
}
// =========================================================
// 正常系: messages が null でも例外なく呼び出せる
// =========================================================
@IsTest
static void testMessagesNull() {
GeminiMock mock = new GeminiMock(200, bodyText('応答'));
Test.setMock(HttpCalloutMock.class, mock);
bfml.FmlExternalIF.IntentBean bean = new bfml.FmlExternalIF.IntentBean();
bean.stream = false;
bean.messages = null;
Test.startTest();
String result = new FmlAIGemini().callAIApi(bean);
Test.stopTest();
System.assertEquals('応答', result);
Map<String, Object> sent = parseSent(mock);
System.assertEquals(false, sent.containsKey('system_instruction'), 'system_instruction は付与されない');
System.assertEquals(0, ((List<Object>) sent.get('contents')).size(), 'contents は空');
}
// =========================================================
// 正常系: null 要素 / role 空白は除外される
// =========================================================
@IsTest
static void testNullAndBlankRoleEntriesSkipped() {
GeminiMock mock = new GeminiMock(200, bodyText('ok'));
Test.setMock(HttpCalloutMock.class, mock);
bfml.FmlExternalIF.IntentBean bean = new bfml.FmlExternalIF.IntentBean();
bean.stream = false;
bean.messages = new List<bfml.FmlExternalIF.MessageBean>{
null, // null 要素 → skip
new bfml.FmlExternalIF.MessageBean('', 'blank role'), // role 空白 → skip
new bfml.FmlExternalIF.MessageBean('user', 'hi') // user のみ採用
};
Test.startTest();
new FmlAIGemini().callAIApi(bean);
Test.stopTest();
List<Object> contents = (List<Object>) parseSent(mock).get('contents');
System.assertEquals(1, contents.size(), 'user メッセージのみが残る');
System.assertEquals('user', (String) ((Map<String, Object>) contents[0]).get('role'));
}
// =========================================================
// 正常系: parameters が body へマージ(トップレベル)される
// =========================================================
@IsTest
static void testParametersOverride() {
GeminiMock mock = new GeminiMock(200, bodyText('ok'));
Test.setMock(HttpCalloutMock.class, mock);
bfml.FmlExternalIF.IntentBean bean = baseBean();
// safetySettings はトップレベル項目 / generationConfig は丸ごと上書きされる
bean.parameters = '{"safetySettings":[{"category":"HARM_CATEGORY_HARASSMENT","threshold":"BLOCK_NONE"}],'
+ '"generationConfig":{"temperature":0.3,"maxOutputTokens":2000}}';
Test.startTest();
new FmlAIGemini().callAIApi(bean);
Test.stopTest();
Map<String, Object> sent = parseSent(mock);
System.assertEquals(true, sent.containsKey('safetySettings'), 'parameters のトップレベル項目が追加される');
Map<String, Object> genConfig = (Map<String, Object>) sent.get('generationConfig');
System.assertEquals(2000, (Integer) genConfig.get('maxOutputTokens'), 'parameters の generationConfig が既存値を上書きする');
System.assertEquals(true, genConfig.containsKey('temperature'), 'temperature が反映される');
}
// =========================================================
// 正常系: parameters が不正 JSON でも握りつぶして継続
// =========================================================
@IsTest
static void testParametersInvalidJson() {
Test.setMock(HttpCalloutMock.class, new GeminiMock(200, bodyText('応答')));
bfml.FmlExternalIF.IntentBean bean = baseBean();
bean.parameters = '{invalid json';
Test.startTest();
String result = new FmlAIGemini().callAIApi(bean);
Test.stopTest();
System.assertEquals('応答', result, '不正 parameters は無視され処理継続');
}
// =========================================================
// 異常系: 応答が空 + finishReason=MAX_TOKENS
// =========================================================
@IsTest
static void testEmptyText_FinishMaxTokens() {
Test.setMock(HttpCalloutMock.class, new GeminiMock(200, bodyEmptyText('MAX_TOKENS')));
Test.startTest();
String result = new FmlAIGemini().callAIApi(baseBean());
Test.stopTest();
System.assertEquals('Error:プロンプトのパラメータ:「max_tokens」を拡大してみてください。', result);
}
// =========================================================
// 異常系: 応答が空 + finishReason がそれ以外 (SAFETY 等)
// =========================================================
@IsTest
static void testEmptyText_FinishOther() {
Test.setMock(HttpCalloutMock.class, new GeminiMock(200, bodyEmptyText('SAFETY')));
Test.startTest();
String result = new FmlAIGemini().callAIApi(baseBean());
Test.stopTest();
System.assertEquals('Error:応答が空でした。', result);
}
// =========================================================
// 異常系: candidates が空配列
// =========================================================
@IsTest
static void testEmptyCandidates() {
Test.setMock(HttpCalloutMock.class, new GeminiMock(200, bodyEmptyCandidates()));
Test.startTest();
String result = new FmlAIGemini().callAIApi(baseBean());
Test.stopTest();
System.assertEquals('Error:応答が空でした。', result, 'candidates 空配列は空応答エラー');
}
// =========================================================
// 異常系: 200 だが candidates キーが無い
// =========================================================
@IsTest
static void testNoCandidatesKey() {
Test.setMock(HttpCalloutMock.class, new GeminiMock(200, bodyNoCandidates()));
Test.startTest();
String result = new FmlAIGemini().callAIApi(baseBean());
Test.stopTest();
System.assertEquals('Error: System Error Try Again', result);
}
// =========================================================
// 異常系: 非 200 + error.message を解析
// =========================================================
@IsTest
static void testErrorResponse_WithMessage() {
Test.setMock(HttpCalloutMock.class, new GeminiMock(400, bodyError('API key not valid')));
Test.startTest();
String result = new FmlAIGemini().callAIApi(baseBean());
Test.stopTest();
System.assertEquals('Error:API key not valid', result, 'error.message を返す');
}
// =========================================================
// 異常系: 非 200 + JSON 解析不能 → フォールバック
// =========================================================
@IsTest
static void testErrorResponse_Fallback() {
Test.setMock(HttpCalloutMock.class, new GeminiMock(503, 'Service Unavailable'));
Test.startTest();
String result = new FmlAIGemini().callAIApi(baseBean());
Test.stopTest();
System.assertEquals('Error:HTTP 503 Service Unavailable', result, '解析不能時は HTTP ステータス+本文');
}
// =========================================================
// 正常系: userkey が友だち(FmlLineMember)→ 保持され、ログが作成される
// - usageMetadata.totalTokenCount から 30 トークンが記録される
// =========================================================
@IsTest
static void testUserkey_Friend_LogCreated_WithTokens() {
bfml__FmlLineMember__c lm = new bfml__FmlLineMember__c();
insert lm;
Test.setMock(HttpCalloutMock.class, new GeminiMock(200, bodyTextWithUsage('こんにちは')));
bfml.FmlExternalIF.IntentBean bean = baseBean();
bean.userkey = lm.Id;
Test.startTest();
String result = new FmlAIGemini().callAIApi(bean);
Test.stopTest();
System.assertEquals('こんにちは', result);
List<bfml__FmlChatGPTLog__c> logs = [
SELECT Id, bfml__Model__c, bfml__Total_tokens__c, bfml__LineMemberID__c
FROM bfml__FmlChatGPTLog__c
];
System.assertEquals(1, logs.size(), '友だちの場合ログが1件作成される');
System.assertEquals(DEFAULT_MODEL, logs[0].bfml__Model__c, 'ログにモデルが記録される');
System.assert(logs[0].bfml__Total_tokens__c == 30, 'totalTokenCount の 30 が記録される');
System.assertEquals(
String.valueOf(lm.Id),
String.valueOf(logs[0].bfml__LineMemberID__c),
'友だちの ID が保持される'
);
}
// =========================================================
// 正常系: totalTokenCount が無い場合は prompt + candidates を合算
// =========================================================
@IsTest
static void testUsage_PromptPlusCandidates() {
bfml__FmlLineMember__c lm = new bfml__FmlLineMember__c();
insert lm;
Test.setMock(HttpCalloutMock.class, new GeminiMock(200, bodyTextWithUsageNoTotal('応答')));
bfml.FmlExternalIF.IntentBean bean = baseBean();
bean.userkey = lm.Id;
Test.startTest();
String result = new FmlAIGemini().callAIApi(bean);
Test.stopTest();
System.assertEquals('応答', result);
List<bfml__FmlChatGPTLog__c> logs = [
SELECT Id, bfml__Total_tokens__c FROM bfml__FmlChatGPTLog__c
];
System.assertEquals(1, logs.size());
System.assert(logs[0].bfml__Total_tokens__c == 12, 'prompt(7) + candidates(5) = 12 が記録される');
}
// =========================================================
// 正常系: userkey が FmlLineMember 以外 → null 化される
// =========================================================
@IsTest
static void testUserkey_NotFriend_Nulled() {
Test.setMock(HttpCalloutMock.class, new GeminiMock(200, bodyText('応答')));
bfml.FmlExternalIF.IntentBean bean = baseBean();
bean.userkey = UserInfo.getUserId(); // User の ID → 友だちではない
Test.startTest();
String result = new FmlAIGemini().callAIApi(bean);
Test.stopTest();
System.assertEquals('応答', result);
List<bfml__FmlChatGPTLog__c> logs = [
SELECT Id, bfml__LineMemberID__c FROM bfml__FmlChatGPTLog__c
];
System.assertEquals(1, logs.size(), 'ログは作成される');
System.assertEquals(null, logs[0].bfml__LineMemberID__c, '友だち以外は LineMemberID が null');
}
// =========================================================
// 正常系: userkey が ID 形式でない → 例外を握りつぶし null 化
// =========================================================
@IsTest
static void testUserkey_InvalidId() {
Test.setMock(HttpCalloutMock.class, new GeminiMock(200, bodyText('応答')));
bfml.FmlExternalIF.IntentBean bean = baseBean();
bean.userkey = 'not-an-id';
Test.startTest();
String result = new FmlAIGemini().callAIApi(bean);
Test.stopTest();
System.assertEquals('応答', result, '不正 userkey でも処理継続');
}
}
