1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Oracle APEXとOCIのAIで中学受験の算数PDFをOCR&自動分類してみた

Last updated at Posted at 2025-12-20

今回の主な内容

  • OCI上のローコード基盤 Oracle APEXを利用して、問題PDFの読み取りとマスターテーブル(某KのK育社の過去問の統一カテゴリなど)に基づいたカテゴライズとアドバイス作成をAIを利用して行う

作成したAPEXアプリのイメージ

PDFをアップロードしてボタンをクリックすると指定テーブルからカテゴリから選択され、簡易アドバイスを受け取れる。

image.png

背景

中学受験を間近に控えている子供が解いている算数の問題を某KのK育社の過去問の統一カテゴリでジャンル分けしたい、なんなら問題に沿ったアドバイスも欲しくなり、自分のAI学習の一環 かつ 正確さ以上に我が家の納期最優先で作成しました。

前提条件

・あくまでもご家庭内での紙やExcelでの分析の代替利用として留めてください。
・読み取りや判定の正確性を担保できません。あくまでも参考に留めてください。
・受験前の突貫で作成しているため、考慮外の操作によるバグもありますがご自身で対応してくださいね。

  • OCI?Oracle APEX?という方はこちらからどうぞ!
  • コードや設定は中心部分の抜粋とさせていただきます。
  • 今回利用するOCIリソースは、一部Always Free対象外もあります。
  • 説明にある「OCR」は画像OCR用の OCI Vision ではなく、PDFや帳票向けの OCI Document Understanding(AnalyzeDocument API)を利用しています。 「OCR」と表記している箇所は、すべて Document Understanding によるTEXT_EXTRACTION を指します。

主な必要リソース

  • OCI Object Storage
  • Oracle Autonomous AI Database(26ai)
  • Oracle APEX 24.2.11
  • OCI Document Understanding (AnalyzeDocument API)
  • OCI Generative AI (今回のModelはxai.grok-3を利用)

作成の流れ

OCIのアカウントを作成する

手順は割愛 この辺りを参考にしてください
【入門&再入門】はじめてのOracle Cloud Infrastructure [+最新情報]
OCIチュートリアル

有料アカウントへのアップグレード

手順は割愛 この辺りを参考にしてください

OCI 無料アカウントの有料アカウントへのアップグレードとリージョンサブスクライブ

Object Storage、Autonomous DatabaseやAPEXはFree tier範囲で今回の要件で利用できますが、
Document Understanding OCR、OCI Generative AIは(自分の利用条件では微々たる金額ですが)課金が発生すると思われます。

Autonomous AI Database 26aiを作成する

手順は割愛 この辺りを参考にポチポチしてください
ADBインスタンスを作成してみよう

必要なポリシーを必要なグループに与える

今回は個人利用なのでAdministratorsで実行しているのですが、実務では必ず適切なポリシーで制限しましょう。
例)
Allow group apex-ocr-group to read object-family in <compartment名>
Allow group apex-ocr-group to write object-family in <compartment名>
Allow group apex-ocr-group to use ai-service-document-family in <compartment名>
Allow group apex-ocr-group to use ai-service-generative-family in <compartment名>

AIサービスの必要なモデルが利用可能なリージョンを確認する

OCI Document Understanding、OCI Generative AIがホームリージョンで提供されているか確認

DBからObject Storageアクセス用クレデンシャルを作成し登録する

手順は割愛 ドキュメントURLを参考にポチポチしてください
今回は"apex_ai_cred"という名前で登録。

PDF登録用のObject StorageのBucketを作成する

1. quiz_mathという名前で作成しておきます。

その際、デフォルトの可視性=プライベートで指定していることを忘れずに確認。

image.png

必要なOCI情報他を控えておく

  • AIサービスやObject Storageが利用するリージョンコード(us-phoenix-1、ap-tokyo-1など)
  • オブジェクト・ストレージのバケット名(今回はquiz_math)
  • オブジェクト・ストレージ・ネームスペース( Object StorageのBucket>詳細にネームスペースの記載あり)
  • AIサービスが利用する、任意の実行対象コンパートメントのOCID(Compartment>対象をクリック>OCIDをコピー)
  • Document Understanding - AnalyzeDocument APIエンドポイントAPI
  • Generative AI Service Inference - Chat APIエンドポイントAPI
  • 利用ModelのOCID (生成AI > チャット > モデル詳細の表示)
  • 上記で登録したクレデンシャル名

アプリを新規作成する

手順は割愛 この辺りを参考にしてください

今回はGOUKAKU_APPとして作成しました。

ページを作成する

1. 今回は空白ページで作成(ページ2: 算数-問題登録)
image.png

image.png

ページを作成したアプリの状態
image.png

カテゴリーテーブルを作成する

生成AIに渡すカテゴリテーブルを作成します

1.APEXのメニューからSQLワークショップ>SQLコマンドをクリック

image.png

2.下記のようなDDLを張り付けてテーブル作成
自分は算数以外もこのテーブルでカテゴリを管理しているためSUBJECT_CODEを設定しています。
算数以外に使わない場合も残したままで利用可能です。

CREATE TABLE category_master (
  category_id   NUMBER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  subject_code  VARCHAR2(20)  NOT NULL,
  category_name VARCHAR2(200) NOT NULL,
  display_order NUMBER,
  is_active     CHAR(1) DEFAULT 'Y'
);

COMMENT ON TABLE category_master IS
  '教科ごとの問題カテゴリを管理するマスタテーブル';

COMMENT ON COLUMN category_master.category_id IS
  'カテゴリを一意に識別するID(IDENTITY主キー)';

COMMENT ON COLUMN category_master.subject_code IS
  '教科コード(例: MATH / SCIENCE / JAPANESE など)';

COMMENT ON COLUMN category_master.category_name IS
  'カテゴリ名(画面表示および分類結果に使用)';

COMMENT ON COLUMN category_master.display_order IS
  '表示順(LOVや一覧画面での並び順制御用)';

COMMENT ON COLUMN category_master.is_active IS
  '有効フラグ(Y: 有効 / N: 無効)';

カテゴリーテーブルのデータを登録する

1. ロードするカテゴリを自分でExcelで作成
こんなイメージです。

image.png

2.CATEGORY_MASTERテーブルへのファイルのロード
APEXのメニューからSQLワークショップ>ユーティリティ>データ・ワークショップ>データのロードをクリック
image.png

作成したファイルを登録します。
※エラーが発生した場合は、テーブルのカラム定義と合ってない可能性があります。定義の見直しや不要な行列をカットしてから再ロードしてください。
※category_id列は自動生成されるためファイル内に定義は不要です。

image.png

PL/SQLのファンクションを作成

  • コーディングはChatGPTを利用しています。
  • 比較的小さめのPDFを前提としています。
doc_ocr_from_blob
create or replace FUNCTION doc_ocr_from_blob (
    p_region         IN VARCHAR2,
    p_mime_type      IN VARCHAR2,  
    p_blob           IN BLOB,
    p_compartment_id IN VARCHAR2
) RETURN CLOB
IS
    l_url         VARCHAR2(4000);
    l_b64         CLOB := EMPTY_CLOB();
    l_body        CLOB;
    l_resp        CLOB;
    l_text        CLOB := EMPTY_CLOB();

    l_pos         INTEGER := 1;
    l_chunk_size  INTEGER := 12000;
    l_len         INTEGER;
    l_raw         RAW(32767);
    l_enc         RAW(32767);

    l_page_cnt    PLS_INTEGER;
    l_line_cnt    PLS_INTEGER;
BEGIN
    -- BLOB -> Base64
    l_len := DBMS_LOB.getlength(p_blob);

    WHILE l_pos <= l_len LOOP
        DECLARE
            l_read INTEGER := LEAST(l_chunk_size, l_len - l_pos + 1);
        BEGIN
            DBMS_LOB.read(p_blob, l_read, l_pos, l_raw);
            l_enc := utl_encode.base64_encode(l_raw);
            l_b64 := l_b64 || utl_raw.cast_to_varchar2(l_enc);
            l_pos := l_pos + l_read;
        END;
    END LOOP;

    -- Document Understanding endpoint
    l_url := 'https://document.aiservice.' || p_region ||
             '.oci.oraclecloud.com/20221109/actions/analyzeDocument';

    -- リクエストJSON(GUIサンプル準拠)
    l_body :=
          '{' ||
          '"compartmentId":"' || p_compartment_id || '",' ||
          '"features":[{' ||
             '"featureType":"TEXT_EXTRACTION",' ||
             '"textExtraction": {' ||
                '"language": "JPN"' ||
             '}' ||
          '}],' ||
          '"document":{' ||
             '"source":"INLINE",' ||
             '"data":"' || l_b64 || '"' ||
          '}' ||
          '}';

    apex_web_service.g_request_headers.delete;
    apex_web_service.g_request_headers(1).name  := 'Content-Type';
    apex_web_service.g_request_headers(1).value := 'application/json';

    l_resp := apex_web_service.make_rest_request(
        p_url                  => l_url,
        p_http_method          => 'POST',
        p_body                 => l_body,
        p_credential_static_id => 'apex_ai_cred'
    );

    -- 念のためログ
    apex_debug.message('DOC RESP(sample)=' || substr(l_resp, 1, 1000));

    -- JSONをパース
    apex_json.parse(l_resp);

    -- pages[].lines[].text を順に連結
    l_page_cnt := apex_json.get_count('pages');

    FOR i IN 1 .. NVL(l_page_cnt, 0) LOOP
        l_line_cnt := apex_json.get_count('pages[%d].lines', i);

        FOR j IN 1 .. NVL(l_line_cnt, 0) LOOP
            l_text := l_text
                      || apex_json.get_varchar2(
                             'pages[%d].lines[%d].text', i, j
                         )
                      || chr(10);
        END LOOP;
    END LOOP;

    RETURN l_text;
END;
/
classify_category_by_llm

create or replace FUNCTION classify_category_by_llm (
    p_text           IN CLOB,
    p_subject_code   IN VARCHAR2,  -- 例: 'MATH'
    p_model_id       IN VARCHAR2,  -- ocid1.generativeaimodel... または エンドポイントで指定するID
    p_compartment_id IN VARCHAR2,
    p_region         IN VARCHAR2,  -- 例: 'us-phoenix-1'(Generative AIが有効なリージョン),
    p_prompt_text    IN VARCHAR2
) RETURN NUMBER
IS
    l_url             VARCHAR2(4000);
    l_categories_json CLOB;
    l_req             CLOB;
    l_resp            CLOB;
    l_values          apex_json.t_values;
    l_answer_text     CLOB;
    l_cat_values      apex_json.t_values;
    l_category_id     NUMBER;
    c_cred            CONSTANT VARCHAR2(100) := 'apex_ai_cred';
BEGIN
    ----------------------------------------------------------------------
    -- 1. URL
    ----------------------------------------------------------------------
    l_url :=
        'https://inference.generativeai.' || p_region ||
        '.oci.oraclecloud.com/20231130/actions/chat';

    apex_debug.info('LLM URL=%s', l_url);

    ----------------------------------------------------------------------
    -- 2. CATEGORY_MASTER からカテゴリ一覧を JSON に
    ----------------------------------------------------------------------
    SELECT json_arrayagg(
               json_object(
                   'id'   VALUE category_id,
                   'name' VALUE category_name
               )
           )
      INTO l_categories_json
      FROM category_master
     WHERE subject_code = p_subject_code;

    IF l_categories_json IS NULL THEN
        apex_debug.warn('No categories for subject_code=%s', p_subject_code);
        RETURN NULL;
    END IF;

    ----------------------------------------------------------------------
    -- 3. リクエスト JSON 組み立て (chatRequest 形式)
    ----------------------------------------------------------------------
    apex_json.initialize_clob_output;
    apex_json.open_object; -- root

    -- 共通
    apex_json.write('compartmentId', p_compartment_id);

    -- servingMode
    apex_json.open_object('servingMode');
    apex_json.write('servingType', 'ON_DEMAND');
    apex_json.write('modelId', p_model_id);
    apex_json.close_object; -- servingMode

    -- chatRequest
    apex_json.open_object('chatRequest');
    apex_json.write('apiFormat', 'GENERIC');

    -- messages
    apex_json.open_array('messages');

    -- SYSTEM message
    apex_json.open_object;
    apex_json.write('role', 'SYSTEM');
    apex_json.open_array('content');
    apex_json.open_object;
    apex_json.write('type', 'TEXT');

apex_json.write(
    'text',
    p_prompt_text
    /* =======================
       ◆ カテゴリ一覧
       ======================= */
    || 'カテゴリ一覧: ' || l_categories_json
);

    apex_json.close_object;      -- content[1]
    apex_json.close_array;       -- content
    apex_json.close_object;      -- SYSTEM message

    -- USER message(問題文)
    apex_json.open_object;
    apex_json.write('role', 'USER');
    apex_json.open_array('content');
    apex_json.open_object;
    apex_json.write('type', 'TEXT');
    apex_json.write('text', p_text);
    apex_json.close_object;
    apex_json.close_array;       -- content
    apex_json.close_object;      -- USER message

    apex_json.close_array;       -- messages

    -- パラメータ
    apex_json.write('maxTokens',      256);
    apex_json.write('temperature',    0.0);
    apex_json.write('numGenerations', 1);

    apex_json.close_object;      -- chatRequest
    apex_json.close_object;      -- root

    l_req := apex_json.get_clob_output;
    apex_json.free_output;

    apex_debug.info('LLM REQ=%s', l_req);

    ----------------------------------------------------------------------
    -- 4. REST 呼び出し
    ----------------------------------------------------------------------

    apex_web_service.g_request_headers.delete;
    apex_web_service.g_request_headers(1).name  := 'Content-Type';
    apex_web_service.g_request_headers(1).value := 'application/json';
    l_resp := apex_web_service.make_rest_request(
                  p_url                  => l_url,
                  p_http_method          => 'POST',
                  p_body                 => l_req,
                  p_credential_static_id => c_cred
              );

    apex_debug.info('LLM RESP=%s', l_resp);

    ----------------------------------------------------------------------
    -- 5. レスポンス解析
    -- 実際のレスポンス構造に合わせてパスを調整します。
    -- まず全体をログに出しておき、その上で content[1].text を読む想定。
    ----------------------------------------------------------------------
    IF l_resp IS NULL THEN
        apex_debug.error('LLM empty response');
        RETURN NULL;
    END IF;

    apex_json.parse(l_values, l_resp);

    -- 期待するパス例:
    -- chatResponse.choices[1].message.content[1].text
    l_answer_text :=
        apex_json.get_clob(
            p_path   => 'chatResponse.choices[1].message.content[1].text',
            p_values => l_values
        );

    apex_debug.info('LLM RAW ANSWER=%s', l_answer_text);

    IF l_answer_text IS NULL THEN
        RETURN NULL;
    END IF;

    -- LLMの返答は {"category_id":1,"category_name":"○○"} のJSON文字列という前提
    apex_json.parse(l_cat_values, l_answer_text);
    l_category_id :=
        apex_json.get_number(
            p_path   => 'category_id',
            p_values => l_cat_values
        );

    RETURN l_category_id;

EXCEPTION
    WHEN OTHERS THEN
        apex_debug.error('classify_category_by_llm error: %s', SQLERRM);
        RETURN NULL;
END;
/

ページにコンポーネントを配置

コンポーネント一覧

名前 タイプ ラベル 説明
Question 静的コンテンツ - 問題全体を構成するリージョン
P2_UPLOAD_FILE ファイルのアップロード ファイルアップロード OCR 対象となるPDFファイルをアップロードする
P2_PREVIEW 静的コンテンツ - アップロードしたファイルのプレビュー表示用リージョン
P2_UPLOAD ボタン 1. Upload アップロード処理を実行するボタン
P2_FILE_NAME テキスト・フィールド ファイル名 アップロードされたファイル名を表示する
P2_OCR ボタン 2. OCR OCI Document Understandingを実行するボタン
P2_OCR_TEXT テキストエリア OCRテキスト OCI Document Understanding により抽出された文字列を表示する
P2_CATEGORIZE ボタン 3. Categorize OCR テキストをもとに OCI Generative AI で分類を実行するボタン
P2_CATEGORY 選択リスト カテゴリ LLM により判定された問題カテゴリを表示する
P2_ADVICE_TEXT テキストエリア アドバイス LLM が生成した学習アドバイスを表示する
P2_CLEAR ボタン Clear 入力内容・結果をクリアして初期状態に戻す

ポイント

  • Question(静的コンテンツ)
    配置しただけ

  • P2_UPLOAD_FILE(ファイルのアップロード)
    image.png

右クリック>動的アクションの追加
image.png

Trueイベントで「JavaScriptコードの実行」
image.png

コードサンプル
ファイルプレビューに表示するためのコードです。

const fileInput    = document.querySelector('#P2_UPLOAD_FILE');
const previewRegion = document.querySelector('#P2_PREVIEW');

console.log('fileInput: ' + fileInput);
console.log('P2_PREVIEW: ' + previewRegion);

if (fileInput && fileInput.files && fileInput.files[0]) {
  //Uploaded Imageを消す

  const file = fileInput.files[0];
  const reader = new FileReader();

  reader.onload = e => {
    const mimeType = file.type;

    //PDF以外は未検証です。
    if (mimeType.startsWith('image/')) {
      previewRegion.innerHTML = `
        <img src="${e.target.result}"
             style="max-width:100%; border:1px solid #ccc; border-radius:8px; margin-top:8px;">
      `;
    } else if (mimeType === 'application/pdf') {
      previewRegion.innerHTML = `
        <iframe src="${e.target.result}" width="100%" height="400px"
                style="border:1px solid #ccc; border-radius:8px; margin-top:8px;"></iframe>
      `;
    } else {
      previewRegion.innerHTML = '<p>このファイル形式はプレビューに対応していません。</p>';
    }
  };

  // ファイル名をP2_FILE_NAME にセット
  console.log("filename=" + file.name);

  apex.item("P2_FILE_NAME").setValue(file.name);

  reader.readAsDataURL(file);
} else {
    console.log('ELSE ....');
}
  • P2_PREVIEW
    置いただけ

  • P2_UPLOAD
    アクションは「ページの送信」
    image.png

  • P2_OCR
    アクションは「ページの送信」

  • P2_CATEGORIZE
    アクションは「ページの送信」

  • P2_CATEGORY
    LOVのタイプ「SQL問合せ」

SELECT CATEGORY_NAME D,CATEGORY_ID R FROM CATEGORY_MASTER WHERE SUBJECT_CODE= 'MATH'

image.png

  • P2_CLEAR
    入力値を消去して再表示します。
    image.png
    image.png

プロセスの作成

PRC_P2_UPLOAD

「1. Upload」ボタンをクリック時の処理

  • 名前「PRC_P2_UPLOAD」
  • タイプ「コードを実行」
  • 言語「PL/SQL」
  • PL/SQLコード
PRC_P2_UPLOAD
declare
  l_blob     blob;
  l_filename varchar2(255);
  l_mimetype varchar2(255);
  l_url      varchar2(4000);
  l_resp_blob   blob;


begin
  apex_debug.info('FILE_NAME %s', :P2_FILE_NAME);
  select blob_content, filename, mime_type
    into l_blob, l_filename, l_mimetype
    from apex_application_temp_files
   where name = :P2_UPLOAD_FILE;

  l_url := 'https://objectstorage.{Bucketの存在するリージョンコード}.oraclecloud.com/n/{namespace}/b/quiz_math/o/' || UTL_URL.ESCAPE(l_filename, TRUE, 'UTF-8');
  apex_debug.info('l_url %s', l_url);
  -- :P2_URL := l_url;
  --:P2_MIME_TYPE := l_mimetype;
  --:P2_FILE_NAME := l_filename;
  --アプリ共通変数(プレビューの再ロード時に利用)
  --:APP_OBJ_NAME := l_filename;
  --:APP_BUCKET_NAME := 'quiz_math';

    -- ヘッダ(Content-Type)
  apex_web_service.g_request_headers.delete;
  apex_web_service.g_request_headers(1).name  := 'Content-Type';
  apex_web_service.g_request_headers(1).value := nvl(l_mimetype,'application/octet-stream');
  
  l_resp_blob := apex_web_service.make_rest_request_b
  (p_url                   => l_url,
    p_http_method           => 'PUT',
    p_body_blob                  => l_blob,
    p_credential_static_id  => 'apex_ai_cred'
  );

  if apex_web_service.g_status_code not in (200,201,204) then
    raise_application_error(
      -20001,
      'PUT failed: status='||apex_web_service.g_status_code||' '
    );
  end if;
  
end;

image.png

PRC_P2_OCR

「2. OCR」ボタンをクリック時の処理

  • 名前「PRC_P2_OCR」
  • タイプ「コードを実行」
  • 言語「PL/SQL」
  • PL/SQLコード
PRC_P2_OCR
DECLARE
  -- ====== 環境設定 ======
  c_ns        CONSTANT VARCHAR2(64)  := '{ネームスペース}';
  c_region    CONSTANT VARCHAR2(64)  := '{Bucketの存在するリージョンコード}';
  c_bucket    CONSTANT VARCHAR2(128) := 'quiz_math';
  c_cred      CONSTANT VARCHAR2(128) := 'apex_ai_cred';

  -- ====== 変数 ======
  v_key  VARCHAR2(4000);
  v_url  VARCHAR2(4000);
  v_blob BLOB;
  v_mime VARCHAR2(256);
  v_text CLOB;

  -- URL encode(省略、既存のまま)
  FUNCTION encode_key(p_key VARCHAR2) RETURN VARCHAR2 IS
    l_out VARCHAR2(32767);
  BEGIN
    FOR seg IN (
      SELECT regexp_substr(p_key, '[^/]+', 1, LEVEL) s
        FROM dual
      CONNECT BY regexp_substr(p_key, '[^/]+', 1, LEVEL) IS NOT NULL
    ) LOOP
      l_out := l_out
               || CASE WHEN l_out IS NULL THEN '' ELSE '/' END
               || apex_util.url_encode(seg.s);
    END LOOP;
    RETURN NVL(l_out, '');
  END;

  FUNCTION ext_to_mime(p_key VARCHAR2) RETURN VARCHAR2 IS
    l_low VARCHAR2(4000) := LOWER(p_key);
  BEGIN
    IF l_low LIKE '%.pdf' THEN
      RETURN 'application/pdf';
    ELSE
      RETURN 'application/octet-stream';
    END IF;
  END;

BEGIN
  -- 0) 入力チェック
  IF :P2_FILE_NAME IS NULL THEN
    RAISE_APPLICATION_ERROR(
      -20000,
      '対象のPDFが指定されていません(OBJECT_NAME が空)'
    );
  END IF;

  v_key  := :P2_FILE_NAME;
  v_mime := ext_to_mime(v_key);
  --:P2_MIME_TYPE := v_mime;

  -- 1) Object Storage GET
  v_url :=
       'https://objectstorage.'
    || c_region
    || '.oraclecloud.com'
    || '/n/'
    || c_ns
    || '/b/'
    || c_bucket
    || '/o/'
    || encode_key(v_key);

  apex_web_service.g_request_headers.delete;

  v_blob := apex_web_service.make_rest_request_b(
              p_url                  => v_url,
              p_http_method          => 'GET',
              p_credential_static_id => c_cred
            );

  IF apex_web_service.g_status_code <> 200 THEN
    apex_error.add_error(
      p_message          => 'Object Storage GET 失敗: '
                            || apex_web_service.g_status_code,
      p_display_location => apex_error.c_inline_in_notification
    );
    RETURN;
  END IF;

  -- 2) OCI Document Understanding OCR:読み取りエラーは例外にしない
  BEGIN
    v_text := doc_ocr_from_blob(
                p_region         => c_region,
                p_mime_type      => v_mime,
                p_blob           => v_blob,
                p_compartment_id => '{コンパートメント OCID}'
              );

    :P2_OCR_TEXT := v_text;
    apex_debug.info('==OCR SUCCESS==');

  EXCEPTION
    WHEN OTHERS THEN
      -- ★ OCR失敗は例外にしない(手動で編集)
      apex_debug.error('OCR エラー: ' || SQLERRM);
      :P2_OCR_TEXT := '読み取りエラーなので手動で入力してね';
      -- ★ apex_error は呼ばない → 画面に赤エラーが出ない
  END;

EXCEPTION
  WHEN OTHERS THEN
    -- Object Storage GET など全体のエラーはこちら(従来通り表示)
    apex_error.add_error(
      p_message          => 'OCR処理中にエラー: ' || SQLERRM,
      p_display_location => apex_error.c_inline_in_notification
    );
END;

image.png

PRC_P2_CATEGORY

「3. Categorize」ボタンをクリック時の処理

  • 名前「PRC_P2_CATEGORY」
  • タイプ「コードを実行」
  • 言語「PL/SQL」
  • PL/SQLコード
PRC_P2_CATEGORY
DECLARE
  v_text        CLOB;
  v_category_id NUMBER;
  v_advice_text VARCHAR2;
  c_region      CONSTANT VARCHAR2(64) := '{リージョンコード}';
  c_prompt_text   VARCHAR2;
BEGIN
  apex_debug.info('==CATEGORY 1==');

  -- OCR結果がない場合は何もしない(必要ならメッセージだけでもOK)
  IF :P2_OCR_TEXT IS NULL THEN
    apex_debug.info('==OCR text==');
    :P2_CATEGORY := NULL;
    RETURN;
  END IF;

  v_text := :P2_OCR_TEXT;

 c_prompt_text :=
  'あなたは小学生算数の問題をカテゴリ分類するAIです。'
|| '次のカテゴリ一覧から最も適切なものを1つ選び、'
|| '{"category_id":数値,"category_name":"カテゴリ名"} のJSONだけを返してください。'
|| '理由説明や補足文は一切出力してはいけません。JSONのみを返してください。'

|| '【ニュートン算の判定基準】'
|| '次の語句・内容が含まれる場合は、必ず "割合と比 - ニュートン算" を選びます。'
|| ' ・タンク、水槽、容器、貯水槽'
|| ' ・毎分◯L、◯L/分、流入、流出、給水、排水'
|| ' ・Aの蛇口、Bの蛇口など複数の蛇口'
|| ' ・一定の速さで増える、減る'
|| ' ・水位が時間とともに変化する'
|| ' ・満タンになる時間、空になる時間'
|| ' ・入れる速さと抜く速さの合成'
|| '以上のいずれか1つでも含まれればニュートン算と判断します。'
|| '曖昧な場合もニュートン算を最優先してください。'

|| '【N進法・規則性(禁止数字問題)の判定基準】'
|| '次の特徴が出てきた場合は、'
|| '"規則性" または "整数の性質" または "記数法" に分類します。'
|| ' ・4や9など特定の数字を使わない番号付け'
|| ' ・使える数字が制限されている'
|| ' ・会員番号が規則的に並ぶ'
|| ' ・n番目の番号、または番号からnを求める'
|| ' ・1,2,3,5,6,7,8,10,… など飛び番号の列'
|| ' ・疑似N進法(擬似8進法など)'
|| ' ・規則に従って並んだ数列の第n項'
|| ' ・番号から順番を逆算する'
|| 'これらはニュートン算ではありません。'

|| '【速さサブカテゴリの判定(必ずこの順で判定)】'
|| '速さに関する問題は、以下の6つのサブカテゴリから必ず1つ選びます。'
|| '判定順は必ず次のとおりです:'
|| '① 流水算 → ② 時計算 → ③ 通過算 → ④ 速さと比 → ⑤ 旅人算 → ⑥ 速さ'

|| '【① 流水算】'
|| '環境が動く(流れ・動く歩道・ベルト)ため、'
|| '人や船の速さと環境の速さの合成が本質となる問題。'
|| '以下の語句が1つでもあれば必ず「速さ - 流水算」。'
|| ' ・動く歩道'
|| ' ・動くベルト'
|| ' ・川の流れ'
|| ' ・流れに乗る/流れに逆らう'
|| ' ・流速'
|| ' ・上り(川)/下り(川)'

|| '【② 時計算】'
|| '時計の針の動きを速さとして扱い、'
|| '角度・重なり・追いつきを考える問題。'
|| '以下があれば必ず「速さ - 時計算」。'
|| ' ・長針、短針、秒針'
|| ' ・時計'
|| ' ・角度'
|| ' ・重なる'
|| ' ・何時何分'

|| '【③ 通過算】'
|| '列車・車・人などが、'
|| '点(電柱・標識)や長さ(橋・トンネル)を通過する問題。'
|| '物体の長さが本質となる。'
|| '以下があれば必ず「速さ - 通過算」。'
|| ' ・電柱'
|| ' ・標識'
|| ' ・橋'
|| ' ・トンネル'
|| ' ・ホーム'
|| ' ・列車'
|| ' ・車両'
|| ' ・通過'
|| '※列車同士の追い越しで長さが関係する場合も通過算とする。'

|| '【④ 速さと比】'
|| '速さ・時間・距離について、'
|| '比(A:B)、割合(%)、倍を用いて整理するのが本質の問題。'
|| '以下が明確に出る場合は「速さ - 速さと比」。'
|| ' ・比'
|| ' ・A:B'
|| ' ・倍、何倍'
|| ' ・割合'
|| ' ・%'
|| '※単なる言葉上の「2倍速い」だけで比処理が本質でない場合は除外する。'

|| '【⑤ 旅人算(重要)】'
|| '2人以上、または2つ以上の移動体が登場し、'
|| '出会う・追いつく・追い越す・すれ違うなど、'
|| '相対的な位置関係の変化を扱う問題。'
|| '以下があり、かつ2人以上登場する場合のみ旅人算とする。'
|| ' ・出会う'
|| ' ・すれ違う'
|| ' ・追いつく'
|| ' ・追い越す'
|| ' ・同時に出発'
|| ' ・後から出発'
|| '【超重要】'
|| '登場人物や移動体が1つだけの場合は旅人算にしてはいけません。'
|| '歩く→走る、途中で速さが変わる、行き帰り、坂道で速さが変わるだけの問題は'
|| '相対移動が無ければ旅人算ではありません。'

|| '【⑥ 速さ】'
|| '上記①〜⑤のどれにも当てはまらない速さの問題。'
|| '例:'
|| ' ・1人が途中で歩く→走る'
|| ' ・行き帰りで速さが変わる'
|| ' ・所要時間・距離・速さを単独で求める'
|| '流水・時計・通過・比・相対移動が無ければ必ず「速さ」に分類する。'

|| '【出力制約】'
|| 'category_name は次のいずれかのみを使用する:'
|| '"速さ","旅人算","通過算","流水算","時計算","速さと比","ニュートン算","規則性","整数の性質","記数法"'
;


  -- 1) LLM でカテゴリ推定
  BEGIN
    v_category_id := classify_category_by_llm(
                       p_text           => v_text,
                       p_subject_code   => 'MATH',
                       p_model_id       => '{モデルOCID}',
                       p_compartment_id => '{コンポーネントOCID}',
                       p_region         => c_region,
                       p_prompt_text    => c_prompt_text
                     );
    apex_debug.info('==CATEGORY LLM done: category_id=' || v_category_id);

  EXCEPTION
    WHEN OTHERS THEN
      -- ★ ポリシー:カテゴリ判定のエラーは例外にしないで不明をセット
      apex_debug.error('LLMカテゴリ推定エラー: ' || SQLERRM);
      v_category_id := 46;
  END;

  :P2_CATEGORY := v_category_id;
  apex_debug.info('==CATEGORY 2 DONE==');

  -- 2) LLM でadvice

  BEGIN
    v_advice_text := generate_advice_by_llm(
        p_text => '娘がこの問題を間違えました。問題文のキーワード:' || v_text ,
        p_subject_code   => 'MATH',
        p_model_id       => '{モデルOCID}',
        p_compartment_id => '{コンポーネントOCID}',
        p_region         => '{リージョンコード}',
        p_prompt_text    =>
            'あなたは中学受験を目指す小学生6年生のためのベテラン家庭教師です。' ||
            '以下の問題とこれをテストで間違えたという事実をもとに、' ||
            '1) 現状の課題を一言でまとめる' ||
            '2) 今日からできるシンプルなアドバイスを1〜2個だけ出す ' ||
            'という形式で、日本語で優しく短く答えてください。'
    );
    apex_debug.info('==CATEGORY LLM done: v_advice_text=' || v_advice_text);

  EXCEPTION
    WHEN OTHERS THEN
      -- ★ ポリシー:カテゴリ判定のエラーは例外にしないで不明をセット
      apex_debug.error('LLMアドバイス作成エラー: ' || SQLERRM);
      v_advice_text := '★作成エラー★';
  END;

  :P2_ADVICE_TEXT := v_advice_text;
  apex_debug.info('==CATEGORY 3 DONE==');

EXCEPTION
  WHEN OTHERS THEN
    -- CATEGORY全体の想定外エラーも画面には出さず、デバッグのみ
    apex_debug.error('==CATEGORY unexpected error==' || SQLERRM);
    :P2_CATEGORY := NULL;
END;

image.png

使い方

間違えた問題PDFを作成(アプリ外)

我が家が利用したアプリ

  • Adobe Acrobat(デスクトップ)
  • 宿題スキャナー

1. 間違えた問題をPDF化します。
※Adobe Acrobatで取り込む前に我が家は「宿題スキャナー」で消しゴムをかけて書き込みのないPDFにしています。

アプリの利用

1. PDFファイルをドラッグアンドドロップ
image.png
プレビューが表示されます

※ APEXのファイルプレビュー仕様により、PDFタイトルが文字化けする場合がありますが、OCR処理には影響ありません。

image.png

2. 「1.Upload」をクリック
image.png

3. 「2.OCR」をクリック
image.png

OCRで読み取られたテキストが表示されます
image.png

4. 「3.カテゴリー」をクリック

image.png

カテゴリが解析され、学習アドバイスが作成されています!
image.png

まとめ

これを発展させると簡単に問題の一覧化やグラフを作成した苦手傾向チャートなども作成できます。
いろいろカスタマイズしてより精度の高い判定もトライできそうです。こんな風にOCIはビジネスだけでなく受験伴走家庭の用途でも協力な武器になります。

下記の過去記事を参考にトライしてみてください。少し古いので他のOralce APEXのQiita記事も参考にしてくださいね!合格祈願!

シリーズ

Oracle ApexでCRUD画面を爆速開発 - その1(構成周り、一覧表示)
Oracle ApexでCRUD画面を爆速開発 - その2(詳細表示)
Oracle ApexでCRUD画面を爆速開発 - その3(登録/更新処理,Validation,アクセス制限など)
Oracle ApexでCRUD画面を爆速開発 - その4(選択リストの内容で一覧表示内容を更新させる)
Oracle ApexでCRUD画面を爆速開発 - その5(Classic Reportのヘッダの上にグループヘッダ行を作る)
[Oracle ApexでCRUD画面を爆速開発 - その6(Classic Reportのレコード条件に応じて行の背景色を変える)]
(https://qiita.com/harukb/items/b976f3fad9d58bdb4587)
[Oracle ApexでCRUD画面を爆速開発 - その7(積み上げグラフを作成する)]
(https://qiita.com/harukb/items/725a8fedf077bbc3c106)
[Oracle ApexでCRUD画面を爆速開発 - その8(ロード時の条件に応じて別ページにリダイレクト) ]
(https://qiita.com/harukb/items/cad739a77254a47fb669)
[Oracle ApexでCRUD画面を爆速開発 - その9(地図表示してReportと連動してみる) ]
(https://qiita.com/harukb/items/e36f5739fea205dc068c)
[Oracle ApexでCRUD画面を爆速開発 - その10(単表ではなく、結合を含むクエリのInteractive Gridでデータ更新する) ]
(https://qiita.com/harukb/items/b0d2ed586053708c3c32)

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?