Agentforce × Prompt Builder × Apexで構築する、連動選択リスト対応のCaseカテゴリー自動分類フロー

Agentforce × Prompt Builder × Apexを組み合わせ、連動選択リストに従ってCaseのカテゴリーを自動分類するフローの実装方法をご紹介します。

はじめに

こんにちは、CI統括本部のH.Yです。

Salesforceで生成AIを業務に組み込む方法として、Prompt Builder は非常に魅力的な機能です。
プロンプトテンプレートを使うことで、レコードの内容をもとにLLMへ指示を出し、要約・分類などを手軽に実行できます。

カスタマーサポートの現場において、日々作成される問い合わせチケット(Case)の「カテゴリー分類」をAIに自動化させたい、というニーズは非常に多くあります。
プロンプトテンプレートを使用して、Caseの内容をAIに読み込ませれば、適切なカテゴリーを推測させることも可能です。

しかし、これを実際のSalesforce組織で運用する際には、「連動選択リスト」という仕様が大きな課題となります。

単にAIへ「内容を読んでカテゴリー1〜4を推測して」と指示を出すと、 親カテゴリーの値としては正しいものの、それに紐づく子カテゴリーとしては設定されていない「存在しない組み合わせの値」をハルシネーションしてしまうという問題です。
その結果、レコード保存時にバリデーションエラーやシステムエラーを引き起こしてしまいます。

今回は、Flow・Prompt Builder・Apexを組み合わせて、AIに「確実に存在する選択肢の中から選ばせる」ための実装方法をご紹介します。

AIに「正しい選択肢」を選ばせるための解決アプローチ

今回やりたいことは、Caseの説明をもとに、カテゴリー1〜4のような多段階のカテゴリー項目をAIで自動分類することです。
ただ、AIに「どの親カテゴリーの時に、どの子カテゴリーが選択可能か」という複雑な連動仕様をすべてプロンプト内で教え込むのは、プロンプトの肥大化や精度の観点から非現実的です。

そこで、以下のような「AIの推論」「フロー・Apexによる制御」を組み合わせる方法を選びました。

①【AIの処理】 プロンプトテンプレートで、Caseの説明から「親カテゴリー」を推論する。
②【Apexの処理】 決定した親カテゴリーの値をトリガーに、ApexクラスがSalesforceのスキーマ情報を動的に参照し、「その親の値に対して、有効な子カテゴリーの選択肢リスト」を抽出する。
③【AIの処理】 抽出された「有効な選択肢リスト」を動的に子カテゴリー用のプロンプトテンプレートに渡し、AIに対して「このリストの中にある言葉以外は出力するな」と強く条件をかけて推論させる。

この構成にすると、AIの分類能力を活かしつつ、Salesforce側の連動選択リスト制約も守ることができます。

これをレコードトリガーフロー上で順番に制御していきます。

システム概要図


実装のポイント①:Apexによる連動選択リストの動的取得

今回のシステム構成において、重要な役割を果たすのが、フローから呼び出すApexクラス(DepPickListCtrl.cls)です。
このクラスは、親カテゴリーの値を受け取り、それに連動する有効な子カテゴリーだけを抽出し、カンマ区切りの文字列としてフローに返します。

具体的には、以下の流れで処理を行っています。

1. フローからのデータ受け取り
@InvocableMethod を通じて、フローから「オブジェクト名」「親・子の項目名」「推論された親カテゴリーの値」を受け取ります。

2. Salesforceのメタデータの確認
受け取った情報をもとにSalesforceのスキーマ情報を参照し、親カテゴリーの値が選択肢リストの何番目のインデックスに該当するかを特定します。

3. 有効な子カテゴリーの絞り込みとテキスト化
子カテゴリーの全選択肢をループ処理し、特定した親に対して有効な子カテゴリーを抽出します。
抽出した値は、カンマ区切りの文字列に結合してフローへ返却します。

以下が、その制御を行っているApexクラスの処理部分です。
public class DepPickListCtrl {
        
    @InvocableMethod
    //フローから呼び出されるメソッド
    //オブジェクト名、項目名、親カテゴリーの値が渡されてくる
    public static List<String> getDependentOptionsForPrompt(List<DependencyRequest> requests){
        List<String> results = new List<String>();
        for(DependencyRequest req: requests){
            results.add(generateDependencyString(req.ObjName,req.ControllingFieldName,req.DependentFieldName,req.selectedParentLabel));
        }
        return results;
    }
    
    public class DependencyRequest{
        @InvocableVariable(required=true) public String ObjName; //オブジェクト名
        @InvocableVariable(required=true) public String ControllingFieldName; //親となるカテゴリー
        @InvocableVariable(required=true) public String DependentFieldName;   //子となるカテゴリー
        @InvocableVariable(required=true) public String selectedParentLabel;  //親カテゴリーの値
    }
    
    public static String generateDependencyString(String ObjName,String ControllongFieldName,String DependentFieldName,String parentLabel){
        //組織内のオブジェクト情報を取得する
        Schema.SObjectType targetType = Schema.getGlobalDescribe().get(ObjName); 
        Map<String, Schema.SObjectField> fieldMap = targetType.getDescribe().fields.getMap();
        
        // 子項目のメタデータを取得
        Schema.DescribeFieldResult controllerFieldDesc = fieldMap.get(ControllongFieldName).getDescribe();
        Schema.DescribeFieldResult dependentFieldDesc  = fieldMap.get(DependentFieldName).getDescribe();
                
        // 親の選択肢リストから、選ばれたラベルが何番目(インデックス)かを探す
        Integer parentIndex = -1;
        List<Schema.PicklistEntry> controllerValues = controllerFieldDesc.getPicklistValues();
        System.debug(controllerValues);
        for (Integer i = 0; i < controllerValues.size(); i++) {
            if (controllerValues[i].getLabel() == parentLabel) {
                parentIndex = i;
                break;
            }
        }
        
        System.debug('parentIndex:'+ parentIndex);
        if (parentIndex == -1) {
            return null;
        }
        
        // 子の選択肢をループして、親インデックスに対応するものだけを抽出
        List<String> validChildren = new List<String>();
        for (Schema.PicklistEntry entry : dependentFieldDesc.getPicklistValues()) {
            if (isChildValidForParent(entry, parentIndex)) {
                validChildren.add(entry.getLabel());
            }
        }  
        
        if (validChildren.isEmpty()) {
            System.debug('ValidChildrenがEmpty');
            return null;
        }
        
        return String.join(validChildren, ', ');
    }
    
    private static Boolean isChildValidForParent(Schema.PicklistEntry entry, Integer parentIndex) {
        // validForの中身を取得するために一度JSONシリアライズ
        String jsonEntry = JSON.serialize(entry);
        Map<String, Object> entryMap = (Map<String, Object>) JSON.deserializeUntyped(jsonEntry);
        String validFor = (String) entryMap.get('validFor');

        if (String.isBlank(validFor)) return true; // 連動設定がない場合は常に有効

        // Base64をビット列に変換し、parentIndexの位置が '1' かどうかをチェック
        List<Integer> validIndices = getValidParentIndices(validFor);
        return validIndices.contains(parentIndex);
    }
    
    // Base64ビットマップをインデックス番号のリストに変換
    private static List<Integer> getValidParentIndices(String validFor) {
        List<Integer> indices = new List<Integer>();
        String hex = '';
        String base64Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
        
        for (Integer i = 0; i < validFor.length(); i++) {
            Integer val = base64Chars.indexOf(validFor.substring(i, i + 1));
            String bin = '';
            for (Integer j = 0; j < 6; j++) {
                bin = (Math.mod(val, 2) == 0 ? '0' : '1') + bin;
                val /= 2;
            }
            hex += bin;
        }

        for (Integer i = 0; i < hex.length(); i++) {
            if (hex.substring(i, i + 1) == '1') {
                indices.add(i);
            }
        }
        return indices;      
    }
    
}
DepPickListCtrl.cls
このApexアクションをフローで使うことで、現在の親カテゴリーに対して有効な子カテゴリーの値を取得し、そのリストをプロンプトテンプレートへ直接渡すことができるようになります。

実装のポイント②:選択肢を絞り込むプロンプトテンプレート

次に、Apexが出力した「有効な子カテゴリーの値のリスト」を、子カテゴリー推論用のプロンプトテンプレートに動的入力パラメータ(ValidOptions)として流し込みます。

プロンプトテンプレート内では、以下のようにAIへ指示を出しています。

## 役割・目的
サポート担当者として、以下の判断ルールに従い、後述の【キーワードリスト】の中から適切な値のみを出力してください。

## 【キーワードリスト】
{!$Input:ValidOptions}

## 判断ルール
1. 上位カテゴリーが設定されていない(null、または空である)場合は、**1文字も出力しないでください**(真っ白な状態で返してください)。
2. 上位カテゴリーが設定されている場合に限り、{!$Input:Case.Description} を読み、内容を理解してください。
3. キーワードリスト(カンマ区切り)の中に、内容と関連性が高いものがあるか判断し、一致するものがあれば、その選択肢の値を**一言一句正確に**出力してください。
4. キーワードリストの中から必ず**1つだけ**選択し、その単語のみを出力してください。
5. 適切な値がない場合は、**1文字も出力しないでください**(真っ白な状態で返してください)。

## 禁止事項
* 「該当なし」「不明」「空文字」といった、キーワードリストにない言葉は**絶対に出力しないでください**。
* {!$Input:ValidOptions} の中にない言葉は**絶対に出力しないでください**。
* 説明や挨拶などの追加テキストは**一切不要**です。
* システム側で無駄な翻訳や変換を行わないでください。
ポイントは、禁止事項で「ValidOptions(Apexから渡された選択肢)にない言葉は絶対に出力するな」と念押ししている点です。
さらに「適切な値がない場合は1文字も出力するな」と指示することで、レコード保存時のバリデーションエラーを防ぎます。

実装のポイント③:フローを使った一連の処理の組み立て

最後に、これらすべての要素を1つのレコードトリガーフローで制御します。

今回の仕組みでは、プロンプト側でも制約をかけていますが、最終的な担保はフロー側で実施しています。
具体的には、以下のような判定を毎回挟みます。

・ 空文字ではないか
・ 子カテゴリーの場合は、Apexが返した候補一覧に含まれているか

この判定を入れておけば、AIが想定外の値を返しても、そのまま保存されることはありません。

親カテゴリーが正しく決まった時だけ、次の子カテゴリーの処理に進むようにしています。
もし途中でうまく分類できなかった場合は、そこまで決まった内容でレコードを更新して終了するという、安全なステップになっています。

このステップをカテゴリー2、カテゴリー3へと繋いでいくことで、多段階の連動選択リストであっても親子の連動関係を守りながら自動分類を完了させることができます。

実際の利用イメージ


まとめ:実運用を想定するAI活用

Prompt Builder は非常に強力で、数年前なら膨大なコードが必要だった「テキストの意図解釈」を、少ない実装で実現できるようにしてくれます。

ただ、「AIをどう業務に組み込むか」を考えるとき、プロンプトだけで完結しないケースは多くあります。
そうしたときこそ、FlowやApexといったSalesforceの既存機能をうまく組み合わせることで、実運用に耐える形へ落とし込めるはずです。

同じように、AI活用を検討されている方の参考になれば幸いです。