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)
- 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 |
-
|
| 883 |
-
|
| 884 |
-
|
| 885 |
-
|
| 886 |
-
|
| 887 |
-
|
| 888 |
-
|
| 889 |
-
|
| 890 |
-
|
| 891 |
-
|
| 892 |
-
|
| 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 |
-
|
| 919 |
-
|
| 920 |
-
|
| 921 |
-
|
| 922 |
-
|
| 923 |
-
|
| 924 |
-
|
| 925 |
-
|
| 926 |
-
|
| 927 |
-
|
| 928 |
-
|
| 929 |
-
|
| 930 |
-
|
| 931 |
-
|
| 932 |
-
|
| 933 |
-
|
| 934 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 935 |
else:
|
| 936 |
-
|
| 937 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|