Friday, August 14, 2026

PeopleSoft RAG Without a Vector Database: Building Native Semantic Retrieval Inside Oracle AI Database

PeopleSoft RAG Without a Vector Database: Building Native Semantic Retrieval Inside Oracle AI Database

Upload a PDF on a PeopleSoft page, store it in a PeopleTools record, index it into vectors, and retrieve answers to plain-English questions — with every stage of the pipeline running inside the PeopleSoft database. No external search service, no middleware, no document ever touching a filesystem.


Every PeopleSoft AI architecture I've seen — including ones I've built — follows the same pattern: PeopleSoft on one side, an external AI stack on the other (Azure AI Search, a standalone vector database, a middleware layer), and an integration bridge between them. It works, but it carries two permanent taxes: your document content leaves the database, and you must replicate PeopleSoft's row-level security in a foreign system and keep it in sync forever.

Oracle 23ai changes the equation. With AI Vector Search, the native VECTOR datatype, and an ONNX model runtime inside the database, the entire RAG pipeline can live where your PeopleSoft data already lives. I built this end to end on an HCM PUM image — this post walks through the complete flow, all the objects, and the landmines.

The architecture in one picture

┌────────────── PIA (browser) ──────────────┐
│  VA_RAG page: [Upload Document]  [Ask]    │
└───────────────┬───────────────┬───────────┘
                │ AddAttachment │ CreateSQL
                ▼               ▼
┌────────────────── PeopleSoft Database (Oracle 23ai) ─────────────────┐
│                                                                      │
│  1. STORE    PS_VA_RAG_ATT   (attachment rows, record:// storage)    │
│  2. ASSEMBLE PS_VA_RAG_DOC   (header + reassembled BLOB)             │
│  3. INDEX    PS_VA_RAG_CHUNK (text chunks + VECTOR(384) + HNSW)      │
│                                                                      │
│  DBMS_VECTOR_CHAIN: UTL_TO_TEXT → UTL_TO_CHUNKS                      │
│  MINILM_MODEL (ONNX, in-database): VECTOR_EMBEDDING()                │
│  Retrieval: VECTOR_DISTANCE(...) ORDER BY ... FETCH APPROX FIRST k   │
└──────────────────────────────────────────────────────────────────────┘

Four stages: upload (a normal PeopleSoft attachment), store (PeopleTools chunked attachment rows plus a document header), index (parse → chunk → embed, one PL/SQL call), retrieve (one SQL statement, rendered as ranked cards on the same page).

The key property: documents never exist on a filesystem. They travel browser → web server → app server → attachment record, the same path as every PeopleSoft attachment you've ever managed, and everything downstream is SQL and PL/SQL against tables.

What it looks like working

I indexed a  Leave Guide PDF and asked, on the PeopleSoft page:

how many days off do I get when I have a baby

Top results: the Paid Parental Leave section (12 weeks, eligibility rules), XX County Family and Medical Leave (18 weeks bonding leave), and the protected-leave overview — ranked by cosine distance, with zero keyword overlap between the question and the text. "Days off when I have a baby" found "paid parental leave" and "bonding with a new child" on meaning alone. That's the semantic search payoff, and it happened entirely inside the database.





Prerequisites

  • Oracle 23ai under your PeopleSoft database. PeopleTools 8.62 certifies 23ai, but PUM images still ship on 19c — I upgraded the image database in place with AutoUpgrade (19.26 → 23.26, about 2h20m on a VirtualBox VM; that adventure is a post of its own).
  • COMPATIBLE ≥ 23.4 (required for the VECTOR datatype) and vector_memory_size set (I used 512M) for the HNSW in-memory index.
  • The CONTEXT component of Oracle Text installed in your PDB — see Landmine #1, because on a PUM image it probably isn't.

Step 0 — Load the embedding model (one-time setup)

The database ships vector infrastructure but no embedding model. Oracle publishes a pre-converted, "augmented" ONNX build of all-MiniLM-L12-v2 — augmented meaning the tokenizer is baked in, which is what makes it work with the in-database runtime (a raw Hugging Face export will not). Download it (search "Oracle pre-built ONNX embedding model" on blogs.oracle.com/machinelearning for the current link — the pre-authenticated URLs rotate), place it on the DB server, and load it:

-- as SYSDBA in the PDB.
-- This is the ONLY Oracle directory in the whole design, used once,
-- for the model file. Documents never touch the filesystem.
CREATE OR REPLACE DIRECTORY ONNX_DIR AS '/opt/oracle/psft/db/oracle-server/models';
GRANT READ ON DIRECTORY ONNX_DIR TO SYSADM;
GRANT EXECUTE ON DBMS_VECTOR TO SYSADM;
GRANT EXECUTE ON CTXSYS.DBMS_VECTOR_CHAIN TO SYSADM;   -- after Oracle Text is installed
GRANT CREATE MINING MODEL TO SYSADM;

-- as SYSADM:
EXEC DBMS_VECTOR.LOAD_ONNX_MODEL('ONNX_DIR','all_MiniLM_L12_v2.onnx','MINILM_MODEL');

-- the moment it becomes real:
SELECT VECTOR_EMBEDDING(MINILM_MODEL USING 'annual leave policy' AS data) FROM dual;

Step 1 — UPLOAD: a normal PeopleSoft attachment

Three PeopleTools objects:

Record VA_RAG_ATT — the attachment storage table. New record in App Designer, drag in the delivered subrecord FILE_ATTDET_SBR (ATTACHSYSFILENAME, FILE_SEQ, VERSION, FILE_SIZE, LASTUPDDTTM, FILE_DATA), keys ATTACHSYSFILENAME + FILE_SEQ, Build → Create Table.

URL definition — PeopleTools > Utilities > Administration > URLs: identifier VA_RAG_ATT_URL, URL record://VA_RAG_ATT. This is what tells AddAttachment to store into the database record rather than an FTP/HTTP repository.

Page + component — a work record (VA_RAG_WRK) carrying: ATTACHADD (Char 1, push button), QUESTION (Char 254), ASKBTN (Char 1, push button), ANSWER (Long Character). All fields at level 0 on the page; ANSWER gets Rich Text Enabled + Display Only (it will render our HTML result cards). Component search record INSTALLATION, registered to a menu and permission list.

Upload button FieldChange:

Local number &ret;
Local string &sysFile, &userFile, &docId, &status, &err;

/* next numeric doc id */
SQLExec("SELECT LPAD(NVL(MAX(TO_NUMBER(DOC_ID)),0)+1,8,'0') FROM PS_VA_RAG_DOC WHERE REGEXP_LIKE(DOC_ID,'^[0-9]+$')", &docId);
If None(&docId) Then
   &docId = "00000001";
End-If;

&sysFile = &docId | "_";      /* unique storage prefix — see Landmine #4 */

&ret = AddAttachment(URL.VA_RAG_ATT_URL, &sysFile, "", &userFile, 0);

If &ret = %Attachment_Success Then
   /* resolve the actual stored name (tools appends the user filename) */
   SQLExec("SELECT ATTACHSYSFILENAME FROM PS_VA_RAG_ATT WHERE ATTACHSYSFILENAME LIKE :1 || '%' AND FILE_SEQ = 1", &docId | "_", &sysFile);

   SQLExec("INSERT INTO PS_VA_RAG_DOC (DOC_ID, OPRID, ATTACHSYSFILENAME, ATTACHUSERFILE, DEPTID, DOC_STATUS, UPLOAD_DTTM, ERROR_MSG) VALUES (:1,:2,:3,:4,' ','N',SYSTIMESTAMP,' ')", &docId, %OperatorId, &sysFile, &userFile);

   SQLExec("BEGIN VA_RAG.INGEST_FROM_ATT(:1); END;", &docId);

   SQLExec("SELECT DOC_STATUS, ERROR_MSG FROM PS_VA_RAG_DOC WHERE DOC_ID=:1", &docId, &status, &err);
   If &status = "I" Then
      MessageBox(0, "", 0, 0, "Indexed: " | &userFile);
   Else
      MessageBox(0, "", 0, 0, "Indexing failed: " | &err);
   End-If;
End-If;

The user browses, picks a PDF, and PeopleTools streams it over HTTPS into the database.

Step 2 — STORE: chunked attachment rows + a document header

Two tables hold the document:

PS_VA_RAG_ATT (built by App Designer above) — where AddAttachment lands the file. Important detail: PeopleTools does not store one BLOB per file. It splits the file into pieces — one row per FILE_SEQ, sized by the "Maximum Attachment Chunk Size" in PeopleTools Options (default ~28KB). My 500KB leave guide became 18 rows.

PS_VA_RAG_DOC — the document header, created directly in SQL (see Landmine #6 on why the vector-era objects stay out of App Designer):

CREATE TABLE PS_VA_RAG_DOC (
  DOC_ID            VARCHAR2(20)   NOT NULL,
  OPRID             VARCHAR2(30)   DEFAULT ' ' NOT NULL,
  ATTACHSYSFILENAME VARCHAR2(128)  DEFAULT ' ' NOT NULL,
  ATTACHUSERFILE    VARCHAR2(128)  DEFAULT ' ' NOT NULL,
  DEPTID            VARCHAR2(10)   DEFAULT ' ' NOT NULL,
  DOC_STATUS        VARCHAR2(1)    DEFAULT 'N' NOT NULL,   -- N/P/I/E
  DOC_BLOB          BLOB,
  UPLOAD_DTTM       TIMESTAMP      DEFAULT SYSTIMESTAMP,
  ERROR_MSG         VARCHAR2(1000) DEFAULT ' ',
  CONSTRAINT PS_VA_RAG_DOC_PK PRIMARY KEY (DOC_ID)
);

DOC_STATUS gives you an operational lifecycle: New → Parsing → Indexed, or Error with the message captured — which paid for itself repeatedly during the build.

Step 3 — INDEX: reassemble → parse → chunk → embed

The chunk table and its vector index:

CREATE TABLE PS_VA_RAG_CHUNK (
  DOC_ID     VARCHAR2(20)  NOT NULL,
  CHUNK_SEQ  NUMBER        NOT NULL,
  CHUNK_TEXT VARCHAR2(4000 CHAR),
  EMBEDDING  VECTOR(384, FLOAT32),
  CONSTRAINT PS_VA_RAG_CHUNK_PK PRIMARY KEY (DOC_ID, CHUNK_SEQ),
  CONSTRAINT PS_VA_RAG_CHUNK_FK FOREIGN KEY (DOC_ID)
    REFERENCES PS_VA_RAG_DOC (DOC_ID) ON DELETE CASCADE
);

CREATE VECTOR INDEX VA_RAG_CHUNK_HNSW ON PS_VA_RAG_CHUNK (EMBEDDING)
  ORGANIZATION INMEMORY NEIGHBOR GRAPH DISTANCE COSINE
  WITH TARGET ACCURACY 95;

And the ingestion procedures — the heart of the whole build:

  PROCEDURE INGEST_FROM_ATT (p_doc_id IN VARCHAR2) IS
    v_sysfile VARCHAR2(128);
    v_doc     BLOB;
  BEGIN
    SELECT ATTACHSYSFILENAME INTO v_sysfile
      FROM PS_VA_RAG_DOC WHERE DOC_ID = p_doc_id;

    -- reassemble PeopleTools' chunked attachment rows into one BLOB
    DBMS_LOB.CREATETEMPORARY(v_doc, TRUE);
    FOR r IN (SELECT FILE_DATA FROM PS_VA_RAG_ATT
               WHERE ATTACHSYSFILENAME = v_sysfile
               ORDER BY FILE_SEQ) LOOP
      DBMS_LOB.APPEND(v_doc, r.FILE_DATA);
    END LOOP;

    UPDATE PS_VA_RAG_DOC SET DOC_BLOB = v_doc WHERE DOC_ID = p_doc_id;
    COMMIT;

    INGEST_DOC(p_doc_id);
  END INGEST_FROM_ATT;

  PROCEDURE INGEST_DOC (p_doc_id IN VARCHAR2) IS
    v_doc    BLOB;
    v_text   CLOB;
    v_err    VARCHAR2(1000);
    v_params JSON := JSON('{"by":"words","max":"300","overlap":"40",
                            "split":"sentence","normalize":"all"}');
  BEGIN
    UPDATE PS_VA_RAG_DOC SET DOC_STATUS='P', ERROR_MSG=' ' WHERE DOC_ID=p_doc_id;
    SELECT DOC_BLOB INTO v_doc FROM PS_VA_RAG_DOC WHERE DOC_ID=p_doc_id;

    -- PDF/DOCX/HTML -> plain text (Oracle Text filters under the hood)
    v_text := DBMS_VECTOR_CHAIN.UTL_TO_TEXT(v_doc);
    IF v_text IS NULL OR LENGTH(v_text) < 20 THEN
      RAISE_APPLICATION_ERROR(-20001,'Parser returned no text (scanned/image PDF?)');
    END IF;

    DELETE FROM PS_VA_RAG_CHUNK WHERE DOC_ID=p_doc_id;

    -- chunk AND embed in a single INSERT...SELECT
    INSERT INTO PS_VA_RAG_CHUNK (DOC_ID, CHUNK_SEQ, CHUNK_TEXT, EMBEDDING)
    SELECT p_doc_id,
           JSON_VALUE(c.column_value,'$.chunk_id'   RETURNING NUMBER),
           JSON_VALUE(c.column_value,'$.chunk_data' RETURNING VARCHAR2(4000)),
           VECTOR_EMBEDDING(MINILM_MODEL
             USING JSON_VALUE(c.column_value,'$.chunk_data'
                              RETURNING VARCHAR2(4000)) AS data)
      FROM TABLE(DBMS_VECTOR_CHAIN.UTL_TO_CHUNKS(v_text, v_params)) c;

    UPDATE PS_VA_RAG_DOC SET DOC_STATUS='I' WHERE DOC_ID=p_doc_id;
    COMMIT;
  EXCEPTION WHEN OTHERS THEN
    v_err := SUBSTR(SQLERRM,1,1000);
    ROLLBACK;
    UPDATE PS_VA_RAG_DOC SET DOC_STATUS='E', ERROR_MSG=v_err WHERE DOC_ID=p_doc_id;
    COMMIT;
    RAISE;
  END INGEST_DOC;

Read that INSERT...SELECT again: parse, chunk, and embed is one SQL statement. UTL_TO_CHUNKS is a table function streaming JSON chunk descriptors; VECTOR_EMBEDDING runs the ONNX transformer per row. My leave guide became 34 chunks with 34 embeddings in a few seconds, synchronously, while the user watched the page. PeopleCode's total involvement: SQLExec("BEGIN VA_RAG.INGEST_FROM_ATT(:1); END;", &docId);

Step 4 — RETRIEVE: one SQL statement, ranked cards on the page

The Ask button (VA_RAG_WRK.ASKBTN FieldChange) embeds the question inline and ranks by cosine distance:

Local SQL &sql;
Local string &docName, &chunk, &html, &q, &esc;
Local number &seq, &dist;
Local integer &rank;

&q = VA_RAG_WRK.QUESTION.Value;

&sql = CreateSQL(
   "SELECT d.ATTACHUSERFILE, c.CHUNK_SEQ, SUBSTR(c.CHUNK_TEXT,1,1200), " |
   "ROUND(VECTOR_DISTANCE(c.EMBEDDING, VECTOR_EMBEDDING(MINILM_MODEL USING :1 AS data), COSINE),3) " |
   "FROM PS_VA_RAG_CHUNK c JOIN PS_VA_RAG_DOC d ON d.DOC_ID = c.DOC_ID " |
   "WHERE d.DOC_STATUS = 'I' " |
   "ORDER BY 4 FETCH APPROX FIRST 5 ROWS ONLY", &q);

&html = "<div style='font-family:Segoe UI,Arial,sans-serif;'>";
&html = &html | "<h3>Results for: <span style='color:#1a5276;'>" | &q | "</span></h3>";

&rank = 0;
While &sql.Fetch(&docName, &seq, &chunk, &dist)
   &rank = &rank + 1;
   &esc = Substitute(Substitute(Substitute(&chunk, "&", "&amp;"), "<", "&lt;"), ">", "&gt;");
   &html = &html | "<div style='border:1px solid #d5d8dc; border-left:4px solid #2e86c1; border-radius:4px; padding:8px 12px; margin:8px 0; background:#fbfcfc;'>";
   &html = &html | "<div style='font-size:11px; color:#566573;'><b>#" | &rank | "</b> | " | &docName | " | chunk " | &seq | " | <span style='background:#eaf2f8; padding:1px 6px; border-radius:8px;'>distance " | &dist | "</span></div>";
   &html = &html | "<div style='font-size:13px; color:#1c2833;'>" | &esc | "</div></div>";
End-While;
&sql.Close();
&html = &html | "</div>";

VA_RAG_WRK.ANSWER.Value = &html;

Points worth noticing:

  • PeopleCode never touches a vector. It binds a string and fetches strings and numbers. VECTOR_EMBEDDING() produces the query vector and VECTOR_DISTANCE() consumes it, entirely inside the SQL. The tools layer is blissfully unaware the VECTOR datatype exists — which matters, because App Designer has no such field type and never will at current releases.
  • FETCH APPROX FIRST k engages the HNSW index for approximate nearest-neighbor search — the difference between a graph walk and a full scan as the corpus grows.
  • The HTML-escape line is not cosmetic. Parsed PDF text contains < and &; unescaped, it breaks rendering — and it's an injection surface once other users' documents are searchable.
  • The rich-text ANSWER field renders the styled cards: rank, source document, chunk number, a distance badge, and the text. It looks like a product, not a debug dump.

The landmines (read this section twice)

These cost me real hours and none are documented in a PeopleSoft context:

1. PUM image PDBs ship without Oracle Text — and DBMS_VECTOR_CHAIN doesn't exist without it. The upgrade log shows Oracle Text UPGRADED for CDB$ROOT, but the application PDB never had the CONTEXT component installed (PeopleSoft dropped its Oracle Text dependency when Search Framework moved to Elasticsearch). DBMS_VECTOR_CHAIN is owned by CTXSYS and is created by the Oracle Text installation itself. If you get ORA-04042 trying to grant it, this is why. Fix: @?/ctx/admin/catctx.sql <pwd> SYSAUX TEMP NOLOCK in the PDB, then utlrp, then GRANT EXECUTE ON CTXSYS.DBMS_VECTOR_CHAIN TO SYSADM;

2. Guaranteed restore points block the COMPATIBLE advance. Vector features need COMPATIBLE ≥ 23.4, and leftover AutoUpgrade restore points throw ORA-38880 at mount — after the spfile already says 23, so the database won't even mount for you to drop them. Escape the chicken-and-egg from the NOMOUNT instance: set COMPATIBLE back to 19 in the spfile, mount, drop the restore points, then advance for real. The advance is one-way — snapshot the VM first.

3. The model load needs tablespace headroom. LOAD_ONNX_MODEL writes the ~130MB model into the loading schema's default tablespace — for a PeopleSoft schema that's PSDEFAULT, which on a PUM image is nearly full. ORA-01652 mid-load means grow PSDEFAULT. Consider a dedicated tablespace for the doc/chunk tables while you're there; document BLOBs and vectors add up.

4. AddAttachment's "system filename" parameter is a prefix, not a name. Preset it and PeopleTools appends the user's filename to it (at least in my tools release). If your reassembly join expects an exact match, you'll chase empty BLOBs — my first PIA upload produced a 0-byte reassembly for exactly this reason. Treat it as a unique prefix (I use the doc ID) and resolve the actual stored name with a LIKE lookup after the upload. Bonus: unique prefixes mean re-uploading the same file never collides on VERSION.

5. UTL_TO_TEXT is text extraction, not OCR. Scanned or image-based PDFs return empty text. Guard for it in the pipeline (my package raises ORA-20001 with a clear message into DOC_STATUS='E') and set expectations: image documents need OCR upstream, which is outside the in-database story.

6. Keep the vector objects out of App Designer. App Designer has no VECTOR field type, and an alter-by-recreate build would silently drop a database-added vector column and its index. My approach: the attachment record (VA_RAG_ATT) and the page objects are proper PeopleTools objects; the doc/chunk tables are database-level DDL managed alongside the ONNX model and grants — which all live outside PeopleTools anyway. (An alternative for PS-metadata purists: text columns in a PS record, vectors in a DB-only shadow table joined on the keys.)

Honest limitations

MiniLM — the class of model practical to load in-database today — sits noticeably below the large API embedding models on nuanced language. It handled HR policy text well in my testing; validate against your corpus. You're spending database CPU on AI workload, which your DBAs will rightly scrutinize. Naive chunking mangles tables inside PDFs (retrieval still worked via surrounding prose, but table-aware chunking is a future refinement). And the adoption gate is real: production PeopleSoft databases are overwhelmingly on 19c today, so treat this as R&D and architecture positioning until your 23ai upgrade lands.

Where this goes next

Three extensions, each a follow-up post:

  • Security-trimmed retrieval — the architectural centerpiece, and the real reason to prefer in-database RAG for HR content. Because retrieval is just SQL, the WHERE clause can join to PeopleSoft SJT security tables so chunks a user cannot see are eliminated before vector ranking runs — enforced by the same tables that protect the transactional data. External RAG stacks can only approximate this by replicating security into the index (fragile) or filtering after retrieval (leaky).
  • Generation — the page currently returns ranked source chunks; adding an LLM synthesis step (Integration Broker REST to an approved endpoint, top-k chunks as context) turns search results into cited answers.
  • REST exposure — a JSON_OBJECT/JSON_ARRAYAGG wrapper already returns the full result set as one CLOB, making an IB Provider service operation a five-line handler with %OperatorId injected server-side — retrieval-as-an-API for other assistants and copilots.


Monday, May 11, 2026

PeopleSoft + Invoice AI: Embedded Finance Automation Inside Voucher Entry

 

Most AI demos around ERP systems follow the same pattern.

A user opens a chatbot.
The chatbot asks questions.
The chatbot calls an API.
The chatbot returns an answer.

That is useful, but it still sits outside the real transaction flow.

For Episode 5, I wanted to test a different pattern: what happens when the agent is embedded directly inside the PeopleSoft page where the user is already working?

The result is an AI-powered invoice parser embedded inside the PeopleSoft Finance Voucher Entry page.

The goal is simple: reduce manual invoice data entry while still keeping PeopleSoft as the system of control.


Use Case

In PeopleSoft Finance, voucher entry is still a manual-heavy process in many organizations.

A user may receive an invoice PDF and then manually enter:

  • Supplier name
  • Invoice number
  • Invoice date
  • Gross amount
  • Sales tax
  • Freight
  • Purchase order number

This is not complex work, but it is repetitive. It is also easy to make mistakes, especially when users are switching between a PDF and the PeopleSoft page.

So the idea was:

Upload an invoice PDF directly from the Voucher Entry page, extract the values, and populate the PeopleSoft fields for user review.

Not auto-save.
Not bypass validation.
Not replace PeopleSoft business rules.

Just assist the user at the point of entry.


What I Built

I created a lightweight embedded widget inside PeopleSoft.

The widget appears as a floating assistant button on the PeopleSoft page. When opened, it provides an upload option for an invoice PDF.

But the upload button is not blindly enabled everywhere. The script checks the current PeopleSoft component context and enables the upload only when the user is on the configured Voucher Entry component, VCHR_EXPRESS.

That matters because this is not just a generic browser extension style demo. It is page-aware.

The widget understands where it is running inside PeopleSoft.

High-level flow:

  1. User opens PeopleSoft Voucher Entry.
  2. Embedded widget checks the current component.
  3. User uploads invoice PDF.
  4. PDF is sent to Azure Document Intelligence using the prebuilt invoice model.
  5. Extracted data is normalized.
  6. Supplier name is populated first.
  7. PeopleSoft server-side validation is triggered only for supplier name.
  8. PeopleSoft derives Supplier ID, Short Supplier Name, Location, and Address Sequence.
  9. Invoice fields are populated into the page.
  10. User reviews the values before saving the voucher.

That last step is important. The user remains in control.


Why This Is Different from a Chatbot

This is not a chatbot asking, “Please upload your invoice.”

This is embedded automation inside the PeopleSoft transaction page.

The assistant knows:

  • Which PeopleSoft component is active
  • Which fields exist on the page
  • Which field should trigger PeopleSoft validation
  • Which fields should be populated without server refresh
  • When PeopleSoft has derived supplier details
  • When values need to be reapplied after page refresh behavior

That is the difference between a generic AI assistant and a contextual enterprise assistant.

A chatbot can answer questions.
A page-aware assistant can participate in the transaction flow.


Technical Design

The demo uses JavaScript injected into the PeopleSoft page.

The widget creates:

  • Floating action button
  • Upload panel
  • File input for PDF
  • Status messages
  • Parsed invoice summary
  • Field population logic
  • Page validation logic

The script looks for the PeopleSoft content iframe, reads the current URL, extracts the component name, and checks whether the active component matches VCHR_EXPRESS.

Only then does the upload button become active.

The invoice PDF is converted to base64 and sent to Azure Document Intelligence using the prebuilt-invoice model. The result is polled until the document analysis succeeds.

From the Azure response, the script extracts and normalizes key invoice fields:

  • Vendor Name
  • Invoice ID
  • Invoice Date
  • Due Date
  • Purchase Order
  • Subtotal
  • Total Tax
  • Freight
  • Invoice Total
  • Currency
  • Line items

Then those values are mapped back to PeopleSoft voucher fields.

Example mapping:

vendor_name     -> VCHR_ADDSRCH_VW_NAME1
invoice_number -> VCHR_ADDSRCH_VW_INVOICE_ID
invoice_date -> VCHR_ADDSRCH_VW_INVOICE_DT
gross_amount -> VCHR_ADDSRCH_VW_GROSS_AMT
sales_tax -> VCHR_ADDSRCH_VW_SALETX_AMT
freight -> VCHR_ADDSRCH_VW_FREIGHT_AMT
po_number -> VCHR_ADDSRCH_VW_PO_ID

Key Design Decision: Let PeopleSoft Derive Supplier Details

One of the most important parts of this demo is how supplier handling works.

The script does not directly populate Supplier ID, Short Supplier Name, Supplier Location, or Address Sequence.

Instead, it populates only the supplier name and triggers PeopleSoft’s delivered validation behavior.

That allows PeopleSoft to derive:

  • Supplier ID
  • Supplier short name
  • Supplier location
  • Address sequence

This is the right pattern.

AI should not become the system of record.
AI should assist the user, but PeopleSoft should still apply its own rules.

The assistant extracts a supplier name from the invoice. PeopleSoft decides how that supplier resolves inside the application.

That keeps the design safer and more realistic.


Why I Avoided Server Actions for Amount Fields

Another interesting issue came up during the demo.

When amount and date fields were populated and PeopleSoft events were triggered, some values were reset back to 0.00.

That happens because PeopleSoft pages often have server-side logic, recalculation, defaulting, and refresh behavior tied to field changes.

So I changed the design.

For supplier name, I trigger PeopleSoft submit action because I want delivered supplier validation.

For invoice fields like amount, tax, freight, invoice number, invoice date, and PO number, I populate them DOM-only.

No change.
No blur.
No submitAction.

This avoids unnecessary server-side resets.

The script also reapplies invoice values a few times after short delays to survive PeopleSoft refresh/recalculation behavior.

That may sound small, but this is where real PeopleSoft UI automation gets tricky. The hard part is not extracting invoice values. The hard part is respecting how PeopleSoft pages behave after field changes.


Why This Pattern Matters

This demo shows a practical pattern for PeopleSoft AI integration:

AI extracts or recommends. PeopleSoft validates. User reviews.

That is the balance enterprise systems need.

You do not want AI blindly writing transactional data into ERP pages and saving it.
You also do not want AI sitting completely outside the workflow.

The sweet spot is embedded assistance.

The user stays on the PeopleSoft page.
The assistant reduces manual work.
PeopleSoft keeps control of validation and transaction rules.

That same pattern can apply beyond invoices.

Examples:

  • Expense entry from receipts
  • Supplier onboarding document extraction
  • Employee document classification
  • Benefits form extraction
  • Journal upload assistance
  • Contract metadata extraction
  • Procurement request assistance
  • Page-aware help and validation guidance

The bigger idea is not invoice parsing alone.

The bigger idea is embedding contextual AI inside PeopleSoft transactions.


Demo vs Production

This version is a demo.

The current script stores the Azure Document Intelligence key directly in JavaScript. That is acceptable only for a controlled proof of concept. It should never be used like that in production.

For a production-ready design, I would move the document processing behind a secure backend layer.

A better production flow would be:

  1. PeopleSoft page uploads invoice to a secure backend endpoint.
  2. Backend validates user/session/context.
  3. Backend calls Azure Document Intelligence.
  4. Secrets stay server-side.
  5. Backend returns normalized invoice fields.
  6. PeopleSoft page populates values for review.
  7. PeopleSoft validation and save process remain unchanged.

The backend could be:

  • Azure Function
  • FastAPI service
  • API Management plus backend API
  • PeopleSoft Integration Broker wrapper
  • Internal middleware service

The important part is this: do not expose keys in browser JavaScript.

The demo proves the user experience.
The production version needs proper security.

Code

Make sure you move the url and secret key to IScript.

/* ============================================================

   AI POWERED INVOICE PARSER — PEOPLESOFT FINANCE DEMO


   DEMO ONLY:

   Azure key is stored in JavaScript.


   Final design:

   1. Upload invoice PDF

   2. Parse using Azure Document Intelligence prebuilt-invoice

   3. Populate Supplier Name first

   4. Trigger PeopleSoft submitAction ONLY for Supplier Name

   5. Wait for PeopleSoft to derive Supplier ID / Short Name / Location

   6. Populate invoice fields DOM-only:

      - Invoice Number

      - Invoice Date

      - Gross Invoice Amount

      - Sales Tax Amount

      - Freight Amount

      - PO Number

   7. Reapply invoice fields a few times to survive PeopleSoft refresh/recalc


   IMPORTANT:

   Do NOT trigger input/change/blur/submitAction for amount/date fields.

   PeopleSoft may reset them to 0.00.

   ============================================================ */


(function () {

  var CONFIG = {

    docIntelEndpoint: 'https://xxxxxxxxxxxxx.cognitiveservices.azure.com',

    docIntelKey: ' xxxxxxxxxxxxxxxx',

    apiVersion: '2024-11-30',

    modelId: 'prebuilt-invoice',


    maxPdfBytes: 10 * 1024 * 1024,

    pollIntervalMs: 1200,

    maxPollAttempts: 30,


    supplierResolutionTimeoutMs: 7000,

    supplierResolutionPollMs: 300,


    reapplyDelaysMs: [500, 1500, 3000]

  };


  var INVOICE_UPLOAD_COMPONENTS = ['VCHR_EXPRESS'];


  /*

    Only fields we intentionally write.


    Supplier ID / Short Supplier Name / Supplier Location are NOT written.

    PeopleSoft should derive those from Supplier Name.

  */

  var VOUCHER_FIELD_MAP = {

    vendor_name:     'VCHR_ADDSRCH_VW_NAME1',

    invoice_number:  'VCHR_ADDSRCH_VW_INVOICE_ID',

    invoice_date:    'VCHR_ADDSRCH_VW_INVOICE_DT',

    gross_amount:    'VCHR_ADDSRCH_VW_GROSS_AMT',

    sales_tax:       'VCHR_ADDSRCH_VW_SALETX_AMT',

    freight:         'VCHR_ADDSRCH_VW_FREIGHT_AMT',

    po_number:       'VCHR_ADDSRCH_VW_PO_ID'

  };


  /*

    Read-only check fields.

    Used to confirm PeopleSoft resolved supplier.

  */

  var PS_DERIVED_SUPPLIER_FIELDS = {

    supplier_id:     'VCHR_ADDSRCH_VW_VENDOR_ID',

    vendor_short:    'VCHR_ADDSRCH_VW_VENDOR_NAME_SHORT',

    vendor_location: 'VCHR_ADDSRCH_VW_VNDR_LOC',

    address_seq:     'VCHR_ADDSRCH_VW_ADDR_SEQ_NUM'

  };


  function domReady(fn) {

    if (document.body) {

      fn();

    } else if (document.readyState === 'loading') {

      document.addEventListener('DOMContentLoaded', fn);

    } else {

      setTimeout(fn, 300);

    }

  }


  function initInvoiceParserWidget() {

    if (document.getElementById('invoice-ai-fab')) return;


    if (!document.body) {

      setTimeout(initInvoiceParserWidget, 300);

      return;

    }


    injectStyles();

    buildWidgetShell();

    wireWidgetEvents();


    setTimeout(function () {

      var tooltip = document.getElementById('invoice-ai-tooltip');

      if (tooltip) {

        tooltip.classList.add('iai-show');


        setTimeout(function () {

          tooltip.classList.remove('iai-show');

        }, 3500);

      }

    }, 1800);


    setInterval(refreshUploadButton, 2000);

    refreshUploadButton();

  }


  function injectStyles() {

    var css =

      '#invoice-ai-fab{position:fixed!important;bottom:24px!important;right:24px!important;width:56px!important;height:56px!important;border-radius:50%!important;background:linear-gradient(135deg,#1a4d2e,#2e8b57)!important;border:none!important;cursor:pointer!important;box-shadow:0 4px 16px rgba(26,77,46,.45)!important;z-index:2147483647!important;display:flex!important;align-items:center!important;justify-content:center!important;padding:0!important;transition:transform .2s ease!important;}'

      + '#invoice-ai-fab:hover{transform:scale(1.08)!important;}'

      + '#invoice-ai-badge{position:absolute;top:-2px;right:-2px;width:18px;height:18px;background:#e74c3c;border-radius:50%;font-size:10px;font-weight:700;color:white;display:flex;align-items:center;justify-content:center;border:2px solid white;pointer-events:none;}'

      + '#invoice-ai-tooltip{position:fixed!important;bottom:90px!important;right:24px!important;background:#1a4d2e;color:white;padding:8px 14px;border-radius:20px;font-size:12px;font-weight:500;white-space:nowrap;box-shadow:0 3px 12px rgba(0,0,0,.2);z-index:2147483646!important;opacity:0;transform:translateY(6px);transition:opacity .3s,transform .3s;pointer-events:none;font-family:Segoe UI,sans-serif;}'

      + '#invoice-ai-tooltip.iai-show{opacity:1;transform:translateY(0);}'

      + '#invoice-ai-tooltip:after{content:"";position:absolute;bottom:-5px;right:20px;border-left:5px solid transparent;border-right:5px solid transparent;border-top:5px solid #1a4d2e;}'

      + '#invoice-ai-panel{position:fixed!important;bottom:92px!important;right:24px!important;width:390px!important;height:560px!important;border-radius:16px!important;overflow:hidden!important;box-shadow:0 8px 40px rgba(0,0,0,.22)!important;border:1px solid #d0e0d0!important;display:flex!important;flex-direction:column!important;background:#fff!important;z-index:2147483645!important;transform:scale(.85) translateY(20px);transform-origin:bottom right;opacity:0;pointer-events:none;transition:transform .25s cubic-bezier(.34,1.56,.64,1),opacity .2s ease;}'

      + '#invoice-ai-panel.iai-open{transform:scale(1) translateY(0)!important;opacity:1!important;pointer-events:all!important;}'

      + '#invoice-ai-hdr{background:linear-gradient(135deg,#1a4d2e,#2e8b57);padding:14px 16px;display:flex;align-items:center;gap:10px;flex-shrink:0;}'

      + '#invoice-ai-hdr h3{font-size:13px;font-weight:600;margin:0;color:white;font-family:Segoe UI,sans-serif;}'

      + '#invoice-ai-hdr p{font-size:11px;opacity:.75;margin:2px 0 0;color:white;font-family:Segoe UI,sans-serif;}'

      + '#invoice-ai-hdr-status{margin-left:auto;display:flex;align-items:center;gap:5px;font-size:11px;color:white;opacity:.85;flex-shrink:0;font-family:Segoe UI,sans-serif;}'

      + '#invoice-ai-hdrdot{width:7px;height:7px;background:#4ade80;border-radius:50%;animation:iaiBlink 2s infinite;}'

      + '#invoice-ai-closebtn{background:rgba(255,255,255,.2);border:none;color:white;width:26px;height:26px;border-radius:50%;cursor:pointer;font-size:14px;line-height:26px;text-align:center;flex-shrink:0;margin-left:6px;}'

      + '@keyframes iaiBlink{0%,100%{opacity:1;}50%{opacity:.35;}}'

      + '#invoice-ai-body{flex:1;overflow:auto;position:relative;padding:14px;background:#ffffff;font-family:Segoe UI,sans-serif;}'

      + '#invoice-ai-summary{font-size:13px;color:#333;line-height:1.45;}'

      + '#invoice-ai-summary h4{margin:0 0 8px 0;font-size:14px;color:#1a4d2e;}'

      + '#invoice-ai-summary p{margin:6px 0;}'

      + '#invoice-ai-summary table{width:100%;border-collapse:collapse;margin-top:10px;font-size:12px;}'

      + '#invoice-ai-summary th,#invoice-ai-summary td{border-bottom:1px solid #e5e5e5;text-align:left;padding:6px 4px;vertical-align:top;}'

      + '#invoice-ai-summary th{color:#1a4d2e;font-weight:600;}'

      + '#invoice-ai-summary code{background:#f4f4f4;padding:2px 4px;border-radius:4px;}'

      + '#invoice-ai-action-bar{display:flex;align-items:center;gap:8px;padding:8px 12px;background:#f8faf8;border-top:1px solid #e0e8e0;flex-shrink:0;}'

      + '#invoice-ai-upload-btn{width:38px;height:38px;border-radius:50%;background:#2e8b57;border:2px solid transparent;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:background .2s;}'

      + '#invoice-ai-upload-btn:hover{background:#1a4d2e;}'

      + '#invoice-ai-upload-btn[disabled]{background:#aaa;cursor:not-allowed;}'

      + '#invoice-ai-upload-btn:focus{outline:3px solid #1a4d2e;outline-offset:3px;}'

      + '#invoice-ai-status{font-size:11px;color:#555;font-family:Segoe UI,sans-serif;flex:1;}'

      + '#invoice-ai-status.iai-uploading{color:#1a4d2e;font-weight:600;}'

      + '#invoice-ai-status.iai-success{color:#1a7f3e;font-weight:600;}'

      + '#invoice-ai-status.iai-error{color:#c0392b;font-weight:600;}'

      + '#invoice-ai-toast{position:fixed;top:20px;left:50%;transform:translateX(-50%);background:#1a4d2e;color:white;padding:12px 20px;border-radius:8px;font-size:13px;font-family:Segoe UI,sans-serif;box-shadow:0 4px 16px rgba(0,0,0,.2);z-index:2147483647;display:none;}'

      + '#invoice-ai-toast.iai-show{display:block;animation:iaiToastIn .3s ease;}'

      + '@keyframes iaiToastIn{from{opacity:0;transform:translateX(-50%) translateY(-10px);}to{opacity:1;transform:translateX(-50%) translateY(0);}}';


    var style = document.createElement('style');

    style.id = 'invoice-ai-styles';

    style.textContent = css;

    document.head.appendChild(style);

  }


  function buildWidgetShell() {

    var tooltip = document.createElement('div');

    tooltip.id = 'invoice-ai-tooltip';

    tooltip.textContent = 'AI Powered Invoice Parser';

    document.body.appendChild(tooltip);


    var fab = document.createElement('button');

    fab.id = 'invoice-ai-fab';

    fab.title = 'AI Powered Invoice Parser';

    fab.setAttribute('aria-label', 'Open AI Powered Invoice Parser');

    fab.setAttribute('aria-expanded', 'false');


    fab.innerHTML =

      '<span id="invoice-ai-badge">AI</span>'

      + '<svg id="iai-ic-doc" width="26" height="26" viewBox="0 0 24 24" fill="none">'

      + '<path d="M7 3h7l5 5v13H7V3z" stroke="white" stroke-width="2" stroke-linejoin="round"/>'

      + '<path d="M14 3v5h5" stroke="white" stroke-width="2" stroke-linejoin="round"/>'

      + '<path d="M9 13h6M9 17h6" stroke="white" stroke-width="2" stroke-linecap="round"/>'

      + '</svg>'

      + '<svg id="iai-ic-close" width="26" height="26" viewBox="0 0 24 24" fill="none" style="display:none">'

      + '<path d="M18 6L6 18M6 6L18 18" stroke="white" stroke-width="2.5" stroke-linecap="round"/>'

      + '</svg>';


    document.body.appendChild(fab);


    var panel = document.createElement('div');

    panel.id = 'invoice-ai-panel';

    panel.setAttribute('role', 'dialog');

    panel.setAttribute('aria-modal', 'true');


    panel.innerHTML =

      '<div id="invoice-ai-hdr">'

      + '<div style="width:34px;height:34px;background:rgba(255,255,255,.18);border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:16px;flex-shrink:0" aria-hidden="true">&#128196;</div>'

      + '<div><h3>AI Powered Invoice Parser</h3><p>Upload invoice PDF and populate voucher fields</p></div>'

      + '<div id="invoice-ai-hdr-status" aria-live="polite"><div id="invoice-ai-hdrdot" aria-hidden="true"></div><span>Demo</span></div>'

      + '<button id="invoice-ai-closebtn" aria-label="Close">&#x2715;</button>'

      + '</div>'

      + '<div id="invoice-ai-body">'

      + '<div id="invoice-ai-summary">'

      + '<h4>Upload invoice PDF</h4>'

      + '<p>This will extract invoice values and populate the Voucher page fields.</p>'

      + '<p><strong>Page check:</strong> <span id="invoice-ai-page-check">Checking...</span></p>'

      + '<p><strong>Expected page:</strong> Voucher Entry / <code>VCHR_EXPRESS</code></p>'

      + '<p style="color:#c0392b"><strong>Review all values before saving the voucher.</strong></p>'

      + '</div>'

      + '</div>'

      + '<div id="invoice-ai-action-bar">'

      + '<button id="invoice-ai-upload-btn" aria-label="Upload invoice PDF" title="Upload invoice PDF" disabled>'

      + '<svg width="17" height="17" viewBox="0 0 24 24" fill="none" aria-hidden="true">'

      + '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>'

      + '</svg></button>'

      + '<input type="file" id="invoice-ai-file-input" accept="application/pdf,.pdf" style="display:none">'

      + '<span id="invoice-ai-status" aria-live="polite">Open Voucher Entry, then upload invoice</span>'

      + '</div>';


    document.body.appendChild(panel);


    var toast = document.createElement('div');

    toast.id = 'invoice-ai-toast';

    toast.setAttribute('role', 'status');

    document.body.appendChild(toast);

  }


  function wireWidgetEvents() {

    var fab = document.getElementById('invoice-ai-fab');

    var panel = document.getElementById('invoice-ai-panel');

    var closeBtn = document.getElementById('invoice-ai-closebtn');

    var uploadBtn = document.getElementById('invoice-ai-upload-btn');

    var fileInput = document.getElementById('invoice-ai-file-input');


    var isOpen = false;


    function openPanel() {

      isOpen = true;

      panel.classList.add('iai-open');

      fab.setAttribute('aria-expanded', 'true');


      document.getElementById('iai-ic-doc').style.display = 'none';

      document.getElementById('iai-ic-close').style.display = 'block';


      var tooltip = document.getElementById('invoice-ai-tooltip');

      if (tooltip) tooltip.classList.remove('iai-show');


      var badge = document.getElementById('invoice-ai-badge');

      if (badge) badge.remove();


      refreshUploadButton();

    }


    function closePanel() {

      isOpen = false;

      panel.classList.remove('iai-open');

      fab.setAttribute('aria-expanded', 'false');


      document.getElementById('iai-ic-doc').style.display = 'block';

      document.getElementById('iai-ic-close').style.display = 'none';


      fab.focus();

    }


    closeBtn.addEventListener('click', function (e) {

      e.stopPropagation();

      closePanel();

    });


    fab.addEventListener('click', function (e) {

      e.stopPropagation();

      isOpen ? closePanel() : openPanel();

    });


    document.addEventListener('click', function (e) {

      if (isOpen && !panel.contains(e.target) && e.target !== fab) {

        closePanel();

      }

    });


    document.addEventListener('keydown', function (e) {

      if (isOpen && (e.key === 'Escape' || e.keyCode === 27)) {

        closePanel();

      }

    });


    uploadBtn.addEventListener('click', function () {

      if (uploadBtn.disabled) return;


      fileInput.value = '';

      fileInput.click();

    });


    fileInput.addEventListener('change', function (e) {

      var file = e.target.files && e.target.files[0];


      if (file) {

        handleInvoiceFile(file);

      }

    });

  }


  function refreshUploadButton() {

    var btn = document.getElementById('invoice-ai-upload-btn');

    var pageCheck = document.getElementById('invoice-ai-page-check');


    if (!btn) return;


    var pg = getPageContext();

    var ok = isInvoiceUploadPage();


    btn.disabled = !ok;

    btn.title = ok ? 'Upload invoice PDF' : 'Open Voucher Entry to upload';

    btn.setAttribute('aria-label', btn.title);


    if (pageCheck) {

      pageCheck.textContent = ok

        ? 'Ready on ' + (pg.component || 'Voucher page')

        : 'Not on Voucher page. Current component: ' + (pg.component || 'unknown');


      pageCheck.style.color = ok ? '#1a7f3e' : '#c0392b';

    }


    if (!ok) {

      setStatus('Open Voucher Entry, then upload invoice');

    }

  }


  function isInvoiceUploadPage() {

    var pg = getPageContext();


    for (var i = 0; i < INVOICE_UPLOAD_COMPONENTS.length; i++) {

      if (pg.component === INVOICE_UPLOAD_COMPONENTS[i]) {

        return true;

      }

    }


    return false;

  }


  function handleInvoiceFile(file) {

    if (file.type !== 'application/pdf' && !/\.pdf$/i.test(file.name)) {

      setStatus('Only PDF files are supported', 'iai-error');

      showToast('Only PDF files are supported');

      return;

    }


    if (file.size > CONFIG.maxPdfBytes) {

      setStatus('PDF too large', 'iai-error');

      showToast('PDF too large. Max allowed: ' + Math.round(CONFIG.maxPdfBytes / 1024 / 1024) + 'MB');

      return;

    }


    if (!CONFIG.docIntelKey || CONFIG.docIntelKey === 'PASTE_KEY_1_HERE') {

      setStatus('Azure key missing in CONFIG', 'iai-error');

      showToast('Add Azure key in CONFIG.docIntelKey');

      return;

    }


    if (!isInvoiceUploadPage()) {

      setStatus('Open Voucher Entry first', 'iai-error');

      showToast('Not on Voucher page');

      return;

    }


    setStatus('Reading PDF...', 'iai-uploading');

    renderWorking('Reading PDF and preparing request...');


    fileToBase64(file)

      .then(function (base64Pdf) {

        setStatus('Analyzing invoice...', 'iai-uploading');

        renderWorking('Analyzing invoice using AI powered document extraction...');

        return analyzeInvoiceWithAzure(base64Pdf);

      })

      .then(function (azureResult) {

        setStatus('Normalizing extracted invoice data...', 'iai-uploading');


        var normalized = normalizeAzureInvoiceResult(azureResult);


        console.log('[InvoiceAI] Normalized invoice:', normalized);

        console.log('[InvoiceAI] Raw extraction result:', azureResult);


        renderSummary(normalized);

        populateVoucherFields(normalized.voucherFields);

      })

      .catch(function (err) {

        console.error('[InvoiceAI] Error:', err);

        setStatus('Invoice parsing failed', 'iai-error');

        renderError(err);

        showToast(err.message);

      });

  }


  function fileToBase64(file) {

    return new Promise(function (resolve, reject) {

      var reader = new FileReader();


      reader.onload = function () {

        var dataUrl = String(reader.result || '');

        var parts = dataUrl.split(',');


        if (parts.length < 2) {

          reject(new Error('Could not read PDF as base64'));

          return;

        }


        resolve(parts[1]);

      };


      reader.onerror = function () {

        reject(new Error('Could not read file'));

      };


      reader.readAsDataURL(file);

    });

  }


  function analyzeInvoiceWithAzure(base64Pdf) {

    var endpoint = removeTrailingSlash(CONFIG.docIntelEndpoint);


    var analyzeUrl =

      endpoint

      + '/documentintelligence/documentModels/'

      + encodeURIComponent(CONFIG.modelId)

      + ':analyze?_overload=analyzeDocument&api-version='

      + encodeURIComponent(CONFIG.apiVersion);


    return fetch(analyzeUrl, {

      method: 'POST',

      headers: {

        'Content-Type': 'application/json',

        'Ocp-Apim-Subscription-Key': CONFIG.docIntelKey

      },

      body: JSON.stringify({

        base64Source: base64Pdf

      })

    })

    .then(function (response) {

      if (response.status !== 202) {

        return response.text().then(function (text) {

          throw new Error('Analyze failed. HTTP ' + response.status + ': ' + safeShortText(text));

        });

      }


      var operationLocation =

        response.headers.get('Operation-Location')

        || response.headers.get('operation-location');


      if (!operationLocation) {

        throw new Error('No Operation-Location header returned');

      }


      return pollAzureResult(operationLocation, 0);

    });

  }


  function pollAzureResult(operationLocation, attempt) {

    if (attempt >= CONFIG.maxPollAttempts) {

      throw new Error('Analysis timed out. Try again with a smaller or clearer PDF.');

    }


    return delay(CONFIG.pollIntervalMs)

      .then(function () {

        setStatus('Waiting for result... attempt ' + (attempt + 1), 'iai-uploading');


        return fetch(operationLocation, {

          method: 'GET',

          headers: {

            'Ocp-Apim-Subscription-Key': CONFIG.docIntelKey

          }

        });

      })

      .then(function (response) {

        if (!response.ok) {

          return response.text().then(function (text) {

            throw new Error('Result polling failed. HTTP ' + response.status + ': ' + safeShortText(text));

          });

        }


        return response.json();

      })

      .then(function (result) {

        var status = String(result.status || '').toLowerCase();


        if (status === 'succeeded') {

          return result;

        }


        if (status === 'failed') {

          var msg = 'Invoice analysis failed';


          if (result.error && result.error.message) {

            msg += ': ' + result.error.message;

          }


          throw new Error(msg);

        }


        return pollAzureResult(operationLocation, attempt + 1);

      });

  }


  function normalizeAzureInvoiceResult(result) {

    var analyzeResult = result.analyzeResult || {};

    var documents = analyzeResult.documents || [];

    var doc = documents.length ? documents[0] : {};

    var fields = doc.fields || {};


    function getField(names) {

      for (var i = 0; i < names.length; i++) {

        if (fields[names[i]]) return fields[names[i]];

      }


      return null;

    }


    function fieldValue(names) {

      var f = getField(names);


      if (!f) return '';


      if (f.valueString !== undefined) return f.valueString;

      if (f.valueDate !== undefined) return f.valueDate;

      if (f.valueTime !== undefined) return f.valueTime;

      if (f.valuePhoneNumber !== undefined) return f.valuePhoneNumber;

      if (f.valueNumber !== undefined) return String(f.valueNumber);

      if (f.valueInteger !== undefined) return String(f.valueInteger);


      if (f.valueCurrency !== undefined) {

        if (f.valueCurrency.amount !== undefined) return String(f.valueCurrency.amount);

        if (f.content !== undefined) return cleanAmount(f.content);

      }


      if (f.content !== undefined) return f.content;


      return '';

    }


    function fieldConfidence(names) {

      var f = getField(names);


      if (!f || f.confidence === undefined || f.confidence === null) {

        return null;

      }


      return f.confidence;

    }


    var vendorName = fieldValue(['VendorName', 'MerchantName', 'SupplierName']);

    var vendorAddress = fieldValue(['VendorAddress']);


    var invoiceNumber = fieldValue(['InvoiceId', 'InvoiceNumber']);

    var invoiceDate = fieldValue(['InvoiceDate']);

    var dueDate = fieldValue(['DueDate']);

    var purchaseOrder = fieldValue(['PurchaseOrder', 'PurchaseOrderNumber']);

    var subTotal = fieldValue(['SubTotal']);

    var totalTax = fieldValue(['TotalTax', 'Tax']);

    var freight = fieldValue(['Freight', 'Shipping', 'ShippingAmount']);

    var invoiceTotal = fieldValue(['InvoiceTotal', 'AmountDue', 'Total']);

    var amountDue = fieldValue(['AmountDue']);

    var currency = inferCurrency(fields, invoiceTotal);


    var voucherFields = {

      vendor_name: vendorName,

      invoice_number: invoiceNumber,

      invoice_date: invoiceDate,

      gross_amount: cleanAmount(invoiceTotal || amountDue || subTotal),

      sales_tax: cleanAmount(totalTax),

      freight: cleanAmount(freight),

      po_number: purchaseOrder

    };


    var lineItems = extractLineItems(fields);


    var confidenceValues = [

      fieldConfidence(['VendorName', 'MerchantName', 'SupplierName']),

      fieldConfidence(['InvoiceId', 'InvoiceNumber']),

      fieldConfidence(['InvoiceDate']),

      fieldConfidence(['InvoiceTotal', 'AmountDue', 'Total']),

      fieldConfidence(['TotalTax', 'Tax'])

    ].filter(function (v) {

      return typeof v === 'number';

    });


    var avgConfidence = null;


    if (confidenceValues.length) {

      avgConfidence = confidenceValues.reduce(function (a, b) {

        return a + b;

      }, 0) / confidenceValues.length;

    }


    return {

      status: 'success',

      confidence: avgConfidence,

      voucherFields: voucherFields,

      rawExtracted: {

        vendorName: vendorName,

        vendorAddress: vendorAddress,

        invoiceNumber: invoiceNumber,

        invoiceDate: invoiceDate,

        dueDate: dueDate,

        purchaseOrder: purchaseOrder,

        subTotal: cleanAmount(subTotal),

        totalTax: cleanAmount(totalTax),

        freight: cleanAmount(freight),

        invoiceTotal: cleanAmount(invoiceTotal),

        amountDue: cleanAmount(amountDue),

        currency: currency

      },

      lineItems: lineItems

    };

  }


  function extractLineItems(fields) {

    var itemsField = fields.Items || fields.LineItems;

    var items = [];


    if (!itemsField || !itemsField.valueArray) {

      return items;

    }


    itemsField.valueArray.forEach(function (item, idx) {

      var obj = item.valueObject || {};


      function itemVal(possibleNames) {

        for (var i = 0; i < possibleNames.length; i++) {

          var f = obj[possibleNames[i]];

          if (!f) continue;


          if (f.valueString !== undefined) return f.valueString;

          if (f.valueNumber !== undefined) return String(f.valueNumber);

          if (f.valueInteger !== undefined) return String(f.valueInteger);

          if (f.valueCurrency && f.valueCurrency.amount !== undefined) return String(f.valueCurrency.amount);

          if (f.content !== undefined) return f.content;

        }


        return '';

      }


      items.push({

        line: idx + 1,

        description: itemVal(['Description', 'ProductCode', 'Item']),

        quantity: itemVal(['Quantity']),

        unitPrice: cleanAmount(itemVal(['UnitPrice', 'Price'])),

        amount: cleanAmount(itemVal(['Amount', 'TotalPrice']))

      });

    });


    return items;

  }


  /*

    Core field population logic.

  */

  function populateVoucherFields(fields) {

    var cf = getContentFrame();


    if (!cf || !cf.document) {

      setStatus('Could not access voucher page', 'iai-error');

      showToast('Voucher page not loaded in iframe');

      return;

    }


    var doc = cf.document;

    var supplierName = fields.vendor_name || '';

    var supplierEl = doc.getElementById(VOUCHER_FIELD_MAP.vendor_name);


    if (!supplierName || !supplierEl) {

      setStatus('Supplier not detected. Populating invoice fields only.', 'iai-uploading');


      setInvoiceFieldsDomOnly(fields);

      scheduleInvoiceFieldReapply(fields);


      setStatus('Invoice fields populated. Review before saving.', 'iai-success');

      showToast('Invoice fields populated. Review before saving.');

      return;

    }


    var existingSupplierName = getElementValue(supplierEl);

    var alreadySameSupplier = normalizeText(existingSupplierName) === normalizeText(supplierName);

    var alreadyResolved = isSupplierResolved();


    if (alreadySameSupplier && alreadyResolved) {

      console.log('[InvoiceAI] Supplier already resolved. Skipping supplier submitAction.');


      setStatus('Supplier already resolved. Populating invoice fields...', 'iai-uploading');


      setInvoiceFieldsDomOnly(fields);

      scheduleInvoiceFieldReapply(fields);


      setStatus('Invoice fields populated. Review before saving.', 'iai-success');

      showToast('Invoice fields populated. Review before saving.');

      return;

    }


    setStatus('Populating supplier and waiting for PeopleSoft validation...', 'iai-uploading');


    setDomValueOnly(cf, supplierEl, supplierName);

    triggerSupplierSubmitActionOnly(cf, supplierEl);


    waitForSupplierResolution()

      .then(function () {

        setStatus('Supplier resolved. Populating invoice fields...', 'iai-uploading');


        setInvoiceFieldsDomOnly(fields);

        scheduleInvoiceFieldReapply(fields);


        setStatus('Invoice fields populated. Review before saving.', 'iai-success');

        showToast('Invoice fields populated. Review before saving.');

      })

      .catch(function (err) {

        console.warn('[InvoiceAI] Supplier wait ended:', err.message);


        /*

          Still populate invoice fields for demo.

          Supplier may have resolved visually even if polling missed it.

        */

        setInvoiceFieldsDomOnly(fields);

        scheduleInvoiceFieldReapply(fields);


        setStatus('Invoice fields populated. Review supplier and amounts.', 'iai-success');

        showToast('Invoice fields populated. Review supplier and amounts.');

      });

  }


  /*

    Supplier Name:

    One PeopleSoft server action only.

    No duplicate onchange/onblur calls.

  */

  function triggerSupplierSubmitActionOnly(frameWindow, element) {

    try {

      if (typeof frameWindow.submitAction_win0 === 'function' && element.form && element.id) {

        frameWindow.submitAction_win0(element.form, element.id);

        console.log('[InvoiceAI] submitAction_win0 fired for supplier:', element.id);

        return;

      }


      /*

        Fallback only if submitAction_win0 does not exist.

        Avoid this path if possible.

      */

      element.focus();


      element.dispatchEvent(new frameWindow.Event('change', {

        bubbles: true,

        cancelable: true

      }));


      element.dispatchEvent(new frameWindow.Event('blur', {

        bubbles: true,

        cancelable: true

      }));


      console.log('[InvoiceAI] Fallback change/blur fired for supplier:', element.id);

    } catch (e) {

      console.warn('[InvoiceAI] Supplier submit action failed:', e);

    }

  }


  /*

    Invoice fields:

    DOM value only.

    No input/change/blur/onchange/onblur/submitAction.

  */

  function setInvoiceFieldsDomOnly(fields) {

    var cf = getContentFrame();


    if (!cf || !cf.document) {

      setStatus('Could not access voucher page', 'iai-error');

      showToast('Voucher page not loaded in iframe');

      return;

    }


    var doc = cf.document;

    var populated = 0;

    var skipped = [];


    Object.keys(fields).forEach(function (key) {

      if (key === 'vendor_name') return;


      var psFieldId = VOUCHER_FIELD_MAP[key];


      if (!psFieldId) {

        skipped.push(key + ' no mapping');

        return;

      }


      var el = doc.getElementById(psFieldId);


      if (!el) {

        skipped.push(key + ' field not found: ' + psFieldId);

        return;

      }


      var value = normalizeVoucherFieldValue(key, fields[key]);


      if (value === null || value === undefined || value === '') {

        return;

      }


      setDomValueOnly(cf, el, value);


      populated++;

      console.log('[InvoiceAI] DOM-only populated', psFieldId, '=', value);

    });


    if (skipped.length) {

      console.warn('[InvoiceAI] Skipped fields:', skipped);

    }


    console.log('[InvoiceAI] DOM-only field count:', populated);

  }


  /*

    Reapply values after PeopleSoft delayed refresh/recalculation.

    This is the key fix for amounts resetting to 0.00.

  */

  function scheduleInvoiceFieldReapply(fields) {

    CONFIG.reapplyDelaysMs.forEach(function (ms) {

      setTimeout(function () {

        console.log('[InvoiceAI] Reapplying invoice fields after', ms, 'ms');

        setInvoiceFieldsDomOnly(fields);

      }, ms);

    });

  }


  function waitForSupplierResolution() {

    var startTime = Date.now();


    return new Promise(function (resolve, reject) {

      function check() {

        if (isSupplierResolved()) {

          resolve(true);

          return;

        }


        if (Date.now() - startTime >= CONFIG.supplierResolutionTimeoutMs) {

          reject(new Error('Supplier resolution timeout'));

          return;

        }


        setTimeout(check, CONFIG.supplierResolutionPollMs);

      }


      check();

    });

  }


  function isSupplierResolved() {

    var cf = getContentFrame();


    if (!cf || !cf.document) {

      return false;

    }


    var doc = cf.document;

    var supplierIdEl = doc.getElementById(PS_DERIVED_SUPPLIER_FIELDS.supplier_id);

    var shortNameEl = doc.getElementById(PS_DERIVED_SUPPLIER_FIELDS.vendor_short);

    var locEl = doc.getElementById(PS_DERIVED_SUPPLIER_FIELDS.vendor_location);


    var supplierId = supplierIdEl ? getElementValue(supplierIdEl) : '';

    var shortName = shortNameEl ? getElementValue(shortNameEl) : '';

    var loc = locEl ? getElementValue(locEl) : '';


    return !!(supplierId || shortName || loc);

  }


  function setDomValueOnly(frameWindow, element, value) {

    var tag = String(element.tagName || '').toUpperCase();

    var proto;


    if (tag === 'TEXTAREA') {

      proto = frameWindow.HTMLTextAreaElement.prototype;

    } else if (tag === 'SELECT') {

      proto = frameWindow.HTMLSelectElement.prototype;

    } else {

      proto = frameWindow.HTMLInputElement.prototype;

    }


    var descriptor = Object.getOwnPropertyDescriptor(proto, 'value');


    if (descriptor && descriptor.set) {

      descriptor.set.call(element, value);

    } else {

      element.value = value;

    }

  }


  function normalizeVoucherFieldValue(key, value) {

    if (value === null || value === undefined) return '';


    var s = String(value).trim();


    if (!s) return '';


    if (key === 'invoice_date' && /^\d{4}-\d{2}-\d{2}/.test(s)) {

      var parts = s.substring(0, 10).split('-');

      return parts[1] + '/' + parts[2] + '/' + parts[0];

    }


    if (key === 'gross_amount' || key === 'sales_tax' || key === 'freight') {

      return cleanAmount(s);

    }


    return s;

  }


  function getElementValue(el) {

    if (!el) return '';

    return String(el.value || el.getAttribute('value') || '').trim();

  }


  function normalizeText(value) {

    return String(value || '')

      .trim()

      .replace(/\s+/g, ' ')

      .toUpperCase();

  }


  function renderWorking(message) {

    var body = document.getElementById('invoice-ai-summary');


    if (!body) return;


    body.innerHTML =

      '<h4>Processing invoice...</h4>'

      + '<p>' + escapeHtml(message) + '</p>'

      + '<p>Please wait. The invoice parser may take a few seconds.</p>';

  }


  function renderError(err) {

    var body = document.getElementById('invoice-ai-summary');


    if (!body) return;


    body.innerHTML =

      '<h4 style="color:#c0392b">Invoice parsing failed</h4>'

      + '<p>' + escapeHtml(err.message || String(err)) + '</p>'

      + '<p>Check the key, endpoint, CORS settings, PDF format, and browser console.</p>';

  }


  function renderSummary(normalized) {

    var body = document.getElementById('invoice-ai-summary');


    if (!body) return;


    var v = normalized.voucherFields || {};

    var raw = normalized.rawExtracted || {};


    var confidenceText = normalized.confidence === null || normalized.confidence === undefined

      ? 'Not available'

      : Math.round(normalized.confidence * 100) + '%';


    var lineItemsHtml = '';


    if (normalized.lineItems && normalized.lineItems.length) {

      lineItemsHtml =

        '<h4 style="margin-top:14px">Line Items</h4>'

        + '<table>'

        + '<thead><tr><th>#</th><th>Description</th><th>Qty</th><th>Amount</th></tr></thead>'

        + '<tbody>'

        + normalized.lineItems.slice(0, 5).map(function (item) {

          return '<tr>'

            + '<td>' + escapeHtml(item.line) + '</td>'

            + '<td>' + escapeHtml(item.description || '') + '</td>'

            + '<td>' + escapeHtml(item.quantity || '') + '</td>'

            + '<td>' + escapeHtml(item.amount || '') + '</td>'

            + '</tr>';

        }).join('')

        + '</tbody></table>';

    }


    body.innerHTML =

      '<h4>Invoice processed</h4>'

      + '<p><strong>Supplier Name:</strong> ' + escapeHtml(v.vendor_name || 'Not detected') + '</p>'

      + '<p><strong>Invoice #:</strong> ' + escapeHtml(v.invoice_number || 'Not detected') + '</p>'

      + '<p><strong>Invoice Date:</strong> ' + escapeHtml(v.invoice_date || 'Not detected') + '</p>'

      + '<p><strong>Due Date:</strong> ' + escapeHtml(raw.dueDate || 'Not detected') + '</p>'

      + '<p><strong>Total:</strong> ' + escapeHtml(raw.currency || '') + ' ' + escapeHtml(v.gross_amount || 'Not detected') + '</p>'

      + '<p><strong>Tax:</strong> ' + escapeHtml(v.sales_tax || 'Not detected') + '</p>'

      + '<p><strong>Freight:</strong> ' + escapeHtml(v.freight || 'Not detected') + '</p>'

      + '<p><strong>PO:</strong> ' + escapeHtml(v.po_number || 'Not detected') + '</p>'

      + '<p><strong>Avg Confidence:</strong> ' + escapeHtml(confidenceText) + '</p>'

      + '<p style="color:#c0392b"><strong>Review before saving voucher.</strong></p>'

      + lineItemsHtml;

  }


  function inferCurrency(fields, totalText) {

    var currencyFields = ['InvoiceTotal', 'AmountDue', 'SubTotal', 'TotalTax'];


    for (var i = 0; i < currencyFields.length; i++) {

      var f = fields[currencyFields[i]];


      if (f && f.valueCurrency && f.valueCurrency.currencyCode) {

        return f.valueCurrency.currencyCode;

      }

    }


    var text = String(totalText || '');


    if (text.indexOf('$') >= 0) return 'USD';

    if (text.indexOf('₹') >= 0) return 'INR';

    if (text.indexOf('€') >= 0) return 'EUR';

    if (text.indexOf('£') >= 0) return 'GBP';


    return '';

  }


  function getPageContext() {

    var ctx = {

      component: '',

      menu: '',

      portal: '',

      node: '',

      pageTitle: '',

      href: ''

    };


    var cf = getContentFrame();


    try {

      ctx.href = cf ? cf.location.href : window.location.href;


      var m = ctx.href.match(/\/ps[cp][^/]*\/[^/]+\/([^/]+)\/([^/]+)\/[ch]\/([^?#/]+)/i);


      if (m) {

        ctx.portal = m[1];

        ctx.node = m[2];


        var parts = m[3].split('.');

        ctx.menu = parts[0] || '';

        ctx.component = parts[1] || '';

      }

    } catch (e) {}


    try {

      if (cf) {

        ctx.pageTitle = (cf.szCrefLabel || cf.szPinCrefLabel || '').trim();


        if (!ctx.pageTitle && cf.document) {

          ctx.pageTitle = cf.document.title || '';

        }

      }

    } catch (e2) {}


    return ctx;

  }


  function getContentFrame() {

    var iframeEl =

      document.getElementById('ptifrmtgtframe')

      || document.getElementById('ptifrmcontent')

      || document.querySelector('iframe[name="TargetContent"]')

      || document.querySelector('iframe[id*="tgtframe"]')

      || document.querySelector('iframe[id*="content"]');


    if (iframeEl) {

      try {

        return iframeEl.contentWindow;

      } catch (e) {}

    }


    return null;

  }


  function setStatus(msg, cls) {

    var el = document.getElementById('invoice-ai-status');


    if (!el) return;


    el.textContent = msg;

    el.className = cls || '';

  }


  function showToast(msg) {

    var toast = document.getElementById('invoice-ai-toast');


    if (!toast) return;


    toast.textContent = msg;

    toast.classList.add('iai-show');


    setTimeout(function () {

      toast.classList.remove('iai-show');

    }, 3500);

  }


  function cleanAmount(value) {

    if (value === null || value === undefined) return '';


    var s = String(value).trim();


    if (!s) return '';


    s = s.replace(/[,$₹€£\s]/g, '');


    var negative = false;


    if (/^\(.*\)$/.test(s)) {

      negative = true;

      s = s.replace(/[()]/g, '');

    }


    s = s.replace(/[^0-9.\-]/g, '');


    if (!s) return '';


    return negative ? '-' + s : s;

  }


  function removeTrailingSlash(s) {

    return String(s || '').replace(/\/+$/, '');

  }


  function delay(ms) {

    return new Promise(function (resolve) {

      setTimeout(resolve, ms);

    });

  }


  function safeShortText(text) {

    text = String(text || '');

    return text.length > 500 ? text.substring(0, 500) + '...' : text;

  }


  function escapeHtml(value) {

    return String(value === null || value === undefined ? '' : value)

      .replace(/&/g, '&amp;')

      .replace(/</g, '&lt;')

      .replace(/>/g, '&gt;')

      .replace(/"/g, '&quot;')

      .replace(/'/g, '&#039;');

  }


  domReady(function () {

    initInvoiceParserWidget();

  });

})();

PeopleSoft RAG Without a Vector Database: Building Native Semantic Retrieval Inside Oracle AI Database

PeopleSoft RAG Without a Vector Database: Building Native Semantic Retrieval Inside Oracle AI Database Upload a PDF on a PeopleSoft page, ...