Check and measure a form
By the end of this lesson, you can build a test set, measure a form against it, add check expressions and field patterns, and read in the measurement whether a change helped.
Lesson 1 left the profile pfx_supplier_forms with the document type invoice, the form HARBOUR_INVOICE and one result, for harbour-invoice-01-standard.pdf.
This lesson starts from that state. It changes the document type and the form, and it adds a test set and measurements.
It does not change the result of lesson 1.
Check a result without the correct values
Section titled “Check a result without the correct values”ADM must decide for each new document whether a person must read it. For a new document, nobody knows the correct values.
The checks use only the result itself. adm_ai_form_config_api.check_result runs two kinds of checks after the model answered:
- Field patterns of the form. Each field in
field_patternsmust match its regular expression. A Harbour customer number looks likeC-10233, so the pattern is^C-[0-9]{5}$. A missing value fails the check too. A key likeline_items.item_numberchecks that column in every line. - Check expressions of the document type. A check is a SQL condition on the fields.
line_checksrun once for each line item, andchecksrun once for the document. A condition that is false or null is an issue, with the name of the check.
A result with an issue gets the status REVIEW. Lesson 1 gave the Harbour invoice three field patterns and no check expressions.
The field patterns are checks, not part of the JSON schema. A pattern in the schema makes the model write every value in that form, also a value that it cannot read. A check after the answer finds a value in the wrong form instead.
Build the test set
Section titled “Build the test set”A check decides what happens to one document. A measurement shows how good the checks and the model are on many documents. It needs a test set: documents whose correct values a person wrote down. A test case holds the document, its form, and the values and line items that are right. The test set belongs to the profile. A document that is no form is a test case too: its correct result is that it goes to a person.
Typing the values of 15 documents takes long, so the course gives you a script. Run @02_seed_test_set.sql. It writes one test case for each sample, with the source SAMPLE.
A test case names its form, so the script skips the documents whose form does not exist yet. Lesson 3 runs it again.
test set: 15 document(s) writtenskipped, because the form does not exist yet: 8 document(s)The 15 test cases are the 13 Harbour invoice files, the image of the scan, and the letter harbour-letter-prices.pdf. The letter has no form.
Open the tab Test set of the profile. It lists the test cases with their form and their source. Choose the name of a document to see its values.

A test case page has the form and one field for each field of the document type, and a table for the line items. The credit note shows negative amounts.

You add a test case in three ways. Add document on the tab Test set lets you choose a document and type its values. Save as test case on the preview of Try a document keeps the values that the model read, and you correct them. The same button is on the result page of a document. Check every value that you keep: the model must not be the source of the correct answer.
02_seed_test_set.sql calls adm_ai_form_extract_api.set_expected once for each sample:
adm_ai_form_extract_api.set_expected( p_profile_id => l_profile_id, p_document_id => l_document_id, p_form_code => l_expected.get_string('form_code'), p_fields => case when l_expected.get_object('fields') is not null then l_expected.get_object('fields').to_clob end, p_line_items => case when l_expected.get_array('line_items') is not null then l_expected.get_array('line_items').to_clob end);set_expected checks that the form belongs to the profile, that the document is in its scope, and that every field is in the schema of the document type.
A call without p_form_code writes a test case for a document that is no form.
The procedure approve adds every document that a person approves, with the source REVIEW. Lesson 5 builds a test set that way.
Measure the form against the test set
Section titled “Measure the form against the test set”A measurement runs the extraction for every selected test case and compares the result with the test case.
It writes one row for each document to adm_ai_form_measurements, and nothing else: no result, no annotation, no tag. Each row gets one of five outcomes:
| Outcome | The values are | The checks | What it means for the extraction |
|---|---|---|---|
RIGHT |
right | passed | The document needs no person. |
FALSE_ALARM |
right | failed | A person reads a correct document. |
CAUGHT |
wrong | failed | A person corrects the document. |
MISSED |
wrong | passed | A wrong value goes into ADM, and nobody looks at it. |
FAILED |
An error stopped the extraction. |
MISSED is the number that decides whether people can trust the OK results. A MISSED result needs a new check. A CAUGHT result needs a better rule, or it stays a task for a person.
A run has a label. The label names the run, and each run needs a new label. Use a label that says what you changed.
The first measurement uses the form HARBOUR_INVOICE and the documents whose name ends in .pdf. That leaves out the image of the scan, which lesson 4 measures.
The name filter is a like pattern: % stands for any text. The Manager labels the field Name contains, but it does not add the % for you.
A text without % matches only a document with exactly that name.
- Open the tab Measurements and choose Run measurement.
- Enter the Label
baseline. Choose the FormHARBOUR_INVOICE. Enter%.pdfin the field Name contains. Leave the models, PromptWith rulesand ReasoningModel defaultas they are.

- Choose Start. The measurement runs as a background job. The profile page shows its progress, and the run appears in the list when it starts. Choose the label of the run when it has finished.

The run page shows the counts of each outcome, the tokens and the seconds. The table lists each document with its outcome, the form that was found, the wrong fields and the first issue.
02_measure.sql takes a label, the form, a pattern for the document names, the prompt variant and the reasoning switch:
adm_ai_form_extract_api.measure( p_profile_id => adm_ai_form_config_api.get_profile_id('pfx_supplier_forms'), p_label => '&1', p_form_code => nullif('&2', '%'), p_name_like => '&3', p_prompt => '&4', p_think => '&5' = 'Y');commit;Then it prints the report of 02_report.sql: the outcome of each document, then the totals. Run the first measurement. It takes about 30 seconds.
You can print the report of a finished run again with @02_report.sql <label>. The recordings in this lesson are such reports.
@02_measure.sql baseline HARBOUR_INVOICE %.pdf rules NDOCUMENT_NAME OUTCOME SECONDS DETAILS------------------------------------------ ----------- ------- ------------------------------------------------------------------------------------------------------------------------harbour-invoice-12-scanned.pdf CAUGHT 0.0 wrong: form none | issues: the document has no text (8 characters); a person must read it
DOCUMENTS RIGHT FALSE_ALARM CAUGHT MISSED FAILED MEDIAN_S COMPLETION_TOKENS--------- ------ ----------- ------ ------ ------ -------- ----------------- 13 12 0 1 0 0 1.8 1261Twelve of the 13 files are RIGHT. The model read every invoice with text correctly, and the checks of lesson 1 passed.
The thirteenth file, harbour-invoice-12-scanned.pdf, is CAUGHT. The PDF has no text. ADM calls no model for it and sends it to a person. Lesson 4 explains scans.
No result is MISSED. This is a statement about these 13 files and this model. It does not show that the checks work, because no value was wrong.
The next sections add checks, and they test the checks in two ways: with a measurement for a check that fires when it must not, and with a wrong value that you enter yourself.
Add check expressions to the document type
Section titled “Add check expressions to the document type”The amounts of an invoice depend on each other: the lines add up to the net amount, and the net amount plus the VAT gives the total. These are the rules BR-CO-13 and BR-CO-15 of EN 16931, the European standard for electronic invoices. A check that compares them finds a value that the model misread, because the model read each value from another place of the page.
The first version has one line check and three document checks:
{ "line_checks": [ {"name": "quantity x unit price = amount", "expr": "abs(quantity * unit_price - amount) <= 0.01"} ], "checks": [ {"name": "sum of lines - discount = net amount", "expr": "abs(sum_amount - nvl(discount_amount, 0) - net_amount) <= 0.01"}, {"name": "net amount + VAT = total", "expr": "abs(net_amount + vat_amount - total_amount) <= 0.01"}, {"name": "net amount x VAT rate = VAT", "expr": "abs(round(net_amount * vat_rate / 100, 2) - vat_amount) <= 0.01"} ]}A line check sees the columns of one line item. A document check sees the fields of the document and the aggregates of the line items: line_count, and
sum_<column>, min_<column> and max_<column> for each number column. sum_amount is the sum of the line amounts.
The expressions run as SQL in the schema of ADM. For this reason, the API accepts only a short list of tokens: the field names, numbers, strings,
the operators + - * / ( ) , = <> != < <= > >=, the keywords and or not is null between in, and the functions
abs round trunc sign nvl coalesce greatest least mod. It refuses every other token when you save the document type.
Write check expressions in the guide has the full list.
- On the tab Document types & forms, choose the document type
invoice. - In the section Line checks, choose Add line check. Enter the name and the expression of the line check above.
- In the section Checks, choose Add check three times, and enter the name and the expression of each document check.
- Below each expression, the manager asks the server to check it while you type. A valid expression shows the SQL that runs. An expression with a token that is not allowed shows the reason.

- Choose Save.
02_add_checks.sql adds the keys line_checks and checks to the configuration of the document type:
l_config.put('line_checks', json_array_t(q'~[ {"name": "quantity x unit price = amount", "expr": "abs(quantity * unit_price - amount) <= 0.01"}]~'));l_config.put('checks', json_array_t(q'~[ ... the three checks above ... ]~'));
adm_ai_form_config_api.update_doc_type( p_doc_type_id => l_doc_type_id, p_config_json => l_config.to_clob);update_doc_type replaces the whole configuration of the document type. The script therefore reads the stored configuration first with
l_config := json_object_t(adm_ai_form_config_api.get_doc_type_config(l_doc_type_id)), adds the two keys, and saves all of it.
adm_ai_form_config_api.validate_expression tests an expression without saving it, and returns the SQL that runs.
The second section of the script tests one allowed and one refused expression. Run @02_add_checks.sql:
document type invoice: 1 line check, 3 checks
ABS ( "net_amount" + "vat_amount" - "total_amount" ) <= 0.01ORA-20616: The check expression "total_amount > (select max(total_amount) from invoices)" is not allowed: "select" is not a field here. Allowed names are customer_name, customer_number, discount_amount, document_type, due_date, invoice_date, invoice_number, line_count, max_amount, max_quantity, max_unit_price, min_amount, min_quantity, min_unit_price, net_amount, order_reference, sum_amount, sum_quantity, sum_unit_price, total_amount, vat_amount, vat_rate (at position 17)Measure again with the label checks-v1. In the Manager, choose Run measurement again, enter the label and the same form and name filter. In SQL, run:
@02_measure.sql checks-v1 HARBOUR_INVOICE %.pdf rules NDOCUMENT_NAME OUTCOME SECONDS DETAILS------------------------------------------ ----------- ------- ------------------------------------------------------------------------------------------------------------------------harbour-invoice-09-credit-note.pdf FALSE_ALARM 1.9 issues: line 1: quantity x unit price = amountharbour-invoice-12-scanned.pdf CAUGHT 0.0 wrong: form none | issues: the document has no text (8 characters); a person must read it
DOCUMENTS RIGHT FALSE_ALARM CAUGHT MISSED FAILED MEDIAN_S COMPLETION_TOKENS--------- ------ ----------- ------ ------ ------ -------- ----------------- 13 11 1 1 0 0 1.8 1340
The check now fires on a correct document. The credit note is FALSE_ALARM: the values are right and the check quantity x unit price = amount failed.
A credit note prints a positive quantity and a positive unit price, and the amount is negative: 1 × 142.50 and -142.50. The check did not allow for that sign.
Without the measurement, this check would send every credit note to a person, and nobody would know why.
Compare the amounts without their signs. The document check sum of lines - discount = net amount still uses the signs, so a wrong sign in the totals stays visible.
Open the document type invoice again. In the section Line checks, replace the expression with abs(abs(quantity * unit_price) - abs(amount)) <= 0.01 and choose Save.

02_fix_line_check.sql sets the line check to abs(abs(quantity * unit_price) - abs(amount)) <= 0.01 in the same way as 02_add_checks.sql. Run @02_fix_line_check.sql.
Measure with the label checks:
@02_measure.sql checks HARBOUR_INVOICE %.pdf rules NDOCUMENT_NAME OUTCOME SECONDS DETAILS------------------------------------------ ----------- ------- ------------------------------------------------------------------------------------------------------------------------harbour-invoice-12-scanned.pdf CAUGHT 0.0 wrong: form none | issues: the document has no text (8 characters); a person must read it
DOCUMENTS RIGHT FALSE_ALARM CAUGHT MISSED FAILED MEDIAN_S COMPLETION_TOKENS--------- ------ ----------- ------ ------ ------ -------- ----------------- 13 12 0 1 0 0 1.7 1353FALSE_ALARM is 0 again, and the credit note is RIGHT. The checks accept all 12 correct invoices.
Add field patterns to the form
Section titled “Add field patterns to the form”A field pattern checks the format of one value. Lesson 1 gave the form patterns for the invoice number, the customer number and the date.
Two more cover values that arithmetic does not reach. A discount is a positive number, and the document prints it with a minus sign (-39,37).
Every item number looks like a Harbour item number. The second pattern matters for the image of lesson 4, where the model reads the item numbers too.
| Field | Pattern |
|---|---|
discount_amount |
^[0-9.]+$ |
line_items.item_number |
^[A-Z]{3}-[A-Z0-9-]+$ |
- On the tab Document types & forms, choose the form
HARBOUR_INVOICE. - In the section Field patterns, choose Add pattern. Choose the field
discount_amountand enter its regular expression. - Choose Add pattern again. Choose the field
line_items.item_numberand enter its regular expression.

- Choose Save.
02_add_patterns.sql reads the configuration of the form, adds the two patterns to field_patterns, and saves it:
l_config := json_object_t(adm_ai_form_config_api.get_form_config(l_form_id));
-- get_object returns a reference: put changes l_config too.l_patterns := l_config.get_object('field_patterns');l_patterns.put('discount_amount', '^[0-9.]+$');l_patterns.put('line_items.item_number', '^[A-Z]{3}-[A-Z0-9-]+$');
adm_ai_form_config_api.update_form(p_form_id => l_form_id, p_config_json => l_config.to_clob);Run @02_add_patterns.sql.
Measure with the label patterns:
@02_measure.sql patterns HARBOUR_INVOICE %.pdf rules NDOCUMENT_NAME OUTCOME SECONDS DETAILS------------------------------------------ ----------- ------- ------------------------------------------------------------------------------------------------------------------------harbour-invoice-12-scanned.pdf CAUGHT 0.0 wrong: form none | issues: the document has no text (8 characters); a person must read it
DOCUMENTS RIGHT FALSE_ALARM CAUGHT MISSED FAILED MEDIAN_S COMPLETION_TOKENS--------- ------ ----------- ------ ------ ------ -------- ----------------- 13 12 0 1 0 0 1.6 1378The totals do not change: 12 RIGHT, 0 FALSE_ALARM, 0 MISSED. The new patterns accept every correct value of the test set.
Make a check refuse a wrong value
Section titled “Make a check refuse a wrong value”On this test set, the model made no mistake that a check could catch. To see what the checks report, you enter a wrong value yourself. Approving a result is what a reviewer does in lesson 5. The reviewer can correct values, and ADM runs the checks on the corrected values. It refuses the approval and changes nothing when a check fails. Use this to try a wrong value on the result of lesson 1.
- On the tab Results, choose
harbour-invoice-01-standard.pdf. - In the review section, change the value of
total_amountfrom557.63to575.63, as if two digits had changed places. The number fields use the number format of your browser. - Clear the check box Add to the test set, and choose Approve. The manager shows the issue and approves nothing.

Reload the page to discard your change. Enter only a value that fails a check: ADM approves a value that passes all checks.
02_try_checks.sql calls approve three times for the result of harbour-invoice-01-standard.pdf, each time with one wrong corrected value. The parameter p_add_to_test_set is false.
The call raises e_form_checks_failed when a check fails, and the script prints the message:
adm_ai_form_extract_api.approve( p_profile_id => l_profile_id, p_document_id => l_document_id, p_corrections => '{"total_amount": 575.63}', p_add_to_test_set => false);Run @02_try_checks.sql. The three values are a total with two digits exchanged, a date as printed (14.03.2026), and a discount with a minus sign.
Corrected value: {"total_amount": 575.63} ORA-20619: The corrected values fail the checks: net amount + VAT = totalCorrected value: {"invoice_date": "14.03.2026"} ORA-20619: The corrected values fail the checks: invoice_date does not match ^20[0-9]{2}-[01][0-9]-[0-3][0-9]$: "14.03.2026"Corrected value: {"discount_amount": -39.37} ORA-20619: The corrected values fail the checks: discount_amount does not match ^[0-9.]+$: -39.37; sum of lines - discount = net amountEach issue names what failed. The total fails the check net amount + VAT = total. The date does not match the field pattern of lesson 1, and the issue shows the value that failed.
The discount fails the new field pattern and the check sum of lines - discount = net amount. The pattern says what is wrong with the value. The check shows that the totals no longer add up.
A result that the model reads wrong gets the same issues, and the status REVIEW.
Measure the form without its rules
Section titled “Measure the form without its rules”The rules of a form cost work: somebody reads the documents and writes them. To see what they give on your documents, measure the prompt variant naive.
It keeps the description of the form and the JSON schema, and it leaves out all rules of the profile, the document type and the form. The checks stay the same.
Choose Run measurement. Enter the label naive, the form HARBOUR_INVOICE and the name filter %.pdf. Choose the Prompt Naive and choose Start.

The fourth argument of 02_measure.sql is the prompt variant:
@02_measure.sql naive HARBOUR_INVOICE %.pdf naive NDOCUMENT_NAME OUTCOME SECONDS DETAILS------------------------------------------ ----------- ------- ------------------------------------------------------------------------------------------------------------------------harbour-invoice-12-scanned.pdf CAUGHT 0.0 wrong: form none | issues: the document has no text (8 characters); a person must read it
DOCUMENTS RIGHT FALSE_ALARM CAUGHT MISSED FAILED MEDIAN_S COMPLETION_TOKENS--------- ------ ----------- ------ ------ ------ -------- ----------------- 13 12 0 1 0 0 2.3 1677All 12 invoices with text are RIGHT without rules. The completion tokens are 1,677 here and 1,378 with the rules (label patterns); the recordings do not show why.
For gpt-5.6-luna, the description of the form and the schema are enough on these 13 files: the German number format, the dates and the BILL TO block need no rule.
A weaker model, or a layout that is harder to read, can need them. This measurement does not show that.
The result changes what you do next. Do not add a rule for a field that the measurement shows as right.
When a measurement shows a wrong value, add the rule at the lowest level where it is true, run the measurement again with a new label, and compare the two runs.
A rule that states what a field means, for example that the customer is the BILL TO company, can stay: a test set of 13 documents cannot show every layout that the issuer prints.
Measure a model with reasoning
Section titled “Measure a model with reasoning”Some models can reason before they answer: they write out their thinking first, and the answer comes after it.
The profile does not turn reasoning on. The reasoning switch of a measurement turns it on or off for that run, and the reasoning level of the profile sets how much the model reasons.
This measurement uses three invoices: harbour-invoice-%-d% matches the ambiguous dates, the other delivery address and the discount.
- Choose Run measurement. Enter the label
reasoning-off, the formHARBOUR_INVOICEand the name filterharbour-invoice-%-d%. Choose ReasoningOffand Start. - Run the measurement again with the label
reasoning-onand ReasoningOn. - Open the profile with Edit. In the section How documents are read, open Advanced settings of the text model and choose the Reasoning level
high. Choose Save.

- Run the measurement a third time with the label
reasoning-highand ReasoningOn.
The fifth argument of 02_measure.sql turns reasoning on:
@02_measure.sql reasoning-off HARBOUR_INVOICE harbour-invoice-%-d% rules N@02_measure.sql reasoning-on HARBOUR_INVOICE harbour-invoice-%-d% rules Y02_set_reasoning_level.sql writes the key g_reasoning_level into the config of the text model of the profile:
@02_set_reasoning_level.sql high@02_measure.sql reasoning-high HARBOUR_INVOICE harbour-invoice-%-d% rules YAll three runs read the three invoices right. The totals of 02_report.sql give these values:
| Run | Reasoning | Reasoning level | RIGHT |
Median seconds | Completion tokens |
|---|---|---|---|---|---|
reasoning-off |
Off | – | 3 of 3 | 2.3 | 317 |
reasoning-on |
On | Default | 3 of 3 | 1.6 | 317 |
reasoning-high |
On | high |
3 of 3 | 2.7 | 449 |
With the default level, the run with reasoning on has the same token count as the run with reasoning off, and the same result.
With the level high, the model wrote 449 tokens for the three documents instead of 317, and the median time was 2.7 seconds.
The median time of the run with reasoning on is shorter than that of the run with reasoning off. With three documents, this difference is within the spread of single calls: they took between 1.4 and 2.8 seconds in the three runs.
Reasoning gave no better result on these invoices, because every invoice was already right. Measure it on your own forms before you turn it on.
After the measurements, set the level back. In the Manager, choose the Reasoning level Default in the profile. In SQL, run @02_set_reasoning_level.sql none.
Decide when a form is good enough
Section titled “Decide when a form is good enough”Measure a form until both numbers are good enough for your process:
MISSEDis 0 on the test set. EachMISSEDresult is a value that goes into ADM unchecked. Add a check for it, or accept the risk for that field.- The share of documents that need a person is low enough. That share is
CAUGHTplusFALSE_ALARM. Lower it with rules and with better checks.
A form whose result stays wrong after a few rules is still useful: its documents go to a person, with the fields that the model read correctly as a start.
A test set of 13 documents is small. On these documents, gpt-5.6-luna made no mistake with the rules, and none without them. That does not prove that a form is always right.
Measure again after each change of a rule, a check or the model, and add the documents that people correct (lesson 5).