2024年11月30日土曜日

ClaudeのPostgreSQLのMCPサーバーを使ってローカルのAlloyDB Omniに問い合わせる

最近話題のClaudeのMCP(Model Context Protocol)ですが、MCPサーバーの実装サンプルにPostgreSQLがあったので動かしてみました。

このブログはOracle APEXに関する技術ブログですが、現時点ではOracle DatabaseのMCPサーバーは無く、また、MCPサーバーを組み込めるアプリもClaude Desktop appのみです。将来にOracle Databaseに接続するMCPサーバーや、MCPサーバーを組み込めるWebコンポーネントが作成されることで、Oracle APEXでも活用できるようになることを期待しています。

macOS上で行った作業を紹介します。

MCPサーバーを組み込むClaude Desktop appをインストールします。本作業ではmacOS版をインストールしましたが、Windows版もあるのでWindowsでも作業はできそうです。



PostgreSQLのサーバーですが、以前の記事でGoogle AlloyDB Omniを使ったときに簡単にインストールできたので、今回もAlloyDB Omniを使います。podmanを使用します。

後でWorldのサンプル・データベースをインストールするので、podmanのボリュームとしてpgSampleWorldを作成します。

podman volume create pgSampleWorld

% podman volume create pgSampleWorld

pgSampleWorld

% 


AlloyDB Omniのコンテナ・イメージをダウンロードします。

podman pull google/alloydbomni

% podman pull google/alloydbomni

Trying to pull docker.io/google/alloydbomni:latest...

Getting image source signatures

Copying blob sha256:9a05f2493d03e0bbb1066e3bb909096d9703c5418d053689f8f7ec5c1d7f2b60

Copying blob sha256:2fdd8bd0abec7d6bf9c9413fa4822358118e9e09fbd29407074e295e7480643f

Copying blob sha256:f5852518415488bf422f9b7f2b17ffd7d4d7185e7c8ab8bf2147deeba4634f9b

Copying blob sha256:0185c6859b00ddb146dc31ca9b6beaed53efb3407563057f05fb266b3f434287

Copying blob sha256:205213b8936196dbb62c16b3a96e0b7e220299b3a9ca113bccfd126dadb699e5

Copying blob sha256:c1e0ef7b956a07c7b090256aa16cbb0550a34d0625d1d23c5b1a76e92a58d01e

Copying blob sha256:f1c87667e7842cd5137ab5ab073fd291aab49798d524866faf5e29baf799b2ef

Copying blob sha256:f4e912a38a04c24a3b2c9357881eb1d2c4a79966b599994cf923a3b861deb717

Copying blob sha256:867c56a9d8dc8e0f8bf8540326bb5638c709e265ad3375249cc036c62b16338b

Copying blob sha256:149990bdbe96c90c9632f7494d32f308f2f410a17dc862496798163b371df491

Copying blob sha256:7b43d926df6c0192fef4a132f46301cb25c933a70475037b5817c9ee977cf7f9

Copying blob sha256:4f4fb700ef54461cfa02571ae0db9a0dc1e0cdb5577484a6d75e68dc38e8acc1

Copying blob sha256:5fe5be224054609d6669eb71c74f8414c654ed0495c9298a32aec1bd29ac4ef3

Copying blob sha256:b5897c548647a8d823411b5f365fccd16994b35efcd75164b5fc82d77c396ebe

Copying blob sha256:2cc17a1ffe78834f475a35e67e612c3af76ac44d459e0ab42339f0fb2347feb7

Copying blob sha256:8714a15a9f79d2f01947ddb11b2760a05d897325efc39ff988dc4e073cf7a93c

Copying blob sha256:94947c0ccc56de03ab622a35e70c99ab4636dae5dae427dfba5df79b2a84177f

Copying blob sha256:215fbeb3c59b120bf53d3081b76272a835b13ae7a77dd072d59e70cfcda3bb07

Copying blob sha256:5e6b93867b6c54a5b48c6497faf8bf50214ea208d0be670cea6db9d9f23482fd

Copying blob sha256:6bf938e955c48891853605a1636950a912b1c81b9749205ba17c26241e005702

Copying blob sha256:92a15b9c822ec96fc09ed2ceacfec6f0a20058f9dc2c601c490985ec306826f7

Copying blob sha256:a503bc9e96a17982d6a2ae33314b3127dc7bf9acb4db2ac1ebbb899b32147aeb

Copying blob sha256:4c80b84ecf5976c85858871790f90a50816f48ceefccab5c843d2c0bde955036

Copying blob sha256:e01e5606c56fe7fc61177d8118a6274a5fbb146c881f00de6a8bc4780412c6df

Copying blob sha256:09e9c6b61a697d1af8b69f1aa5f2d048adfbbfd705ca5752943cd49772d884e3

Copying blob sha256:0bf7b01fb1e47ec1361fadec05df0a950da5a4c46b49779eb34d0e091e165a5e

Copying blob sha256:d220fb7d6aeb40b0998b710f1a4b61c2ce10876ed6e8dd5c8cc0009b5eb07289

Copying blob sha256:5ed9845c51464bbab10c8deb0bd80b0bc484e2064fa4076176cb5a4421169557

Copying config sha256:fe0702e3d2389cf3cdf28f1f6c4342b3da53981820f5c9fba1249a7efc351eac

Writing manifest to image destination

fe0702e3d2389cf3cdf28f1f6c4342b3da53981820f5c9fba1249a7efc351eac

% 


AlloyDB Omniのコンテナを作成します。名前はmy-omniとしました。POSTGRES_PASSWORDに設定した値は、MCPサーバーの構成ファイルに書き込むので忘れないようにします。

podman run --name my-omni -e POSTGRES_PASSWORD=<パスワード> -v pgSampleWorld:/var/lib/postgresql/data -p 5432:5432 -d google/alloydbomni

% podman run --name my-omni -e POSTGRES_PASSWORD=********** -v pgSampleWorld:/var/lib/postgresql/data -p 5432:5432 -d google/alloydbomni

dc8cb2273f0d1e25de431f3f7960e301afcc17dea88940e56791e754852e3129

% 


コンテナmy-omniが実行されていることを確認します。

podman ps

% podman ps

CONTAINER ID  IMAGE                                COMMAND     CREATED        STATUS        PORTS                   NAMES

dc8cb2273f0d  docker.io/google/alloydbomni:latest  postgres    3 minutes ago  Up 3 minutes  0.0.0.0:5432->5432/tcp  my-omni

% 


PostgreSQLのworldのサンプル・データベースをダウンロードします。



ダウンロードしたworld-1.0.tar.gzを解凍します。

tar xzvf world-1.0.tar.gz

 % tar xzvf world-1.0.tar.gz 

x dbsamples-0.1/

x dbsamples-0.1/world/

x dbsamples-0.1/world/world.sql

x dbsamples-0.1/world/README

%       


展開したファイルに含まれているworld.sqlを実行します。

cd dbsamples-0.1/world
podman exec -i my-omni psql -U postgres < ./world.sql

% cd dbsamples-0.1/world 

world % podman exec -i my-omni psql -U postgres < ./world.sql

BEGIN

SET

CREATE TABLE

CREATE TABLE

CREATE TABLE

COPY 4079

COPY 239

COPY 984

ALTER TABLE

ALTER TABLE

ALTER TABLE

ALTER TABLE

ALTER TABLE

COMMIT

ANALYZE

ANALYZE

ANALYZE

 world % 


以上でPostgreSQLの準備はできました。

Claude Desktop appにPostgreSQLのMCPサーバーを組み込みます。以下を参照します。

https://github.com/modelcontextprotocol/servers/tree/main/src/postgres

~/Library/Application\ Support/Claude以下にclaude_desktop_config.jsonというファイルを作成し、以下を記述します。[パスワード]の部分はAlloyDB Omniのコンテナ作成時に与えたパスワードに置き換えます。

{
  "mcpServers": {
    "postgres": {
      "command": "npx",
      "args": [
        "-y",
        "@modelcontextprotocol/server-postgres",
        "postgresql://postgres:[パスワード]@localhost:5432/postgres"
      ]
    }
  }
}

% cd ~/Library/Application\ Support/Claude

Claude % cat claude_desktop_config.json 

{

  "mcpServers": {

    "postgres": {

      "command": "npx",

      "args": [

        "-y",

        "@modelcontextprotocol/server-postgres",

        "postgresql://postgres:*********@localhost:5432/postgres"

      ]

    }

  }

}

Claude % 


Claude Desktop Appを起動します。すでに起動している場合は再起動します。

チャットを入力する画面の右下にトンカチのアイコンが表示されます。postgresのMCPサーバーだけであれば、1になっているはずです。

ClaudeSettings...を開きます。


Developerを開き、MCPサーバーとしてpostgresが認識されていることを確認します。途中の作業を失念してしまいましたが、もし、Developerがないときは、メニューのどこかに(Help?)Enable Developer Modeというコマンドがあるはずなので、それを実行してDeveloper Modeに変更します。


Claudeに問い合わせを行います。

「私のpostgresから、どのような情報を問い合わせることができますか?」

MCPサーバーへの接続の承認が求められます。Allow for This Chatをクリックします。


Claudeがデータベースに問い合わせを発行して、回答してくれます。


「東京の人口を教えてください。」

MCPサーバーpostgresへ送信されたSELECT文とその結果も回答に含まれています。View Result from query from postgres(local)をクリックすると表示されます。


すごい。

MCPサーバーでは、TypeScriptとPython向けのSDKが提供されています。


Oracle Database向けのMCPサーバーがあれば、嬉しいのですが。

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

2024年11月29日金曜日

Oracle APEXのアプリからOpenAIのBatch APIを呼び出す

OpenAIが2024年4月にBatch APIをリリースしています。少し時間が経ちましたが、OpenAIのBatch APIを呼び出して、Chat Completions API + Structured Outputsで文章のJSON表現を取り出す仕組みと、Embeddings APIでエンべディングを取得する仕組みを、Oracle APEXのアプリケーションとして実装してみました。OpenAI Batch APIの呼び出しは、パッケージUTL_OPENAI_BATCH_APIに実装しています。APEXアプリケーションは、そのパッケージに実装したファンクションを呼び出して動作を確認するために作成しています。

Oracle APEXでは、ほぼすべての処理をデータベースで実行していて、OpenAIのChat Completions APIやEmbeddings APIはデータベースのサーバーから呼び出されます。ブラウザからデータベースを介してChat Completions APIが呼び出されるときは、ブラウザからORDSを介したデータベースへの接続と、データベースからOpenAIのAPIサーバーへの接続の両方が、APIの処理が終わるまで維持されます。データベースはAPIの処理中は、その応答を待機して何も処理は行いませんが、セッションつまりサーバー・プロセスは占有したままになります。リソースの利用効率としては、あまり良くありません。OpenAIの新しい推論モデルo1のような、レスポンスが返されるまでに長時間かかる場合は、特に良くありません。

OpenAIのBatch APIを呼び出すことにより、この点が改善されます(注: 現時点ではo1-previewはBatch APIでは使えないので、通常のモデルでの話です)。Batch APIという名前の通り、会話の用途には使用できませんが、ドキュメントの要約、Structured Outputsを指定したドキュメントのJSON出力、エンべディングの生成などは、対話的に処理する必要がありません。Batch APIの応答は待機する必要がなく、Retrieve batchによるポーリングを行うことによりリクエストの完了を確認します。占有されるサーバー・プロセスは発生しません。また、コストも50%程度削減できるようです。削減されるコストについては、OpenAIのPricingのページを確認してください。

Batch APIのリクエストはOpenAI Files APIを使って、ファイルとしてOpenAIのストレージにアップロードする必要があります。また、結果のアウトプット・ファイルやエラーが記載されたファイルも、OpenAIのストレージに作成されます。そのために以前の記事「OpenAIのFiles APIを使ってファイルをアップロードする」で紹介している、OpenAIのFiles APIを呼び出すPL/SQLパッケージUTL_OPENAI_FILES_APIをあらかじめ作成しておきます。

今回作成したパッケージUTL_OPENAI_BATCH_APIのコードは、記事の末尾に添付します。

作成したAPEXアプリケーションのエクスポートは以下です。
https://github.com/ujnak/apexapps/blob/master/exports/sample-openai-batch-api.zip

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

最初にOpenAIへ発行するリクエストやレスポンスを保持する表を作成します。クイックSQLの以下のモデルを使用します。

OPENAI_BATCH_SUBMISSIONSはBatch APIで発行するリクエストを保持します。表OPENAI_BATCH_REQUESTSはそれぞれのバッチに含まれるChat Completions APIまたはEmbeddings APIのリクエストを保持します。OPENAI_BATCH_SUBMISSIONSとはSUBMISSION_IDをキーとした親子関係があります。表OPENAI_BATCH_RESPONSESはoutput_file_idで指定されたバッチの結果出力ファイルの内容をパースして、それぞれのリクエストに対応したレスポンスごとに保存します。OPENAI_BATCH_REQUESTSとはCUSTOM_IDで紐づきます。バッチの入力ファイル、出力ファイルともに改行で区切られたJSONファイル(Newline Delimited JSON - NDJSONまたはJSON LInes - JSONL)なので、OPENAI_BATCH_REQUESTSおよびOPENAI_BATCH_RESPONSESともに、1行のJSONが表の1行になります。



バッチ・リクエストの発行と結果の取得は、アプリケーションのホーム・ページに実装しています。パッケージUTL_OPENAI_BATCH_APIには、おおむねボタン名に対応したプロシージャまたはファンクションが含まれています。ボタンのクリックで、パッケージに実装されたそれらの処理が呼び出されます。

Createボタンを押すとダイアログが開きます。


Endpointとして/v1/chat/completionsまたは/v1/embeddingsのどちらかを選択します。Completion Windowも指定します。以下では24hを指定しています。

Create Batchをクリックして、表OPENAI_BATCH_SUBMISSIONSに1行挿入します。


Createの横にDeleteボタンがあります。選択されているSubmission IDの行を表OPENAI_BATCH_SUBMISSIONSから削除する際に使用しますが、そのSUBMISSION_IDに紐づくリクエストが表OPENAI_BATCH_REQUESTSに存在する場合はエラーが発生します。


Endpointとして/v1/chat/completionsを指定した場合は、Append Chatのボタンが表示されます。

今回は以下の情報を設定しています。Modelとしてはgpt-4o-miniSystem Messageに「あなたは日本の昔話に詳しいアシスタントです。User Messageとして、次のメッセージ「以下の物語より、登場人物とその関係についてJSON形式で表現してください。」に続けて、青空文庫より竹取物語をコピペしました。

https://www.aozora.gr.jp/cards/001072/files/48310_42692.html

Structured Outputsとして、以下のJSON Schemaを指定しています。

以上でボタンAppend Chatをクリックします。


竹取物語は長文で、レポートにエラーが発生します。


このエラーは対話モード・レポートで発生しているエラーです。リクエストは正常に表OPENAI_BATCH_REQUESTSに追加されています。

表示列からBodyを除くと、対話モード・レポートのエラーが無くなります。


同様に、リクエストにカチカチ山を追加します。

https://www.aozora.gr.jp/cards/000329/files/18377_11982.html


複数のリクエストを追加したのち、ボタンSubmit Batchをクリックします。

複数のリクエストを改行区切りのJSONでファイルにまとめて、そのファイルをOpenAIのストレージにアップロードしたのち、Batch APIのCreate Batchリクエストを発行します。


Create Batchの発行直後はStatusvalidatingになるようです。

Update Batchをクリックすると、発行済みのバッチ・リクエストのステータスを更新します。


バッチが処理中の場合はin_progressになります。statusが取りえる状態は、OpenAIのBatch APIのガイドに一覧されています。


バッチ処理が完了するとStatuscompletedになります。statusの種類にはfailedがありますが、これはvalidationでの失敗から遷移する状態で、バッチ処理が失敗していてもstatusはcompletedになります。失敗しているのはバッチに含まれている、個々のリクエストであって、その場合はバッチ処理自体は成功していると見做されているようです。


アプリケーションにはList batchのページが含まれています。このページでは、Batch APIのList batchリクエストを発行し、今までに発行したバッチ処理を一覧します。

この中にError File Idの項目があります。バッチに含まれているリクエストでエラーが発生している場合、そのエラー・メッセージはError Fileに書き込まれます。Error Fileが作成されていると、そのError FileにError File IDが割り当てられます。

エラーの内容はError File IDを指定して、OpenAIのストレージからError Fileをダウンロードすることで確認できます。

パッケージUTL_OPENAI_BATCH_APIに含まれるプロシージャdownload_batch_responseでは、output_file_idで指定できるバッチの出力ファイルを表OPENAI_BATCH_SUBMISSIONSの列RESPONSE_FILEに保存するとともに、error_file_idがあればError Fileを列ERROR_FILEに保存します。


バッチ処理が完了した後、ボタンGet Resultをクリックすることにより、OpenAIのストレージに保存されたOutput FileとError File(もしあれば)を表OPENAI_BATCH_SUBMISSIONSへダウンロードします。また、続けてプロシージャparse_batch_responseを呼び出し、バッチのレスポンスに含まれるChat Completions APIとしてのレスポンスに加えて、そのレスポンスに含まれる最初のメッセージも取り出し、表OPENAI_BATCH_RESPONSESの列BODYFIRST_RESPONSEに保存します。


OPENAI_BATCH_RESPONSESの対話モード・レポートの編集アイコンをクリックすると、フォーム形式でレスポンスを確認できます。


竹取物語のJSON出力として、以下が得られました。
{
  "characters" :
  [
    {
      "name" : "竹取の翁",
      "role" : "主人公",
      "relations" :
      {
        "妻" : "竹取の翁の妻",
        "娘" : "赫映姫(かぐやひめ)"
      }
    },
    {
      "name" : "竹取の妻",
      "role" : "翁の妻",
      "relations" :
      {
        "夫" : "竹取の翁",
        "娘" : "赫映姫(かぐやひめ)"
      }
    },
    {
      "name" : "赫映姫",
      "role" : "翁の養女、月の姫",
      "relations" :
      {
        "父" : "竹取の翁",
        "母" : "竹取の妻"
      }
    },
    {
      "name" : "石造皇子",
      "role" : "求婚者の一人",
      "relations" :
      {
        "姫" : "赫映姫(かぐやひめ)"
      }
    },
    {
      "name" : "車持皇子",
      "role" : "求婚者の一人",
      "relations" :
      {
        "姫" : "赫映姫(かぐやひめ)"
      }
    },
    {
      "name" : "阿倍御主人",
      "role" : "求婚者の一人",
      "relations" :
      {
        "姫" : "赫映姫(かぐやひめ)",
        "大臣" : "大納言大伴御行の友人"
      }
    },
    {
      "name" : "大納言大伴御行",
      "role" : "求婚者の一人",
      "relations" :
      {
        "姫" : "赫映姫(かぐやひめ)",
        "友人" : "阿倍御主人"
      }
    },
    {
      "name" : "中納言石上麻呂",
      "role" : "求婚者の一人",
      "relations" :
      {
        "姫" : "赫映姫(かぐやひめ)"
      }
    }
  ]
}
カチカチ山のJSON出力です。
{
  "characters" :
  [
    {
      "name" : "おじいさん",
      "role" : "物語の主人公。おばあさんと二人三脚で生活している。たぬきにだまされて悲劇に見舞われる。",
      "relations" :
      {
        "おばあさん" : "妻",
        "たぬき" : "敵",
        "白うさぎ" : "友人"
      }
    },
    {
      "name" : "おばあさん",
      "role" : "おじいさんの妻。たぬきに騙されて命を落とす。",
      "relations" :
      {
        "おじいさん" : "夫",
        "たぬき" : "敵"
      }
    },
    {
      "name" : "たぬき",
      "role" : "物語の悪役。おじいさんの畑を荒らし、おばあさんを欺き、最終的におじいさんを裏切る。",
      "relations" :
      {
        "おじいさん" : "敵",
        "おばあさん" : "敵",
        "白うさぎ" : "競争相手"
      }
    },
    {
      "name" : "白うさぎ",
      "role" : "おじいさんの友人。たぬきの復讐を助けようとする。",
      "relations" :
      {
        "おじいさん" : "友人",
        "たぬき" : "敵"
      }
    }
  ]
}
すごい。

Any sufficiently advanced technology is indistinguishable from magic.」

生のRequest File(バッチのリクエストとなったファイル - OpenAIのAPIではInput File)、Response File(バッチの出力ファイル - OpenAIのAPIではOutput File)、Error Fileは、Batchesのページに表OPENAI_BATCH_SUBMISSIONSの対話モード・レポートを作成してあり、そのレポートからダウンロードできるようになっています。


Embeddingsの場合は、Endpointとして/v1/embeddingsを設定したエントリを作成し、ボタンAdd Embeddingsをクリックしてリクエストを追加します。


バッチ処理のリクエストの発行、確認および結果のダウンロードはChat Completions APIのときと同様にボタンSubmit BatchUpdate StatusGet Resultをクリックして実行します。

Embeddingsに関しては、Output Fileのそれぞれの行のJSONからbodyを取り出すところまでは処理しています。bodyからエンべディングを取り出す実装は含めていません。


OpenAIのBatch APIを使うことにより、以下が可能になります。
  1. データベースを効果的に利用できます。
  2. OpenAIへ支払う費用が削減(50%オフなので半減)できます。
  3. Structured Outputsにより非定型文書よりグラフとして扱えるデータを生成したり、セマンティック検索で使用するエンべディングを生成することができます。それらの形式のデータは、直接Oracle Database 23aiのグラフやベクトルの機能で活用できます。
今回の記事は以上になります。

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






バー・チャートのY軸のラベルを動的に変更する

バー・チャートにY軸のラベルを動的に変更する方法を紹介します。

以下の動画のように、選択リストでまたはドルを選択したときに、Y軸の表示を... 円または... ドルのように表示を変えます。以前に「チャートのプロパティを動的に変更する」という記事にて、Y軸の最大値の変更方法について紹介しています。今回の記事はラベルを変更するためにconverterを入れ替えています。APEXのチャートはOracle JETを使用していますが、プロパティが異なると変更の仕方も変わるようです。


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

バー・チャートのシリーズSQL問合せとして、以下を設定しています。ページ・アイテムP1_CURRENCYDOLLAR(表示値はドル)またはYEN(表示値は)のどちらかを選択する選択リストP1_RATEは1ドルに相当する円レートを設定する数値フィールドです。
with price_v as (
    select 1599 price, 'notebook' product from dual
    union all
    select 999  price, 'smart phone' product from dual
    union all
    select 1299 price, 'tablet' product from dual
)
select
    case when :P1_CURRENCY = 'YEN' then
        ceil(price * :P1_RATE)
    else
        price
    end price,
    product
from price_v

Y軸がPRICEになります。yの値のパターン# ドルとし、初期はドル表示とします。


ページ・アイテムP1_CURRENCYを変更したときに、動的アクションで以下のJavaScriptを実行します。クラスIntlNumberConverterはOracle JETが提供しているクラスです。プロパティconverterには、コンバータとなるクラス(IntlNumberConverterやIntlDataTimeConverter)のインスタンスを作成し、それをconverterに割り当てる必要があります。
let p = '# ' + apex.items.P1_CURRENCY.displayValueFor(
    apex.items.P1_CURRENCY.value
);

let currencyConverter = new oj.IntlNumberConverter({
        style: 'decimal',
        pattern: p
});
console.log('pattern is updated ', p);

let chart = apex.region("PRICE").widget();

chart.ojChart(
    'option',
    'yAxis.tickLabel.converter',
    currencyConverter
);
chart.ojChart(
    'option',
    'valueFormats.value.converter',
    currencyConverter
);
chart.ojChart(
    'option',
    'valueFormats.label.converter',
    currencyConverter
);

Y軸の表示(その他の設定はツール・チップ内での表示に使用されます)について、converterを設定すると表示が変わります。通貨が変わったことをチャート自体に反映させるため、後続の処理としてバー・チャートのリフレッシュを実行します。


Y軸のパターンの初期状態は# ドルですが、ページが再ロードされたときのP1_CURRENCYの値が(変更イベントが発生しないため)反映されません。動的アクション初期化時に実行オンにするという対処方法もありますが、今回はチャート・リージョンの初期化JavaScriptファンクションを記述することにより対応します。

以下のコードを記述します。
function( options ) {
    let p = '# ' + apex.items.P1_CURRENCY.displayValueFor(
        apex.items.P1_CURRENCY.value
    );

    let currencyConverter = {
        style: 'decimal',
        pattern: p
    };
    console.log('pattern is updated ', p);
    
    options.yAxis.tickLabel.converter    = currencyConverter;
    options.valueFormats.label.converter = currencyConverter;
    options.valueFormats.value.converter = currencyConverter;

    return options;
}


以上でアプリケーションは完成です。

チャートをカスタマイズするためには、Oracle JETのリファレンスを読み解く必要があります。今回の記事がOracle APEXのアプリケーション作成の参考になれば幸いです。