Skip to content

The PDF in the inbox

You build the invoice inbox: supplier invoices arrive as PDF files, and your database turns each one into an invoice header and its lines.

Four documents go through it. Three are invoices. One is not an invoice.

By the end, one call reads the whole inbox. It posts what is safe to post, and it names the reason for everything it refuses.

Three words from this domain, because the lessons use them:

  • A purchase order is what your company agreed to buy. An invoice that points at no purchase order is an invoice nobody agreed to.
  • The net amount is everything except tax. The total is the net amount plus the tax.
  • To post an invoice is to write it into your own tables as something you owe.

You need a working UC AI that can reach a provider. The precheck script in the next section tests exactly that and names the fix for anything it finds. If it reports a problem, the installation guide and the network setup guide have the answers.

Before you run anything: the whole document goes to your provider. An invoice is not a question about your data. It is your data, and the one in this course has a bank account number, an IBAN and a VAT number printed on it.

The recorded output comes from OpenAI and gpt-5.6-terra. Anthropic works the same way: change the provider constant and the model constant. You do not need the largest model, and lesson 5 measures which one you do need.

  1. Run 00_setup.sql. It creates five tables and seeds three purchase orders. You can run it more than one time, because it drops the objects of an earlier run first.

  2. Run 00_load_pdfs.sql. It puts the four sample documents into inv_documents.

  3. Run 00_precheck.sql. It checks the three things every lesson needs, and it names the fix for each failure.

The precheck prints this when your database is ready:

1. UC AI is installed. Version 26.3.
2. The demo schema is in place (5 tables, 4 documents).
3. The provider read the PDF: FT-2026-04417
Tokens used: 2252.

Check 3 sends a real PDF and not a text prompt, because a proxy can pass a small request and refuse a large one.

FileWhat it is
ferrotek.pdfA clean one-page invoice from a British supplier.
nordwind.pdfThe same kind of invoice, in German, with comma decimals.
halvorsen.pdfTwo pages. Every amount is on page 2.
ferrotek-delivery-note.pdfNot an invoice. It has no price on it anywhere.

The PDFs are in examples/extract-structured-data/pdf/. They are real text PDFs, not pictures of paper. Lesson 5 measures what changes when they are not.

There is no error that tells you this.

The generate_text you know takes p_user_prompt and p_system_prompt. Neither of them can hold a BLOB, and no other parameter can either. A file goes in through the other overload, the one that takes p_messages:

uc_ai.generate_text(
p_messages => l_messages -- a json_array_t you build yourself
, p_provider => uc_ai.c_provider_openai
, p_model => uc_ai_openai.c_model_gpt_5_6_terra
);

You build the array with uc_ai_message_api. A message with a file needs a content array, because the message carries two things: the file, and the question about it.

declare
l_blob blob;
l_messages json_array_t := json_array_t();
l_content json_array_t := json_array_t();
l_result json_object_t;
begin
select content into l_blob from inv_documents where filename = 'ferrotek.pdf';
l_messages.append(uc_ai_message_api.create_system_message(
'You read supplier invoices. Answer in two or three sentences.'));
l_content.append(uc_ai_message_api.create_file_content(
p_media_type => 'application/pdf'
, p_data_blob => l_blob
, p_filename => 'ferrotek.pdf'
));
l_content.append(uc_ai_message_api.create_text_content(
'What is this invoice, and what do we owe?'));
l_messages.append(uc_ai_message_api.create_user_message(l_content));
l_result := uc_ai.generate_text(
p_messages => l_messages
, p_provider => uc_ai.c_provider_openai
, p_model => uc_ai_openai.c_model_gpt_5_6_terra
);
sys.dbms_output.put_line(l_result.get_clob('final_message'));
sys.dbms_output.put_line('Tokens: ' || l_result.get_object('usage').to_clob);
end;
/

The call works:

Recorded answergpt-5.6-terra2026-08-25

This is FerroTek Components Ltd invoice FT-2026-04417 to Meridian Field Services Ltd for drive-system parts and four hours of on-site senior engineer support.

The total amount due is GBP 2,563.80, including GBP 45.00 delivery and GBP 427.30 VAT; payment is due 13 August 2026.

Your wording will differ. What must match is the data, and the checks below.

A PDF has two costs, and they are not related.

How big the request is. create_file_content puts the file into the request as base64 text, and base64 needs four characters for every three bytes. So the request grows by a third before the model reads anything. The first block of the script measures it:

PDF on disk: 3838 bytes
Base64 on the wire: 5120 characters

That is arithmetic, so your numbers are the same. A 5 MB scan becomes 6.7 MB of text in one HTTPS request. Tell your DBA that number when you ask about the proxy.

What the model charges. The usage object holds it, and the call above printed it:

{"prompt_tokens":2256,"completion_tokens":78,"reasoning_tokens":0,"total_tokens":2334}

A 3.8 kB file became a 5 kB request and cost 2,256 input tokens. Bytes on the wire tell you whether the request will get through. Tokens tell you what it costs. Lesson 5 collects the token counts for all four documents, and they are not in the order the file sizes would suggest.

The answer is correct. Now ask the same question a second time, with the same PDF, the same prompt and the same model:

Recorded answergpt-5.6-terra2026-08-25

This is FerroTek Components Ltd invoice FT-2026-04417 for drive-system parts and four hours of on-site senior engineer support, billed to Meridian Field Services Ltd under PO-4500198231.

Total due is £2,563.80 GBP, including £45.00 delivery and £427.30 VAT; payment is due 13 August 2026.

Both answers are right, and the facts did not change. The wording did. “The total amount due is” became “Total due is”, and GBP 45.00 became £45.00.

PL/SQL that pulls 2563.80 out of the first paragraph fails on the second. A third run can use a third shape. No substr and no regular expression stays correct, because the answer is English, and English changes.

A better prompt does not fix this. Stop asking for a paragraph. Ask for named fields instead. Lesson 2 does that.

The last part of the script makes each of these three mistakes on purpose, so you see the real error once and recognise it later. Each one catches its own error, so the script still runs to the end.

A file is legal only in a user message. Put it in the system message and more than the file goes missing.

UC AI reads a system message by taking its content as text. Here the content is an array, so UC AI drops the whole system message: the file and the 'You read invoices.' instruction with it. There is no error and no warning:

Recorded answer, and the file never arrivedgpt-5.6-luna2026-08-25

NO DOCUMENT

The model answered a question about a document it never received. Nothing in the result says that the file was missing.

create_file_content leaves the key out when p_filename is null. The layer that builds the provider request then adds it back as "filename": null, and OpenAI answers with a 500:

ORA-20302: Error response from provider Responses API: HTTP 500 from provider,
response: { "error": { "message": "The server had an error processing your
request. Sorry about that! You can retry your request, ..." } }

The message tells you to retry, and a retry fails the same way every time. Always give the file a name.

Here UC AI stops the call before anything leaves your database:

ORA-20303: Unsupported file media type: application/msword

The error names the type, and it costs no tokens.

Run this to see the four documents and their sizes:

select id, filename, file_bytes, status
from inv_documents
order by id;

Your output must match this, because the loader script fixes every value:

ID FILENAME FILE_BYTES STATUS
1 ferrotek.pdf 3838 NEW
2 nordwind.pdf 3941 NEW
3 halvorsen.pdf 6254 NEW
4 ferrotek-delivery-note.pdf 3905 NEW

Then read the invoice yourself, so you know what the right answer is. Open ferrotek.pdf. It has five printed lines, a subtotal of 2,091.50, a delivery charge of 45.00, VAT of 427.30, and a total of 2,563.80.

Write those numbers down. Lesson 2 returns a net amount that is not among those figures.

  • A file reaches a model only through the p_messages overload, and only inside a user message. UC AI drops a file in a system message and raises no error.
  • Give every file a name. UC AI leaves the key out when you do not, and OpenAI answers with a 500 that tells you to retry.
  • uc_ai_message_api.create_file_content(p_media_type => l_doc.media_type, p_data_blob => l_doc.content, p_filename => l_doc.filename)

Full reference: File analysis has the message-building recipe for every provider, and the table of which provider takes which file type. This course does not repeat it.