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:
adm_ai_form_config_apimanages the configuration: profiles, document types and forms.adm_ai_form_extract_apiextracts documents, approves results and measures the quality of a configuration.
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.
Prerequisites
Section titled “Prerequisites”- The AI Pack is installed, see Installing the AI Pack.
- A model provider that
uc_aisupports, 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_processandprocess_new_documentsneeds an administrator.extractneeds the view right on the document.extract_documentandapproveneed the edit right.
The three levels of the configuration
Section titled “The three levels of the configuration”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.
Profile configuration
Section titled “Profile configuration”| 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}Document type configuration
Section titled “Document type configuration”| 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.
Form configuration
Section titled “Form configuration”| 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.
Read the values from the file
Section titled “Read the values from the file”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
Xis 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,mistralorresponses_api.ollamareads 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.
Write check expressions
Section titled “Write check expressions”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 centabs(round(net_amount * vat_rate / 100, 2) - vat_amount) <= 0.01
-- a credit note has negative amountsdocument_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 discountabs(sum_amount - nvl(discount_amount, 0) - net_amount) <= 0.01A check that cannot run on the data, for example a division by zero, is an issue too, not an error.
Documents without line items
Section titled “Documents without line items”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_countis0.- Every
sum_<column>,min_<column>andmax_<column>isnull. - 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 changeabs(opening_balance + nvl(sum_amount, 0) - closing_balance) <= 0.005A 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.
Checks of the JSON schema
Section titled “Checks of the JSON schema”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
requiredmust be in the result. It can benull, empty or only blanks only when itstypeincludes"null", for example["string", "null"]. Use that type for a value that is not on every document. - A value must have one of the
typevaluesstring,number,integerorboolean. Anintegermust 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).
How a document is extracted
Section titled “How a document is extracted”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.
- Read. A document of type
image/*goes to thevision_model, which picks the form and reads the values. An image that theocr_text_extractionof 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 thanmin_text_charscharacters goes to review with the methodNONE, without a model call. - 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_modelsuggests a form as a hint for the reviewer. The model never picks the form of a text document. - Extract. The
text_modelreads the fields, with the schema of the document type as the answer schema. A form with aline_patternreads 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 aline_patternneeds no model call for such text. A form with"model_input": "file"sends the file to thevision_modelinstead of the text, see Read the values from the file. - Check.
adm_ai_form_config_api.check_resulttests the result against the JSON schema, then runs the field patterns and the checks. - 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.
Text that the extraction reads
Section titled “Text that the extraction reads”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.
Read scans and images with OCR
Section titled “Read scans and images with OCR”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_charscharacters, and the version has no stored OCR text with enough characters. - The MIME type of the document is in
apply_to.mime_types. Ifapply_to.annotation_keyis set, the document has this annotation, withannotation_valuewhen that is set too. - The setting
AI_TEXT_EXTRACTIONisY. 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.
Store the OCR text
Section titled “Store the OCR text”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.
Markers and line patterns on OCR text
Section titled “Markers and line patterns on OCR text”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.
Approve a result
Section titled “Approve a result”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).
Process new documents automatically
Section titled “Process new documents automatically”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_logswith the job idprocess_form_extractions. Then it records the document asFAILEDwithrecord_failure, without values, and with the issueThe 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_rundocuments 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 thanAI_JOB_MAX_ATTEMPTStimes in a row. - Its extraction of the latest version is
REVIEWwith the methodNONEand did not read OCR text, and the version now has OCR text with at leastmin_text_charscharacters. 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;/Run your own code after an extraction
Section titled “Run your own code after an extraction”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
OKextraction,extract_documenttakes back the result and the work of the hook. It writes the extraction asFAILED, 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 issueThe extraction failed while running the AFTER_FORM_EXTRACTION hook. An administrator finds the details in the debug log. - For an approval,
approveraises your error and changes nothing.
-- in my_erp.import_invoiceif not purchase_order_exists(l_order_reference) then raise_application_error(-20701, 'The purchase order ' || l_order_reference || ' does not exist.');end if;Read the results from your application
Section titled “Read the results from your application”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.
Measure a configuration
Section titled “Measure a configuration”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.
Limitations
Section titled “Limitations”- The line items are always the property
line_itemsof 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 aREVIEWresult 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_vandapprove. - ADM does not check that
source_folder_pathexists. 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_documentfor them. - The job waits only for the OCR fallback. It does not wait for the
ocr_text_extractionof a RAG collection. Such a document first goes to review with the methodNONE. The job extracts it again when the OCR text arrives. Theocr_text_extractionof 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.