Skip to content

Form extraction

Form extraction reads the data of a form out of a document: the fields of an invoice, the lines of a delivery note. A language model reads the values. Checks that you write as data decide whether a result can be used without a person. A result that passes every check becomes annotations of the document. Any other result goes to review.

The feature is part of the AI Pack. Two packages make up its interface:

The form extraction manager is an APEX region for the same tasks, for administrators.

The tutorial Extract data from PDF forms builds the same workflow step by step. This page is the reference.

  • The AI Pack is installed, see Installing the AI Pack.
  • A model provider that uc_ai supports, for example an Ollama server. A server that needs authentication needs an APEX web credential.
  • Every call of adm_ai_form_config_api, measure, documents_to_process and process_new_documents needs an administrator. extract needs the view right on the document. extract_document and approve need the edit right.

The configuration has three levels. Each level is a row with a JSON configuration. adm_ai_form_config_api validates the JSON before it saves it, and refuses a key that it does not know. Never write the config_json columns directly: the checks run as dynamic SQL, and only the API makes sure that they contain nothing but the allowed tokens.

  • A profile names the documents to extract, the models, and rules for every form of the profile.
  • A document type defines the fields that every form of this type returns, as a JSON schema, and the checks on them. Examples: invoice, delivery_note.
  • A form is one layout of one issuer, for example the invoice of one supplier. Markers recognize it in the text of a document.
declare
l_profile_id number;
l_doc_type_id number;
l_form_id number;
begin
adm_context_api.system_login;
l_profile_id := adm_ai_form_config_api.create_profile(
p_profile_name => 'supplier_invoices'
, p_config_json => '{"source_folder_path": "/shared/inbox",
"text_model": {"provider": "ollama", "model": "qwen3.5:9b"}}'
);
l_doc_type_id := adm_ai_form_config_api.create_doc_type(
p_profile_id => l_profile_id
, p_doc_type => 'invoice'
, p_config_json => '{...}' -- see "Document type configuration"
);
l_form_id := adm_ai_form_config_api.create_form(
p_profile_id => l_profile_id
, p_form_code => 'HARBOUR_INVOICE'
, p_doc_type_id => l_doc_type_id
, p_description => 'an invoice of Harbour Office Supplies GmbH, Hamburg'
, p_config_json => '{...}' -- see "Form configuration"
);
commit;
end;
/

The p_description of a form is part of the prompt. Describe the document, not the layout.

Key Required Default Meaning
source_folder_path One of the two - Documents in this folder and below it.
source_file_annotation_key One of the two - Documents with this annotation, and documents in or below a folder with this annotation.
text_model Yes - {"provider", "model", "config"} of the model that reads text. config goes to uc_ai.generate_text unchanged.
vision_model No - The same for images (image/*), and for forms that read the file (model_input). Without it, an image goes to review.
ocr_text_extraction No - OCR for documents that the built-in text extraction cannot read, and for images. See Read scans and images with OCR.
review_tag No extraction-review The tag that marks a document for review. Lower case.
min_text_chars No 20 Less text than this, for example a scan without a text layer, goes to review without a model call. ADM counts the characters that are not blank. See Text that the extraction reads.
max_text_chars No - More text than this goes to review. A model that cuts a long prompt does so without an error.
max_file_bytes No 20971520 (20 MB) A larger file goes to review when its form reads the file. See Read the values from the file.
rules No - Rules for every form of the profile, as an array of strings.
process_new_documents No false true lets the AI queue job extract new documents, see Process new documents automatically.
max_documents_per_run No 20 Most documents of this profile in one run of the job.

The two source keys select documents the same way as for a RAG configuration. The API writes the defaults into the stored configuration. update_profile replaces the whole configuration, so a key that you leave out gets its default again.

To change one key, read the stored configuration with get_profile_config, change it with json_object_t, and pass all of it back. get_doc_type_config and get_form_config do the same for update_doc_type and update_form:

declare
l_profile_id number := adm_ai_form_config_api.get_profile_id('supplier_invoices');
l_config json_object_t;
begin
adm_context_api.system_login;
l_config := json_object_t(adm_ai_form_config_api.get_profile_config(l_profile_id));
l_config.put('process_new_documents', true);
adm_ai_form_config_api.update_profile(p_profile_id => l_profile_id, p_config_json => l_config.to_clob);
commit;
end;
/

Options of the model, for example the temperature or the context size of an Ollama model, go into config:

{
"source_folder_path": "/shared/inbox",
"text_model": {
"provider": "ollama",
"model": "qwen3.5:9b",
"config": {
"g_base_url": "https://ollama.example.com/api",
"g_apex_web_credential": "MY_OLLAMA",
"g_extra_body": {"options": {"temperature": 0, "num_ctx": 16384}},
"ollama": {"g_use_responses_api": false}
}
},
"review_tag": "extraction-review",
"max_text_chars": 40000,
"rules": ["Return dates as YYYY-MM-DD."],
"process_new_documents": true,
"max_documents_per_run": 20
}
Key Required Meaning
json_schema Yes A JSON schema of type object with properties. It is the answer schema of the model. A property line_items of type array holds the line items.
annotation_fields No Scalar fields that become the annotations <doc_type>.<field> of an accepted result.
rules No Rules for every form of this type.
line_checks No Checks that run once for each line item: [{"name", "expr"}].
checks No Checks that run once for the document: [{"name", "expr"}].
allow_empty_line_items No true accepts a result without line items, for example a bank statement without transactions. The default is false. See Documents without line items.

A property name must be a plain identifier, and it must not be a keyword of the check expressions. The array of line items is always the property line_items.

{
"json_schema": {
"type": "object",
"properties": {
"invoice_number": {"type": "string"},
"net_amount": {"type": "number"},
"vat_amount": {"type": "number"},
"total_amount": {"type": "number"},
"line_items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"quantity": {"type": "number"},
"unit_price": {"type": "number"},
"amount": {"type": "number"}
}
}
}
},
"required": ["invoice_number", "net_amount", "vat_amount", "total_amount", "line_items"]
},
"annotation_fields": ["invoice_number", "total_amount"],
"rules": ["The issuer of the invoice is never the customer."],
"line_checks": [
{"name": "quantity x unit price = amount", "expr": "abs(quantity * unit_price - amount) <= 0.01"}
],
"checks": [
{"name": "sum of lines = net amount", "expr": "abs(sum_amount - net_amount) <= 0.01"},
{"name": "net + VAT = total", "expr": "abs(net_amount + vat_amount - total_amount) <= 0.01"}
]
}

update_doc_type validates every form of the document type again. A schema change that removes a field that a form still uses is refused, and the error names the form.

Key Required Default Meaning
markers Yes - Regular expressions. All of them must match the text of the document.
field_patterns No - {"field": "regular expression"}. A key line_items.<column> checks that column in every line.
line_pattern With line_columns - A regular expression that matches one line item. \n stands for a line break.
line_columns With line_pattern - {"column": group number}: the group of line_pattern that holds each column.
decimal_comma No false true reads 1.234,56 as a number, false reads 1,234.56.
model_input No text file sends the file to the vision_model to read the values. See Read the values from the file.
rules No - Rules for this form only.
{
"markers": ["VAT ID DE 812 334 905", "^(INVOICE|CREDIT NOTE)$"],
"field_patterns": {
"invoice_number": "^INV-[0-9]{4}-[0-9]{5}$",
"line_items.item_number": "^[A-Z]{3}-[A-Z0-9-]+$"
},
"line_pattern": "^([0-9]{1,3})\\n([A-Z]{3}-[A-Z0-9-]+)\\n(.*)\\n([0-9,]+)\\n([0-9.]+,[0-9]{2})\\n(-?[0-9.]+,[0-9]{2})$",
"line_columns": {"item_number": 2, "quantity": 4, "unit_price": 5, "amount": 6},
"decimal_comma": true,
"rules": ["Dates are printed as DD.MM.YYYY."]
}

Markers and line patterns use the match parameter m: ^ and $ match at the start and the end of each line. ADM removes blanks at the end of the lines first. A form with "active": "N" (p_active of create_form) is ignored.

A line_pattern reads the line items without the model, which is faster and exact for a fixed layout. read_lines converts a number column with the decimal separator of the form. A value that is no number becomes null, and the checks then report it.

The text of a PDF does not keep the layout of the page. Two layouts lose information:

  • A grid with empty cells, for example a timesheet with one column for each day. The text keeps the values but not the empty cells, so the model cannot tell which column a value belongs to.
  • Check boxes in two columns. The text can list one column after the other, so the X is no longer next to its label.

For such a form, set "model_input": "file". ADM then finds the form with the markers on the text, as before, and sends the file to the vision_model of the profile to read the values. The model also reads the line items, so a form that reads the file cannot have a line_pattern or line_columns. The result has the method FILE.

{
"markers": ["Brightline Staffing Ltd", "^WEEKLY TIMESHEET$"],
"model_input": "file"
}

Make sure that the profile meets these conditions. create_form and update_form refuse the form if it does not:

  • The profile has a vision_model.
  • Its provider can read a PDF: openai, anthropic, google, oci, mistral or responses_api. ollama reads images only.

update_profile refuses to remove the vision_model, or to change it to another provider, while a form of the profile reads the file. This includes inactive forms. Change the form to "model_input": "text" first.

At run time, ADM sends only PDFs and images (image/*). Another file, for example a Word document, goes to review without a model call. So does a file larger than max_file_bytes. The default limit is 20 MB, below the limits of the providers. max_text_chars still applies to the text that the markers read.

In a test on 2026-11-09 with gpt-5.6-luna, eight PDFs of two forms (a weekly timesheet grid with empty cells, and a leave request with check boxes in two columns) were measured twice with each setting:

model_input Right of 8 Prompt tokens for each document Median seconds
text 6 and 4 about 650 2.7
file 8 and 8 about 1850 4.2

With text, the checks did not catch every wrong value, because a wrong placement in the grid can still give the right totals. Measure your own forms before you change the setting: the result of eight documents is no error rate.

A model call with a file takes more time and more tokens than a call with the text. measure shows the tokens and the seconds of each document, so measure the form with both values before you change it. UC AI stops waiting for an answer after 180 seconds, so a very large PDF can fail. With process_new_documents, a run of the job takes longer for these forms; AI_JOB_MAX_SECONDS stops the job from starting more documents.

A check is a SQL condition. It passes when it is true. False or null is an issue with the name of the check.

Level Key Sees
Line line_checks The columns of one line item. The issue is line <n>: <name>.
Document checks The scalar fields of the document, and the aggregates of the line items.

The aggregates are line_count, and sum_<column>, min_<column> and max_<column> for each number column of a line item. A property of type number or integer is a number. Every other scalar is text.

The expressions run as dynamic SQL with the rights of the schema owner. For this reason, only these tokens are allowed:

Token Allowed
Names The fields and aggregates of the level
Literals Numbers, and strings in single quotes without a quote inside
Operators + - * / ( ) , = <> != < <= > >=
Keywords and or not is null between in
Functions abs round trunc sign nvl coalesce greatest least mod

An expression has at most 1000 characters. The API refuses any other token when you save the document type, and again before every evaluation. adm_ai_form_config_api.validate_expression returns the SQL that would run, so you can test an expression before you save it.

-- the VAT matches the rate, to the cent
abs(round(net_amount * vat_rate / 100, 2) - vat_amount) <= 0.01
-- a credit note has negative amounts
document_type <> 'credit_note' or total_amount < 0
-- every line has a positive quantity (a line check)
quantity > 0
-- the lines add up to the net amount, less a discount
abs(sum_amount - nvl(discount_amount, 0) - net_amount) <= 0.01

A check that cannot run on the data, for example a division by zero, is an issue too, not an error.

By default, a result without line items gets the issue no line items, and the checks do not run. This applies when the schema has the property line_items.

Some documents are correct with an empty table, for example a bank statement without transactions. Set "allow_empty_line_items": true in the document type for such documents. The checks then run on an empty table:

  • line_count is 0.
  • Every sum_<column>, min_<column> and max_<column> is null.
  • The line checks find no line, so they give no issue.

Write the document checks so that they are true for an empty table. Use nvl for an aggregate:

-- the transactions add up to the change of the balance; no transactions means no change
abs(opening_balance + nvl(sum_amount, 0) - closing_balance) <= 0.005

A check without nvl, such as abs(sum_amount - net_amount) <= 0.01, is null on an empty table. The result then goes to review with the name of the check as the issue.

create_doc_type and update_doc_type refuse allow_empty_line_items when the schema has no property line_items.

Before the field patterns and the checks, check_result tests the result against the json_schema. It does this for the document and for each line item. An issue names the field, for example customer_name is required, total: expected a number, got "abc", currency: "XYZ" is not one of EUR, USD or line 2: quantity: expected a number, got "two".

  • A property in required must be in the result. It can be null, empty or only blanks only when its type includes "null", for example ["string", "null"]. Use that type for a value that is not on every document.
  • A value must have one of the type values string, number, integer or boolean. An integer must have no decimals.
  • A value must be one of the values in enum.
  • A property that is not required can be missing or null.
  • Each line item must be an object.

check_result does not test other keywords, such as format, nested objects, or keys that the schema does not have. A value of the wrong type can also make a check unable to run. The issue is then <name> (could not be evaluated).

extract_document takes one document through these steps. extract takes the same steps and writes nothing but the OCR text of the profile (see Store the OCR text), so you can try a configuration on a document.

  1. Read. A document of type image/* goes to the vision_model, which picks the form and reads the values. An image that the ocr_text_extraction of the profile reads is the exception: ADM reads it as text. ADM reads every other document as text too, see Text that the extraction reads. A text with fewer than min_text_chars characters goes to review with the method NONE, without a model call.
  2. Match. The markers of the active forms decide the form of a text document. With no form, or more than one, the document goes to review. For no form, the text_model suggests a form as a hint for the reviewer. The model never picks the form of a text document.
  3. Extract. The text_model reads the fields, with the schema of the document type as the answer schema. A form with a line_pattern reads its line items with the pattern, but only from the text of the built-in text extraction. From OCR text, the model reads the line items. When the document type has no field besides the line items, a form with a line_pattern needs no model call for such text. A form with "model_input": "file" sends the file to the vision_model instead of the text, see Read the values from the file.
  4. Check. adm_ai_form_config_api.check_result tests the result against the JSON schema, then runs the field patterns and the checks.
  5. Publish. The result gets one of these statuses:
Status Meaning Annotations Review tag
OK Every check passed. Set Removed
REVIEW A person must look at the document: an issue, no form, no text, too much text. Removed Added
FAILED An error stopped the extraction, or the hook refused the result. Removed Added
APPROVED A person approved the result. Set Removed

The annotations are <doc_type>.<field> for each field in annotation_fields. The issues column holds one line for each issue. A failure gives a sentence for the reviewer, never the Oracle error. The details of a failure are in the APEX debug log.

adm_ai_form_extractions keeps one row for each profile and document, for the latest version that was extracted. A new extraction replaces the row.

ADM takes the text of a document from the first source in this chain that has enough text. “Enough” means at least min_text_chars characters that are not blank.

stored OCR text on the version ── enough ──────────────────────────────▶ text_source OCR
│ not enough, or none
▼
built-in text extraction ──────── enough ──────────────────────────────▶ text_source FILTER
│ not enough
▼
ocr_text_extraction of the profile ── applies to the document ─────────▶ text_source OCR
│ no section, or apply_to does not match
▼
review with the method NONE (text_source FILTER)

The column text_source of the result records the source:

text_source Source
OCR OCR text. ADM stored it on the version earlier, or the ocr_text_extraction of the profile read it in this extraction.
FILTER The built-in text extraction of ADM (Oracle Text). ADM reads the file again for each extraction.

The stored OCR text comes from the OCR fallback (the setting AI_OCR_FALLBACK_CONFIG), from the ocr_text_extraction of a RAG configuration, or from the ocr_text_extraction of a profile. The built-in text extraction reads no text from a scan, so OCR text is the only text of a scan. ADM does not use the text of an llm_text_extraction. Its prompt can change the text, and the default prompt removes page headers and footers, where markers often are. An image that goes to the vision_model has no text_source.

To get the same text as the extraction, call document_text with the profile:

select adm_ai_form_extract_api.document_text(
p_document_id => :document_id
, p_profile_id => adm_ai_form_config_api.get_profile_id('supplier_invoices')
) as text
from dual;

Without p_profile_id, document_text uses the default min_text_chars of 20 and no OCR of a profile. document_text does not call the OCR of the profile by default, so that a person who only reads the text sends no file to a provider. With p_allow_ocr => true, it runs the full chain. That call can send the file to the OCR provider, and it stores the OCR text on the version, as the extraction does.

The default min_text_chars of a profile and of the OCR fallback are both 20, and both count the characters that are not blank. Thus the OCR fallback reads every document that has too little text for the profile. Keep the min_text_chars of a profile at or below the value of the OCR fallback. If the value of the profile is higher, a document with text between the two values goes to review with the method NONE, and the OCR fallback does not read it.

The optional profile key ocr_text_extraction lets the profile read a document with OCR itself. It has the keys and rules of the ocr_text_extraction section of a RAG configuration:

Key Required Meaning
provider Yes mistral, oci or ollama. An Ollama model reads images only, not PDF files.
model Ollama only The OCR model. Mistral uses its default OCR model, and OCI ignores the value.
config No g_apex_web_credential, g_base_url, and an oci object for OCI.
options No The options of uc_ai.ocr_text, for example {"tables": true}.
apply_to No mime_types (default ["application/pdf"], which ADM writes into the configuration), and optionally annotation_key and annotation_value.
"ocr_text_extraction": {
"provider": "ollama",
"model": "qwen3.5:9b",
"config": {"g_base_url": "https://ollama.example.com/api", "g_apex_web_credential": "MY_OLLAMA"},
"apply_to": {"mime_types": ["image/png", "image/jpeg"]}
}

The section is off until you add it. ADM uses it for a document when all of these are true:

  • The built-in text extraction found fewer than min_text_chars characters, and the version has no stored OCR text with enough characters.
  • The MIME type of the document is in apply_to.mime_types. If apply_to.annotation_key is set, the document has this annotation, with annotation_value when that is set too.
  • The setting AI_TEXT_EXTRACTION is Y. This setting also controls the OCR of a RAG collection and the OCR fallback, which send a file to an external service for its text.

An image whose MIME type is in apply_to.mime_types goes through OCR, then through the markers, and then to the text_model. The markers decide its form, as for every other text. If the profile also has a vision_model, the OCR has priority for these images. Any other image goes to the vision_model.

When the OCR call fails, the extraction is FAILED. The issue is a sentence such as The extraction failed while reading the document with OCR (ollama). An administrator finds the details in the debug log. The automatic processing tries the document again until AI_JOB_MAX_ATTEMPTS failures. A blank page that OCR cannot read also gives FAILED.

ADM stores the OCR text of the profile on the version, as index_content with the source OCR. The full-text search and RAG collections then use the text. A second extraction or a measure run reads the stored text and makes no OCR call. ADM uses the same rules as the OCR fallback: when a RAG collection has chunked the version, or waits for its own text extraction of the version, ADM does not store the text. The extraction then uses the text for this run only, and the debug log records this at info level.

OCR returns Markdown, for example headings with #, bold text with **, and tables with |. The model reads this text as it is. For the markers, ADM removes the Markdown first:

  • <br> becomes a line break, and the separator row of a table (|---|---|) is removed.
  • Every other | becomes a line break. ADM removes the blanks around it, so each table cell is on a line of its own, as in the text of the built-in text extraction.
  • ADM removes the # marks of a heading, ** and __, a single * or _ around text, and the blanks at the start of a line.

A form can get the text of the built-in text extraction for one document and OCR text for another. Write each marker so that it matches both texts. To see the text that the markers get, call marker_text with the text and its source:

select adm_ai_form_extract_api.marker_text(
adm_ai_form_extract_api.document_text(:document_id, adm_ai_form_config_api.get_profile_id('supplier_invoices'))
, 'OCR'
) as marker_text
from dual;

The line_pattern of a form is not used for OCR text. The layout of an OCR table is different from one provider and one model to the next, so the model reads the line items.

A person corrects a result and approves it with approve. p_corrections holds only the changed fields. p_line_items holds the complete line items, when the person changed them. p_form_code sets the form when the extraction found none or the wrong one.

begin
adm_ai_form_extract_api.approve(
p_profile_id => adm_ai_form_config_api.get_profile_id('supplier_invoices')
, p_document_id => :document_id
, p_corrections => '{"total_amount": 285.24}'
);
commit;
end;
/

The checks run again on the corrected values, the JSON schema included. When one fails, approve raises adm_ai_error.e_form_checks_failed with the issues and changes nothing. A key in p_corrections that is not a scalar field of the schema raises adm_ai_error.e_form_unknown_field, also without a change.

A result that was read from an older version of the document cannot be approved. approve raises adm_ai_error.e_form_extraction_stale (a CONFLICT error), changes nothing and runs no hook. Run extract_document again first. To approve the result that a person reviewed, pass its version as p_version_id. When the stored result is of another version, approve raises the same error. A successful call sets the status APPROVED and publishes the annotations. By default it also copies the result into the test set (p_add_to_test_set).

A profile with "process_new_documents": true needs no call. ADM_AI_RAG_QUEUE_JOB runs every five minutes. After the RAG stages, it extracts the new documents of each such profile:

  • It extracts one document at a time and commits after each one. An error rolls back only that document. The job writes the error to adm_job_logs with the job id process_form_extractions. Then it records the document as FAILED with record_failure, without values, and with the issue The extraction failed while writing and publishing the result. This failure counts against the attempt budget like every other failure.
  • It starts no document after AI_JOB_MAX_SECONDS. An extraction that has started finishes, so a run can take longer by the time of one model call.
  • It takes at most max_documents_per_run documents of a profile, the oldest upload first.

documents_to_process returns the documents that the next run extracts. A document is new when it is in the scope of the profile and one of these is true:

  • It has no extraction of the profile.
  • Its extraction is for an older version.
  • Its extraction of the latest version is FAILED, fewer than AI_JOB_MAX_ATTEMPTS times in a row.
  • Its extraction of the latest version is REVIEW with the method NONE and did not read OCR text, and the version now has OCR text with at least min_text_chars characters. The new extraction reads the OCR text, so this occurs one time for each version.

When the OCR fallback is on, the job does not extract a document that waits for the OCR fallback. Such a document is not an image, and the OCR fallback will read its latest version in a later run. A document that the ocr_text_extraction of the profile reads does not wait: the job extracts it, and the profile reads it with its own OCR. The job writes no result and no review tag for the document. A later run extracts the document with the OCR text. If the OCR fallback fails AI_JOB_MAX_ATTEMPTS times for the version, the document does not wait any more. The job then extracts it, and the result is REVIEW with the method NONE.

A call of extract_document does not wait for the OCR fallback. It extracts the document immediately, with the text that is available, and with the OCR of the profile when it applies.

A document that failed AI_JOB_MAX_ATTEMPTS times stays FAILED until it gets a new version, or until you call extract_document for it. The column failed_attempts holds the count.

process_new_documents does the same work in the transaction of its caller, without a commit. When a document raises, it rolls back that document to a savepoint, records it with record_failure, and goes on:

begin
adm_context_api.system_login;
adm_ai_form_extract_api.process_new_documents(
p_profile_id => adm_ai_form_config_api.get_profile_id('supplier_invoices')
);
commit;
end;
/

The hook AFTER_FORM_EXTRACTION runs when a result is accepted: when an extraction ends with OK, and when a person approves a result. It runs in the same transaction, after the row and the annotations are written. Register it like every other hook:

update adm_hooks
set hook_plsql = q'[
my_erp.import_invoice(
p_document_id => :p_document_id,
p_version_id => :p_version_id,
p_profile_id => :p_profile_id,
p_extraction_id => :p_extraction_id,
p_status => :p_status
);]'
where hook_key = 'AFTER_FORM_EXTRACTION';
commit;

p_status is OK or APPROVED. Read the values from adm_ai_form_extractions with p_extraction_id.

Hook code that branches on the column method must also handle FILE, the method of a form that reads the file.

A hook can refuse a result with an error from -20700 .. -20999:

  • For an OK extraction, extract_document takes back the result and the work of the hook. It writes the extraction as FAILED, with your message as the issue. The document goes to review, and the automatic processing tries it again while attempts are left. Any other error of the hook gives the issue The extraction failed while running the AFTER_FORM_EXTRACTION hook. An administrator finds the details in the debug log.
  • For an approval, approve raises your error and changes nothing.
-- in my_erp.import_invoice
if not purchase_order_exists(l_order_reference) then
raise_application_error(-20701, 'The purchase order ' || l_order_reference || ' does not exist.');
end if;

The view adm_my_form_extractions_v shows the extraction results of the documents that the ADM user of the session can view. It follows adm_my_documents_v: a session without an ADM context sees no rows, and a profile gives no right of its own.

Column Meaning
extraction_id The row in adm_ai_form_extractions.
profile_name The profile.
document_id, document_name The document.
version_id The version that was extracted.
form_code, doc_type The form and its document type. null when no form was found.
status, method The status, and TEXT, IMAGE, FILE or NONE. FILE is a form that read the file.
text_source Where the text came from: FILTER or OCR. null when no text was read, for example for an image.
fields, line_items The values, as a JSON object and a JSON array.
issues One line for each issue, or the reason of a failure.
model The model that read the values. null when no model was called.
prompt_tokens, completion_tokens The tokens of all model calls of the extraction. 0 when no model was called.
seconds The run time of the model calls. For a FAILED extraction, the time of the whole extraction.
extracted_at The time of the extraction.
reviewed_by, reviewed_at Who approved the result, and when.
select x.document_name
, json_value(x.fields, '$.invoice_number') as invoice_number
, json_value(x.fields, '$.total_amount' returning number) as total_amount
, l.item_number
, l.amount
from adm_my_form_extractions_v x
, json_table(x.line_items, '$[*]'
columns (item_number varchar2(100) path '$.item_number'
, amount number path '$.amount')) l
where x.profile_name = 'supplier_invoices'
and x.status in ('OK', 'APPROVED');

See Querying ADM from SQL for how to set the ADM context.

A change to a rule, a check or a model can make the results better or worse. measure runs the extraction over the test set of a profile and records one row for each document in adm_ai_form_measurements. It writes no extraction and no annotation. It stores the OCR text that the profile reads, as an extraction does.

The test set is adm_ai_form_expected, one test case for each document. approve adds an approved result to it with the source REVIEW. set_expected adds a test case that you prepared, with the source SAMPLE, and delete_expected deletes one. Both need an administrator. A test case without a form means that the document is no form and must go to review.

begin
adm_context_api.system_login;
adm_ai_form_extract_api.set_expected(
p_profile_id => adm_ai_form_config_api.get_profile_id('supplier_invoices')
, p_document_id => :document_id
, p_form_code => 'HARBOUR_INVOICE'
, p_fields => '{"invoice_number": "INV-2026-00412", "net_amount": 468.6, "vat_amount": 89.03, "total_amount": 557.63}'
, p_line_items => '[{"quantity": 4, "unit_price": 24.9, "amount": 99.6}]'
);
commit;
end;
/

set_expected refuses a form of another profile, a document outside the scope of the profile, and a field or a line item column that is not in the schema of the document type. A call for a document that already has a test case replaces it. Without p_form_code, p_fields and p_line_items must be null too.

begin
adm_context_api.system_login;
adm_ai_form_extract_api.measure(
p_profile_id => adm_ai_form_config_api.get_profile_id('supplier_invoices')
, p_label => 'qwen3.5 9b, rules v2'
);
commit;
end;
/

A test case belongs to one version of its document. measure extracts that version, not the latest one, so a new upload does not change the result of a measurement. The row in adm_ai_form_measurements shows the version in version_id. To measure a new upload, call set_expected again. Deleting a version also deletes its test case.

measure compares each value with the test case by the type that the document type declares for it. This is the same for fields and for line item columns. Line items must have the same count and the same order.

Type The value is right when
null The result has JSON null or no value.
boolean The result has a JSON boolean with the same value.
integer The result has a JSON number that is exactly equal.
number The result has a JSON number that differs by less than 0.005.
string The result has a JSON string that is equal. An empty string is not equal to a text.

p_model and p_vision_model measure another model with the same configuration. p_prompt => 'naive' leaves the rules out of the prompt, as a baseline. p_form_code and p_name_like measure a part of the test set.

Each document gets one outcome:

Outcome Values Status
RIGHT Right OK. Or the document is no form and went to review.
FALSE_ALARM Right The checks sent it to review.
CAUGHT Wrong The checks sent it to review.
MISSED Wrong OK: a wrong value would be published.
FAILED - An error stopped the extraction.

A number is right when it is a JSON number and equal to the cent. Text must be equal. The line items must have the same count and the same values in the same order. MISSED is the outcome to bring to zero: add a check or a field pattern that catches the wrong value.

  • The line items are always the property line_items of the schema. A document type has one table of line items.
  • Two profiles that extract the same document and use the same document type code write the same annotations <doc_type>.<field>. The last extraction overwrites the annotations of the other profile, and a REVIEW result of one profile removes them.
  • The form extraction manager is for administrators. To let other users review their documents, build the review page in your own application on adm_my_form_extractions_v and approve.
  • ADM does not check that source_folder_path exists. A profile with a wrong path finds no documents, and reports no error.
  • The automatic processing extracts a document again when it gets a new version, and one time when OCR text arrives after a result without text. A change to the configuration does not extract the documents again. Call extract_document for them.
  • The job waits only for the OCR fallback. It does not wait for the ocr_text_extraction of a RAG collection. Such a document first goes to review with the method NONE. The job extracts it again when the OCR text arrives. The ocr_text_extraction of the profile avoids this wait.
  • The markers get OCR text without its Markdown, but other differences stay. For example, an OCR model can join two lines or read a character wrong. Make sure that the markers match the OCR text of real scans.