rishisriv-bh commited on
Commit
c8d9f5f
Β·
1 Parent(s): e07a434

Match bank reconciliations in code, not in the prompt (#3)

Browse files

- Match bank reconciliations in code, not in the prompt (8789837f73ab72b671bd4e816efc930e6262f66c)
- Fix four defects found reviewing the matcher (e0cb588e46fa24b90e69f25dd6a101a3e0805f50)

Files changed (1) hide show
  1. app_chatgpt.py +722 -58
app_chatgpt.py CHANGED
@@ -10,6 +10,7 @@ import sys
10
  import time
11
  import traceback
12
  import re
 
13
  from datetime import datetime
14
  from azure.core.credentials import AzureKeyCredential
15
  from azure.ai.documentintelligence import DocumentIntelligenceClient
@@ -340,6 +341,438 @@ except Exception:
340
  logger.info("ℹ️ tiktoken unavailable β€” using char/3 token estimate")
341
 
342
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
343
  _ERP_RELEVANT_KEYWORDS = (
344
  "date", "desc", "memo", "narrat", "detail", "name", "payee", "vendor",
345
  "customer", "party", "reference", "ref", "invoice", "inv", "bill",
@@ -469,6 +902,14 @@ def extract_transactions_from_pdf(file_path):
469
  logger.info(f"πŸ“‹ Account #{account_idx + 1}: {account_number} ({account_type})"
470
  f" | Opening: {begin_bal} Closing: {end_bal}")
471
 
 
 
 
 
 
 
 
 
472
  # ── Transactions ─────────────────────────────────────────────────
473
  txns_field = account_obj.get("Transactions")
474
  if not txns_field or not txns_field.value_array:
@@ -525,6 +966,7 @@ def extract_transactions_from_pdf(file_path):
525
  # Returns: (dataframe, source_label)
526
  # dataframe = pandas DataFrame of transactions (trimmed/chunked downstream)
527
  # source_label = "CSV" or "PDF (Bank: ..., Period: ...)" for context
 
528
  def extract_transactions(file):
529
  # Handle both string paths (Gradio filepath) and file objects
530
  if isinstance(file, str):
@@ -538,7 +980,7 @@ def extract_transactions(file):
538
  if filename.endswith(".pdf"):
539
  df, meta = extract_transactions_from_pdf(file_path)
540
  label = f"PDF (Bank: {meta.get('bank','N/A')}, Period: {meta.get('start','?')} β†’ {meta.get('end','?')})"
541
- return df, label
542
 
543
  elif filename.endswith(".csv"):
544
  try:
@@ -546,7 +988,7 @@ def extract_transactions(file):
546
  logger.info(f"βœ… Successfully read CSV file: {file_path}")
547
  logger.info(f"πŸ“ˆ File contains {len(df)} rows and {len(df.columns)} columns")
548
  logger.info(f"🏷️ Column names: {list(df.columns)}")
549
- return df, "CSV"
550
  except Exception as e:
551
  error_msg = f"Error processing {file_path}: {e}"
552
  logger.error(f"❌ {error_msg}")
@@ -826,6 +1268,215 @@ def _reconcile_one_chunk(chunk, external_data, erp_label, ext_label,
826
  return reports
827
 
828
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
829
  # βœ… Reconcile the statements using OpenAI
830
  def reconcile_statements_openai(erp_file, external_file, statement_type):
831
  session_start = datetime.now()
@@ -863,7 +1514,7 @@ def reconcile_statements_openai(erp_file, external_file, statement_type):
863
  # ── Step 1: Read ERP CSV ─────────────────────────────────────────────
864
  yield "⏳ Step 1/3 β€” Reading ERP statement…", "", None, None
865
  logger.info("πŸ“Š Extracting data from ERP file...")
866
- erp_df, erp_label = extract_transactions(erp_file)
867
 
868
  # ── Step 2: Read external statement (CSV or PDF via Azure DI) ────────
869
  is_pdf = ext_path.lower().endswith(".pdf")
@@ -873,68 +1524,81 @@ def reconcile_statements_openai(erp_file, external_file, statement_type):
873
  yield f"⏳ Step 2/3 β€” Reading {stmt_label} CSV…", "", None, None
874
 
875
  logger.info(f"πŸ“Š Extracting data from {stmt_label}...")
876
- external_df, ext_label = extract_transactions(external_file)
 
 
 
 
 
 
 
877
 
878
  # ── Step 3: AI Reconciliation ────────────────────────────────────────
879
  # Trim the ERP ledger to reconciliation-relevant columns, then check the
880
  # token budget. The external statement is always sent whole; only the
881
  # (usually much larger) ERP side is chunked if it doesn't fit.
882
- erp_df = trim_erp_columns(erp_df)
883
- external_data = external_df.to_string(index=False)
884
-
885
- erp_budget = MAX_PROMPT_TOKENS - PROMPT_OVERHEAD_TOKENS - estimate_tokens(external_data)
886
- if erp_budget <= 0:
887
- raise ValueError(
888
- f"The {stmt_label} alone ({estimate_tokens(external_data)} tokens) exceeds the "
889
- f"model input budget of {MAX_PROMPT_TOKENS} tokens. Please split the statement.")
890
-
891
- erp_chunks = chunk_dataframe(erp_df, erp_budget)
892
- n_batches = len(erp_chunks)
893
-
894
- partial_reports = []
895
- for idx, chunk in enumerate(erp_chunks):
896
- if n_batches > 1:
897
- yield (f"⏳ Step 3/3 β€” Reconciling ERP batch {idx + 1}/{n_batches} "
898
- f"against the {stmt_label}…", "", None, None)
899
- batch_note = (f"\n**NOTE:** This is one batch of a large ERP "
900
- f"ledger. You are seeing ONLY a subset of the ERP rows, reconciled "
901
- f"against the COMPLETE {stmt_label}.\n")
902
- else:
903
- yield f"⏳ Step 3/3 β€” AI is reconciling the statements ({stmt_label})…", "", None, None
904
- batch_note = ""
905
-
906
- # _reconcile_one_chunk re-splits adaptively if the gateway still
907
- # rejects the prompt for size, so it can return >1 partial report.
908
- partial_reports += _reconcile_one_chunk(
909
- chunk, external_data, erp_label, ext_label, is_vendor, batch_note)
910
- logger.info(f"βœ… Received batch {idx + 1}/{n_batches} response from OpenAI")
911
-
912
- # The adaptive split may have produced more partials than planned batches.
913
- n_batches = len(partial_reports)
914
-
915
- if n_batches == 1:
916
- raw_content = partial_reports[0]
917
  else:
918
- # Merge per-batch reports into one. If the merge prompt itself fits,
919
- # let GPT consolidate; otherwise fall back to labelled concatenation.
920
- yield f"⏳ Step 3/3 β€” Consolidating {n_batches} batches into one report…", "", None, None
921
- consolidation_prompt = build_consolidation_prompt(partial_reports, stmt_label, n_batches)
922
- _concat = "\n\n---\n\n".join(
923
- f"## Batch {i + 1} of {n_batches}\n\n{rep}"
924
- for i, rep in enumerate(partial_reports))
925
- if estimate_tokens(consolidation_prompt) <= MAX_PROMPT_TOKENS - PROMPT_OVERHEAD_TOKENS:
926
- logger.info(f"🧩 Consolidating {n_batches} batch reports via OpenAI...")
927
- try:
928
- raw_content = _call_openai(consolidation_prompt)
929
- except Exception as e:
930
- if _is_token_limit_error(e):
931
- logger.warning("⚠️ Consolidation rejected for size β€” concatenating instead")
932
- raw_content = _concat
933
- else:
934
- raise
 
 
 
 
 
 
 
 
 
 
 
 
 
 
935
  else:
936
- logger.warning("⚠️ Consolidation prompt too large β€” concatenating batch reports instead")
937
- raw_content = _concat
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
938
 
939
  logger.info(f"πŸ“„ Response length: {len(raw_content)} characters")
940
 
 
10
  import time
11
  import traceback
12
  import re
13
+ import difflib
14
  from datetime import datetime
15
  from azure.core.credentials import AzureKeyCredential
16
  from azure.ai.documentintelligence import DocumentIntelligenceClient
 
341
  logger.info("ℹ️ tiktoken unavailable β€” using char/3 token estimate")
342
 
343
 
344
+ # ═══════════════════════════════════════════════════════════════════════════
345
+ # Deterministic reconciliation engine
346
+ # ═══════════════════════════════════════════════════════════════════════════
347
+ # Matching is arithmetic, not language. Asking the model to emit the match
348
+ # table let it silently drop rows: the Aug-2026 VCC run narrated "1,450 ERP
349
+ # rows / 313 bank lines" for files that actually hold 4,042 ERP rows and 588
350
+ # bank lines, and confirmed ~10% of the real transactions (0 of the 24 on
351
+ # 08/17). Everything below is computed in pandas; the model is handed the
352
+ # finished result and only writes the prose around it.
353
+
354
+ _DATE_RE = re.compile(r"(\d{1,2}/\d{1,2}/\d{2,4})")
355
+ _REF_RE = re.compile(r"\b(\d{6,12})\b")
356
+ _NOISE_RE = re.compile(
357
+ r"beginning bal|ending bal|current period|account total|end of report|"
358
+ r"^\s*total\b|^\s*page\b", re.I)
359
+
360
+
361
+ def _money(value):
362
+ """'1,710.00' / '(25.00)' / '25.00-' / 25.0 / '' β†’ float."""
363
+ if value is None or (isinstance(value, float) and pd.isna(value)):
364
+ return 0.0
365
+ if isinstance(value, (int, float)):
366
+ return float(value)
367
+ s = str(value).strip().replace(",", "").replace("$", "")
368
+ if not s or s in {"-", ".", "nan"}:
369
+ return 0.0
370
+ neg = (s.startswith("(") and s.endswith(")")) or s.startswith("-") or s.endswith("-")
371
+ s = s.strip("()-").strip()
372
+ try:
373
+ return -float(s) if neg else float(s)
374
+ except ValueError:
375
+ return 0.0
376
+
377
+
378
+ def _text(value):
379
+ """Cell β†’ clean string. pandas NaN is a truthy float, so `or ""` is not enough."""
380
+ if value is None or (isinstance(value, float) and pd.isna(value)):
381
+ return ""
382
+ s = str(value).strip()
383
+ return "" if s.lower() == "nan" else s
384
+
385
+
386
+ def _norm_name(text):
387
+ """Vendor name β†’ comparison key. The ERP truncates payees at 27 chars, so
388
+ comparisons must survive one side being cut mid-word."""
389
+ s = re.sub(r"[^a-z0-9 ]", " ", str(text or "").lower())
390
+ s = re.sub(r"\b(inc|llc|ltd|co|corp|company|the|dba|of|and)\b", " ", s)
391
+ return re.sub(r"\s+", " ", s).strip()
392
+
393
+
394
+ # Payment-rail boilerplate. Bank descriptions read "Acworth Recycling dba
395
+ # Payment Instruction 1923-039-116-08 - Acworth Recycling dba Atlas Demolition
396
+ # - ach"; only the text before the first marker is the payee.
397
+ _RAIL_RE = re.compile(
398
+ r"\b(payment instruction|reversal|ref\.|wire|eft|trace|memo|ach|check)\b", re.I)
399
+
400
+
401
+ def _bank_payee(description):
402
+ """The payee portion of a bank description, minus the rail boilerplate."""
403
+ text = _text(description)
404
+ m = _RAIL_RE.search(text)
405
+ head = text[:m.start()] if m else text
406
+ return head.strip(" -\u2013\u2014|,") or text
407
+
408
+
409
+ def _name_close(a, b):
410
+ """True when two payee strings plausibly denote the same vendor.
411
+
412
+ `b` is matched against its payee portion rather than the whole bank
413
+ description: comparing a 27-char truncated ERP payee against a string that
414
+ also carries "Payment Instruction", a reference number and a channel
415
+ suffix diluted the ratio far below any usable threshold, so this returned
416
+ False even for obvious pairs and the tolerance pass never fired.
417
+ Containment handles the truncation in either direction.
418
+ """
419
+ a, b = _norm_name(a), _norm_name(_bank_payee(b))
420
+ if not a or not b:
421
+ return False
422
+ if a in b or b in a:
423
+ return True
424
+ return difflib.SequenceMatcher(None, a, b).ratio() >= 0.82
425
+
426
+
427
+ def _find_col(df, *keywords, exclude=()):
428
+ """First column whose header contains any keyword (case-insensitive).
429
+
430
+ Skips pandas' "Unnamed: N" placeholders for blank header cells β€” these are
431
+ everywhere in a report-style export, and "Unnamed" contains "name", which
432
+ otherwise wins the payee lookup and silently poisons every downstream key.
433
+ """
434
+ for col in df.columns:
435
+ low = str(col).strip().lower()
436
+ if low.startswith("unnamed"):
437
+ continue
438
+ if any(k in low for k in keywords) and not any(x in low for x in exclude):
439
+ return col
440
+ return None
441
+
442
+
443
+ def _parse_date(value):
444
+ """Pull a date out of a cell that may also carry other text."""
445
+ if value is None or (isinstance(value, float) and pd.isna(value)):
446
+ return pd.NaT
447
+ if isinstance(value, datetime):
448
+ return pd.Timestamp(value)
449
+ text = _text(value)
450
+ if not text:
451
+ return pd.NaT
452
+ m = _DATE_RE.search(text)
453
+ if m:
454
+ return pd.to_datetime(m.group(1), errors="coerce")
455
+ # Textual forms such as "Aug 29, 2026" β€” Azure DI and CSV exports both emit
456
+ # these. Parse the whole cell, but only when it is short enough to be a
457
+ # date rather than a description that happens to name a month.
458
+ if len(text) <= 32:
459
+ return pd.to_datetime(text, errors="coerce")
460
+ return pd.NaT
461
+
462
+
463
+ def normalize_erp_ledger(df):
464
+ """Collapse a G/L *report* export into one row per real transaction.
465
+
466
+ VCC's Infor exports are print reports, not data extracts β€” the Aug-2026
467
+ workbook carries 18,446 merged-cell ranges β€” so four things must be undone
468
+ before any matching is possible:
469
+
470
+ β€’ Date and payee arrive concatenated in the 'Date' column, leaving the
471
+ declared 'Name or Reference' column empty. July's export kept them in
472
+ separate columns, so both layouts are detected rather than assumed.
473
+ β€’ The entry number only prints on the first line of a report group; 28%
474
+ of August rows carry none. It is forward-filled.
475
+ β€’ One payment is split across N distribution lines (58% of August
476
+ entries; one has 32). The bank clears the entry, never the line, so
477
+ lines are summed per entry. This alone is why same-date/same-vendor
478
+ items "didn't come back as a match".
479
+ β€’ Debits and credits coexist on one entry (retainage). The bank clears
480
+ the net, so the net is what we keep.
481
+
482
+ Returns a frame of entry / date / payee / amount / n_lines / memo.
483
+ """
484
+ date_col = _find_col(df, "date")
485
+ name_col = _find_col(df, "name", "payee", "vendor", "reference", "desc")
486
+ entry_col = _find_col(df, "entry", "voucher", "check", "cheque", "doc")
487
+ debit_col = _find_col(df, "debit")
488
+ credit_col = _find_col(df, "credit")
489
+ amount_col = _find_col(df, "amount", "amt", exclude=("debit", "credit"))
490
+ memo_col = df.columns[0] if len(df.columns) else None
491
+
492
+ if date_col is None:
493
+ raise ValueError(
494
+ "Could not find a date column in the ERP export. Expected a header "
495
+ "containing 'date'; got: " + ", ".join(map(str, df.columns[:12])))
496
+ if debit_col is None and credit_col is None and amount_col is None:
497
+ raise ValueError(
498
+ "Could not find an amount column in the ERP export. Expected "
499
+ "'debit'/'credit' or 'amount'; got: "
500
+ + ", ".join(map(str, df.columns[:12])))
501
+
502
+ records, last_entry = [], ""
503
+ for _, row in df.iterrows():
504
+ raw_date = row.get(date_col)
505
+ date = _parse_date(raw_date)
506
+ if pd.isna(date):
507
+ continue # header, subtotal, page break or balance line
508
+
509
+ # Payee: prefer its own column; fall back to the text the date column
510
+ # carries after the date (the merged-cell case).
511
+ payee = _text(row.get(name_col)) if name_col else ""
512
+ if not payee:
513
+ payee = _DATE_RE.sub("", _text(raw_date), count=1).strip()
514
+ if _NOISE_RE.search(payee):
515
+ payee = ""
516
+ # On report continuations the merged cell carries the entry number in
517
+ # place of the payee ("08/17/2026 0000261785"). That is an entry key,
518
+ # not a name β€” keeping it as a payee splits one payment in two.
519
+ if re.fullmatch(r"\d{5,}", payee):
520
+ row_entry_from_name, payee = payee, ""
521
+ else:
522
+ row_entry_from_name = ""
523
+
524
+ if entry_col is not None:
525
+ entry = _text(row.get(entry_col))
526
+ if entry:
527
+ last_entry = entry
528
+ elif row_entry_from_name:
529
+ last_entry = row_entry_from_name
530
+ entry = last_entry
531
+
532
+ if amount_col is not None:
533
+ amount = _money(row.get(amount_col))
534
+ else:
535
+ amount = _money(row.get(debit_col)) - _money(row.get(credit_col))
536
+
537
+ memo = _text(row.get(memo_col)) if memo_col is not None else ""
538
+ if _NOISE_RE.search(memo):
539
+ continue
540
+
541
+ records.append({"entry": entry, "date": date, "payee": payee,
542
+ "amount": amount, "memo": memo})
543
+
544
+ if not records:
545
+ raise ValueError("No dated transaction rows were found in the ERP export.")
546
+
547
+ # Assign group ids sequentially rather than grouping on the key columns.
548
+ # Neither signal is reliable alone: the entry number is blank on 28% of
549
+ # rows (so grouping on it alone merges distinct payments), and the payee
550
+ # text varies within one entry (so grouping on it splits a single
551
+ # payment). A new transaction starts when the entry number changes, or
552
+ # when a named payee differs from the one its group was opened with.
553
+ gid, prev = -1, None
554
+ for rec in records:
555
+ starts_group = prev is None or (
556
+ rec["entry"] != prev["entry"]
557
+ or (rec["payee"] and prev["payee"] and rec["payee"] != prev["payee"])
558
+ )
559
+ if starts_group:
560
+ gid += 1
561
+ rec["gid"] = gid
562
+ prev = rec
563
+
564
+ raw = pd.DataFrame.from_records(records)
565
+
566
+ def first_named(series):
567
+ for v in series:
568
+ if v:
569
+ return v
570
+ return ""
571
+
572
+ grouped = (raw.groupby("gid", sort=False)
573
+ .agg(entry=("entry", "first"),
574
+ date=("date", "first"),
575
+ payee=("payee", first_named),
576
+ amount=("amount", "sum"),
577
+ n_lines=("amount", "size"),
578
+ memo=("memo", "first"))
579
+ .reset_index(drop=True))
580
+ # An entry whose distributions net to zero never hits the bank.
581
+ grouped = grouped[grouped["amount"].round(2) != 0].reset_index(drop=True)
582
+ # Count every line read, including those in zero-net entries just dropped,
583
+ # so the report can state how much of the file was actually consumed.
584
+ grouped.attrs["detail_lines_read"] = len(raw)
585
+
586
+ logger.info(f"πŸ“’ ERP normalised: {len(raw)} detail line(s) β†’ {len(grouped)} "
587
+ f"transaction(s) ({int((grouped['n_lines'] > 1).sum())} multi-line)")
588
+ return grouped
589
+
590
+
591
+ def normalize_bank_statement(df):
592
+ """Normalise the bank side and pull out any ERP reference the bank carries.
593
+
594
+ Struxtion writes the originating entry number into the description
595
+ ('Payment Instruction Ref. 0000262036 - Ronning Landscaping Inc. - check'),
596
+ which is an exact join key and present on about half the lines β€” far better
597
+ than any amount/date/name heuristic. It was previously unused.
598
+ """
599
+ date_col = _find_col(df, "date")
600
+ amount_col = _find_col(df, "amount", "amt", "value", exclude=("balance",))
601
+ desc_col = _find_col(df, "desc", "narrat", "detail", "memo", "title",
602
+ "reference", "payee", "name")
603
+ if date_col is None or amount_col is None:
604
+ raise ValueError(
605
+ "Could not find date and amount columns in the bank statement. "
606
+ "Got: " + ", ".join(map(str, df.columns[:12])))
607
+
608
+ out = pd.DataFrame({
609
+ "date": df[date_col].map(_parse_date),
610
+ "amount": df[amount_col].map(_money),
611
+ "desc": (df[desc_col].map(_text) if desc_col is not None else ""),
612
+ })
613
+ out = out[out["amount"].round(2) != 0].reset_index(drop=True)
614
+ out["refs"] = out["desc"].map(lambda s: set(_REF_RE.findall(s)))
615
+ # A reversal carries the same magnitude as the payment it backs out, so it
616
+ # competes for the same match. Flagged here and ranked last, so it is only
617
+ # taken when nothing else fits.
618
+ out["is_reversal"] = out["desc"].str.contains(
619
+ r"revers|returned|nsf|chargeback|stop\s*pay", case=False, regex=True, na=False)
620
+
621
+ n_ref = int(out["refs"].map(bool).sum())
622
+ logger.info(f"🏦 Bank normalised: {len(out)} transaction(s), "
623
+ f"{n_ref} carrying an ERP reference")
624
+ return out
625
+
626
+
627
+ def _ref_keys(entry):
628
+ """'0000261546' β†’ {'0000261546', '261546'} so padded and unpadded refs meet."""
629
+ e = str(entry or "").strip()
630
+ return {e, e.lstrip("0")} - {""}
631
+
632
+
633
+ def match_transactions(erp, bank, amount_tol=0.01, date_tol_days=45):
634
+ """Greedy one-to-one match, strongest evidence first.
635
+
636
+ Passes run in descending confidence and each consumes the rows it matches,
637
+ so a weaker rule can never steal a transaction a stronger rule owns:
638
+ 1. entry reference + amount β€” exact, no tolerance needed
639
+ 2. exact amount, nearest date
640
+ 3. amount within tolerance + payee similarity
641
+ 4. same-payee roll-up: several ERP entries clearing as one bank payment
642
+
643
+ Date is a tie-breaker, not a filter. Measured against the 131 pairs that
644
+ pass 1 matches by reference alone, the lag between the GL posting date and
645
+ the bank clearing date runs from -27 to +19 days β€” the bank frequently
646
+ clears before the GL posts, because the GL books on the AP batch date.
647
+ A tight window (the previous prompt asked for +/- 3 business days) throws
648
+ away more true matches than it prevents false ones, so `date_tol_days` is
649
+ only a sanity guard and ranking is what actually picks between candidates
650
+ of equal amount.
651
+
652
+ Returns (matches, erp_unmatched, bank_unmatched).
653
+ """
654
+ erp = erp.reset_index(drop=True).copy()
655
+ bank = bank.reset_index(drop=True).copy()
656
+ erp_open = set(erp.index)
657
+ bank_open = set(bank.index)
658
+ matches = []
659
+
660
+ def take(ei, bi, method, confidence, erp_rows=None):
661
+ # A roll-up match owns several ERP entries, so the reported amount and
662
+ # line count are the group's. Reporting only the first entry's figures
663
+ # rendered every roll-up as a matched pair whose two sides disagreed.
664
+ rows = list(erp_rows) if erp_rows else [ei]
665
+ e, b = erp.loc[ei], bank.loc[bi]
666
+ entries = [str(erp.at[i, "entry"]) for i in rows if _text(erp.at[i, "entry"])]
667
+ matches.append({
668
+ "erp_entry": ", ".join(entries) if entries else "",
669
+ "erp_date": e["date"],
670
+ "erp_payee": e["payee"],
671
+ "erp_amount": float(erp.loc[rows, "amount"].sum()),
672
+ "erp_lines": int(erp.loc[rows, "n_lines"].sum()),
673
+ "bank_date": b["date"], "bank_desc": b["desc"], "bank_amount": b["amount"],
674
+ "method": method, "confidence": confidence,
675
+ "erp_rows": rows,
676
+ })
677
+ for idx in rows:
678
+ erp_open.discard(idx)
679
+ bank_open.discard(bi)
680
+
681
+ # Amounts are compared on magnitude: the GL books a payment as a positive
682
+ # debit while the bank shows a negative debit.
683
+ bank_by_amt = {}
684
+ for bi in bank.index:
685
+ bank_by_amt.setdefault(round(abs(bank.at[bi, "amount"]), 2), []).append(bi)
686
+
687
+ def candidates(amount):
688
+ return [bi for bi in bank_by_amt.get(round(abs(amount), 2), []) if bi in bank_open]
689
+
690
+ def day_gap(ei, bi):
691
+ """Absolute day distance, or None when either side has no usable date."""
692
+ ed, bd = erp.at[ei, "date"], bank.at[bi, "date"]
693
+ if pd.isna(ed) or pd.isna(bd):
694
+ return None
695
+ return abs((bd - ed).days)
696
+
697
+ def within_days(ei, bi, days):
698
+ gap = day_gap(ei, bi)
699
+ return True if gap is None else gap <= days
700
+
701
+ has_reversal_flag = "is_reversal" in bank.columns
702
+
703
+ def is_reversal(bi):
704
+ return bool(bank.at[bi, "is_reversal"]) if has_reversal_flag else False
705
+
706
+ def nearest(ei, options):
707
+ """Of several equal-amount bank lines, the best candidate.
708
+
709
+ Ordinary lines are preferred over reversals before date is considered.
710
+ August 2026 carried 539,249.00 as two debits and one reversal credit;
711
+ ranking on date alone could hand an ERP payment the reversal, which
712
+ reports the reversal as cleared and leaves a real payment outstanding.
713
+ """
714
+ if not options:
715
+ return None
716
+ return min(options, key=lambda bi: (is_reversal(bi),
717
+ day_gap(ei, bi) is None,
718
+ day_gap(ei, bi) or 0))
719
+
720
+ # ── Pass 1: the bank's own reference to the ERP entry ────────────────────
721
+ for ei in sorted(erp_open):
722
+ keys = _ref_keys(erp.at[ei, "entry"])
723
+ if not keys:
724
+ continue
725
+ hits = [bi for bi in candidates(erp.at[ei, "amount"])
726
+ if keys & bank.at[bi, "refs"]]
727
+ bi = nearest(ei, hits)
728
+ if bi is not None:
729
+ take(ei, bi, "entry reference + amount", "Exact")
730
+
731
+ # ── Pass 2: exact amount inside the clearing window ──────────────────────
732
+ for ei in sorted(erp_open):
733
+ viable = [bi for bi in candidates(erp.at[ei, "amount"])
734
+ if within_days(ei, bi, date_tol_days)]
735
+ bi = nearest(ei, viable)
736
+ if bi is not None:
737
+ take(ei, bi, "exact amount, nearest date", "Exact")
738
+
739
+ # ── Pass 3: amount tolerance + payee similarity ──────────────────────────
740
+ if amount_tol > 0:
741
+ for ei in sorted(erp_open):
742
+ target = abs(erp.at[ei, "amount"])
743
+ viable = [bi for bi in sorted(bank_open)
744
+ if abs(abs(bank.at[bi, "amount"]) - target) <= amount_tol
745
+ and within_days(ei, bi, date_tol_days)
746
+ and _name_close(erp.at[ei, "payee"], bank.at[bi, "desc"])]
747
+ bi = nearest(ei, viable)
748
+ if bi is not None:
749
+ take(ei, bi, "amount tolerance + payee", "High")
750
+
751
+ # ── Pass 4: several ERP entries clearing as one bank payment ─────────────
752
+ # Struxtion batches invoices into a single ACH, so the bank line equals the
753
+ # sum of that vendor's open entries rather than any one of them.
754
+ by_payee = {}
755
+ for ei in sorted(erp_open):
756
+ by_payee.setdefault(_norm_name(erp.at[ei, "payee"]), []).append(ei)
757
+ for payee, idxs in by_payee.items():
758
+ if len(idxs) < 2 or not payee:
759
+ continue
760
+ total = sum(erp.at[i, "amount"] for i in idxs)
761
+ for bi in candidates(total):
762
+ if any(within_days(i, bi, date_tol_days) for i in idxs):
763
+ take(idxs[0], bi, f"roll-up of {len(idxs)} ERP entries", "High",
764
+ erp_rows=idxs)
765
+ break
766
+
767
+ matched_df = pd.DataFrame(matches)
768
+ erp_un = erp.loc[sorted(erp_open)].reset_index(drop=True)
769
+ bank_un = bank.loc[sorted(bank_open)].reset_index(drop=True)
770
+ logger.info(f"πŸ”— Matched {len(matched_df)} of {len(erp)} ERP transaction(s) / "
771
+ f"{len(bank)} bank line(s); unmatched: {len(erp_un)} ERP, "
772
+ f"{len(bank_un)} bank")
773
+ return matched_df, erp_un, bank_un
774
+
775
+
776
  _ERP_RELEVANT_KEYWORDS = (
777
  "date", "desc", "memo", "narrat", "detail", "name", "payee", "vendor",
778
  "customer", "party", "reference", "ref", "invoice", "inv", "bill",
 
902
  logger.info(f"πŸ“‹ Account #{account_idx + 1}: {account_number} ({account_type})"
903
  f" | Opening: {begin_bal} Closing: {end_bal}")
904
 
905
+ # Kept so the extraction can be checked against the statement's own
906
+ # control totals further down β€” see validate_bank_extraction().
907
+ if _safe_float(begin_bal) is not None and _safe_float(end_bal) is not None:
908
+ statement_meta.setdefault("balances", []).append(
909
+ {"account": account_number,
910
+ "beginning": _safe_float(begin_bal),
911
+ "ending": _safe_float(end_bal)})
912
+
913
  # ── Transactions ─────────────────────────────────────────────────
914
  txns_field = account_obj.get("Transactions")
915
  if not txns_field or not txns_field.value_array:
 
966
  # Returns: (dataframe, source_label)
967
  # dataframe = pandas DataFrame of transactions (trimmed/chunked downstream)
968
  # source_label = "CSV" or "PDF (Bank: ..., Period: ...)" for context
969
+ # meta = statement metadata (control balances for PDFs; {} for CSVs)
970
  def extract_transactions(file):
971
  # Handle both string paths (Gradio filepath) and file objects
972
  if isinstance(file, str):
 
980
  if filename.endswith(".pdf"):
981
  df, meta = extract_transactions_from_pdf(file_path)
982
  label = f"PDF (Bank: {meta.get('bank','N/A')}, Period: {meta.get('start','?')} β†’ {meta.get('end','?')})"
983
+ return df, label, meta
984
 
985
  elif filename.endswith(".csv"):
986
  try:
 
988
  logger.info(f"βœ… Successfully read CSV file: {file_path}")
989
  logger.info(f"πŸ“ˆ File contains {len(df)} rows and {len(df.columns)} columns")
990
  logger.info(f"🏷️ Column names: {list(df.columns)}")
991
+ return df, "CSV", {}
992
  except Exception as e:
993
  error_msg = f"Error processing {file_path}: {e}"
994
  logger.error(f"❌ {error_msg}")
 
1268
  return reports
1269
 
1270
 
1271
+ def _fmt_money(value):
1272
+ return f"${value:,.2f}" if value >= 0 else f"-${abs(value):,.2f}"
1273
+
1274
+
1275
+ def _fmt_date(value):
1276
+ return "" if pd.isna(value) else pd.Timestamp(value).strftime("%Y-%m-%d")
1277
+
1278
+
1279
+ def build_narrative_prompt(stats, erp_un, bank_un, stmt_label):
1280
+ """Ask the model for prose only.
1281
+
1282
+ The model is never asked to match, count or total anything β€” those are
1283
+ computed and passed in. It sees the finished figures and a sample of the
1284
+ exceptions, and writes the introduction and the recommended actions.
1285
+ """
1286
+ def sample(df, n=15):
1287
+ if df.empty:
1288
+ return "(none)"
1289
+ rows = df.reindex(df["amount"].abs().sort_values(ascending=False).index).head(n)
1290
+ return "\n".join(
1291
+ f"- {_fmt_date(r['date'])} | {str(r.get('payee') or r.get('desc') or '')[:60]} "
1292
+ f"| {_fmt_money(r['amount'])}"
1293
+ for _, r in rows.iterrows())
1294
+
1295
+ return f"""You are a senior financial analyst writing up a completed bank reconciliation.
1296
+
1297
+ The reconciliation has ALREADY been performed deterministically. The figures below are final.
1298
+ Do NOT recompute, re-match, or second-guess them, and do NOT invent transactions.
1299
+
1300
+ FINAL FIGURES
1301
+ - Period: {stats['period']}
1302
+ - ERP transactions (distribution lines rolled up to entries): {stats['erp_count']} (from {stats['erp_lines']} detail lines)
1303
+ - {stmt_label} transactions: {stats['bank_count']}
1304
+ - Matched: {stats['matched']} ({stats['erp_cov']:.1f}% of ERP, {stats['bank_cov']:.1f}% of {stmt_label})
1305
+ - Unmatched in ERP: {stats['erp_unmatched']} worth {_fmt_money(stats['erp_unmatched_value'])} gross
1306
+ - Unmatched in {stmt_label}: {stats['bank_unmatched']} worth {_fmt_money(stats['bank_unmatched_value'])} gross
1307
+ - Match methods used: {stats['methods']}
1308
+
1309
+ LARGEST UNMATCHED {stmt_label.upper()} ITEMS
1310
+ {sample(bank_un)}
1311
+
1312
+ LARGEST UNMATCHED ERP ITEMS
1313
+ {sample(erp_un)}
1314
+
1315
+ Write EXACTLY two Markdown sections and nothing else:
1316
+
1317
+ ### πŸ“˜ Introduction
1318
+ One paragraph: what this reconciliation covers, the period, the record counts on each
1319
+ side, and how the ledger was rolled up from detail lines to entries. Plain and factual.
1320
+
1321
+ ### 🧾 Suggested Next Steps
1322
+ A numbered list of concrete actions for the accounting team, driven by the unmatched
1323
+ items above. Name the specific vendors and amounts you were given. Distinguish items
1324
+ that are expected to be bank-only (platform fees, reversals, prefunds, treasury
1325
+ transfers) from those that look like genuine posting or timing gaps needing follow-up.
1326
+ """
1327
+
1328
+
1329
+ def render_reconciliation_markdown(matched, erp_un, bank_un, stats, stmt_label, narrative):
1330
+ """Build the report body. Every table and figure here is computed, not written
1331
+ by the model; `narrative` supplies only the introduction and next steps."""
1332
+ parts = []
1333
+ intro, steps = narrative, ""
1334
+ if "### 🧾" in narrative:
1335
+ intro, steps = narrative.split("### 🧾", 1)
1336
+ steps = "### 🧾" + steps
1337
+ parts.append(intro.strip())
1338
+
1339
+ parts.append("### βœ… Matched Transactions\n")
1340
+ if matched.empty:
1341
+ parts.append("_No transactions matched._\n")
1342
+ else:
1343
+ parts.append("| # | ERP Date | ERP Entry | ERP Payee | Lines | ERP Amount | "
1344
+ f"{stmt_label} Date | {stmt_label} Description | {stmt_label} Amount | "
1345
+ "Method | Confidence |")
1346
+ parts.append("|---|---|---|---|---|---|---|---|---|---|---|")
1347
+ for i, (_, r) in enumerate(matched.iterrows(), 1):
1348
+ parts.append(
1349
+ f"| {i} | {_fmt_date(r['erp_date'])} | {r['erp_entry']} | "
1350
+ f"{str(r['erp_payee'])[:38]} | {r['erp_lines']} | {_fmt_money(r['erp_amount'])} | "
1351
+ f"{_fmt_date(r['bank_date'])} | {str(r['bank_desc'])[:60]} | "
1352
+ f"{_fmt_money(r['bank_amount'])} | {r['method']} | {r['confidence']} |")
1353
+ parts.append("")
1354
+
1355
+ parts.append(f"### ❌ Unmatched in {stmt_label}\n")
1356
+ if bank_un.empty:
1357
+ parts.append(f"_Every {stmt_label} line was matched._\n")
1358
+ else:
1359
+ parts.append("| # | Date | Description | Amount |")
1360
+ parts.append("|---|---|---|---|")
1361
+ ordered = bank_un.reindex(bank_un["amount"].abs().sort_values(ascending=False).index)
1362
+ for i, (_, r) in enumerate(ordered.iterrows(), 1):
1363
+ parts.append(f"| {i} | {_fmt_date(r['date'])} | {str(r['desc'])[:80]} | "
1364
+ f"{_fmt_money(r['amount'])} |")
1365
+ parts.append("")
1366
+
1367
+ parts.append("### ❌ Unmatched in ERP\n")
1368
+ if erp_un.empty:
1369
+ parts.append("_Every ERP transaction was matched._\n")
1370
+ else:
1371
+ parts.append("| # | Date | Entry | Payee | Lines | Amount |")
1372
+ parts.append("|---|---|---|---|---|---|")
1373
+ ordered = erp_un.reindex(erp_un["amount"].abs().sort_values(ascending=False).index)
1374
+ for i, (_, r) in enumerate(ordered.iterrows(), 1):
1375
+ parts.append(f"| {i} | {_fmt_date(r['date'])} | {r['entry']} | "
1376
+ f"{str(r['payee'])[:38]} | {r['n_lines']} | {_fmt_money(r['amount'])} |")
1377
+ parts.append("")
1378
+
1379
+ parts.append("### πŸ“Š Reconciliation Summary\n")
1380
+ parts.append("| Metric | Value |")
1381
+ parts.append("|--------|-------|")
1382
+ parts.append(f"| ERP detail lines read | {stats['erp_lines']:,} |")
1383
+ parts.append(f"| ERP transactions (after roll-up) | {stats['erp_count']:,} |")
1384
+ parts.append(f"| {stmt_label} transactions | {stats['bank_count']:,} |")
1385
+ parts.append(f"| Matched | {stats['matched']:,} |")
1386
+ parts.append(f"| ERP coverage | {stats['erp_cov']:.1f}% |")
1387
+ parts.append(f"| {stmt_label} coverage | {stats['bank_cov']:.1f}% |")
1388
+ parts.append(f"| Unmatched in ERP | {stats['erp_unmatched']:,} "
1389
+ f"({_fmt_money(stats['erp_unmatched_value'])} gross) |")
1390
+ parts.append(f"| Unmatched in {stmt_label} | {stats['bank_unmatched']:,} "
1391
+ f"({_fmt_money(stats['bank_unmatched_value'])} gross) |")
1392
+ parts.append("")
1393
+
1394
+ if steps:
1395
+ parts.append(steps.strip())
1396
+ return "\n".join(parts)
1397
+
1398
+
1399
+ def validate_bank_extraction(df, meta, tolerance=0.01):
1400
+ """Check the extracted rows against the statement's own control totals.
1401
+
1402
+ Beginning balance + the sum of every transaction must equal the ending
1403
+ balance. When it does not, rows were dropped during extraction and any
1404
+ reconciliation built on them is quietly wrong β€” which is exactly what
1405
+ happened to VCC in August 2026: 313 of 588 lines were read and nothing
1406
+ in the report said so. Returns (ok, message).
1407
+ """
1408
+ balances = (meta or {}).get("balances") or []
1409
+ if not balances:
1410
+ return True, "" # nothing to check against
1411
+
1412
+ beginning = sum(b["beginning"] for b in balances)
1413
+ ending = sum(b["ending"] for b in balances)
1414
+ extracted = float(pd.to_numeric(df["Amount"], errors="coerce").fillna(0).sum())
1415
+ expected = ending - beginning
1416
+ gap = extracted - expected
1417
+
1418
+ if abs(gap) <= tolerance:
1419
+ logger.info(f"βœ… Bank extraction ties to the statement: "
1420
+ f"{beginning:,.2f} + {extracted:,.2f} = {ending:,.2f}")
1421
+ return True, ""
1422
+
1423
+ message = (
1424
+ f"The {len(df)} transactions read from the statement do not tie to the "
1425
+ f"statement's own totals. Opening {beginning:,.2f} plus the extracted "
1426
+ f"movement {extracted:,.2f} gives {beginning + extracted:,.2f}, but the "
1427
+ f"statement closes at {ending:,.2f} β€” a difference of {gap:,.2f}. "
1428
+ f"Lines were missed during extraction, so the figures below are "
1429
+ f"incomplete. Re-upload the statement or supply it as CSV before "
1430
+ f"relying on this reconciliation.")
1431
+ logger.error(f"❌ Bank extraction does not tie β€” gap {gap:,.2f}")
1432
+ return False, message
1433
+
1434
+
1435
+ def reconcile_deterministic(erp_df, external_df, stmt_label, extraction_warning=""):
1436
+ """Normalise both sides, match, and return (markdown_body, stats).
1437
+
1438
+ Replaces the previous flow, which pasted the raw ledger into a prompt and
1439
+ asked the model to produce the match table. That could not be relied on:
1440
+ on VCC's August 2026 ledger it narrated 1,450 ERP rows and 313 bank lines
1441
+ for files holding 4,042 and 588, and confirmed about a tenth of the real
1442
+ transactions. Matching now happens here; the model only writes the prose.
1443
+ """
1444
+ erp = normalize_erp_ledger(erp_df)
1445
+ bank = normalize_bank_statement(external_df)
1446
+ matched, erp_un, bank_un = match_transactions(erp, bank)
1447
+
1448
+ dates = pd.concat([erp["date"], bank["date"]]).dropna()
1449
+ period = (f"{dates.min():%Y-%m-%d} β†’ {dates.max():%Y-%m-%d}"
1450
+ if not dates.empty else "not determined")
1451
+ methods = (", ".join(f"{v} {k}" for k, v in matched["method"].value_counts().items())
1452
+ if not matched.empty else "none")
1453
+
1454
+ stats = {
1455
+ "period": period,
1456
+ "erp_lines": int(erp.attrs.get("detail_lines_read", erp["n_lines"].sum())),
1457
+ "erp_count": len(erp),
1458
+ "bank_count": len(bank),
1459
+ "matched": len(matched),
1460
+ "erp_cov": (len(erp) - len(erp_un)) / len(erp) * 100 if len(erp) else 0.0,
1461
+ "bank_cov": (len(bank) - len(bank_un)) / len(bank) * 100 if len(bank) else 0.0,
1462
+ "erp_unmatched": len(erp_un),
1463
+ "bank_unmatched": len(bank_un),
1464
+ # Gross, not a signed net: unmatched reversal credits would otherwise
1465
+ # cancel unmatched debits and understate the work remaining.
1466
+ "erp_unmatched_value": float(erp_un["amount"].abs().sum()) if not erp_un.empty else 0.0,
1467
+ "bank_unmatched_value": float(bank_un["amount"].abs().sum()) if not bank_un.empty else 0.0,
1468
+ "methods": methods,
1469
+ }
1470
+
1471
+ narrative = _call_openai(build_narrative_prompt(stats, erp_un, bank_un, stmt_label))
1472
+ body = render_reconciliation_markdown(matched, erp_un, bank_un, stats,
1473
+ stmt_label, narrative)
1474
+ if extraction_warning:
1475
+ body = (f"> ⚠️ **Incomplete statement extraction.** {extraction_warning}\n\n"
1476
+ + body)
1477
+ return body, stats
1478
+
1479
+
1480
  # βœ… Reconcile the statements using OpenAI
1481
  def reconcile_statements_openai(erp_file, external_file, statement_type):
1482
  session_start = datetime.now()
 
1514
  # ── Step 1: Read ERP CSV ─────────────────────────────────────────────
1515
  yield "⏳ Step 1/3 β€” Reading ERP statement…", "", None, None
1516
  logger.info("πŸ“Š Extracting data from ERP file...")
1517
+ erp_df, erp_label, _ = extract_transactions(erp_file)
1518
 
1519
  # ── Step 2: Read external statement (CSV or PDF via Azure DI) ────────
1520
  is_pdf = ext_path.lower().endswith(".pdf")
 
1524
  yield f"⏳ Step 2/3 β€” Reading {stmt_label} CSV…", "", None, None
1525
 
1526
  logger.info(f"πŸ“Š Extracting data from {stmt_label}...")
1527
+ external_df, ext_label, external_meta = extract_transactions(external_file)
1528
+
1529
+ # A statement that does not tie to its own opening/closing balance was
1530
+ # only partly read. Warn rather than reconcile against a silent subset.
1531
+ extraction_ok, extraction_warning = validate_bank_extraction(external_df, external_meta)
1532
+ if not extraction_ok:
1533
+ yield (f"⚠️ {stmt_label} extraction is incomplete β€” continuing, "
1534
+ f"but the report will say so…", "", None, None)
1535
 
1536
  # ── Step 3: AI Reconciliation ────────────────────────────────────────
1537
  # Trim the ERP ledger to reconciliation-relevant columns, then check the
1538
  # token budget. The external statement is always sent whole; only the
1539
  # (usually much larger) ERP side is chunked if it doesn't fit.
1540
+ # ── Step 3: Reconciliation ───────────────────────────────────────────
1541
+ # Bank reconciliations are matched in code (see the deterministic
1542
+ # engine above) and only narrated by the model, so there is no prompt
1543
+ # to outgrow and no batching. Vendor reconciliations still use the
1544
+ # original single-prompt path.
1545
+ if not is_vendor:
1546
+ yield f"⏳ Step 3/3 β€” Matching ERP entries against the {stmt_label}…", "", None, None
1547
+ raw_content, stats = reconcile_deterministic(
1548
+ erp_df, external_df, stmt_label, extraction_warning)
1549
+ logger.info(f"βœ… Deterministic reconciliation: {stats['matched']} matched, "
1550
+ f"ERP {stats['erp_cov']:.1f}%, {stmt_label} {stats['bank_cov']:.1f}%")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1551
  else:
1552
+ erp_df = trim_erp_columns(erp_df)
1553
+ external_data = external_df.to_string(index=False)
1554
+
1555
+ erp_budget = MAX_PROMPT_TOKENS - PROMPT_OVERHEAD_TOKENS - estimate_tokens(external_data)
1556
+ if erp_budget <= 0:
1557
+ raise ValueError(
1558
+ f"The {stmt_label} alone ({estimate_tokens(external_data)} tokens) exceeds the "
1559
+ f"model input budget of {MAX_PROMPT_TOKENS} tokens. Please split the statement.")
1560
+
1561
+ erp_chunks = chunk_dataframe(erp_df, erp_budget)
1562
+ n_batches = len(erp_chunks)
1563
+
1564
+ partial_reports = []
1565
+ for idx, chunk in enumerate(erp_chunks):
1566
+ if n_batches > 1:
1567
+ yield (f"⏳ Step 3/3 β€” Reconciling ERP batch {idx + 1}/{n_batches} "
1568
+ f"against the {stmt_label}…", "", None, None)
1569
+ batch_note = (f"\n**NOTE:** This is one batch of a large ERP "
1570
+ f"ledger. You are seeing ONLY a subset of the ERP rows, reconciled "
1571
+ f"against the COMPLETE {stmt_label}.\n")
1572
+ else:
1573
+ yield f"⏳ Step 3/3 β€” AI is reconciling the statements ({stmt_label})…", "", None, None
1574
+ batch_note = ""
1575
+
1576
+ partial_reports += _reconcile_one_chunk(
1577
+ chunk, external_data, erp_label, ext_label, is_vendor, batch_note)
1578
+ logger.info(f"βœ… Received batch {idx + 1}/{n_batches} response from OpenAI")
1579
+
1580
+ n_batches = len(partial_reports)
1581
+ if n_batches == 1:
1582
+ raw_content = partial_reports[0]
1583
  else:
1584
+ yield f"⏳ Step 3/3 β€” Consolidating {n_batches} batches into one report…", "", None, None
1585
+ consolidation_prompt = build_consolidation_prompt(partial_reports, stmt_label, n_batches)
1586
+ _concat = "\n\n---\n\n".join(
1587
+ f"## Batch {i + 1} of {n_batches}\n\n{rep}"
1588
+ for i, rep in enumerate(partial_reports))
1589
+ if estimate_tokens(consolidation_prompt) <= MAX_PROMPT_TOKENS - PROMPT_OVERHEAD_TOKENS:
1590
+ logger.info(f"🧩 Consolidating {n_batches} batch reports via OpenAI...")
1591
+ try:
1592
+ raw_content = _call_openai(consolidation_prompt)
1593
+ except Exception as e:
1594
+ if _is_token_limit_error(e):
1595
+ logger.warning("⚠️ Consolidation rejected for size β€” concatenating instead")
1596
+ raw_content = _concat
1597
+ else:
1598
+ raise
1599
+ else:
1600
+ logger.warning("⚠️ Consolidation prompt too large β€” concatenating batch reports instead")
1601
+ raw_content = _concat
1602
 
1603
  logger.info(f"πŸ“„ Response length: {len(raw_content)} characters")
1604