2024年4月26日金曜日

LLMのツール呼び出しで呼び出されるファンクションをJavaScriptで記述する

Oracle Database 23cよりMLE(MultiLingual Engine - GraalVMのこと)が組み込まれ、データベースでJavaScriptを実行することができます。

今までローカルLLMを呼び出す際に使用しているデータベースがOracle Database 23c Freeなので、JavaScriptを使うことができます。LLMを扱うような開発者がPL/SQLでコードを書くということはあまり無さそうなので、ツール呼び出しで呼び出されるファンクションをJavaScriptで書けるようにしてみました。

以下のようにツールを登録する際に、JavaScriptのファンクション本体を記述するページ・アイテムを追加します。


ツールの情報を入力するフォームにページ・アイテムP3_JAVASCRIPT_FUNCTION_BODYを作成します。

タイプテキスト領域ラベルJavaScript Function Bodyとします。

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


ツールの作成または変更の適用を実行したときに、本体がJavaScriptで記述されたファンクションの作成または更新を行います。

プロセスを新規に作成します。

識別名前Create or Replace JavaScript Functionタイプコードを実行を選択します。ソースPL/SQLコードには以下を記述します。ファンクションの本体としてJavaScriptがインラインで記述されたファンクションを、動的に作成しています。

declare
l_ddl clob;
begin
/*
* 引数pArgsのタイプとしてCLOBを指定すると値が正しく渡らない。JSONにするべきかもしれないが、PL/SQLとの
* 互換性を考慮して - 23c以前のDBでもこのアプリは動く - VARCHAR2とする。
*/
l_ddl := 'create or replace function ' || :P3_TOOL_NAME || '('
|| '"pArgs" in varchar2) return clob as mle language javascript q' || chr(39) || chr(126)
|| :P3_JAVASCRIPT_FUNCTION_BODY || chr(126) || chr(39) || ';';
apex_debug.info(l_ddl);
execute immediate l_ddl;
end;

サーバー側の条件タイプを選び、PL/SQL式として以下を記述します。ボタン作成または変更の適用がクリックされたときに、JavaScript Function Bodyが空でなければファンクションの作成または更新を行います。
:REQUEST in ('SAVE', 'CREATE') and :P3_JAVASCRIPT_FUNCTION_BODY is not null

フォームが開く時にページ・アイテムP3_JAVASCRIPT_FUNCTION_BODYを初期化するプロセスを作成します。

レンダリング前ヘッダーの前初期化フォームTool Detailの下にプロセスを作成します。

識別名前Load JavaScript Sourceタイプコードを実行を選択します。ソースPL/SQLコードに以下を記述します。

declare
l_text varchar2(32767);
l_clob clob;
l_start integer;
l_end integer;
begin
dbms_lob.createTemporary(
lob_loc => l_clob
,cache => false
,dur => DBMS_LOB.CALL
);
for r in (
select text from user_source where name = upper(:P3_TOOL_NAME) order by to_number(line) asc
)
loop
dbms_lob.writeAppend(
lob_loc => l_clob
,amount => length(r.text)
,buffer => r.text
);
end loop;
-- dbms_output.put_line(l_clob);
l_start := dbms_lob.instr(
lob_loc => l_clob
,pattern => 'q''~'
);
l_clob := dbms_lob.substr(
lob_loc => l_clob
,offset => (l_start + 3)
);
l_clob := dbms_lob.substr(
lob_loc => l_clob
,amount => length(l_clob) - 3
);
-- dbms_output.put_line(l_clob);
:P3_JAVASCRIPT_FUNCTION_BODY := l_clob;
dbms_lob.freeTemporary(
lob_loc => l_clob
);
end;

JavaScriptのファンクションが作成されているときにソースコードを取得するように、サーバー側の条件を設定します。

タイプ行が返されるを選択し、SQL問合せとして以下を記述します。
select 1 from user_mle_procedures where object_name = upper(:P3_TOOL_NAME)

以上でアプリケーションの変更は完了です。

ファンクションGET_CURRENT_WEATHERの本体を以下のJavaScriptコードに置き換えてみます。

/*
* fetchとoracledbのモジュールを使う。
*/
await import("mle-js-fetch");
const oracledb = require("mle-js-oracledb");
const conn = oracledb.defaultConnection();
/* 入力パラメータはJSONだが文字列として渡される */
const lArgs = JSON.parse(pArgs);
const location = lArgs.location.split(",")[0];
console.log("location = ", location);
/* 首都の名前から座標を取得する */
let sql = "select lat, lon from amano_city_locations where upper(capital_en) = upper(:location) or capital_jp = :location";
const coordinates = conn.execute(sql, [location, location]);
console.log(JSON.stringify(coordinates.rows[0]));
/* OpenMeteo.comに現在の天候を問い合わせる */
const url = "https://api.open-meteo.com/v1/forecast?latitude=" + coordinates.rows[0][0] + "&longitude=" + coordinates.rows[0][1] + "&current=weathercode";
console.log(url);
const response = await fetch(url);
const data = await response.json();
console.log(JSON.stringify(data));
/* 天候コードを取り出す */
const weathercode = Number(data.current.weathercode);
console.log(weathercode);
if ( weathercode < 10) {
// 10以下はWMO4501として扱う。
sql = "select description from wmo4501 where code = :weathercode";
} else {
// それ以外はWMO4677mとして扱う。
sql = "select description from wmo4677 where code = :weathercode";
}
/* 検索された天候の説明を戻り値とする */
const weather = conn.execute(sql, [weathercode]);
console.log(JSON.stringify(weather));
/* 戻り値は文字列 */
const ret = JSON.stringify({ "weather" : weather.rows[0][0] });
return ret;

処理の内容はPL/SQLのファンクションと全く同じです。


SQLコマンドより、作成されたJavaScriptのファンクションを呼び出してみます。以下のコマンドを実行します。

declare
l_args clob;
l_ret clob;
begin
DBMS_MLE.set_stdout_to_dbms_output();
l_args := '{ "location": "Tokyo" }';
l_ret := get_current_weather(l_args);
dbms_output.put_line(l_ret);
end;
view raw test_code.sql hosted with ❤ by GitHub


動作が確認できました。

RESTサービスの呼び出し時にACLのエラーが発生することがあります。

PL/SQLの場合は一般的にAPEX_WEB_SERVICEパッケージを使ってRESTサービスを呼び出します。このときはAPEXスキーマからUTL_HTTPパッケージが呼び出されるため、ACLはAPEXスキーマをプリンシパルとして許可が与えられています。今回の処理はAPEXのワークスペース・スキーマがUTL_HTTPを呼び出しているため、プリンシパルをAPEXのワークスペース・スキーマとしてACLを追加する必要があります。

以下のようにして、DBMS_NETWORK_ACL_ADMIN.APPEND_HOST_ACEを呼び出します。principal_nameとしてワークスペース・スキーマ名を指定します。
begin
  DBMS_NETWORK_ACL_ADMIN.APPEND_HOST_ACE(
    host => '*',
    ace => xs$ace_type(
        privilege_list => xs$name_list('connect'),
        principal_name => 'APEXDEV',
        principal_type => xs_acl.ptype_db));
  commit; 
end;
/
今回作成したAPEXアプリケーションのエクスポートを以下に置きました。
https://github.com/ujnak/apexapps/blob/master/exports/chat-with-generative-ai-fc-js.zip

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

Oracle APEXのアプリからいろいろなローカルLLMを呼び出してみる

Apple MシリーズのDocker/Colima上にOracle APEXが動作する環境を作成し(こちらの記事)、Ollamaを使って動かしたローカルLLMを呼び出すAPEXアプリケーションも作成しました(こちらの記事)。

この環境を使って、最近話題のMicrosoftのphi3(Mini, 3.8B, 4bit)、Googleのgemma(7B, 4bit)、Metaのllama3(8B, 4-bit)を動かして、APEXアプリケーションからOpenAI互換のChat Completions APIで呼び出してみます。

呼び出すモデルを変更する度に、アプリケーション・ビルダーを呼び出して置換文字列を変更するのは面倒なので、APIのエンドポイントモデル名Web資格証明をアプリケーションから変更できるように、ページ・アイテムを追加します。

ページ・アイテムP1_TOOL_SETの下に、APIエンドポイントを設定するページ・アイテムP1_API_ENDPOINTを作成します。タイプテキスト・フィールドラベルAPI Endpointとします。

レイアウト新規行の開始オン検証必須の値オンデフォルトタイプ静的を選択し、静的値として&G_API_ENDPOINT.を指定します。

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


モデル名を設定するページ・アイテムP1_MODEL_NAMEを作成します。タイプテキスト・フィールドラベルModel Nameとします。

レイアウト新規行の開始オフとしてページ・アイテムP1_API_ENDPOINTの右に配置します。検証必須の値オンデフォルトタイプ静的を選択し、静的値として&G_MODEL_NAME.を指定します。

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


Web資格証明の静的IDを設定するページ・アイテムP1_CREDENTIAL_STATIC_IDを作成します。タイプテキスト・フィールドラベルCredential Static IDとします。

レイアウト新規行の開始オフとしてページ・アイテムP1_MODEL_NAMEの右に配置します。検証必須の値オフ(ローカルLLMでは指定不要)、デフォルトタイプ静的を選択し、静的値として&G_CREDENTIAL_STATIC_ID.を指定します。

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


カード・リージョンChat HistoryソースSQL問合せを以下に変更し、送受信したメッセージの文字数を検索結果に含めます。

select seq_id, c001, c002, clob001, n001, n002, n003, dbms_lob.getlength(clob001) cnt
from apex_collections
where collection_name = :G_CHAT_HISTORY order by seq_id desc


属性2次本体HTML式を以下に変更し、メッセージの文字数を表示します。
char count: &CNT.
{if N001/}
, prompt_tokens: &N001. completion_tokens: &N002. total_tokens: &N003.
{endif/}


プロセスSend Messageを選択し、パラメータの設定を変更します。

パラメータp_api_endpointタイプアイテムに変更し、アイテムとしてP1_API_ENDPOINTを指定します。


パラメータp_model_nameも同様に、アイテムとしてP1_MODEL_NAMEを指定します。


パラメータp_credential_static_idアイテムとしてP1_CREDENTIAL_STATIC_IDを指定します。


以上でAPEXアプリケーションから呼び出すモデルを変更できるようになりました。

最初にMicrosoftのphi3を呼び出してみます。

% ollama run phi3  

>>> Send a message (/? for help)


API Endpointhttp://host.docker.internal:11434/v1/chat/completionsModel Namephi3です。

以下のメッセージを送信しました。

織田信長が活躍した時代は、一般に何時代と呼ばれていますか?


同様にGoogle gemmaを呼び出してみます。

% ollama run gemma

>>> Send a message (/? for help)


Model Namegemmaを指定します。


Metaのllama3を呼び出してみます。

% ollama run llama3

>>> Send a message (/? for help)


Model Namellama3を指定します。

英語で回答されました。


プロンプトとして「日本語で回答してください。」を設定し、同じ質問をしてみました。


OpenAIのgpt-4-turboを呼び出してみました。


Cohereのcommand-r-plus:104b-q2_Kを呼び出してみました。回答はあっさり「戦国時代」です。


もう少し詳しく回答してもらうために、プロンプトとして「あなたは日本史の先生です。」を設定し、同じ質問をしてみました。


OpenAI Chat Completions APIを呼び出す形でアプリケーションを作成することにより、本家OpenAIとOllamaのローカルLLMの切り替えが容易にできるようになりました。

今回機能を追加したAPEXアプリケーションのエクスポートを以下に置きました。
https://github.com/ujnak/apexapps/blob/master/exports/chat-with-generative-ai-hc.zip

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

2024年4月25日木曜日

OpenAI Chat Completions APIのツール呼び出しを使ってPL/SQLファンクションを呼び出す

こちらの記事「OpenAIのChat Completions APIを呼び出すAPEXアプリを作成する」で作成したAPEXアプリケーションに、ツール呼び出し(以前のFunction Calling)の機能を追加します。このアプリケーションが呼び出しているパッケージUTL_OPENAI_CHAT_APIには、すでにツール呼び出しをハンドリングするコードが含まれているため、主な作業はユーザー・インターフェースの追加になります。

作成したアプリケーションは以下のGIF動画のように動作します。

プロンプトとして「あなたはお天気お兄さんです。」を設定し、メッセージ「現在の東京とパリの天候を教えてください。」を送信します。

LLMはtool_callsとして引数をTokyoとしたツール呼び出しと、Parisを引数としたツール呼び出しを返します。ツールの並列呼び出しは2023年11月に追加された機能です。

TokyoとParisの現在の天候をOpenMeteo.comに問合せて回答を返し、最終的に「現在の東京とパリの天候はどちらも晴れです。」という回答を得ています。

以下より追加した実装について紹介します。

はじめに呼び出すツールに関する設定を保存する表OPENAI_TOOLSを操作するために、対話モード・レポートとフォームのページを作成します。

ページの作成をクリックします。


対話モード・レポートを選択します。


対話モード・レポートのページの名前Toolsとし、フォーム・ページを含めるオンにします。フォーム・ページ名Tool Detailとします。

データ・ソース表/ビューの名前OPENAI_TOOLSを選択します。

へ進みます。


主キー列1ID(Number)を選択します。

ページの作成をクリックします。


以上でツールの設定画面が作成できました。

ツール呼び出しに登録するツールは、PL/SQLのストアド・ファンクションとして作成します。OpenAIではLLMのレスポンスとしてツール呼び出し(roletool_calls)を返す時は、呼び出すファンクション名とその引数となるJSONドキュメントをレスポンスに含めます。

レスポンスとしてツール呼び出しを受け取った場合は、ファンクション名として返されたPL/SQLファンクションにJSONドキュメントを引数として渡して、動的にファンクションを呼び出します。呼び出したファンクションの戻り値(これもJSONドキュメント)をツールの応答(roletool)としてLLMに戻します。

ページを実行し、指定した都市(首都)の天候を取得するファンクションget_current_weatherを設定します。

作成をクリックします。


Tool SetWeatherとします。Tool Typefunctionを指定します。現時点で指定できるTool Typeはfunctionのみのようです。DescriptionGet the current weather in a given locationTool Nameget_current_weatherParametersには以下のJSON Schemaを記述します。
{
  "type": "object",
  "properties": {
    "location": {
      "type": "string",
      "description": "The city name, e.g. Tokyo"
    }
  },
  "required": [
    "location"
  ]
}
作成をクリックします。


PL/SQLファンクションget_current_weatherが、呼び出し可能なツールとして登録されました。


実際に呼び出されるPL/SQLファンクションget_current_weatherを作成します。

ファンクションget_current_weatherのコードは以下です。

create or replace function get_current_weather(
p_args in clob
)
return clob
as
l_args json_object_t;
l_location varchar2(400);
l_location_tokens apex_t_varchar2;
l_lat number;
l_lon number;
l_url varchar2(4000);
l_response clob;
l_response_json json_object_t;
l_weathercode number;
l_weather varchar2(4000);
begin
l_args := json_object_t(p_args);
l_location := l_args.get_string('location');
/* get first part (city name) from the location */
l_location_tokens := apex_string.split(l_location, '[ ,]');
l_location := l_location_tokens(1);
apex_debug.info('location = %s', l_location);
/*
* 都市名から緯度経度の情報を取り出す。
* アマノ技研さんより提供されている「世界の百万都市の位置データ Location Data of Megacities」の
* CSVファイルを表AMANO_CITY_LOCATIONSにロードしています。
* 参照: https://amano-tec.com/data/megacities.html
*/
select lat, lon into l_lat, l_lon
from amano_city_locations
where upper(capital_en) = upper(l_location) or capital_jp = l_location;
/*
* Open-Meteo.comのAPIを呼び出し、指定した座標の現在の天気を取得しています。
* 参照: https://open-meteo.com
*/
l_url := 'https://api.open-meteo.com/v1/forecast?latitude=' || l_lat || '&longitude=' || l_lon || '&current=weathercode';
l_response := apex_web_service.make_rest_request(
p_url => l_url
,p_http_method => 'GET'
);
if apex_web_service.g_status_code <> 200 then
return '{ "error": "open-meteo.com api call failed, response = ' || l_response || '" }';
end if;
l_response_json := json_object_t(l_response);
/*
* Weather Code (WMO 4501)のコードの説明は以下の気象関係コード表のWMO 4501の部分を
* 表WMO4501として読み込んでいます。
* 参照: https://www.jodc.go.jp/data_format/weather-code_j.html
*
* たまにWMO 4677を返しているのでは?と思えるときもあります。なのでコードが10以上の場合は、WMO4677として
* 取り扱います。
*/
l_weathercode := l_response_json.get_object('current').get_number('weathercode');
if l_weathercode < 10 then
select description into l_weather from wmo4501 where code = l_weathercode;
else
select description into l_weather from wmo4677 where code = l_weathercode;
end if;
return '{ "weather" : "' || l_weather || '" }';
exception
when no_data_found then
return apex_string.format('{ "error": %s }', l_response);
end get_current_weather;
/
都市名から緯度経度を求めるために、アマノ技研様が公開している「世界の百万都市の位置データ Location Data of Megacities」のCSVデータをダウンロードし、表AMANO_CITY_LOCATIONS(新規作成)にロードしています。データのロードには、SQLワークショップデータ・ワークショップを使用しています。

また、weathercodeについては「気象関連コード表」に記載されているWMO 4501のHTML表を表WMO4501、WMO 4677のHTML表を表WMO4677として、データをロードします。双方の表は列CODE(NUMBER)、列DESCRIPTION(VARCHAR2(4000))の列を持ちます。データ・ワークショップを使ってHTML表をコピペし、それぞれの表にロードします。

ファンクションget_current_weatherの作成は以上です。

ページ・デザイナホーム・ページを開きます。

リージョンSystem Promptに、チャット呼び出しの際に含めるTool Setを選択するページ・アイテムを作成します。

識別名前P1_TOOL_SETタイプ選択リストラベルTool Setとします。

LOVタイプSQL問合せを選択し、SQL問合せとして以下を記述します。
select tool_set d, tool_set r from openai_tools group by tool_set
追加値の表示オフNULL値の表示オンとし、NULL表示値-- Select Tool Set --とします。


左ペインでプロセス・ビューを開き、プロセスSend Messageのパラメータp_tool_setを選択します。

タイプアイテムに変更し、アイテムとしてP1_TOOL_SETを指定します。


以上でツール呼び出しの組み込みは完了です。

アプリケーションを実行すると、記事の先頭にあるGIF動画のように動作します。

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

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

OpenAIのChat Completions APIを呼び出すAPEXアプリを作成する

以前にOpenAIChat Completions APIが使えるようになった頃に、同じテーマで記事を書いています。それから時間も経ち、OllamaLlama.cppといったOpenAIのChat Completions API互換のAPIをサポートするソフトウェアも利用できるようになりました。Chat Completions API自体には互換性があるため、これらを同様に扱えるひとつのAPEXアプリを作ってみることにしました。

とにかくChat Completions APIを呼び出せれば良いので、ユーザー・インターフェースの作り込みは最小限にします。APIの呼び出しはPL/SQLのパッケージUTL_OPENAI_CHAT_APIにまとめて実装しています。パッケージのコードは記事の末尾に添付します。パッケージUTL_OPENAI_CHAT_APIはツール呼び出しに関する実装も含んでいます。

作成したアプリケーションは以下のように動作します。

以下よりAPEXアプリケーションの作成手順を紹介します。

今回はChat Completions APIを呼び出す対象をOpenAI、モデルは廉価なgpt-3.5-turboを使います。そのため、OpenAIのAPIキーをAPEXのWeb資格証明として作成しておきます。

ワークスペース・ユーティリティWeb資格証明を作成します。


Web資格証明名前OpenAI API Keyとしています。使用するWeb資格証明の指定には静的識別子を使用するため、この名前はなんでもかまいません。

静的識別子としてOPENAI_API_KEY認証タイプHTTPヘッダー資格証明名Authorizationとします。資格証明シークレットしてBearerで始めて空白で区切った後、OpenAIのAPIキーを続けた文字列を設定します。


アプリケーション・ビルダーからアプリケーションの作成を呼び出し、空のアプリケーションを作成します。

名前Chat with Generative AIとします。

アプリケーションの作成をクリックします。


空のアプリケーションが作成されます。Chat Completions APIを呼び出す機能はすべてホーム・ページに実装します。

最初に置換文字列に各種パラメータを設定します。アプリケーション定義の編集を開きます。


アプリケーション定義置換のセクションを開きます。

置換文字列として呼び出すサービスとモデルの指定に必要な情報を設定します。

呼び出す対象はOpenAIなので、G_API_ENDPOINTとしてhttps://api.openai.com/v1/chat/completionsを設定します。G_CHAT_HISTORYにはチャット履歴を保存するAPEXコレクションの名前としてCHAT_HISTORYを設定します。これはアプリケーション内で同じ名前のコレクションを参照させるためのもので、どのような名前でも構いませんが、あえて変更する必要もないでしょう。G_MODEL_NAMEgpt-3.5-turboを指定します。G_CREDENTIALとしてOpenAIのAPIキーを登録したWeb資格証明であるOPENAI_API_KEYを設定します。

以上を設定して、変更の適用をクリックします。


ページ・デザイナホーム・ページを開きます。

最初にチャット履歴を初期化するボタンを作成します。ボタンはブレッドクラムのリージョンChat with Generative AIに作成します。

識別ボタン名INITラベルStart New Conversationとします。レイアウト位置Nextを選択します。

動作アクションとしてこのアプリケーションのページにリダイレクトを選択します。


ターゲットをクリックしリンク・ビルダーを開きます。

ターゲットページ&APP_PAGE_ID.を記述します。これはこのボタンがあるページ番号、つまり自ページの呼び出しを指定しています。詳細リクエストINIT_CONVERSATIONを設定します。

OKをクリックし、リンク・ビルダーを閉じます。


このボタンを押した時に実行されるプロセスを作成します。

レンダリング前ヘッダーの前にプロセスを作成します。

識別名前Init Conversationとします。タイプコードの実行です。ソースPL/SQLコードとして以下を記述します。APEXコレクションが作成されていないか(サインイン直後)、またはREQUESTにINIT_CONVERSATIONが設定されている場合(ボタンStart New Conversationが押されたとき)に、APEXコレクションを初期化します。最初にロールがsystemの空のプロンプトを挿入します。このsystemプロンプトは変更できるように、画面にUIを実装します。

begin
if not apex_collection.collection_exists(:G_CHAT_HISTORY) or :REQUEST = 'INIT_CONVERSATION' then
/*
* チャット履歴を初期化する。
* 空のsystemロールのメッセージを最初に登録する。メッセージがからのままの場合、Chat Completions APIへの
* リエクエストにsystemロールのメッセージ自体を含めない。
*/
apex_collection.create_or_truncate_collection(:G_CHAT_HISTORY);
apex_collection.add_member(
p_collection_name => :G_CHAT_HISTORY
,p_c001 => 'system'
,p_clob001 => ''
);
:P1_REQUEST := '';
:P1_RESPONSE := '';
end if;
end;


systemプロンプトを設定するUIを作成します。

新規にリージョンを作成します。

識別タイトルSystem Promptとします。タイプ静的コンテンツです。


作成したリージョンに、プロンプトを入力するページ・アイテムを作成します。

識別名前P1_PROMPTタイプテキスト領域とします。ラベルPromptです。セッション・ステートデータ型CLOBストレージセッションごと(永続)を選択します。


入力したsystemプロンプトをチャット履歴に設定するボタンを作成します。

識別ボタン名SET_PROMPTラベルSet Promptとします。動作アクションはデフォルトのページの送信です。


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

左ペインでプロセス・ビューを開き、新規にプロセスを作成します。

識別名前Set Promptとします。タイプコードを実行です。ソースPL/SQLコードとして以下を記述します。すでに登録済みのsystemプロンプトを置き換えます。

declare
l_seq number;
begin
/*
* 登録済みのsystemプロンプトの位置を求める。
* コレクション初期化直後に空のsystemプロンプトが作成済みなので、通常は先頭にある。
*/
select seq_id into l_seq from apex_collections where collection_name = :G_CHAT_HISTORY and c001 = 'system';
/* プロンプトを更新する */
apex_collection.update_member(
p_collection_name => :G_CHAT_HISTORY
,p_seq => l_seq
,p_c001 => 'system'
,p_clob001 => :P1_PROMPT
);
end;
view raw set_prompt.sql hosted with ❤ by GitHub

サーバー側の条件ボタン押下時SET_PROMPTを指定します。


ユーザーによるメッセージを送信するUIを作成します。

新規にリージョンを作成します。

識別タイトルUser Messageとします。タイプ静的コンテンツです。


作成したリージョンに、ユーザーによるメッセージを入力するページ・アイテムを作成します。

識別名前P1_MESSAGEタイプテキスト領域とします。ラベルMessageです。セッション・ステートデータ型CLOBストレージリクエストごと(メモリーのみ)を選択します。


入力したメッセージを含めて、Chat Completions APIを呼び出すボタンを作成します。

識別ボタン名SEND_MESSAGEラベルSend Messageとします。動作アクションはデフォルトのページの送信です。


Chat Completions APIのレスポンスを含めた、チャット履歴を表示するリージョンを作成します。

識別タイトルChat Historyタイプカードを選びます。ソースのタイプSQL問合せを選択し、SQL問合せとして以下を記述します。
select seq_id, c001, c002, clob001, n001, n002, n003
from apex_collections
where collection_name = :G_CHAT_HISTORY order by seq_id desc

カードに表示する内容を設定します。プロパティ・エディタ属性タブを開きます。

外観レイアウトとして水平(行)を選びます。カード主キー列1SEQ_IDです。

タイトルC001サブタイトルC002本体CLOB001を選択します。2次本体拡張フォーマットオンにし、HTML式として以下を記述します。
{if N001/}
prompt_tokens: &N001. completion_tokens: &N002. total_tokens: &N003.
{endif/}

デバッグ用にChat Completions APIに送信したリクエストと、受信したレスポンスを表示するページ・アイテムを作成します。

送信したリクエストを保持するページ・アイテムはP1_REQUESTとして作成します。ラベルRequest設定ページの送信時に送信オフにします。セッション・ステートデータ型CLOBストレージセッションごと(永続)を選択します。


同様にレスポンスを保持するページ・アイテムP1_RESPONSEを作成します。ラベルResponseとします。


左ペインでプロセス・ビューを開き、ボタンSEND_MESSAGEをクリックしたときに実行されるプロセスを作成します。

識別名前Send MessageタイプAPIの呼出しとします。設定パッケージUTL_OPENAI_CHAT_APIプロシージャまたはファンクションCHATを選択します。

サーバー側の条件ボタン押下時SEND_MESSAGEを指定します。


パラメータp_contentタイプアイテムアイテムP1_MESSAGEとします。


パラメータp_collection_nameタイプ静的値静的値&G_CHAT_HISTORY.とします。


パラメータp_api_endpointタイプ静的値静的値&G_API_ENDPOINT.とします。


パラメータp_model_nameタイプ静的値静的値&G_MODEL_NAME.とします。


パラメータp_credential_static_idタイプ静的値静的値&G_CREDENTIAL.とします。


パラメータp_request_outの値のアイテムにP1_REQUESTを指定します。


パラメータp_response_outの値のアイテムにP1_RESPONSEを指定します。


その他のパラメータはAPIデフォルトのまま変更しません。

以上でOpenAIのChat Completions APIを呼び出すAPEXアプリは完成です。アプリケーションを実行すると、記事の先頭のGIF動画のように動作します。

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

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


コードにはツール呼び出しの対応が含まれていて、ツールに関する定義を保存する表OPENAI_TOOLSへの参照があります。そのため、以下のDDLを実行して表をあらかじめ作成しておく必要があります。

CREATE TABLE "OPENAI_TOOLS"
( "ID" NUMBER GENERATED BY DEFAULT ON NULL AS IDENTITY MINVALUE 1 MAXVALUE 9999999999999999999999999999 INCREMENT BY 1 START WITH 1 CACHE 20 NOORDER NOCYCLE NOKEEP NOSCALE NOT NULL ENABLE,
"TOOL_SET" VARCHAR2(20 CHAR) NOT NULL ENABLE,
"TOOL_TYPE" VARCHAR2(20 CHAR) DEFAULT ON NULL 'function' NOT NULL ENABLE,
"DESCRIPTION" VARCHAR2(4000 CHAR) NOT NULL ENABLE,
"TOOL_NAME" VARCHAR2(80 CHAR) NOT NULL ENABLE,
"PARAMETERS" CLOB,
CHECK (parameters is json) ENABLE,
CONSTRAINT "OPENAI_TOOLS_ID_PK" PRIMARY KEY ("ID")
USING INDEX ENABLE
) ;


create or replace package utl_openai_chat_api as
/**
* Change History:
* 2024/11/29 - ynakakos change name and strict included in the json_schema to arguments.
* 2024/10/29 - ynakakos add OpenAI Structured Outputs support.
*/
/**
* ツールセット名からJSON形式のツール定義を生成する。
*
* @param p_tool_set ツールセット名
* @return json_array_t toolsとして送信するファンクション定義
*/
function generate_tools(
p_tool_set in varchar2
) return json_array_t;
/**
* OpenAI Chat Completions APIおよび互換APIを呼び出す。
* OpenAIのエンドポイント https://api.openai.com/v1/chat/completionsと互換ががあること。
*
* @param p_content メッセージ本文
* @param p_collection_name チャット履歴を保存するAPEXコレクションの名前
* @param p_api_endpoint Chat Completions APIを呼び出すURL
* @param p_model_name モデル名(必須)
* @param p_max_tokens トークン数の上限
* @param p_stream streamingの許可 - APEXはstreamingが扱えないためデフォルトでfalse
* @param p_credential_static_id Web資格証明の静的ID
* @param p_tool_set 表GENAI_TOOLSのTOO_SET列の名前
* @param p_response_format text、json_object または json_schema
* @param p_json_schema_name respons_formatがjson_schematのときのname属性の値
* @param p_json_schema_strict 同上のstrict属性の値
* @param p_json_schema 同上のschema属性の値 - リクエストに含めるJSON schema
* @param p_request_out デバッグ用 - Chat APIに送信したリクエスト本文
* @param p_response_out デバッグ用 - Chat APIから受信したレスポンス本文
* @param p_transfer_timeout Chat APIの応答のタイムアウト - 秒
* @param p_recursive_calL_count ツール呼び出しを行った後に許可する再帰呼び出しの回数。
*/
procedure chat(
p_content in clob default null
,p_collection_name in varchar2
,p_api_endpoint in varchar2
,p_model_name in varchar2
,p_max_tokens in number default null
,p_stream in boolean default false
,p_credential_static_id in varchar2 default null
/* function calling & Structured Outputs 向け */
,p_tool_set in varchar2 default null
,p_response_format in varchar2 default null
/* p_response_formatがjson_schemaのときに有効 */
,p_json_schema_name in varchar2 default null
,p_json_schema_strict in boolean default true
,p_json_schema in clob default null
/* 以下、デバッグ用 */
,p_request_out out clob
,p_response_out out clob
/* 無視しても良いパラメータ */
,p_transfer_timeout in number default 360 -- 10 min
,p_recursive_call_count in number default 0
);
end utl_openai_chat_api;
/
create or replace package body utl_openai_chat_api as
/**
* APEXコレクションに保存されたチャット履歴からメッセージを生成する。
* ツールからの応答だけを送信する場合は、p_contentをnullにする。
*
* @param p_collection_name チャット履歴を保存するAPEXコレクション名
* @param p_role チャット履歴の最後に追加するメッセージのロール - デフォルトuser
* @param p_content チャット履歴の最後の追加するメッセージ - デフォルトnull
* @return json_array_t 送信するメッセージの配列
*/
function init_message_from_collection(
p_collection_name in varchar2
,p_role in varchar2 default 'user'
,p_content in clob default null
)
return json_array_t
as
l_messages json_array_t;
l_message json_object_t;
begin
/*
* ユーザーからの入力があれば、Chat履歴の最後に追加する。
*/
if p_content is not null then
apex_collection.add_member(
p_collection_name => p_collection_name
,p_c001 => p_role
,p_clob001 => p_content
);
end if;
/*
* 生成AIのChat APIに送信するメッセージを作成する。
  * APEXコレクションより、作成順の昇順でメッセージの配列にする。
*/
l_messages := json_array_t();
for r in (
/*
* contentが空であればメッセージに含めない。
* ロールがsystemでは、メッセージが空のときがある。
*/
select c001, c002, c003, clob001 from apex_collections
where collection_name = p_collection_name and dbms_lob.getlength(clob001) > 0
order by seq_id
)
loop
l_message := json_object_t();
l_message.put('role' ,r.c001);
if r.c001 = 'tool' then -- c001はrole
l_message.put('tool_call_id', r.c002);
l_message.put('name', r.c003);
end if;
if r.c002 = 'tool_calls' then -- c002はfinish_reason
l_message.put('tool_calls', json_array_t(r.clob001));
else
l_message.put('content', r.clob001);
end if;
l_messages.append(l_message);
end loop;
return l_messages;
end init_message_from_collection;
/**
* ツールセット名からJSON形式のツール定義を生成する。
*/
function generate_tools(
p_tool_set in varchar2
) return json_array_t
as
l_tools json_array_t;
l_tool json_object_t;
l_function json_object_t;
l_parameters json_object_t;
begin
l_tools := json_array_t();
for r in (
/* ツールの指定は表OPENAI_TOOLSに保存されている。 */
select * from openai_tools where tool_set = p_tool_set
)
loop
if r.tool_type = 'function' then
/* only function is supported as of 19, Apr. 2024 */
l_tool := json_object_t();
l_tool.put('type','function');
l_function := json_object_t();
l_function.put('name', r.tool_name);
l_function.put('description', r.description);
/* parametersにはJSON Schemaがそのまま設定されている */
l_parameters := json_object_t(r.parameters);
l_function.put('parameters', l_parameters);
l_tool.put('function', l_function);
l_tools.append(l_tool);
end if;
end loop;
return l_tools;
end generate_tools;
/**
* メッセージにツールの指定を含める。
*
* @param p_request Chat APIのリクエスト本文 in/out
* @param p_tool_set toolとして含めるツールを選択するtool_set名(GENAI_TOOLS.TOOL_SET)
* @param p_response_format text、json_object または json_schema
* @param p_json_schema_name respons_formatがjson_schematのときのname属性の値
* @param p_json_schema_strict 同上のstrict属性の値
* @param p_json_schema 同上のschema属性の値 - リクエストに含めるJSON schema
*/
procedure configure_tools(
p_request in out json_object_t
,p_tool_set in varchar2 default null
,p_response_format in varchar2 default null
,p_json_schema_name in varchar2 default null
,p_json_schema_strict in boolean default true
,p_json_schema in clob default null
)
as
l_tools json_array_t;
l_response_format json_object_t;
l_json_schema json_object_t;
begin
/*
* p_tool_setの指定があれば、toolの定義を送信する。
*/
if p_tool_set is not null then
l_tools := generate_tools(
p_tool_set => p_tool_set
);
p_request.put('tools', l_tools);
end if;
/* response_format */
if p_response_format is not null then
l_response_format := json_object_t();
l_response_format.put('type', p_response_format); /* text, json_object or json_schema */
if p_response_format = 'json_schema' then
/*
* json_schemaとして与えられたJSON SchemaをStructured Outputsに
* 必要なname, schema, strictでラップする。
*/
l_json_schema := json_object_t();
l_json_schema.put('name', p_json_schema_name);
l_json_schema.put('schema', json_object_t(p_json_schema));
l_json_schema.put('strict', p_json_schema_strict);
l_response_format.put('json_schema', l_json_schema);
end if;
p_request.put('response_format', l_response_format);
end if;
end configure_tools;
/**
* ツール呼び出し(function calling)の処理を行う。
* tool_callsを解釈しファンクションを呼び出し、ロールをtoolとして呼び出したファンクションの応答を
* APEXコレクションに追記する。
*
* @param p_collectiuon_name Chat履歴を保存するAPEXコレクション名
* @param p_message tool_callsを含んだレスポンス本文
*/
procedure process_tool_calls(
p_collection_name in varchar2
,p_message in json_object_t
)
as
l_tool_calls json_array_t;
l_tool_call json_object_t;
l_tool_call_id varchar2(80);
l_function_call json_object_t;
l_function_name varchar2(160);
l_function_args clob;
l_function_arg_obj json_object_t;
l_dynamic_sql varchar2(4000);
l_function_out clob;
begin
l_tool_calls := p_message.get_array('tool_calls');
for i in 1..l_tool_calls.get_size()
loop
l_tool_call := treat(l_tool_calls.get(i-1) as json_object_t);
if l_tool_call.get_string('type') = 'function' then
/* only function is supported as type */
l_tool_call_id := l_tool_call.get_string('id');
l_function_call := l_tool_call.get_object('function');
if l_function_call is not null then
l_function_name := l_function_call.get_string('name');
l_function_args := l_function_call.get_clob('arguments');
if l_function_args is null then
/* OpenAI returns arguments as CLOB but could be json_object */
l_function_arg_obj := l_function_call.get_object('arguments');
if l_function_arg_obj is not null then
l_function_args := l_function_arg_obj.to_clob();
end if;
end if;
/* ストアド・プロシージャを動的に呼び出す。 */
apex_debug.info('Calling %s with %s', l_function_name, l_function_args);
l_dynamic_sql := 'begin :a := ' || l_function_name || '(:b); end;';
execute immediate l_dynamic_sql using in out l_function_out, l_function_args;
apex_collection.add_member(
p_collection_name => p_collection_name
,p_c001 => 'tool'
,p_c002 => l_tool_call_id
,p_c003 => l_function_name
,p_clob001 => l_function_out
);
end if;
end if;
end loop;
end process_tool_calls;
/**
* Batch APIの呼び出しも使えるように、メッセージの作成部分をプロシージャCHATより
* 分離した。
*/
function generate_chat_message(
p_content in clob
,p_collection_name in varchar2
,p_model_name in varchar2
,p_max_tokens in number
,p_stream in boolean
/* function calling向け */
,p_tool_set in varchar2
,p_response_format in varchar2
,p_json_schema_name in varchar2
,p_json_schema_strict in boolean
,p_json_schema in clob
)
return clob
as
l_request json_object_t;
l_request_clob clob;
l_messages json_array_t;
l_message json_object_t;
begin
/*
* LLMに送信するメッセージをl_requestとして作成する。
*/
l_request := json_object_t();
/* modelは必ず必要 */
l_request.put('model', p_model_name);
if p_max_tokens is not null then
l_request.put('max_tokens', p_max_tokens);
end if;
/* APEXではstreamingは処理できないので、基本常にfalse */
if p_stream is not null then
l_request.put('stream', p_stream);
end if;
/*
* APIで送信するメッセージをチャット履歴から初期化する。
*/
l_messages := init_message_from_collection(
p_collection_name => p_collection_name
,p_content => p_content
);
l_request.put('messages', l_messages);
/*
* ツール・セットp_tool_setの指定があれば、toolの構成を
* リクエストに含める。
*/
configure_tools(
p_request => l_request
,p_tool_set => p_tool_set
,p_response_format => p_response_format
,p_json_schema_name => p_json_schema_name
,p_json_schema_strict => p_json_schema_strict
,p_json_schema => p_json_schema
);
/*
* temprature, top_pなどのパラメータを設定するとしたら、ここでputする。
*/
/*
* 生成AIのChat APIを呼び出す。
*/
l_request_clob := l_request.to_clob();
return l_request_clob;
end generate_chat_message;
/**
* Chat APIの呼び出し。
*/
procedure chat(
p_content in clob
,p_collection_name in varchar2
,p_api_endpoint in varchar2
,p_model_name in varchar2
,p_max_tokens in number
,p_stream in boolean
,p_credential_static_id in varchar2
/* function calling向け */
,p_tool_set in varchar2
,p_response_format in varchar2
,p_json_schema_name in varchar2
,p_json_schema_strict in boolean
,p_json_schema in clob
/* 以下、デバッグ用 */
,p_request_out out clob
,p_response_out out clob
/* 無視しても良いパラメータ */
,p_transfer_timeout in number
,p_recursive_call_count in number
)
as
l_request_clob clob;
l_message json_object_t;
l_response_clob clob;
l_response json_object_t;
l_choices json_array_t;
l_choice0 json_object_t;
l_role varchar2(80);
l_content clob;
l_usage json_object_t;
/* usage */
l_n001 number;
l_n002 number;
l_n003 number;
/* response_format */
l_response_format json_object_t;
l_finish_reason varchar2(4000);
e_to_many_recursive_calls exception;
/* Ollama Functiion Calling */
l_tools_string clob;
l_tools_obj json_object_t;
l_seq number;
r_member apex_collections%rowtype;
/* JSON構文エラー */
e_bad_json_syntax exception;
pragma exception_init(e_bad_json_syntax, -40441);
begin
/*
* ツールの再帰呼び出しは1回までに限定する。
*/
if p_recursive_call_count > 1 then
raise e_to_many_recursive_calls;
end if;
/*
* 送信メッセージの作成。
*/
l_request_clob := generate_chat_message(
p_content => p_content
,p_collection_name => p_collection_name
,p_model_name => p_model_name
,p_max_tokens => p_max_tokens
,p_stream => p_stream
,p_tool_set => p_tool_set
,p_response_format => p_response_format
,p_json_schema_name => p_json_schema_name
,p_json_schema_strict => p_json_schema_strict
,p_json_schema => p_json_schema
);
p_request_out := l_request_clob; -- デバッグ用
/*
* Chat Completions APIの呼び出し。
*/
apex_web_service.clear_request_headers;
apex_web_service.set_request_headers('Content-Type','application/json',p_reset => false);
l_response_clob := apex_web_service.make_rest_request(
p_url => p_api_endpoint
,p_http_method => 'POST'
,p_body => l_request_clob
,p_credential_static_id => p_credential_static_id
,p_transfer_timeout => p_transfer_timeout
);
p_response_out := l_response_clob;
l_response := json_object_t(l_response_clob);
/*
* 生成AIからの応答を処理する。
*/
l_choices := l_response.get_array('choices');
if l_choices is not null then
/* OpenAI flavor */
l_choice0 := treat(l_choices.get(0) as json_object_t);
l_message := l_choice0.get_object('message');
/* finish_reaonは、後で確認する。 */
l_finish_reason := l_choice0.get_string('finish_reason');
else
/* Ollama flavor */
l_message := l_response.get_object('message');
end if;
l_role := l_message.get_string('role');
if l_finish_reason = 'tool_calls' then
l_content := l_message.get_array('tool_calls').to_clob();
else
l_content := l_message.get_clob('content');
end if;
/* usageも取り出す。 */
l_usage := l_response.get_object('usage');
if l_usage is not null then
/* OpenAI flavor */
l_n001 := l_usage.get_number('prompt_tokens');
l_n002 := l_usage.get_number('completion_tokens');
l_n003 := l_usage.get_number('total_tokens');
else
/* Ollama flavor */
l_n001 := l_response.get_number('prompt_eval_count');
l_n002 := l_response.get_number('eval_count');
end if;
/*
* 生成AIからの応答をAPEXコレクションに追記する。
*
* response_formatの指定がjson_objectまたはjson_schemaの
* 場合は、pretty printする。
*/
if p_response_format in ('json_object', 'json_schema') then
begin
select json_serialize(l_content pretty) into l_content from dual;
exception
when e_bad_json_syntax then
/* 出力を見ると分かるので、構文エラーは無視 */
null;
end;
end if;
apex_collection.add_member(
p_collection_name => p_collection_name
,p_c001 => l_role
,p_c002 => l_finish_reason
,p_clob001 => l_content
,p_n001 => l_n001
,p_n002 => l_n002
,p_n003 => l_n003
);
/*
* finish_reasonがtool_callsであれば、指定されたツールを呼び出す。
*/
if l_finish_reason = 'tool_calls' then
process_tool_calls(
p_collection_name => p_collection_name
,p_message => l_message
);
/*
* ツールを呼び出した結果をLLMに送信する。
*/
chat(
p_collection_name => p_collection_name
,p_api_endpoint => p_api_endpoint
,p_model_name => p_model_name
,p_max_tokens => p_max_tokens
,p_stream => p_stream
,p_credential_static_id => p_credential_static_id
,p_tool_set => p_tool_set
,p_response_format => p_response_format
,p_request_out => p_request_out
,p_response_out => p_response_out
,p_transfer_timeout => p_transfer_timeout
,p_recursive_call_count => (p_recursive_call_count + 1)
);
end if;
end chat;
end utl_openai_chat_api;
/