Seth0330 commited on
Commit
f52c5eb
·
verified ·
1 Parent(s): e40b807

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +184 -146
app.py CHANGED
@@ -10,70 +10,50 @@ from langchain_community.chat_models import ChatOpenAI
10
  from langchain.agents import initialize_agent, Tool, AgentType
11
  from fuzzywuzzy import fuzz
12
 
13
- # ---- Custom CSS for SaaS look ----
 
 
 
14
  st.markdown("""
15
  <style>
16
- #MainMenu, header, footer {visibility: hidden;}
17
- html, body, [class*="css"] {
18
- font-family: 'Inter', 'Segoe UI', Arial, sans-serif !important;
19
- background: #f7f8fa !important;
20
- }
21
- .app-title {
22
- font-size: 2.2rem;
23
- font-weight: 800;
24
- margin-bottom: 0.5rem;
25
- letter-spacing: -1px;
26
- }
27
- .block-card {
28
- background: #fff;
29
- border-radius: 16px;
30
- box-shadow: 0 4px 24px rgba(30,34,90,0.04);
31
- padding: 2.2rem 2rem 2rem 2rem;
32
- margin-bottom: 2rem;
33
- }
34
- .step-num {
35
- color: #fff;
36
- background: #5356FB;
37
- font-weight: 700;
38
- font-size: 1rem;
39
- display: inline-block;
40
- border-radius: 100px;
41
- padding: 0.45em 0.85em;
42
- margin-right: 0.75em;
43
- }
44
- .stButton button {
45
- border-radius: 100px !important;
46
- background: #5356FB !important;
47
- color: #fff !important;
48
- font-weight: 600;
49
- border: none;
50
- box-shadow: 0 2px 8px rgba(30,34,90,0.05);
51
- padding: 0.6em 2em !important;
52
- transition: all 0.18s;
53
- }
54
- .stButton button:hover {
55
- background: #2123A6 !important;
56
- color: #fff !important;
57
- transform: translateY(-1px) scale(1.03);
58
- }
59
- .stSlider > div {padding-top: 0.7em;}
60
- .stSlider label {font-size: 1.07em;}
61
- .stFileUploader > div { border-radius: 30px !important; }
62
- .stFileUploader label {font-size: 1.09em;}
63
- .stStatusWidget {display: none !important;}
64
- /* Remove success box border (for record count) */
65
- .element-container .stAlert {box-shadow:none !important;border:none !important;}
66
  </style>
67
  """, unsafe_allow_html=True)
68
 
69
- st.markdown('<div class="app-title">EZOFIS Accounts Payable Agent</div>', unsafe_allow_html=True)
70
- st.markdown('<span style="color:#444; font-size:1.13em;">Modern workflow automation for finance teams</span>', unsafe_allow_html=True)
71
- st.write("")
72
-
73
- # -- Layout: cards in two columns --
74
- col1, col2 = st.columns([1, 1.15], gap="large")
75
-
76
- # -- Business logic variables
77
  MODELS = {
78
  "OpenAI GPT-4.1": {
79
  "api_url": "https://api.openai.com/v1/chat/completions",
@@ -83,7 +63,6 @@ MODELS = {
83
  "extra_headers": {},
84
  },
85
  }
86
- mdl = "OpenAI GPT-4.1"
87
 
88
  def get_api_key(model_choice):
89
  key = os.getenv(MODELS[model_choice]["key_env"])
@@ -162,7 +141,7 @@ def get_extraction_prompt(model_choice, txt):
162
  "Use this schema:\n"
163
  '{\n'
164
  ' "invoice_header": {...},\n'
165
- ' "line_items": [ ... ]\n'
166
  '}'
167
  "\nIf a field is missing for a line item or header, use null. "
168
  "Do not invent fields. Do not add any header or shipment data to any line item. Return ONLY the JSON object, no explanation.\n"
@@ -171,8 +150,9 @@ def get_extraction_prompt(model_choice, txt):
171
  )
172
 
173
  def ensure_total_due(invoice_header):
 
174
  if invoice_header.get("total_due") in [None, ""]:
175
- for field in ["invoice_total", "invoice_value", "total_before_tax", "balance_due", "amount_paid"]:
176
  if field in invoice_header and invoice_header[field]:
177
  invoice_header["total_due"] = invoice_header[field]
178
  break
@@ -208,11 +188,13 @@ def find_po_number_in_json(po_number, invoice_json):
208
  elif obj is not None:
209
  fields.append(str(obj))
210
  return fields
 
211
  po_str = str(po_number).strip().replace(" ", "").replace(".0", "")
212
  try:
213
  po_int = str(int(float(po_number)))
214
  except:
215
  po_int = po_str
 
216
  all_strs = [str(s).strip().replace(" ", "").replace(".0", "") for s in _flatten(invoice_json)]
217
  for s in all_strs:
218
  if not s:
@@ -228,8 +210,11 @@ def find_best_po_match(inv, po_df, weight_supplier, weight_po_number, weight_cur
228
  inv_supplier = inv_hdr.get("supplier_name") or ""
229
  inv_po_number = inv_hdr.get("purchase_order_number") or inv_hdr.get("po_number") or inv_hdr.get("order_number") or ""
230
  inv_currency = inv_hdr.get("currency") or ""
 
231
  inv_total_due = clean_num(inv_hdr.get("total_due"))
 
232
  inv_line_items = inv.get("line_items", [])
 
233
  scores = []
234
  for idx, row in po_df.iterrows():
235
  po_supplier = row.get("Supplier Name", "")
@@ -240,15 +225,47 @@ def find_best_po_match(inv, po_df, weight_supplier, weight_po_number, weight_cur
240
  po_qty = str(row.get("Item Quantity", ""))
241
  po_unit = str(row.get("Item Unit Price", ""))
242
  po_line_total = clean_num(row.get("Line Item Total", ""))
 
243
  field_details = []
 
244
  s_supplier = weighted_fuzzy_score(inv_supplier, po_supplier)
245
- field_details.append({"field": "Supplier Name","invoice": inv_supplier,"po": po_supplier,"score": s_supplier})
 
 
 
 
 
 
246
  s_po_number = 100 if find_po_number_in_json(po_po_number, inv) else 0
247
- field_details.append({"field": "PO Number (anywhere in JSON)","invoice": "found" if s_po_number else "not found","po": po_po_number,"score": s_po_number})
 
 
 
 
 
 
248
  s_currency = weighted_fuzzy_score(inv_currency, po_currency)
249
- field_details.append({"field": "Currency","invoice": inv_currency,"po": po_currency,"score": s_currency})
250
- s_total = 100 if inv_total_due is not None and po_total is not None and abs(inv_total_due - po_total) < 2 else 0
251
- field_details.append({"field": "Total Due","invoice": inv_total_due,"po": po_total,"score": s_total})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
252
  line_item_score = 0
253
  line_reason = ""
254
  best_line_detail = None
@@ -281,7 +298,11 @@ def find_best_po_match(inv, po_df, weight_supplier, weight_po_number, weight_cur
281
  if total > line_item_score:
282
  line_item_score = total
283
  best_line_detail = detail
284
- line_reason = (f"Best line item: desc_score={desc_score}, qty_score={qty_score}, unit_score={unit_score}, amount_score={amount_score}")
 
 
 
 
285
  wsum = weight_supplier + weight_po_number + weight_currency + weight_total_due + weight_line_item
286
  total_score = (
287
  s_supplier * weight_supplier/100 +
@@ -290,13 +311,15 @@ def find_best_po_match(inv, po_df, weight_supplier, weight_po_number, weight_cur
290
  s_total * weight_total_due/100 +
291
  line_item_score * weight_line_item/100
292
  ) if wsum == 100 else 0
 
293
  reason = (
294
  f"Supplier match: {s_supplier}/100 (invoice: '{inv_supplier}' vs PO: '{po_supplier}'), "
295
  f"PO Number: {s_po_number}/100 ({'found anywhere in JSON' if s_po_number else 'not found'}), "
296
  f"Currency: {s_currency}/100 (invoice: '{inv_currency}' vs PO: '{po_currency}'), "
297
- f"Total Due: {'match' if s_total else 'no match'} (invoice: {inv_total_due} vs PO: {po_total}), "
298
  f"Line item best match: {int(line_item_score)}/100. {line_reason}"
299
  )
 
300
  debug = {
301
  "po_idx": idx,
302
  "po_supplier": po_supplier,
@@ -307,9 +330,11 @@ def find_best_po_match(inv, po_df, weight_supplier, weight_po_number, weight_cur
307
  "best_line_detail": best_line_detail,
308
  "total_score": total_score,
309
  "line_reason": line_reason,
310
- "inv_total_due": inv_total_due
 
311
  }
312
  scores.append((row, total_score, reason, debug))
 
313
  scores.sort(key=lambda tup: tup[1], reverse=True)
314
  if not scores:
315
  return None, 0, "No POs found.", {}
@@ -401,14 +426,24 @@ def extract_text_from_unstract(uploaded_file):
401
  except Exception:
402
  return r.text
403
 
404
- # ---- UI/Workflow in columns ----
 
 
 
 
 
 
 
 
 
 
 
405
 
 
406
  with col1:
407
- st.markdown('<div class="block-card">', unsafe_allow_html=True)
408
- st.markdown('<span class="step-num">1</span> <b>Upload Active Purchase Orders (POs)</b>', unsafe_allow_html=True)
409
- st.markdown('<span style="color:#888;">CSV with PO number, Supplier, Items, etc.</span>', unsafe_allow_html=True)
410
  po_file = st.file_uploader(
411
- "Drop your POs CSV here (max 200MB)",
412
  type=["csv"],
413
  key="po_csv",
414
  label_visibility="collapsed"
@@ -418,11 +453,11 @@ with col1:
418
  po_df = pd.read_csv(po_file)
419
  st.success(f"Loaded {len(po_df)} records from uploaded CSV.")
420
  st.session_state['last_po_df'] = po_df
421
- st.markdown("</div>", unsafe_allow_html=True)
422
 
423
- st.markdown('<div class="block-card">', unsafe_allow_html=True)
424
- st.markdown('<span class="step-num">2</span> <b>Configure Scoring Weights</b>', unsafe_allow_html=True)
425
- st.markdown('<span style="color:#888;">Set weights for matching. Total must equal 100%.</span>', unsafe_allow_html=True)
 
426
  def int_slider(label, value, key):
427
  return st.slider(label, 0, 100, value, 1, key=key, format="%d")
428
  weight_supplier = int_slider("Supplier Name (%)", 25, "w_supplier")
@@ -433,74 +468,70 @@ with col1:
433
  weight_sum = weight_supplier + weight_po_number + weight_currency + weight_total_due + weight_line_item
434
  if weight_sum != 100:
435
  st.warning(f"Sum of weights is {weight_sum}%. Adjust so it equals 100%.")
436
- st.markdown("</div>", unsafe_allow_html=True)
437
 
438
- st.markdown('<div class="block-card">', unsafe_allow_html=True)
439
- st.markdown('<span class="step-num">3</span> <b>Decision Thresholds</b>', unsafe_allow_html=True)
440
  approved_threshold = st.slider("Threshold for 'APPROVED'", min_value=0, max_value=100, value=85, format="%d")
441
  partial_threshold = st.slider("Threshold for 'PARTIALLY APPROVED'", min_value=0, max_value=approved_threshold-1, value=70, format="%d")
442
- st.markdown("</div>", unsafe_allow_html=True)
443
 
 
444
  with col2:
445
- st.markdown('<div class="block-card">', unsafe_allow_html=True)
446
- st.markdown('<span class="step-num">4</span> <b>Upload Invoice/Document</b>', unsafe_allow_html=True)
447
  inv_file = st.file_uploader(
448
- "Drop invoice or document here (PDF, Excel, Image, Word, etc.)",
449
  type=["pdf", "docx", "xlsx", "xls", "png", "jpg", "jpeg", "tiff"],
450
- key="inv_file",
451
  label_visibility="collapsed"
452
  )
453
- st.markdown("</div>", unsafe_allow_html=True)
454
-
455
- st.markdown('<div class="block-card">', unsafe_allow_html=True)
456
- st.markdown('<span class="step-num">5</span> <b>Extract Data</b>', unsafe_allow_html=True)
457
- if st.button("Extract") and inv_file:
458
- with st.spinner("Extracting text from document..."):
459
- text = extract_text_from_unstract(inv_file)
460
- if text:
461
- extracted_info = extract_invoice_info(mdl, text)
462
- if extracted_info:
463
- if "invoice_header" in extracted_info:
464
- extracted_info["invoice_header"] = ensure_total_due(extracted_info["invoice_header"])
465
- st.success("Extraction Complete")
466
- st.markdown("**Invoice Metadata**")
467
- st.json(extracted_info["invoice_header"], expanded=False)
468
- st.markdown("**Line Items**")
469
- st.json(extracted_info["line_items"], expanded=False)
470
- st.session_state['last_extracted_info'] = extracted_info
471
- st.markdown("</div>", unsafe_allow_html=True)
472
-
473
- # AP Decision Card
474
- st.markdown('<div class="block-card">', unsafe_allow_html=True)
475
- st.markdown('<span class="step-num">6</span> <b>AP Agent Decision</b>', unsafe_allow_html=True)
476
- extracted_info = st.session_state.get('last_extracted_info', None)
477
- po_df = st.session_state.get('last_po_df', None)
478
- def po_match_tool_func(input_text):
479
- invoice = st.session_state.get("last_extracted_info")
480
- po_df = st.session_state.get("last_po_df")
481
- if invoice is None or po_df is None:
482
- return json.dumps({
483
- "decision": "REJECTED",
484
- "reason": "Invoice or PO data not found.",
485
- "debug": {},
486
- })
487
- best_row, best_score, reason, debug = find_best_po_match(
488
- invoice, po_df, weight_supplier, weight_po_number, weight_currency, weight_total_due, weight_line_item
489
- )
490
- if best_score > approved_threshold:
491
- status = "APPROVED"
492
- elif best_score > partial_threshold:
493
- status = "PARTIALLY APPROVED"
494
  else:
495
- status = "REJECTED"
496
- return json.dumps({
497
- "decision": status,
498
- "reason": f"Best match score: {int(best_score)}/100. {reason}",
499
- "debug": debug,
500
- "po_row": best_row.to_dict() if best_row is not None else None
501
- })
502
- if extracted_info is not None and po_df is not None:
503
- if st.button("Make a decision (EZOFIS AP AGENT)"):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
504
  tools = [
505
  Tool(
506
  name="po_match_tool",
@@ -509,8 +540,8 @@ with col2:
509
  )
510
  ]
511
  decision_llm = ChatOpenAI(
512
- openai_api_key=get_api_key(mdl),
513
- model=MODELS[mdl]["model"],
514
  temperature=0,
515
  streaming=False,
516
  )
@@ -529,17 +560,24 @@ with col2:
529
  )
530
  with st.spinner("AI is reasoning and making a decision..."):
531
  result = agent.run(prompt)
 
 
532
  try:
533
  result_json = json.loads(result)
534
  st.write(f"**Decision:** {result_json.get('decision', 'N/A')}")
535
  st.write(f"**Reason:** {result_json.get('reason', 'N/A')}")
536
- with st.expander("Debug & Matching Details"):
537
- st.json(result_json.get('debug'))
538
- st.subheader("Extracted Invoice JSON")
539
- st.json(extracted_info)
540
- st.subheader("Matched PO Row")
541
- st.json(result_json.get('po_row'))
542
  except Exception:
543
  st.subheader("AI Decision & Reason")
544
  st.write(result)
545
- st.markdown("</div>", unsafe_allow_html=True)
 
 
 
 
 
 
10
  from langchain.agents import initialize_agent, Tool, AgentType
11
  from fuzzywuzzy import fuzz
12
 
13
+ # --- Streamlit Page Settings ---
14
+ st.set_page_config(page_title="EZOFIS Accounts Payable Agent", layout="wide")
15
+
16
+ # --- Styles for SaaS Feel ---
17
  st.markdown("""
18
  <style>
19
+ .block-card {
20
+ background: #fff;
21
+ border-radius: 20px;
22
+ box-shadow: 0 2px 16px rgba(25,39,64,0.05);
23
+ padding: 32px 26px 24px 26px;
24
+ margin-bottom: 24px;
25
+ }
26
+ .step-num {
27
+ background: #2F49D1;
28
+ color: #fff;
29
+ border-radius: 999px;
30
+ padding: 6px 13px;
31
+ font-weight: 700;
32
+ margin-right: 14px;
33
+ font-size: 20px;
34
+ display: inline-block;
35
+ vertical-align: middle;
36
+ }
37
+ .stButton>button {
38
+ background: #2F49D1 !important;
39
+ color: white !important;
40
+ border-radius: 12px !important;
41
+ padding: 10px 32px !important;
42
+ font-weight: 700;
43
+ border: none !important;
44
+ font-size: 18px !important;
45
+ margin-top: 12px !important;
46
+ }
47
+ .stSlider>div>div>div>div {
48
+ background: #F3F6FB !important;
49
+ border-radius: 999px;
50
+ }
51
+ .css-12w0qpk {padding-top: 0rem;} /* Removes extra padding */
52
+ .css-1kyxreq {padding-top: 0rem;} /* Removes extra padding */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  </style>
54
  """, unsafe_allow_html=True)
55
 
56
+ # --- Model Definitions ---
 
 
 
 
 
 
 
57
  MODELS = {
58
  "OpenAI GPT-4.1": {
59
  "api_url": "https://api.openai.com/v1/chat/completions",
 
63
  "extra_headers": {},
64
  },
65
  }
 
66
 
67
  def get_api_key(model_choice):
68
  key = os.getenv(MODELS[model_choice]["key_env"])
 
141
  "Use this schema:\n"
142
  '{\n'
143
  ' "invoice_header": {...},\n'
144
+ ' "line_items": [{...}]\n'
145
  '}'
146
  "\nIf a field is missing for a line item or header, use null. "
147
  "Do not invent fields. Do not add any header or shipment data to any line item. Return ONLY the JSON object, no explanation.\n"
 
150
  )
151
 
152
  def ensure_total_due(invoice_header):
153
+ # Prefer total_before_tax if total_due mismatches in scoring
154
  if invoice_header.get("total_due") in [None, ""]:
155
+ for field in ["total_before_tax", "invoice_total", "invoice_value", "balance_due", "amount_paid"]:
156
  if field in invoice_header and invoice_header[field]:
157
  invoice_header["total_due"] = invoice_header[field]
158
  break
 
188
  elif obj is not None:
189
  fields.append(str(obj))
190
  return fields
191
+
192
  po_str = str(po_number).strip().replace(" ", "").replace(".0", "")
193
  try:
194
  po_int = str(int(float(po_number)))
195
  except:
196
  po_int = po_str
197
+
198
  all_strs = [str(s).strip().replace(" ", "").replace(".0", "") for s in _flatten(invoice_json)]
199
  for s in all_strs:
200
  if not s:
 
210
  inv_supplier = inv_hdr.get("supplier_name") or ""
211
  inv_po_number = inv_hdr.get("purchase_order_number") or inv_hdr.get("po_number") or inv_hdr.get("order_number") or ""
212
  inv_currency = inv_hdr.get("currency") or ""
213
+ # -- Try both total_due and total_before_tax for matching
214
  inv_total_due = clean_num(inv_hdr.get("total_due"))
215
+ inv_total_before_tax = clean_num(inv_hdr.get("total_before_tax"))
216
  inv_line_items = inv.get("line_items", [])
217
+
218
  scores = []
219
  for idx, row in po_df.iterrows():
220
  po_supplier = row.get("Supplier Name", "")
 
225
  po_qty = str(row.get("Item Quantity", ""))
226
  po_unit = str(row.get("Item Unit Price", ""))
227
  po_line_total = clean_num(row.get("Line Item Total", ""))
228
+
229
  field_details = []
230
+
231
  s_supplier = weighted_fuzzy_score(inv_supplier, po_supplier)
232
+ field_details.append({
233
+ "field": "Supplier Name",
234
+ "invoice": inv_supplier,
235
+ "po": po_supplier,
236
+ "score": s_supplier
237
+ })
238
+
239
  s_po_number = 100 if find_po_number_in_json(po_po_number, inv) else 0
240
+ field_details.append({
241
+ "field": "PO Number (anywhere in JSON)",
242
+ "invoice": "found" if s_po_number else "not found",
243
+ "po": po_po_number,
244
+ "score": s_po_number
245
+ })
246
+
247
  s_currency = weighted_fuzzy_score(inv_currency, po_currency)
248
+ field_details.append({
249
+ "field": "Currency",
250
+ "invoice": inv_currency,
251
+ "po": po_currency,
252
+ "score": s_currency
253
+ })
254
+
255
+ # Try total_due, fallback to total_before_tax
256
+ s_total = 0
257
+ if inv_total_due is not None and po_total is not None and abs(inv_total_due - po_total) < 2:
258
+ s_total = 100
259
+ elif inv_total_before_tax is not None and po_total is not None and abs(inv_total_before_tax - po_total) < 2:
260
+ s_total = 100
261
+ field_details.append({
262
+ "field": "Total Due or Before Tax",
263
+ "invoice": inv_total_due if s_total else inv_total_before_tax,
264
+ "po": po_total,
265
+ "score": s_total
266
+ })
267
+
268
+ # Line item logic as before
269
  line_item_score = 0
270
  line_reason = ""
271
  best_line_detail = None
 
298
  if total > line_item_score:
299
  line_item_score = total
300
  best_line_detail = detail
301
+ line_reason = (
302
+ f"Best line item: desc_score={desc_score}, qty_score={qty_score}, "
303
+ f"unit_score={unit_score}, amount_score={amount_score}"
304
+ )
305
+
306
  wsum = weight_supplier + weight_po_number + weight_currency + weight_total_due + weight_line_item
307
  total_score = (
308
  s_supplier * weight_supplier/100 +
 
311
  s_total * weight_total_due/100 +
312
  line_item_score * weight_line_item/100
313
  ) if wsum == 100 else 0
314
+
315
  reason = (
316
  f"Supplier match: {s_supplier}/100 (invoice: '{inv_supplier}' vs PO: '{po_supplier}'), "
317
  f"PO Number: {s_po_number}/100 ({'found anywhere in JSON' if s_po_number else 'not found'}), "
318
  f"Currency: {s_currency}/100 (invoice: '{inv_currency}' vs PO: '{po_currency}'), "
319
+ f"Total: {'match' if s_total else 'no match'} (invoice: {inv_total_due if s_total else inv_total_before_tax} vs PO: {po_total}), "
320
  f"Line item best match: {int(line_item_score)}/100. {line_reason}"
321
  )
322
+
323
  debug = {
324
  "po_idx": idx,
325
  "po_supplier": po_supplier,
 
330
  "best_line_detail": best_line_detail,
331
  "total_score": total_score,
332
  "line_reason": line_reason,
333
+ "inv_total_due": inv_total_due,
334
+ "inv_total_before_tax": inv_total_before_tax
335
  }
336
  scores.append((row, total_score, reason, debug))
337
+
338
  scores.sort(key=lambda tup: tup[1], reverse=True)
339
  if not scores:
340
  return None, 0, "No POs found.", {}
 
426
  except Exception:
427
  return r.text
428
 
429
+ # ---------------- UI LAYOUT ----------------------
430
+ st.markdown(
431
+ "<h1 style='font-weight:800; margin-bottom:8px;'>EZOFIS Accounts Payable Agent</h1>",
432
+ unsafe_allow_html=True
433
+ )
434
+ st.markdown(
435
+ "<div style='font-size:20px; margin-bottom:28px; color:#24345C;'>Modern workflow automation for finance teams</div>",
436
+ unsafe_allow_html=True
437
+ )
438
+
439
+ # ---- Three columns layout for horizontal flow
440
+ col1, col2, col3 = st.columns([2,2,3])
441
 
442
+ # ---- Step 1: Upload POs (col1) ----
443
  with col1:
444
+ st.markdown("<span class='step-num'>1</span> <b>Upload Active Purchase Orders (POs)</b>", unsafe_allow_html=True)
 
 
445
  po_file = st.file_uploader(
446
+ "CSV with PO number, Supplier, Items, etc.",
447
  type=["csv"],
448
  key="po_csv",
449
  label_visibility="collapsed"
 
453
  po_df = pd.read_csv(po_file)
454
  st.success(f"Loaded {len(po_df)} records from uploaded CSV.")
455
  st.session_state['last_po_df'] = po_df
 
456
 
457
+ # ---- Step 2: Scoring Weights (col1) ----
458
+ with col1:
459
+ st.markdown("<span class='step-num'>2</span> <b>Configure Scoring Weights</b>", unsafe_allow_html=True)
460
+ st.markdown("Set weights for matching. Total must equal 100%.", unsafe_allow_html=True)
461
  def int_slider(label, value, key):
462
  return st.slider(label, 0, 100, value, 1, key=key, format="%d")
463
  weight_supplier = int_slider("Supplier Name (%)", 25, "w_supplier")
 
468
  weight_sum = weight_supplier + weight_po_number + weight_currency + weight_total_due + weight_line_item
469
  if weight_sum != 100:
470
  st.warning(f"Sum of weights is {weight_sum}%. Adjust so it equals 100%.")
 
471
 
472
+ st.markdown("<span class='step-num'>3</span> <b>Set Decision Thresholds</b>", unsafe_allow_html=True)
 
473
  approved_threshold = st.slider("Threshold for 'APPROVED'", min_value=0, max_value=100, value=85, format="%d")
474
  partial_threshold = st.slider("Threshold for 'PARTIALLY APPROVED'", min_value=0, max_value=approved_threshold-1, value=70, format="%d")
 
475
 
476
+ # ---- Step 4: Upload Invoice (col2) ----
477
  with col2:
478
+ st.markdown("<span class='step-num'>4</span> <b>Upload Invoice/Document</b>", unsafe_allow_html=True)
 
479
  inv_file = st.file_uploader(
480
+ "Upload PDF, DOCX, XLSX, PNG, JPG, TIFF",
481
  type=["pdf", "docx", "xlsx", "xls", "png", "jpg", "jpeg", "tiff"],
482
+ key="invoice_file",
483
  label_visibility="collapsed"
484
  )
485
+
486
+ # ---- Step 5: Extract Data (col2) ----
487
+ with col2:
488
+ st.markdown("<span class='step-num'>5</span> <b>Extract Data</b>", unsafe_allow_html=True)
489
+ if st.button("Extract"):
490
+ if inv_file:
491
+ with st.spinner("Extracting text from document..."):
492
+ text = extract_text_from_unstract(inv_file)
493
+ if text:
494
+ mdl = "OpenAI GPT-4.1"
495
+ extracted_info = extract_invoice_info(mdl, text)
496
+ if extracted_info:
497
+ if "invoice_header" in extracted_info:
498
+ extracted_info["invoice_header"] = ensure_total_due(extracted_info["invoice_header"])
499
+ st.success("Extraction Complete")
500
+ st.session_state['last_extracted_info'] = extracted_info
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
501
  else:
502
+ st.warning("Please upload an invoice/document first.")
503
+
504
+ # ---- Step 6: AP Agent Decision (col3) ----
505
+ with col3:
506
+ st.markdown("<span class='step-num'>6</span> <b>AP Agent Decision</b>", unsafe_allow_html=True)
507
+ if st.button("Make a decision (EZOFIS AP AGENT)"):
508
+ extracted_info = st.session_state.get('last_extracted_info', None)
509
+ po_df = st.session_state.get('last_po_df', None)
510
+ if extracted_info is not None and po_df is not None:
511
+ def po_match_tool_func(input_text):
512
+ invoice = st.session_state.get("last_extracted_info")
513
+ po_df = st.session_state.get("last_po_df")
514
+ if invoice is None or po_df is None:
515
+ return json.dumps({
516
+ "decision": "REJECTED",
517
+ "reason": "Invoice or PO data not found.",
518
+ "debug": {},
519
+ })
520
+ best_row, best_score, reason, debug = find_best_po_match(
521
+ invoice, po_df, weight_supplier, weight_po_number, weight_currency, weight_total_due, weight_line_item
522
+ )
523
+ if best_score > approved_threshold:
524
+ status = "APPROVED"
525
+ elif best_score > partial_threshold:
526
+ status = "PARTIALLY APPROVED"
527
+ else:
528
+ status = "REJECTED"
529
+ return json.dumps({
530
+ "decision": status,
531
+ "reason": f"Best match score: {int(best_score)}/100. {reason}",
532
+ "debug": debug,
533
+ "po_row": best_row.to_dict() if best_row is not None else None
534
+ })
535
  tools = [
536
  Tool(
537
  name="po_match_tool",
 
540
  )
541
  ]
542
  decision_llm = ChatOpenAI(
543
+ openai_api_key=get_api_key("OpenAI GPT-4.1"),
544
+ model=MODELS["OpenAI GPT-4.1"]["model"],
545
  temperature=0,
546
  streaming=False,
547
  )
 
560
  )
561
  with st.spinner("AI is reasoning and making a decision..."):
562
  result = agent.run(prompt)
563
+ # Always display debug/info
564
+ st.markdown("<h3 style='margin-top:18px;'>AI Decision & Reason</h3>", unsafe_allow_html=True)
565
  try:
566
  result_json = json.loads(result)
567
  st.write(f"**Decision:** {result_json.get('decision', 'N/A')}")
568
  st.write(f"**Reason:** {result_json.get('reason', 'N/A')}")
569
+ st.markdown("##### Debug & Matching Details")
570
+ st.json(result_json.get('debug'))
571
+ st.markdown("##### Extracted Invoice JSON")
572
+ st.json(extracted_info)
573
+ st.markdown("##### Matched PO Row")
574
+ st.json(result_json.get('po_row'))
575
  except Exception:
576
  st.subheader("AI Decision & Reason")
577
  st.write(result)
578
+
579
+ # Always show extraction/decision debug in full for troubleshooting
580
+ if "last_api" in st.session_state:
581
+ with st.expander("Debug"):
582
+ st.code(st.session_state.last_api)
583
+ st.code(st.session_state.last_raw)