こんにちは。エンジニアの大橋です。
最近、AI エディタを含めた開発ツール界隈がますます盛り上がっています。 GitHub Copilot から始まり、Claude や ChatGPT のコード生成機能、そして Cursor や Windsurf などの AI ネイティブなエディタまで、開発者の生産性を向上させるツールが次々と登場しています。これらのツールは単なるコード補完を超えて、自然言語での指示によるコード生成や、プロジェクト全体を理解したうえでの修正提案など、開発のあり方自体を変えつつあります。
フレクトでも、こうした AI エディタの導入を積極的に進めており、開発効率と品質の向上を両立させる取り組みを行っています。
そこで今回は、Cursor を Salesforce 開発にどの程度活用できるのか、について書きたいと思います。 Cursor を使った開発についての記事は Web 上に多くありますが、Salesforce 開発を扱った例は現状ほとんどありません。そのため、本記事は Salesforce エンジニアにとって実用的になると考えています。
概要
まずは Cursor について簡単に説明したいと思います。 Cursor は Anysphere 社が開発した AI 機能が組み込まれたコードエディタです。 Cursor を使用することで、自然言語による指示でコードやドキュメントを生成・修正することが可能です。
Cursor を使って今回実装した機能は「提案書検索機能」です。 ユースケースとしては、案件PMが自案件と類似の過去案件を探し出し、それを参考にすることで、提案書作成を効率化することです。 検索機能にはベクトル検索を利用し、キーワード一致検索では見つけにくい類似案件を検索できるようにします。
先に全体像を掴んでいただくため、システム構成図をお見せします。(こちらも Cursor を使って自動生成しています。)
以下は、事前に準備できていることを前提とします。
- Data Cloud に提案書のサマリ情報が格納された DMO(Data Model Object)が作成済み
- Data Cloud Vector Database を利用するための Search Index が作成済み
- Data Cloud の Connected App が設定済み
それでは、実際に開発を進めた流れをご紹介していきたいと思います。
要件の作成
今回は社内向けの簡易なシステム開発となるため、要件を以下のように定めました。
- 目的:提案書検索機能を作成する
- 技術要素:Salesforce + LWC (Lightning Web Component) + Apex
設計書の作成
先ほど作成した要件から Cursor で画面設計書を作成しました。
画面設計書の作成するプロンプトには、要件に加えて、以下の資料を LLM のコンテキストとして与えることで、細部まで自動生成することを試みました。
- 設計書ガイド(社内の標準資料)
- 設計書に含めるべき項目が網羅的に定義されています
- Salesforce のオブジェクト定義書
- 検索条件に使用可能な項目が定義されています
生成された設計書は以下になります。
## 1. 提案書検索画面 ### 基本情報 - **画面タイトル**: 提案書検索画面 - **URL**: `/proposal/search` - **主なアクター**: 案件PM - **権限があるロール**: 案件PM、営業マネージャー - **画面概要**: 過去の提案書を効率的に検索し、提案書作成時間の短縮と品質向上を支援する画面 ### 画面レイアウト - **レイアウト種別**: 標準LWCレイアウト - **構成要素**: - ヘッダー - 検索条件エリア - 検索結果一覧エリア - フッター ### 画面項目定義 #### 検索条件 | 項目名 | 種別 | 説明・補足 | 対応DB | |--------|------|------------|--------| | 業種 | ドロップダウンリスト | 検索対象の業種を選択する | ProposalSummary__dlm.industry__c | | 業界 | ドロップダウンリスト | 検索対象の業界を選択する | ProposalSummary__dlm.sector__c | | 提案金額 | 数値入力 | 提案金額を指定して検索する | ProposalSummary__dlm.estimated_amount__c | | プラットフォーム | ドロップダウンリスト | 提案プラットフォームを選択する | ProposalSummary__dlm.platform__c | | キーワード | テキスト入力 | 提案書の要約から検索する | ProposalSummary__dlm.summary__c | #### 検索結果 | 項目名 | 種別 | 説明・補足 | 対応DB | |--------|------|------------|--------| | 検索結果一覧 | データテーブル | 検索条件に合致する提案書の一覧を表示 | ProposalSummary__dlm | | タイトル | リンク | 提案書のタイトルを表示 | ProposalSummary__dlm.title__c | | 顧客名 | テキスト | 提案先の顧客名を表示 | ProposalSummary__dlm.customer__c | | 提案金額 | 数値 | 提案金額を表示 | ProposalSummary__dlm.estimated_amount__c | | 概要 | テキスト | 提案の概要を表示 | ProposalSummary__dlm.summary__c | | 詳細表示ボタン | ボタン | 選択した提案書の詳細を表示する | - | ### イベントと処理 | イベント | 処理内容 | 遷移先 | |----------|----------|--------| | 検索ボタン押下 | 入力された検索条件で提案書を検索し、結果を表示する | - | | リセットボタン押下 | 検索条件をリセットし、検索結果をクリアする | - | | 詳細表示ボタン押下 | 選択した提案書の詳細画面に遷移する | 提案書詳細表示画面 |
実装
先ほど作成した画面設計書をもとに、Cursor で実装しました。
まずはフロントエンドとバックエンドをまとめて実装してみました。 その結果、生成されたコードの依存関係が複雑になり、画面側のコードをデプロイするには、大幅な修正が必要となり、画面の実装結果を確認することができませんでした。
したがって、フロントエンドとバックエンドは分けて実装するのが良いと判断しました。
LWC の実装
画面設計書をもとに、Cursor でフロントエンド(画面)から実装しました。
生成された LWC のコードは HTML、JavaScript、CSS が適切に分離されており、設計書の内容を正確に反映できていました。 特に修正なしでデプロイできました。
画面の実装結果は以下になります。
このような画面がものの数分で作成できるというのは凄いですね。 エンジニアが一から実装すれば数時間はかかる内容だと思います。
LWC は一般的な実装であれば、特に大きな修正なしで実装可能であることが分かりました。
Apex の実装
続いて、バックエンド(データ処理)を実装しました。
実装結果へと進む前に、ベクトル検索の実装方法について確認したいと思います。
ベクトル検索は Data Cloud の Query API 経由で行いますが、この Query API を呼び出すためには、以下の3回の API 呼び出しが必要になります。
- Connected App からアクセストークンを取得
- 1.で取得したアクセストークンを使って、Data Cloud 用のアクセストークンと交換
- 2.で取得した Data Cloud 用のアクセストークンを使って、Query API を呼び出す
Apex から Data Cloud の Query API を呼び出す方法については、過去ブログ:Einstein Copilot と Data Cloud Vector Database を連携する に詳しく書かれていますので、こちらをご覧ください。
では、実際に Apex のコード生成に移りたいと思います。 画面設計書をもとに、Cursor を使って実装しました。
public with sharing class ProposalSearchController { // Connected App設定(カスタム設定またはカスタムメタデータから取得することを推奨) private static final String CLIENT_ID = 'YOUR_CONNECTED_APP_CLIENT_ID'; private static final String CLIENT_SECRET = 'YOUR_CONNECTED_APP_CLIENT_SECRET'; private static final String USERNAME = 'YOUR_SALESFORCE_USERNAME'; private static final String PASSWORD = 'YOUR_SALESFORCE_PASSWORD'; private static final String SECURITY_TOKEN = 'YOUR_SECURITY_TOKEN'; // DataCloud API設定 private static final String DATACLOUD_API_BASE_URL = 'https://api.salesforcedata.com'; private static final String TOKEN_ENDPOINT = '/v1/token'; private static final String QUERY_ENDPOINT = '/v2/query'; /** * Connected Appからアクセストークンを取得 * @return アクセストークン */ @AuraEnabled(cacheable=false) public static String getAccessToken() { try { HttpRequest req = new HttpRequest(); req.setEndpoint('https://login.salesforce.com/services/oauth2/token'); req.setMethod('POST'); req.setHeader('Content-Type', 'application/x-www-form-urlencoded'); String body = 'grant_type=password' + '&client_id=' + EncodingUtil.urlEncode(CLIENT_ID, 'UTF-8') + '&client_secret=' + EncodingUtil.urlEncode(CLIENT_SECRET, 'UTF-8') + '&username=' + EncodingUtil.urlEncode(USERNAME, 'UTF-8') + '&password=' + EncodingUtil.urlEncode(PASSWORD + SECURITY_TOKEN, 'UTF-8'); req.setBody(body); Http http = new Http(); HttpResponse res = http.send(req); if (res.getStatusCode() == 200) { Map<String, Object> responseMap = (Map<String, Object>) JSON.deserializeUntyped(res.getBody()); return (String) responseMap.get('access_token'); } else { throw new CalloutException('アクセストークンの取得に失敗しました。ステータスコード: ' + res.getStatusCode() + ', レスポンス: ' + res.getBody()); } } catch (Exception e) { System.debug('アクセストークン取得エラー: ' + e.getMessage()); throw new AuraHandledException('アクセストークンの取得中にエラーが発生しました: ' + e.getMessage()); } } /** * SalesforceアクセストークンをDataCloud用トークンに変換 * @param accessToken Salesforceアクセストークン * @return DataCloud用トークン */ @AuraEnabled(cacheable=false) public static String convertToDataCloudToken(String accessToken) { try { HttpRequest req = new HttpRequest(); req.setEndpoint(DATACLOUD_API_BASE_URL + TOKEN_ENDPOINT); req.setMethod('POST'); req.setHeader('Content-Type', 'application/json'); req.setHeader('Authorization', 'Bearer ' + accessToken); Map<String, Object> requestBody = new Map<String, Object>{ 'grant_type' => 'urn:salesforce:grant-type:external:cdp', 'subject_token' => accessToken, 'subject_token_type' => 'urn:salesforce:token-type:access-token' }; req.setBody(JSON.serialize(requestBody)); Http http = new Http(); HttpResponse res = http.send(req); if (res.getStatusCode() == 200) { Map<String, Object> responseMap = (Map<String, Object>) JSON.deserializeUntyped(res.getBody()); return (String) responseMap.get('access_token'); } else { throw new CalloutException('DataCloudトークンの変換に失敗しました。ステータスコード: ' + res.getStatusCode() + ', レスポンス: ' + res.getBody()); } } catch (Exception e) { System.debug('DataCloudトークン変換エラー: ' + e.getMessage()); throw new AuraHandledException('DataCloudトークンの変換中にエラーが発生しました: ' + e.getMessage()); } }
上記は生成されたコードのうち、1. と 2. のアクセストークン取得の部分です。 生成されたコードを見ると、1. のアクセストークン取得で、Client Credentials Grant によるパスワード認証をハードコードしているため、認証情報が漏洩してしまっていました。 (ただし、コメントではカスタム設定またはカスタムメタデータから認証情報を取得することを推奨しています。)
これではセキュリティ上問題があるため、今回は Named Credentials(指定ログイン情報) を使用するように修正しました。
Named Credentials を使用すると、Salesforce 上で HTTPコールアウトを行う URL とそこで必要となる認証情報をセットで管理することで、Apex コード内に認証情報を記述せずに済みます。 厳密には、認証情報は Named Credentials に紐づく External Credentials に設定します。
Named Credentials を使用して実装するように Cursor で修正しました。 Cursor により生成されたコードは以下となります。(該当箇所のみ抜粋)
public with sharing class ProposalSearchController { // Named Credentials名 private static final String SALESFORCE_NAMED_CREDENTIAL = 'Salesforce_Auth'; private static final String DATACLOUD_NAMED_CREDENTIAL = 'DataCloud_API'; // DataCloud API設定 private static final String TOKEN_ENDPOINT = '/v1/token'; private static final String QUERY_ENDPOINT = '/v2/query'; /** * Named CredentialsからSalesforceアクセストークンを取得 * @return アクセストークン */ @AuraEnabled(cacheable=false) public static String getAccessToken() { try { HttpRequest req = new HttpRequest(); req.setEndpoint('callout:' + SALESFORCE_NAMED_CREDENTIAL + '/services/oauth2/token'); req.setMethod('POST'); req.setHeader('Content-Type', 'application/x-www-form-urlencoded'); String body = 'grant_type=client_credentials'; req.setBody(body); Http http = new Http(); HttpResponse res = http.send(req); if (res.getStatusCode() == 200) { Map<String, Object> responseMap = (Map<String, Object>) JSON.deserializeUntyped(res.getBody()); return (String) responseMap.get('access_token'); } else { throw new CalloutException('アクセストークンの取得に失敗しました。ステータスコード: ' + res.getStatusCode() + ', レスポンス: ' + res.getBody()); } } catch (Exception e) { System.debug('アクセストークン取得エラー: ' + e.getMessage()); throw new AuraHandledException('アクセストークンの取得中にエラーが発生しました: ' + e.getMessage()); } } /** * SalesforceアクセストークンをDataCloud用トークンに変換 * @param accessToken Salesforceアクセストークン * @return DataCloud用トークン */ @AuraEnabled(cacheable=false) public static String convertToDataCloudToken(String accessToken) { try { HttpRequest req = new HttpRequest(); req.setEndpoint('callout:' + DATACLOUD_NAMED_CREDENTIAL + TOKEN_ENDPOINT); req.setMethod('POST'); req.setHeader('Content-Type', 'application/json'); req.setHeader('Authorization', 'Bearer ' + accessToken); Map<String, Object> requestBody = new Map<String, Object>{ 'grant_type' => 'urn:salesforce:grant-type:external:cdp', 'subject_token' => accessToken, 'subject_token_type' => 'urn:salesforce:token-type:access-token' }; req.setBody(JSON.serialize(requestBody)); Http http = new Http(); HttpResponse res = http.send(req); if (res.getStatusCode() == 200) { Map<String, Object> responseMap = (Map<String, Object>) JSON.deserializeUntyped(res.getBody()); return (String) responseMap.get('access_token'); } else { throw new CalloutException('DataCloudトークンの変換に失敗しました。ステータスコード: ' + res.getStatusCode() + ', レスポンス: ' + res.getBody()); } } catch (Exception e) { System.debug('DataCloudトークン変換エラー: ' + e.getMessage()); throw new AuraHandledException('DataCloudトークンの変換中にエラーが発生しました: ' + e.getMessage()); } }
結果は、1. と 2. のどちらの場合も Named Credentials をコールアウトするように修正されました。
ここで、Named Credentials を使用した場合の正しい実装を確認しておきたいと思います。
- の認証情報を External Credentials に設定し、getAccessToken() メソッドは削除する。
- のアクセストークン取得で Named Credentials をコールアウトし、リクエストボディに 1. で取得したアクセストークンを設定する。
リクエストボディへのアクセストークンの設定には、Named Credentials の
{!$Credential.OAuthToken}
Merge Field を使用する(参考:Merge Fields for Apex Callouts That Use Named Credentials
この部分の正しい実装は、明確に修正方法をプロンプトとして与えないと、修正できませんでした。 理由としては、以下の2点があると思います。
- Web 上のサンプルでは、シンプルに Named Credentials をコールアウトして HTTP リクエストを送信するだけのパターンが多い
- そもそも Web 上に実装例が少ない
2つ目の理由については、過去ブログ:生成AIに自分で書いた一般相対論の学術論文を読ませてみる の「ビジネス分野での応用可能性を考える」でも言及されている内容ですので、良ければ読んでみてください。
このように、複雑な機能の実装は、修正方法をある程度詳細に指示しないと難しいことが分かりました。
ここまで、2. の Data Cloud 用アクセストークン取得の実装まで完了しました。
最後に、3. のベクトル検索の実装に進みたいと思います。
Cursor により生成されたコードは以下となります。(ベクトル検索のクエリ文のみ抜粋)
// ベクトル検索クエリ(DataCloud Query API形式) String vectorQuery = 'SELECT id, title__c, customer__c, industry__c, sector__c, platform__c, estimated_amount__c, summary__c, ' + 'VECTOR_SIMILARITY(embedding__c, VECTOR_EMBEDDING(\'' + String.escapeSingleQuotes(combinedSearchTerm) + '\')) as similarity_score ' + 'FROM ProposalSummary__dlm ' + 'ORDER BY similarity_score DESC ' + 'LIMIT 5';
生成されたコードを見ると、ベクトル検索のクエリ文が誤っています。
VECTOR_SIMILARITY
ではなくvector_search
を使用する必要があります。
そこで、こちらの Salesforce 公式ドキュメントを参照させて、正しいクエリ文の生成を行いました。
しかし結果は、変わらず誤ったクエリ文が生成されました。
公式ドキュメントを閲覧しても正しく修正できなかった理由を Cursor に尋ねると、どうやら正しく Web サイトを閲覧できていなかったようです。
さらに Web サイトを読み込めなかった原因を尋ねると、以下の回答が得られました。
Web サイトの内容が確認できなかった主な原因は、Salesforce ヘルプサイトの動的コンテンツ読み込み機能と Web 検索ツールの技術的制限の組み合わせでした。特に、JavaScript に依存したコンテンツの読み込みと CSS 読み込みエラーにより、実際のドキュメント内容ではなく、エラーメッセージのみが取得されていました。
したがって、動的コンテンツの Salesforce 公式ドキュメントは Cursor での読み込みに失敗する場合がありそうですので、注意しましょう。 ちなみに、Web サイトの指定には、「@Link」と「@Docs」を使用する2種類の方法があります。 どちらの方法でも正しく読み込めませんでした。
なので、公式ドキュメントではなく、先ほど紹介した過去ブログを参照させることにしました。
すると、VECTOR_SIMILARITY
ではなくvector_search
を使用するように正しく修正されました。
しかし、まだFROM句
に指定する Index Object 名が適当な値になっていました。
正しい値を与えていないので、間違えるのは仕方ありませんが、AI に勝手な値を設定されてしまうと、どこが正しい値でどこが勝手に設定された値か分からなくなってしまうので、問題がありそうです。
そこで、「実装時に分からないことがあれば質問してください」というプロンプトを追加することで、Index Object 名を聞いてくれるようになりました。
この方法でハルシネーションを完全に回避することはできないかもしれませんが、減らすことはできそうなので、活用してみてください。
以上で、Apex の実装が完了しました。
まとめ
今回は、Cursor を Salesforce 開発にどの程度活用できるのかを検証してみました。
LWC の開発では、基本的な機能のコンポーネント開発では、Cursor の性能を十分に活用できました。
一方、Apex の開発では、以下の傾向が確認できました。
- 一般的な機能の実装は問題なく生成される
- 複雑な機能の実装は、コンテキストとプロンプトを工夫しないと難しい
また、開発を通じて、以下の知見が得られました。
- フロントエンドとバックエンドは分けて実装した方が、依存関係が整理できて良い
- 動的コンテンツが含まれる Salesforce のドキュメントは、Cursor で正しく読み込めない場合がある
- その場合は、技術ブログやその他 Web サイトを参照させる
- 実装時に分からないことがあれば質問するようにプロンプトに記載しておく
今後の可能性
Salesforce 開発における AI エディタの活用は、これからさらに活発になっていくと思います。 今回の検証で、使い方次第では確実に開発効率を向上させられる手応えを感じました。 Salesforce 開発案件では、複雑な業務要件が多いですが、実案件でナレッジを溜め、さらに活用できるように取り組んでいきたいと思います。
以上です。最後までお読みいただき、ありがとうございました。