2025年1月31日金曜日

日本語を追加学習したサイバーエージェントのDeepSeek-R1のモデルで小説を生成して共有するアプリを作る

ローカルで動作するLLMにプロンプトを与えて小説を生成し、生成された小説をデータベースに保存して共有するAPEXアプリケーションを作成します。

アプリケーションを実行する環境はMシリーズのMacbookで、ローカルLLMの実行にLM Studioを使用します。モデルとしてサイバーエージェントが日本語の追加学習を行なったcyberagent-deepseek-r1-distill-qwen-32b-japaneseの使用を想定しています。エンドポイントとモデルは設定で変更できるので、Ollamaや14bのモデルでもアプリケーションは利用可能です。ローカル環境でのAPEXの環境構築については、こちらの記事を参照してください。

追記:LM StudioまたはOllamaでCORSの対応さえできていれば、APEXのアプリケーションはどこでも作成できます。

作成したAPEXアプリケーションは以下のように動作します。ローカルで実行するLLMでも、ここまでできるようになったのか、と感じます。DeepSeek-R1の他のモデルでも確認してみましたが、英語や中国語やその他の言語が混ざることが多々あり、日本語の生成は今ひとつでした。モデルを公開していただいたサイバーエージェント様、ありがとうございます。


(GIF動画は1秒2枚で作成しています。実際の出力はもっと連続しています。)

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

推論モデルは回答に時間がかかります。そのため、OpenAI互換APIの呼び出しはブラウザのJavaScriptで実行します。受け取ったストリーミングのレスポンスをページに表示します。そのページ上に表示されている文章を、データベースに書き込みます。

最初に生成した小説を保存する表EBAJ_STORIESを作成します。以下のDDLを実行します。
create table ebaj_stories (
    id        number generated by default on null as identity
              constraint ebaj_stories_id_pk primary key,
    title     varchar2(80 char) not null,
    prompt    clob,
    story     clob
);
以下はSQLワークショップSQLコマンドでの実行例です。


空のAPEXアプリケーションを作成します。名前小説生成器とします。


アプリケーションが作成されます。アプリケーション定義の編集を開きます。


アプリケーション定義置換にAPIのエンドポイントURLモデル名を設定します。コンテナとして動作しているデータベースからではなくブラウザからアクセスするため、接続先のホストはlocalhostになります。

LM Studioが宛先の場合は、以下のように設定します。

G_ENDPOINT_URL: http://localhost:8080/v1/chat/completions
G_MODEL_NAME: cyberagent-deepseek-r1-distill-qwen-32b-japanese

LM Studioのローカル・サーバーのSettingsServer Port8080でない場合は、そのポート番号をURLに含めます。また、Enable CORSオンにします。


今回の用途に合わせて、Context Lengthを最大の131072に設定してモデルをロードしています。


Ollamaの場合は、以下のような設定になります。Ollamaではページが生成されたURLとAPI呼び出し先のポート番号の違いは見ていないのか、CORSに関する設定は不要でした。

G_ENDPOINT_URL: http://localhost:11434/v1/chat/completions
G_MODEL_NAME: yuma/DeepSeek-R1-Distill-Qwen-Japanese:32b

以下はLM Studioのローカル・サーバーを呼び出す設定です。


ページ・デザイナでホーム・ページを開き、小説を生成するユーザー・インターフェースを作成します。

置換文字列G_ENDPOINT_URLとして設定した値を、このページで実行するJavaScriptのコードから参照できるように、ページ・アイテムP1_ENDPOINT_URLを作成します。JavaScriptから参照するだけなので、タイプ非表示設定保護された値オンにします。

ソースタイプアイテムを選択し、アイテムとしてG_ENDPOINT_URLを指定します。HTMLページ上に値があればよく、データベースに保存する必要はないので、セッション・ステートストレージリクエストごと(メモリーのみ)を選択します。


同様にページ・アイテムP1_MODEL_NAMEを作成し、ソースアイテムG_MODEL_NAMEを指定します。


静的コンテンツのリージョンとしてPromptを作成します。

このリージョンにプロンプトを入力するテキスト領域、APIリクエストの送信ボタン、生成された小説をデータベースに保存するボタン、保存する際に付けるタイトルを設定するテキスト・フィールドを作成します。

表示上の修飾は不要なので、外観テンプレートBlack with Attributesを選択します。このリージョンの下に小説を表示するリージョンを作成しますが、リージョン間の隙間を空けるために、テンプレート・オプション詳細Bottom MarginMediumを指定します。

ボタンをクリックしたときに実行される処理は、APEXアクションとして実装します。APEXアクションを保持するコンテキストをこのリージョンに作成するため、詳細静的IDとしてPROMPTを設定します。


LLMへの指示を入力するページ・アイテムを作成します。識別名前P1_PROMPT、複数行に渡る長い文章の入力を想定して、タイプテキスト領域とします。外観高さ10行としました。

このページの処理はすべてJavaScriptで実装します。ページ・アイテムの値をセッション・ステートとして(データベースに)保存する必要は無いため、セッション・ステートストレージリクエストごと(メモリーのみ)を選択します。


小説の生成を依頼するボタンGENERATEを作成します。ラベル生成とします。

外観ホットオンテンプレート・オプションWidthStretchを設定し、画面の横幅いっぱいに強調色でボタンを表示します。

ボタンの処理はGENERATEという名前のAPEXアクションとして実装します。ボタンを押した時にこの処理が呼び出されるように、動作アクション動的アクションで定義を選択し、詳細カスタム属性としてdata-action="GENERATE"を指定します。


生成された小説を保存する際に付けるタイトルを入力するページ・アイテムを作成します。

識別名前P1_TITLEタイプテキスト・フィールドラベルTitleとします。セッション・ステートストレージリクエストごと(メモリーのみ)です。


小説をデータベースに保存するボタンSAVEを作成します。ラベル保存とします。

ボタンGENERATEと同様に、ボタンをクリックしたときにAPEXアクションSAVEを呼び出します。詳細カスタム属性data-action="SAVE"を指定します。


生成された小説を保存するデータベース操作は、Ajaxコールバックとして実装します。左ペインでプロセス・ビューを開き、Ajaxコールバックとしてプロセスを作成します。

識別名前SAVE_STORYタイプコードを実行です。ソースPL/SQLコードとして以下を記述します。
begin
    insert into ebaj_stories(title, prompt, story) values(:P1_TITLE, :P1_PROMPT, apex_application.g_x01);
    htp.p('{ "success": true }');
end;

LLMが生成した小説を記述するリージョンを作成します。

識別名前小説タイプ静的コンテンツです。LLMからストリームとして受け取った文字列を、JavaScriptより逐次、リージョンのinnerTextに追記します。

外観テンプレート・オプションHeightとして320pxを選択します。リージョンの高さを固定し、出力が溢れた場合は自動的にスクロールするようにします。

JavaScriptより出力先となるリージョンを特定するため、詳細静的IDとしてNOVEL_CONTAINERを設定します。


生成AIの呼び出しや小説の保存を行うコードを記述したJavaScriptのファイルを、静的アプリケーション・ファイルとして保存します。

共有コンポーネント静的アプリケーション・ファイルを開きます。


以下のファイルをjs/app.jsとして保存します。


ファイルの作成をクリックします。


ディレクトリjsファイル名app.jsを指定し、作成をクリックします。


ファイルの内容をコピペし、変更の保存をクリックします。


メッセージにファイルが保存され、縮小されましたと表示されます。

これで作成したファイルが、静的アプリケーション・ファイルとして参照できるようになりました。参照リンクをコピーした後、取消をクリックしてスクリプト・エディタを終了します。


ホーム・ページのページ・プロパティJavaScriptファイルURLに、以下を記述します。

[module,defer]#APP_FILES#js/app#MIN#.js


以上で小説の生成と保存を行うページは完成しました。

ページを実行して、小説の生成と保存を行います。

Promptに適当な指示を入力し、ボタン生成をクリックします。アプリケーションの名前は小説生成器としましたが、Promptに入力した文字をそのまま生成AIに渡しているだけです。そのため、実際に生成される文章は小説に限られるわけではありません。


生成された小説をデータベースに保存します。

適当なタイトルを入力し、ボタン保存をクリックします。

saved!とメッセージが表示されれば、生成された小説の保存は成功です。


保存された小説を表示するページを作成します。

このページは概ねAPEXの標準の機能を使って作成します。

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


空白ページを選択します。


作成するページの名前小説を読むとします。ページの作成をクリックします。


空白ページが作成されたら、読みたい小説を選択するページ・アイテムと、いくつかのボタンを配置するためのリージョンを作成します。

リージョンの名前Item Containerとし、タイプ静的コンテンツとします。アイテムとボタンを整列させるため、外観テンプレートItem Containerを指定します。


読みたい小説を選択するページ・アイテムを作成します。

識別名前P2_STORYタイプポップアップLOVにします。ラベル小説としました。

設定表示形式インライン・ポップアップを選択します。レイアウトリージョンItem Containerスロットはページ・アイテムの規定の位置であるItemに配置します。

LOVタイプSQL問合せを選択し、SQL問合せとして以下を記述します。

select title d, id r from ebaj_stories

追加値の表示オフNULL値の表示オンとし、NULL表示値- 読みたい小説を選んで -を設定します。


このページ・アイテムはサーバー側の処理で参照するため、セッション・ステートストレージとしてセッションごと(永続)を選択します。


小説を表示するボタンREADを作成します。ラベル読むとします。動作アクションページの送信を設定します。

レイアウトのリージョンはItem ContainerスロットButton Endを選択し、アイテムP2_STORYの右側に配置します。

小説の取り出しと表示は、実際には動的コンテンツのリージョンで行います。このボタンはページを送信することにより、ページ・アイテムP2_STORYの値をセッション・ステートに保存します。


小説を削除するボタンDELETEを作成します。ラベル削除とします。動作アクションページの送信を設定します。

動作確認の要求オンにし、確認メッセージとして本当に削除しますか?スタイル危険を指定します。


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

識別名前DELETEタイプコードの実行です。

ソースPL/SQLコードとして以下の1行を記述します。

delete from ebaj_stories where id = :P2_STORY;

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


P2_STORYに指定された小説が削除されたので、P2_STORYとして保存されているセッション・ステートの値もクリアします。

プロセスを作成します。識別名前P2_STORYのクリアタイプとしてセッション・ステートのクリアを選択します。設定タイプアイテムのクリアを選択し、アイテムとしてP2_STORYを選びます。

サーバー側の条件ボタン押下時DELETEを選択します。


最後に小説を表示するリージョンを作成します。

識別名前小説タイプとして動的コンテンツを選択します。

ソースCLOBを返すPL/SQLファンクション本体に以下を記述します。
declare
    l_story clob;
begin
    if :P2_STORY is not null then
        select story into l_story from ebaj_stories where id = :P2_STORY;
        return apex_markdown.to_html(l_story);
    end if;
    return '';
end;

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

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

Autonomous DatabaseのAPEX 24.1で作成したエクスポートはこちらに置きました。
https://github.com/ujnak/apexapps/blob/master/exports/novel-generator-241.zip

アプリケーションをAutonomous Databaseで実行した場合は、OllamaでもCORSのエラーが発生します。そのため、ollama serveを実行する前に環境変数OLLAMA_ORIGINSを設定して、CORSの制限を緩和します。

export OLLAMA_ORIGINS='*'

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