The PDF in the inbox
By the end of this lesson, send a PDF from a table to a model, then see that two runs return different wording for the same facts.
What you build in this course
Section titled “What you build in this course”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.
Before you start
Section titled “Before you start”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.
Set up the demo schema
Section titled “Set up the demo schema”-
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. -
Run
00_load_pdfs.sql. It puts the four sample documents intoinv_documents. -
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.
The four documents
Section titled “The four documents”| File | What it is |
|---|---|
ferrotek.pdf | A clean one-page invoice from a British supplier. |
nordwind.pdf | The same kind of invoice, in German, with comma decimals. |
halvorsen.pdf | Two pages. Every amount is on page 2. |
ferrotek-delivery-note.pdf | Not 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.
generate_text has no file parameter
Section titled “generate_text has no file parameter”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.
Send the PDF
Section titled “Send the PDF”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:
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.
What a PDF costs
Section titled “What a PDF costs”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 bytesBase64 on the wire: 5120 charactersThat 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 same PDF returns different wording
Section titled “The same PDF returns different wording”The answer is correct. Now ask the same question a second time, with the same PDF, the same prompt and the same model:
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.
Three file-content mistakes
Section titled “Three file-content mistakes”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.
1. The file in the system message
Section titled “1. The file in the system message”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:
NO DOCUMENT
The model answered a question about a document it never received. Nothing in the result says that the file was missing.
2. No filename
Section titled “2. No filename”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 yourrequest. 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.
3. The wrong media type
Section titled “3. The wrong media type”Here UC AI stops the call before anything leaves your database:
ORA-20303: Unsupported file media type: application/mswordThe error names the type, and it costs no tokens.
Verification
Section titled “Verification”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 NEWThen 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.
Key takeaways
Section titled “Key takeaways”- A file reaches a model only through the
p_messagesoverload, 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.