A
AlJeel AP Pipeline · v15.12 Hybrid Flow
Production Hybrid LLM

How the pipeline processes a batch

Per-row resolution flow for Jawal travel invoices. Click any stage to expand details or leave a comment.
Version
v15.12
J26-640 Golden
100.0%
J26-593 Blind
73.1%
Sponsorships
45.9%
vs 0% cascade-alone
Cost / batch
~$0.40
Gemini 3 Pro
Stage 0 — Provisioning
One-command setup before processing. Reads from the AlJeel KB upload (SharePoint sync).
0

Auto-provision batch folder from KB

scripts/provision_jawal_batch.py · reads /mnt/aljeel-ap_kb/current/<BATCH>/

provision_jawal_batch.py 0

Reads the Aljeel-prepared workbook (J26-XXX.xlsx) and the Jawal invoice from the KB volume. Builds:

  • batches/jawal-J26-XXX/ — working dir
  • Spreadsheet.xlsx — Oracle Fusion template populated with description, ticket #, amount, date (NO emp_no or GL combo)
  • raw/ — symlink to the day-folder root with all .msg + OPEX PDFs
  • Copy of Master Data (Manpower + lookups)

After this stage the pipeline can run blind — pipeline never sees Aljeel's final coding.

Stage 1 — Deterministic Cascade (v15.11.2)
13 layers tried in order per row. First confident hit wins; if all miss, the row is unresolved and the hybrid router (Stage 2) re-routes it to the LLM agent.
1

Run process_batch.py — full cascade

PROCESS/run_jawal_batch.sh · produces FILLED-v15.11.2.xlsx

deterministic 0

For every row, the cascade walks layers in order. Each layer either returns a confident (emp_no, cost_center, GL combo) and the cascade stops, or returns None and the next layer runs.

L0Direct emp_no from input

What: reads column P of the input spreadsheet. If Aljeel already wrote an emp_no, trust it.

When: only when Aljeel pre-filled — typically <10% on a new batch, ~98% on J26-788 where everything was hand-coded.

Conf: 1.0 · trace = L0→direct emp_no_col=NNNNNNN

L1Form Emp No (Oracle Fusion)

What: parses any attached Oracle Fusion approval form for the "Person Number" field.

When: ticket folder has an Oracle Fusion-generated PDF form with Person Number filled in.

Conf: 1.0 if Person Number matches Manpower roster.

L1.5Email master deterministic

What: extracts the employee's @aljeel.com email from the .msg approval chain (skipping Sanad + system addresses). Looks up in the 677-row email master.

When: after L0/L1 miss. Most reliable on rows where the employee is the original requester in the forwarded chain.

Conf: 1.0 (Email column lookup) · 0.95 (learned cache fallback)

L2.msg filename regex

What: Aljeel ERP names approval emails like Personal Contribution Approval Requested for Hussein Saleh (1002066) on 2026-04-27. Regex captures the (NNNNNNN) emp_no.

When: ticket folder has at least one ERP-formatted .msg filename.

Conf: 0.95

L3Folder-walk .msg scan

What: if L2 didn't find an emp_no in the filename, walks the entire ticket folder and reads every .msg body looking for an emp_no pattern in the text.

When: ticket folders where filename was sanitized but the body still contains the emp number.

Conf: 0.85

L4GDS-format fuzzy name

What: normalizes GDS passenger format LAST/FIRST TITLE (e.g. SALEH/MARIAM MS(CHD)) → fuzzy-matches against Manpower full names with rapidfuzz. Strips titles (MR/MRS/MISS/DR/CHD/MSTR).

When: no emp_no in evidence but the passenger name is unique enough in Manpower (Levenshtein score >= 88).

Conf: 0.6–0.9 by match score · ambiguous → cascade continues.

L5Phonetic / transliteration

What: double-metaphone phonetic match for Arabic→English variants (Mohammed/Mohammad/Muhammad, Hussein/Hussain, Khalid/Khaled, Abdel/Abdul). Custom 50-entry variance map.

When: L4 returned two candidates equally close — phonetic breaks the tie.

Conf: 0.75

L6Arabic name match

What: matches against Manpower Arabic Name column using token-set fuzzy with Arabic normalization (alef variants, hamza, tatweel).

When: .msg evidence has the Arabic name but no Latin transliteration matches Manpower.

Conf: 0.85

L7Approver → subordinate

What: if the approver in the .msg is a known line manager AND has exactly one direct report whose name fuzzy-matches the passenger, return that subordinate.

When: approval-chain rich, passenger name ambiguous.

Conf: 0.8

L7.5Reverse manager lookup

What: passenger IS a line manager not in Manpower (or new hire). Reverse-lookup: who has this person as Manager? If all subordinates share one cost center, use that as the manager's home CC.

When: rare — senior staff whose own Manpower record is incomplete.

Conf: 0.7 · flags MANAGER_CC_FRAGMENTED when subordinates disagree.

L7.7LLM email extractor (legacy)

What: Gemini reads the .msg approval thread + OPEX PDF, returns explicit allocation (emp_no, CC, DIV, agency, solution) if mentioned by name.

When: when L0–L7.5 all miss. In v15.12 this rarely runs — the hybrid router at Stage 2 catches uncertain rows earlier and sends them to the richer full-evidence agent at Stage 3B.

Conf: high/medium per Gemini self-assessment.

L8Cross-batch passenger cache

What: learns passenger_name → emp_no mappings across all prior batches (cached in cache/passenger_to_empno.json). If we've resolved this exact name confidently before, reuse the answer.

When: consultants/regulars who travel repeatedly. Also catches names that L4 was just below threshold on.

Conf: 0.75 + cross-batch consistency check.

L9Sponsorship / external auto-route

What: last resort. If description has sponsorship markers (HF/CRM-/EP-/conference) AND no employee was found, mark as external sponsorship: account=60307021, segments default to General (000), flag EMPLOYEE_NOT_IN_MASTER.

When: guest doctors, KOLs, conference attendees who are never in the employee master.

Conf: 0.4 — intentionally low so QC flags it for review.

not_resolved

What: all 13 layers missed. Row is marked not_resolved, CC defaults to 999999. The hybrid router (Stage 2) catches this and sends it to the full-evidence LLM agent (Stage 3B).

After whichever layer wins, two post-processing passes run on the cascade output:

  • OPEX-ref segment override (v11-labadi): if description matches an OPEX event ref, override CC/DIV/Agency/Solution with sponsorship-budget-derived segments regardless of the employee's home department.
  • Family-cluster unification (v15.11.2): if multiple rows are detected as one family travel cluster (CHD markers, shared route, same date), unify all rows to the modal sponsor's emp_no.
Stage 2 — Hybrid Router
Decides per row: did cascade get it confidently, or does this need the LLM full-evidence agent?
2

Routing decision per row

scripts/run_hybrid_v15_12.py · first-match-wins on 5 triggers

conditional 0
OPEX_PDF_UNRESOLVED
Ticket folder contains OPEX-*.pdf AND cascade didn't produce clean sponsorship coding
SPONSORSHIP_KEYWORD
Cascade said travel (60301003) but description matches HF/CRM-/EP-/AATS/IEPC/SIS-/ISHLT/DDW
NOT_RESOLVED
Cascade ended L9 — cost_center is 000000, 999999, or blank
SPONSORSHIP_SIGNAL_UNKNOWN_TRIP
Trip Purpose = UNKNOWN AND text signal indicates sponsorship
EMPLOYEE_NOT_IN_MASTER
Cascade flagged the passenger as not present in Manpower
Stage 3 — Branched Resolution
Cascade-only path for confident rows; full-evidence LLM agent for uncertain ones.
Cascade keeps the row
3A

Trust deterministic output

~85% of rows on a typical batch

no LLM cost 0

No LLM call. Cascade result becomes the final answer. Travel rows where Manpower fuzzy + Aljeel email anchor resolved the employee cleanly. Family-cluster unification already applied.

LLM full-evidence agent
3B

Read entire ticket folder

~15% of rows · sponsorships, unresolved, edge cases

Gemini 3 Pro 0

For each routed row, builds a multimodal prompt containing:

  • Full row description (passenger, route, ticket #, amount, date)
  • Every .msg body in the folder, complete (no truncation)
  • Every OPEX-*.pdf attached as Gemini file_data (full PDF, no excerpt)
  • Any other PDF in the folder (bookings, vouchers)
  • Master data snapshot — employee email lookup, CC/DIV/Solution/Agency lookup

Model cascade: gemini-pro-latest (3 Pro) → gemini-2.5-progemini-2.5-flash. Cache keyed by ticket # so re-runs are free.

Returns JSON: emp_no, account, cost_center, div, solution, agency, confidence, reasoning. For sponsorships, the "emp_no" is the requesting employee (not the doctor/guest traveler) — found in OPEX form or approval chain.

Stage 4 — Cascade Overlay (LLM rows only)
Cascade wins on deterministic rules even when the row went LLM. Protects J26-640's 100% golden score.
4

Apply 6 overlay rules

cascade beats LLM on specific edge cases

guardrails 0

After the LLM returns its answer for a routed row, six narrow cascade-wins rules overlay back on top. Reason: the deterministic cascade has hardcoded knowledge (G&A vs S&M split, OPEX-prefix→agency mapping, solution-from-route) that the LLM doesn't have access to. These overlay rules protect J26-640's 100% golden score from any LLM drift.

R1G&A travel split

Rule: if employee's Manpower division code is 888 (G&A), cascade's 60301004 (G&A Travel) beats LLM's 60301003 (S&M Travel).

Why: Aljeel splits travel expense at the account level by department type. G&A employees go to a different GL than commercial. LLM doesn't see this mapping; cascade does.

R2Travel solution-from-route

Rule: on travel rows (account 60301003/04), if cascade derived solution from the route (e.g. RUH→JED maps to solution X for this employee's home division), cascade's solution wins.

Why: solution code reflects which Aljeel business unit benefits from the trip. Cascade has Aljeel's internal route-to-solution mapping built in; LLM has to guess from text.

R3Sponsorship agency mapping

Rule: on sponsorship rows, if the OPEX prefix in the description (HF, CRM-, EP-, SIS-, AATS, IEPC) maps to a known agency, use that mapping: HF→10072 (Abbott), CRM→10038, EP→10095, etc.

Why: Aljeel's sponsorship-agency mapping is encoded as a lookup table from event-prefix to agency code. Cascade applies it deterministically; LLM can pull the wrong agency from event branding.

R4Sponsorship solution mapping

Rule: same logic as R3, but for solution code: OPEX prefix→solution lookup (HF→10050, CRM→10094, etc.).

Why: solution code identifies the medical specialty (cardiology, cardiac rhythm management, electrophysiology). Cascade has the canonical Aljeel mapping; LLM picks up adjacent words.

R5Family-cluster unification (v15.11.2)

Rule: if Stage 1 detected a family travel cluster (CHD markers in description + shared route + same date), all rows in the cluster are unified to the modal sponsor's emp_no. LLM's per-row guesses on family rows are discarded.

Why: when sponsor brings spouse + 2 children on annual leave tickets, all 4 rows charge to the sponsor's home cost center (see the SALEH family case where rows 21–23 were unified to Hussein Saleh 1002066).

R6Per-field cascade fallback

Rule: if LLM returns blank or null on any of CC / DIV / agency / solution, keep cascade's value for that specific field. Mix-and-match per field.

Why: LLM sometimes omits fields it's uncertain about. Cascade's default-better-than-blank policy keeps the row Oracle-ingestible.

Stage 5 — QC + Final Write
5

QC catches + Static field write

qc_catches_within_batch.py + cross_batch_fraud.py

soft flags 0

After resolution + overlay, every row passes through QC. "QC catches" are soft flags raised on rows that look risky — they don't block the GL post but they tag the row so Aljeel finance reviews it before approval. Two scopes run: qc_catches_within_batch.py (anomalies inside this batch) and cross_batch_fraud.py (anomalies vs prior batches in the cache).

QC-1NO_APPROVAL

What: the approval chain in the .msg evidence doesn't cover this row — no approval signature, or the approval is from someone not in the approval workflow.

Severity: HIGH on annual tickets and training; MEDIUM on regular travel.

Cleared automatically: if v15.11.2 family-cluster unification reassigns the row to a sponsor whose approval covers the whole family.

QC-2MISSING_HR_APPROVAL

What: Mai (HR) is not in the approval chain on a training or annual-ticket row.

Scope: Mai is required only on HR-related items — NOT on sponsorships (those route through compliance, not HR).

Severity: HIGH.

QC-3FAMILY_CLUSTER_UNIFIED

What: informational tag added to every row in a family cluster after the modal emp_no is applied. Tells the reviewer "this row was reassigned because it's part of a family travel group."

Severity: INFO (not a problem, just transparency).

QC-4ROUND_AMOUNT

What: ticket amount ends in a suspicious round number (.00, .50) and is exactly at a typical fraud threshold (SAR 500, 1,000, 1,500, 2,000).

Why it's a signal: genuine airline fares have decimal complexity from VAT (15%); round numbers suggest manually-entered amounts.

Severity: MEDIUM.

QC-5DUP_ROUTE_STRICT

What: the same employee has two tickets on the same route in the same week that aren't a clean return pair (i.e. not a balanced there-and-back).

Why: could be legitimate (changed booking) or could be double-charging.

Severity: MEDIUM.

QC-6PERSONAL_CONTRIB_SELF_APPROVAL

What: personal-contribution approval where the employee approved their own request (no manager intervention in the chain).

Severity: MEDIUM — needs manager oversight.

QC-7VAT_MISMATCH

What: VAT amount on the row doesn't match the expected rate. Domestic airline tickets carry 15% KSA VAT; international tickets are zero-rated. Anything else triggers the flag.

Severity: MEDIUM.

QC-8EMD_FEE

What: ticket is actually an EMD fare-adjustment line (rebooking/upgrade fee) rather than a fresh booking. No separate ticket folder expected.

Severity: INFO — prevents false NO_FOLDER flags.

QC-9NO_FOLDER

What: invoice line claims a ticket # but no evidence folder exists for it. Could mean Jawal forgot to send the booking PDF, or the ticket # is wrong on the invoice.

Severity: HIGH — finance should request the missing documentation from Jawal.

QC-10EVIDENCE_FILE_CORRUPTED

What: .msg evidence file is on disk but its bytes are 0 — corrupted at upload. Approval coverage cannot be verified.

Severity: MEDIUM — Aljeel should re-pull from Outlook/SharePoint.

Status: proposed for v15.11.3; not yet shipped.

QC-X1POTENTIAL_REBOOKING_FRAUD (cross-batch)

What: same employee + same route appears with a price discrepancy between the OPEX form value and the invoice value, OR appears in this batch and a prior one with different amounts.

Example: ALKHATIB/YAZAN MR (J26-593 R138) — form said SAR 850, invoice charged SAR 1,550.

Severity: HIGH.

QC-X2MIXED_FAMILY_CLUSTER (cross-batch)

What: a row that looks like a family-cluster member but the cluster spans multiple batches or doesn't fully match the in-batch family-cluster signature.

Severity: MEDIUM.

Static-field overrides (applied to every row, no matter how it resolved):

  • *Invoice Header Identifier = 1
  • **Supplier[..] = شركة جوال للسفر والسياحة المحدودة
  • **Supplier Number = 10394
  • *Supplier Site[..] = شركة جوال للسفر
  • Invoice Type = Standard on regular invoices, Debit Memo on refund rows (negative amount OR CN- prefix)

Final "GL Description" column (appended at the right): Account Desc · CC Name · Contribution · Solution Name · Agency Name

Example: Travel Tickets Expense · Technical Services HO · Technical Services · General · Technical Services

6

Output: Oracle-ingestible xlsx + summary

Spreadsheet-J26-XXX-FILLED-v15.12.xlsx

final 0

Saved to batches/jawal-J26-XXX/output/:

  • Spreadsheet-J26-XXX-FILLED-v15.12.xlsx — the Oracle Fusion upload sheet
  • summary-v15.12.json — match method breakdown, flag counts, row status
  • catches-within-batch.json · catches-cross-batch.json

If a ground-truth Details sheet exists for the batch, score_against_truth.py emits a comparison MD next to the output.

Performance benchmarks
Tested on J26-640 (golden, known-good) and J26-593 (blind, never seen by pipeline before).
J26-640 Golden
100%
No regression vs v15.11.2 baseline
J26-593 Blind
73.1%
all-5-segments-exact · +10.6pp over cascade alone
Travel rows
90.6%
unchanged from cascade-alone
Sponsorships
45.9%
17/37 · was 0/37 cascade-alone
Cost / batch
$0.42
J26-593 · ~29 LLM calls
Runtime
~4 min
160 rows · 5-wide concurrent
General comments / open questions
Anything not tied to a specific stage.
📝

General feedback

questions, architectural concerns, ideas

0