2024年6月28日金曜日

Oracle APEX 24.1のAIアシスタントを使ってみる

Oracle APEX 24.1で追加されたAIアシスタント(注:動的アクションはOpen AIアシスタントとなっていますが、AIアシスタント開くという意味です)を使った、サンプル・アプリケーションを作成してみます。アプリケーションを開発する環境はOracle CloudのAlways FreeのOracle Database 23aiを使用します。

作成するアプリケーションは以下のように動作します。AIアシスタントには、色々とカスタマイズするためのオプションがあります。オプションの設定によって、チャットの使い勝手や動作を変えることができるため、この動画とは異なるユーザー・インターフェースにもできます。

以下の画面では質問文からエンべディングを生成し、データベースに保存した意味的に近いチャンクを取り出し、最初のプロンプトとしてLLMに送信するメッセージに含めています。ユーザーとのチャットによるやり取りは、その後のメッセージとして追加されます。

エンべディング・モデルはCohereのembed-multilingual-light-v3.0チャットにはcommand-r-plusを使います。これらの機能を呼び出すために、Cohereにユーザー登録を行いTrial Keyを取得しておく必要があります。


CohereのAPIを呼び出すため、ワークスペースにWeb資格証明を作成します。

名前COHERE_API_KEY静的IDCOHERE_API_KEYとします。認証タイプHTTPヘッダー資格証明名(つまりHTTPヘッダー名)としてAuthorizationを指定します。

資格証明シークレットしてBearerで始めて空白で区切り、CohereのTrial Keyを続けた文字列を設定します。

URLに対して有効https://api.cohere.aiとします。


ワークスペース・ユーティリティ生成AIとして、Cohereを作成します。

AIプロバイダCohere名前Cohere静的IDCOHERE_AIとします。

アプリケービョン・ビルダーで使用オフ資格証明は先ほど作成したCOHERE_API_KEYを選択します。AIモデルとしてCohereで最も性能のよいcommand-r-plusを指定します。


以上でAIサービスを使う準備ができました。

これからAPEXアプリケーションを作成します。

最初に、以下のDDLを実行して、チャンクとエンべディングを保存する表EBMJ_EMBEDDINGSを作成します。URLを保存する表とチャンクとエンべディングを保存する表で親子関係の表を作るべきですが、省略して1つの表に保存しています。
create table ebmj_embeddings (
    id                  number generated by default on null as identity
                        constraint ebmj_embeddings_id_pk primary key,
    url                 varchar2(400 char),
    chunk_id            number,
    chunk               varchar2(4000 char),
    embedding           vector
);

APEXアプリケーションを作成します。

名前APEX Chatとし、アプリケーション作成ウィザードを起動します。


ページの追加をクリックします。


空白を選択します。


ページ名Documentsとします。このページには、PDFを選択してデータベースにロードする機能を実装します。

ページの追加をクリックします。


ページDocumentsが追加されました。再度、ページの追加をクリックします。


EBMJ_EMBEDDINGSソースとした、対話グリッドのページを追加します。

対話グリッドを選択します。


ページ名Embeddingsとします。表またはビューとしてEBMJ_EMBEDDINGSを選択します。編集を許可を選択します。

ページの追加をクリックします。


以上のページ構成で、アプリケーションの作成を実行します。


APEXアプリケーションが作成されます。


GitHubに、このブログ日日是Oracle APEXの記事のPDF版が置いてあります。今回は、このPDFをデータベースに知識として取り込むことにします。

GitHubのリポジトリを参照するRESTデータ・ソースを作成します。

共有コンポーネントRESTデータ・ソースを開きます。


作成をクリックします。


RESTデータ・ソースの作成最初からとします。

へ進みます。


RESTデータ・ソース・タイプとして簡易HTTPを選択します。名前GitHub APEXKBとします。PDF版の記事が置いてあるURLエンドポイントは以下です。

https://api.github.com/repos/ujnak/APEXKB/contents/

へ進みます。


リモート・サーバーがすでに登録されている場合は、そのまま使用します。そうでない場合は、リモート・サーバー- 新規作成 -として、ベースURLhttps://api.github.com/repos/サービスURLパスujnak/APEXKB/contents/とします。

へ進みます。


ページ区切りタイプページ区切りなしとします。

へ進みます。


公開されているリポジトリなので認証は不要です。

検出をクリックします。


データのプレビューが表示されます。

RESTデータ・ソースの作成をクリックします。


以上でPDF版の記事を一覧するRESTデータ・ソースが作成されました。


このRESTデータ・ソースから記事を選択しデータベースへロードする機能を、ページ番号2Documentsのページに実装します。

記事の一覧にはタイプがContent Rowのリージョンを使います。Oracle APEX 24.1より、Content Rowで行選択ができる新機能が追加されました。その機能を使うため、選択された行を保存するページ・アイテムと、すべての行を選択するチェックボックスを作成します。

ページ・デザイナでページDocumentsを開きます。


ページ上で選択した記事を、データベースにロードするボタンを作成します。

識別ボタン名LOADラベルLoadとします。外観ホットオンテンプレート・オプションではWidthStretchを指定します。動作アクションはデフォルトのページの送信です。


レポートに一覧されている記事を、すべて選択するチェックボックスとなるページ・アイテムを作成します。

識別名前P2_SELECT_ALLタイプチェックボックです。ラベルすべて選択とします。セッション・ステートストレージとしてリクエストごと(メモリーのみ)を選択します。


レポート上で選択された記事を保持するページ・アイテムを作成します。

識別名前P2_SELECTEDタイプ非表示とします。ブラウザ上の処理で値が変更されるため、設定保護された値オフにします。セッション・ステートデータ型CLOBストレージリクエストごと(メモリーのみ)を指定します。


記事の一覧と選択を行うリージョンを作成します。

識別名前Documentsとします。タイプContent Rowを選択します。

ソース位置RESTソースRESTソースとして先ほど作成したGitHub APEXKBを選択します。


DOWNLOAD_URLを選択し、ソース主キーをオンにします。行が選択されたときに、ページ・アイテムP2_SELECTEDに主キーの値、つまりDOWNLOAD_URLが保存されます。


リージョンDocuments属性タブを開きます。

外観表示複数(レポート)です。設定Title&NAME.Description<a href="&DOWNLOAD_URL.">&DOWNLOAD_URL.</a>とします。

記事のエントリ自体を小さくするため、AppearanceStyleCompactを選択し、Remove Paddingオンにします。

行選択タイプ複数選択を選び、現在の選択のページ・アイテムP2_SELECTEDすべてのページ・アイテムの選択P2_SELECT_ALLを指定します。

ページ区切りタイプスクロールに変更し、総数の表示オンにします。


ブレッドクラムを選択し、タイプスマート・フィルタに変更します。

ソースフィルタ済リージョンDocumentsを指定します。デフォルトで行検索に使用するP2_SEARCHが作成されます。


以上でページ上に配置するコンポーネントは作成できました。

ボタンLOADをクリックしたときに実行するプロセスを作成します。

識別名前LoadソースPL/SQLコードとして以下を記述します。選択された複数の記事のダウンロードURLが、ページ・アイテムP2_SELECTED:(コロン)区切りで渡されます。それぞれのダウンロードURLごとにPDFの取得、テキストの抽出およびチャンク分割を行います。分割されたチャンクをまとめてCohereのEmbed APIを呼び出してエンべディングを生成し、表EBMJ_EMBEDDINGSに保存しています。

declare
l_docs apex_t_varchar2;
l_content blob;
l_text clob;
l_chunks vector_array_t;
l_url varchar2(4000);
e_get_document_failed exception;
l_chunk_count integer;
l_chunk_text apex_t_varchar2;
l_call_count integer;
/*
* CohereのEmbed APIを呼び出し、引数のチャンクからエンベディングを生成する。
* 生成したエンベディングは表EBMJ_EMBEDDINGSへ保存する。
*/
procedure generate_embeddings(
p_chunk_count in integer
,p_chunk_text in apex_t_varchar2
,p_call_count in integer default 0
)
as
l_request_json json_object_t;
l_request clob;
l_texts json_array_t;
l_response clob;
l_response_json json_object_t;
l_embeddings json_array_t;
l_embedding json_array_t;
e_embed_failed exception;
l_chunk ebmj_embeddings.chunk%type;
l_embed ebmj_embeddings.embedding%type;
l_chunk_id ebmj_embeddings.chunk_id%type;
begin
/*
* Cohere Embed APIに送信するリクエストを生成する。
*/
l_request_json := json_object_t();
l_request_json.put('model', 'embed-multilingual-light-v3.0');
l_request_json.put('input_type', 'search_document');
l_texts := json_array_t();
for i in 1 .. p_chunk_count
loop
l_texts.append(p_chunk_text(i));
end loop;
l_request_json.put('texts', l_texts);
l_request := l_request_json.to_clob();
/*
* Cohere Embed APIを呼び出す。
*/
apex_web_service.clear_request_headers;
apex_web_service.set_request_headers('Accept','application/json', p_reset => false);
apex_web_service.set_request_headers('Content-Type','application/json', p_reset => false);
l_response := apex_web_service.make_rest_request(
p_url => 'https://api.cohere.ai/v1/embed'
,p_http_method => 'POST'
,p_body => l_request
,p_credential_static_id => 'COHERE_API_KEY'
);
if apex_web_service.g_status_code <> 200 then
apex_debug.info(l_response);
raise e_embed_failed;
end if;
/*
* レスポンスからエンベディングを取り出し、表EBMJ_EMBEDDINGSへ保存する。
*/
l_response_json := json_object_t.parse(l_response);
l_embeddings := l_response_json.get_array('embeddings');
for i in 1 .. p_chunk_count
loop
l_chunk := l_texts.get(i-1).to_string();
l_embed := to_vector(l_embeddings.get(i-1).to_string());
l_chunk_id := (p_call_count * 96) + i;
insert into ebmj_embeddings(url, chunk, embedding, chunk_id) values(l_url, l_chunk, l_embed, l_chunk_id);
-- apex_debug.info('url %s, id %s, chunk %s, embed %s', l_url, l_chunk_id, l_chunk, from_vector(l_embed));
end loop;
end generate_embeddings;
begin
/*
* ドキュメントが何も選択されていなければ処理をスキップ。
*/
if :P2_SELECTED is null then
apex_debug.info('Do nothing!');
return;
end if;
/*
* それぞれのダウンロードURLをhttps:のコロンで区切られないように .pdf: で区切る。
* 一番最後のファイルは.pdfで : がないため、拡張子は削除されない。
*/
l_docs := apex_string.split(:P2_SELECTED, '\.pdf\:');
for i in l_docs.first .. l_docs.last
loop
/*
* ダウンロードするPDFファイルごとの処理
*/
l_url := l_docs(i);
if substr(l_url, -4) <> '.pdf' then
/* 削除された拡張子.pdfを戻す */
l_url := l_url || '.pdf';
end if;
/*
* GitHubからPDFファイルをダウンロードする。
* ダウンロードしたファイルはl_contentにBLOBとして保存される。
*/
l_url := utl_url.escape(l_url, false, 'AL32UTF8');
apex_debug.info('Download PDF file from GitHub, %s', l_url);
apex_web_service.clear_request_headers();
l_content := apex_web_service.make_rest_request_b(
p_url => l_url
,p_http_method => 'GET'
);
if apex_web_service.g_status_code <> 200 then
raise e_get_document_failed;
end if;
/*
* PDFファイルからテキストを取り出す。
*/
l_text := dbms_vector_chain.utl_to_text(
data => l_content
,params => JSON(
json_object(
key 'plaintext' value true
)
)
);
apex_debug.info('Text retrieved length = %s', dbms_lob.getlength(l_text));
/*
* 取り出したテキストをチャンクに分割する。チャンクごとにエンベディングを生成するが、
* CohereのAPI呼び出しの回数を減らすため、最大96のチャンクをまとめて、APIを呼び出す。
*/
l_chunk_count := 0;
l_chunk_text := apex_t_varchar2();
l_call_count := 0;
for r in (
/*
* チャンクは500ワードごとにしている。CohereのEmbed APIのパラメータ texts の
* 説明:
* An array of strings for the model to embed. Maximum number of texts per call is 96.
* We recommend reducing the length of each text to be under 512 tokens for optimal quality.
*/
select rownum, t.chunk_offset, t.chunk_length, t.chunk_text
from vector_chunks(
l_text
by words
max 500
overlap 0
split by recursively
language japanese
normalize all
) t
)
loop
apex_debug.info('ID %s, Chunk Offset %s, Length %s', r.rownum, r.chunk_offset, r.chunk_length);
l_chunk_count := l_chunk_count + 1;
apex_string.push(l_chunk_text, r.chunk_text);
/*
* チャンクの個数が96でCohereのEmbed APIを呼び出す。
*/
if l_chunk_count = 96 then
generate_embeddings(
p_chunk_count => l_chunk_count
,p_chunk_text => l_chunk_text
,p_call_count => l_call_count
);
dbms_session.sleep(1); /* CohereのFree枠に収めるため、1秒待つ */
l_chunk_count := 0;
l_chunk_text := apex_t_varchar2();
l_call_count := l_call_count + 1;
end if;
end loop;
/*
* 残りのチャンクを対象に、CohereのEmbed APIを呼び出す。
*/
if l_chunk_count > 0 then
generate_embeddings(
p_chunk_count => l_chunk_count
,p_chunk_text => l_chunk_text
,p_call_count => l_call_count
);
dbms_session.sleep(1);
end if;
end loop;
end;
サーバー側の条件ボタン押下時LOADを指定します。


以上でPDF記事を選択して、データベースにロードする機能の実装は完了です。

タイトルにGoogleが含まれている記事を、データベースにロードしてみます。

スマート・フィルタでGoogleを検索し、検索されたすべての行を選択したのち、Google Fontsの記事を選択から外しています。その後に選択した記事すべてをデータベースにロードしています。


これから、本記事の本題であるAIアシスタントを実装します。

ページ・デザイナホーム・ページを開き、デフォルトで作成されているページ・ナビゲーション削除します。


AIへの質問を入力するページ・アイテムを作成します。

識別名前P1_QUESTIONタイプテキスト領域とします。ラベル質問とします。


AIとのチャットを開始するボタンを作成します。

識別ボタン名ASKラベル問い合わせるとします。動作アクション動的アクションで定義を選択します。ボタンをクリックしたときに、動的アクションとして実装されているOpen AIアシスタントを実行します。


AIアシスタント表示形式として、ダイアログインラインの2種類を選択できます。今回は、まず最初にインラインを実装します。そのために、AIアシスタントとの会話を扱うリージョンを作成します。

識別名前AIチャットとします。タイプ静的コンテンツです。リージョンとしての装飾は不要なので、外観テンプレートBlank with Attributesを選択します。

詳細静的IDとしてchatを指定します。AIアシスタントには、このリージョンchatを会話に使うように指示します。


AIアシスタントとの会話の結果、採用した回答を保存するページ・アイテムを作成します。

識別名前P1_RESPタイプテキスト領域とします。ラベル回答とします。

セッション・ステートデータ型VARCHAR2ストレージセッションごと(永続)を選択します。


データベースに保存されたチャンクより、質問の内容に近いものを検索した結果を保存するページ・アイテムを作成します。

識別名前P1_PROMPTタイプMarkdownエディタを選択します。ただし、これは検索結果を確認するために、画面に表示されるページ・アイテムを選んでいます。通常は非表示にするのが適切でしょう。

ラベル知識とし、セッション・ステートデータ型CLOBストレージリクエストごと(メモリーのみ)を選択します。


ボタンASKに動的アクションを作成します。

識別名前onClick Start AI Assistantとします。ボタンのデフォルトである、クリックタイミングで実行します。


最初にサーバー側で、質問文に類似したチャンクを取り出しプロンプトとする処理を実行します。

TRUEアクションとしてサーバー側のコードを選択し、設定PL/SQLコードとして以下を記述します。

declare
l_request_json json_object_t;
l_request clob;
l_texts json_array_t;
l_response clob;
l_response_json json_object_t;
l_embeddings json_array_t;
l_embedding json_array_t;
e_embed_failed exception;
l_embed vector;
l_prompt clob;
l_norm varchar2(4000);
begin
/*
* Cohere Embed APIに送信するリクエストを生成する。
*/
l_request_json := json_object_t();
l_request_json.put('model', 'embed-multilingual-light-v3.0');
l_request_json.put('input_type', 'search_query');
l_texts := json_array_t();
l_texts.append(:P1_QUESTION);
l_request_json.put('texts', l_texts);
l_request := l_request_json.to_clob();
/*
* Cohere Embed APIを呼び出す。
*/
apex_web_service.clear_request_headers;
apex_web_service.set_request_headers('Accept','application/json', p_reset => false);
apex_web_service.set_request_headers('Content-Type','application/json', p_reset => false);
l_response := apex_web_service.make_rest_request(
p_url => 'https://api.cohere.ai/v1/embed'
,p_http_method => 'POST'
,p_body => l_request
,p_credential_static_id => 'COHERE_API_KEY'
);
if apex_web_service.g_status_code <> 200 then
apex_debug.info(l_response);
raise e_embed_failed;
end if;
/*
* レスポンスから質問文のベクトルを取り出す。
*/
l_response_json := json_object_t.parse(l_response);
l_embeddings := l_response_json.get_array('embeddings');
l_embed := to_vector(l_embeddings.get(0).to_string());
/*
* 回答に使う文字列をP1_PROMPTへ保存する。
*/
l_prompt := '';
for r in (
select chunk
from ebmj_embeddings
order by vector_distance(l_embed, embedding) asc
fetch first 2 partitions by url, 2 rows only
)
loop
l_prompt := l_prompt || '######';
l_prompt := l_prompt || apex_application.CRLF;
l_norm := r.chunk;
-- CRとLFを取り除く。
l_norm := replace(l_norm, chr(10));
l_norm := replace(l_norm, chr(13));
l_norm := replace(l_norm, '\n');
l_prompt := l_prompt || l_norm;
l_prompt := l_prompt || apex_application.CRLF;
end loop;
:P1_PROMPT := l_prompt;
end;
送信するアイテムとして質問文であるP1_QUESTION戻すアイテムとしてプロンプトであるP1_PROMPTを指定します。実行初期化時に実行オフ結果を待機オンにします。


TRUEアクションとしてOpen AIアシスタントを作成します。


生成AIサービスCohereを選択します。

システム・プロンプトに以下を記述します。

あなたはOracle APEXの専門家です。

この文字列はOpenAIのChat Completions APIの呼び出しではrolesystemcontentになります。

"messages": [
      { 
             "role" : "system",
             "content" : "ここの文字列"
      }
 ]

システム・プロンプトにはアプリケーション・アイテムやページ・アイテムの置換文字列を含めることができますが、これはページ・レンダリングの前に値が設定されている必要があります。

ようこそメーセージに以下を記述します。

Oracle APEXの質問に答えます。

この文字列はAIアシスタントに表示されるだけで、API呼び出しのメッセージには含まれません。

外観表示形式インラインダイアログを選択できます。ダイアログを選択すると、ダイアログのタイトルが追加の設定項目になります。タイトルAIチャットとした場合、以下のような画面に変わります。


今回は表示形式インラインコンテナ・セレクタ#chatを指定します。


最初のプロンプトタイプにはなしアイテムJavaScript式のどれかを選択できます。

OpenAIのChat Completions APIの呼び出しでは、roleがsystemのメッセージの直後に追加するroleusercontentを設定しています。

"messages": [
      { 
             "role" : "system",
             "content" : "プロンプト"
      },
      { 
             "role" : "user",
             "content" : "最初のプロンプト"
      }              
 ]

今回はタイプアイテムアイテムとしてページ・アイテムP1_PROMPTを指定します。

表示メッセージとして以下を記述しています。名前の通り表示だけで、API呼び出しには含まれません。

### 前提知識を追加しました。 ###

即時アクション・プロンプトとして&P1_QUESTION.を指定しています。即時アクション・プロンプトの指定があるときは、この値をroleがuserのcontentとしてメッセージに含め、APIを呼び出します。

即時アクション・プロンプトが無指定の場合は、システム・プロンプト最初のプロンプトは設定されていますが、APIの呼び出しは行われていず、ユーザーの追加入力待ちになります。

この状態のときにクイック・アクションが表示されます。クイック・アクションは2つまで設定できます。

例えば今回の実装で、即時アクション・プロンプト空白にして、クイック・アクションメッセージ1&P1_QUESTION.メッセージ2今までの要約をお願いします。を設定します。


この場合、ボタン問い合わせるをクリックすると選択肢としてOracle APEXからGoogleのサービスを呼び出せますか?今までの要約をお願いします。が表示されます。

どちらかをクリックすると、roleusercontentクイック・アクションの文字列が追加され、APIが呼び出されます。


最初のプロンプトとしてJavaScript式を選択すると、APIに含めるメッセージと会話として表示するデータを別々に制御できます。

例えば以下を記述します。
{
    fullContent: apex.items.P1_PROMPT.value + "###" + apex.items.P1_QUESTION.value,
    content: "**" + apex.items.P1_QUESTION.value + "**",
    type: "markdown",
    commit: true
}

fullContentAPIに含まれる文字列です。contentチャット画面に表示される文字列です。typeとしてmarkdownhtmlを選択できるため、画面上に表示される文字列を修飾できます。committrueの場合は、すぐにAPI呼び出しを行うfalseの場合はAPI呼び出しは行いません


レスポンスの使用タイプアイテムアイテムP1_RESPJavaScript検出式として以下を記述します。

this.fullContent.toLowerCase().includes( "json" ) ? this.fullContent : ""

this.fullContentはAIからのレスポンスで、そのレスポンスに文字列jsonが含まれている場合に、this.fullContentを戻すようにしています。この検出ができたときにAIのレスポンスにはボタン・ラベルで指定されたラベル(無指定の場合はこれを使用)のボタンが表示されます。


そのボタンを押すとJavaScript検出式で返された文字列が、ページ・アイテムP1_RESPに設定されます。また、この処理を持ってAIチャットは終了し、チャット画面が初期化されます。


レスポンスの使用タイプJavaScriptコードにすると、受け取ったAIのレスポンスをコードで処理することができます。以下のコードはタイプアイテムアイテムとしてP1_RESPを設定したときと同じ動きをするJavaScriptコードになります。

apex.items.P1_RESP.value = this;


今回の記事は以上になります。

今回作成したAPEXアプリケーションのエクスポートを以下に置きました。
https://github.com/ujnak/apexapps/blob/master/exports/apex-chat-sample-ai-assistant.zip

Oracle APEXのアプリケーション作成の参考になれば幸いです。