Add independent bank identity detection and APGB parser
This commit is contained in:
@@ -0,0 +1,165 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from .base import BaseParser, StatementMeta, amount, extract_text, finalize, norm
|
||||||
|
from .common import date_iso, find, infer_mode
|
||||||
|
|
||||||
|
|
||||||
|
class AndhraPradeshGrameenaBankParser(BaseParser):
|
||||||
|
bank_name = "Andhra Pradesh Grameena Bank"
|
||||||
|
parser_name = "AndhraPradeshGrameenaBankParser"
|
||||||
|
|
||||||
|
_DATE_LINE = re.compile(r"^\s*(\d{2}-\d{2}-\d{4})\s+(.*)$")
|
||||||
|
_BALANCE = re.compile(r"(-?[0-9,]+\.\d{2})\s*(Cr|Dr)\s*$", re.I)
|
||||||
|
_MONEY = re.compile(r"(?<![A-Za-z0-9])([0-9,]+\.\d{2})(?![A-Za-z0-9])")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def detect(cls, text: str) -> float:
|
||||||
|
upper = (text or "").upper()
|
||||||
|
score = 0.0
|
||||||
|
if "ANDHRA PRADESH GRAMEENA BANK" in upper:
|
||||||
|
score += 0.78
|
||||||
|
if "COREBANKING.APGB.IN" in upper or "APGB.IN" in upper:
|
||||||
|
score += 0.14
|
||||||
|
if "UBIN0CG" in upper:
|
||||||
|
score += 0.06
|
||||||
|
if "STATEMENT OF ACCOUNT" in upper and "PAGE TOTAL" in upper and "GRAND TOTAL" in upper:
|
||||||
|
score += 0.02
|
||||||
|
return min(score, 0.999)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _signed_balance(value: str, marker: str) -> float | None:
|
||||||
|
parsed = amount(value)
|
||||||
|
if parsed is None:
|
||||||
|
return None
|
||||||
|
return -abs(parsed) if marker.upper() == "DR" else abs(parsed)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _reference(narration: str) -> str:
|
||||||
|
for pattern in (
|
||||||
|
r"\bCMP\d{12,}\b",
|
||||||
|
r"\bNACH[A-Z0-9/\-]+\b",
|
||||||
|
r"\b\d{12,18}\b",
|
||||||
|
):
|
||||||
|
match = re.search(pattern, narration, re.I)
|
||||||
|
if match:
|
||||||
|
return match.group(0)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def parse(self, path, text=None):
|
||||||
|
pdf_path = Path(path)
|
||||||
|
text = text or extract_text(pdf_path)
|
||||||
|
meta = StatementMeta(
|
||||||
|
bank_name=self.bank_name,
|
||||||
|
source_file=pdf_path.name,
|
||||||
|
parser_name=self.parser_name,
|
||||||
|
confidence="High",
|
||||||
|
)
|
||||||
|
|
||||||
|
meta.customer_name = norm(find(r"Name\s*:\s*([^\n]+?)(?:\s+A/C\s+NO|$)", text, flags=re.I))
|
||||||
|
meta.account_number = find(r"A/C\s*NO\s*:\s*([0-9]+)", text, flags=re.I)
|
||||||
|
meta.customer_id = find(r"Cust\s*ID\s*:\s*([0-9]+)", text, flags=re.I)
|
||||||
|
meta.ifsc = find(r"IFSC\s*CODE\s*:\s*([A-Z0-9]+)", text, flags=re.I)
|
||||||
|
period = re.search(r"STATEMENT\s+OF\s+ACCOUNT\s+(\d{2}-\d{2}-\d{4})\s+to\s+(\d{2}-\d{2}-\d{4})", text, re.I)
|
||||||
|
if period:
|
||||||
|
meta.period_from = date_iso(period.group(1))
|
||||||
|
meta.period_to = date_iso(period.group(2))
|
||||||
|
|
||||||
|
bf = re.search(r"\bB/F\s+([0-9,]+\.\d{2})\s*(Cr|Dr)", text, re.I)
|
||||||
|
if bf:
|
||||||
|
meta.opening_balance = self._signed_balance(bf.group(1), bf.group(2))
|
||||||
|
|
||||||
|
grand_total = re.search(
|
||||||
|
r"Grand\s+Total\s*:\s*([0-9,]+\.\d{2})\s+([0-9,]+\.\d{2})\s+([0-9,]+\.\d{2})\s*(Cr|Dr)",
|
||||||
|
text,
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
if grand_total:
|
||||||
|
meta.total_debit = amount(grand_total.group(1))
|
||||||
|
meta.total_credit = amount(grand_total.group(2))
|
||||||
|
meta.closing_balance = self._signed_balance(grand_total.group(3), grand_total.group(4))
|
||||||
|
|
||||||
|
rows: list[dict] = []
|
||||||
|
current: dict | None = None
|
||||||
|
previous_balance = meta.opening_balance
|
||||||
|
|
||||||
|
for page_no, page_text in enumerate(re.split(r"\f", text), start=1):
|
||||||
|
for raw_line in page_text.splitlines():
|
||||||
|
stripped = raw_line.strip()
|
||||||
|
match = self._DATE_LINE.match(stripped)
|
||||||
|
if match:
|
||||||
|
if current is not None:
|
||||||
|
rows.append(current)
|
||||||
|
previous_balance = current["balance"]
|
||||||
|
current = None
|
||||||
|
|
||||||
|
txn_date, remainder = match.groups()
|
||||||
|
balance_match = self._BALANCE.search(remainder)
|
||||||
|
if not balance_match:
|
||||||
|
continue
|
||||||
|
balance_value = self._signed_balance(balance_match.group(1), balance_match.group(2))
|
||||||
|
before_balance = remainder[:balance_match.start()].rstrip()
|
||||||
|
money_matches = list(self._MONEY.finditer(before_balance))
|
||||||
|
narration_end = money_matches[0].start() if money_matches else len(before_balance)
|
||||||
|
narration = norm(before_balance[:narration_end])
|
||||||
|
|
||||||
|
debit = credit = None
|
||||||
|
if previous_balance is not None and balance_value is not None:
|
||||||
|
delta = round(balance_value - previous_balance, 2)
|
||||||
|
if delta < -0.009:
|
||||||
|
debit = abs(delta)
|
||||||
|
elif delta > 0.009:
|
||||||
|
credit = delta
|
||||||
|
elif money_matches:
|
||||||
|
# The statement prints debit before credit. The common
|
||||||
|
# running-balance validator will independently verify it.
|
||||||
|
debit = amount(money_matches[-1].group(1))
|
||||||
|
|
||||||
|
current = {
|
||||||
|
"transaction_date": txn_date,
|
||||||
|
"value_date": txn_date,
|
||||||
|
"narration": narration,
|
||||||
|
"reference_no": self._reference(narration),
|
||||||
|
"debit": debit,
|
||||||
|
"credit": credit,
|
||||||
|
"balance": balance_value,
|
||||||
|
"source_page": page_no,
|
||||||
|
"mode": infer_mode(narration),
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
|
||||||
|
if current is not None:
|
||||||
|
continuation = norm(stripped)
|
||||||
|
upper = continuation.upper()
|
||||||
|
if not continuation:
|
||||||
|
continue
|
||||||
|
if (
|
||||||
|
upper.startswith((
|
||||||
|
"PAGE TOTAL", "GRAND TOTAL", "TRANSACTION DETAILS", "HTTPS://",
|
||||||
|
"UNLESS THE", "BRANCH MANAGER", "****END", "MUCH MORE",
|
||||||
|
))
|
||||||
|
or set(continuation) <= {"-"}
|
||||||
|
or "STATEMENT OF ACCOUNT,IT WILL BE TAKEN" in upper
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
current["narration"] = norm(f"{current['narration']} {continuation}")
|
||||||
|
current["reference_no"] = current["reference_no"] or self._reference(current["narration"])
|
||||||
|
|
||||||
|
# Do not carry an unfinished row into footer text on another page;
|
||||||
|
# append it at the page boundary while preserving its source page.
|
||||||
|
if current is not None:
|
||||||
|
rows.append(current)
|
||||||
|
previous_balance = current["balance"]
|
||||||
|
current = None
|
||||||
|
|
||||||
|
df = pd.DataFrame(rows)
|
||||||
|
result = finalize(df, meta)
|
||||||
|
if result.empty:
|
||||||
|
raise ValueError(
|
||||||
|
"Andhra Pradesh Grameena Bank format was identified, but no transaction rows could be extracted."
|
||||||
|
)
|
||||||
|
return meta, result
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BankIdentity:
|
||||||
|
name: str
|
||||||
|
category: str
|
||||||
|
aliases: tuple[str, ...] = ()
|
||||||
|
ifsc_prefixes: tuple[str, ...] = ()
|
||||||
|
domains: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BankIdentityMatch:
|
||||||
|
name: str
|
||||||
|
category: str
|
||||||
|
confidence: float
|
||||||
|
evidence: tuple[str, ...]
|
||||||
|
|
||||||
|
|
||||||
|
# Commercial-bank identity registry. Parsing is intentionally independent of
|
||||||
|
# this list: templates extract transactions; this registry only assigns the
|
||||||
|
# institution name. The list covers current public/private banks, SFBs,
|
||||||
|
# payments banks, RRBs after the 2025 One-State-One-RRB amalgamation, major
|
||||||
|
# foreign banks operating in India, and legacy names that still appear in PDFs.
|
||||||
|
BANK_IDENTITIES: tuple[BankIdentity, ...] = (
|
||||||
|
# Public sector banks
|
||||||
|
BankIdentity("State Bank of India", "Public Sector Bank", ("STATE BANK OF INDIA", "SBI"), ("SBIN",), ("sbi.co.in", "onlinesbi.sbi")),
|
||||||
|
BankIdentity("Bank of Baroda", "Public Sector Bank", ("BANK OF BARODA", "BOB"), ("BARB",), ("bankofbaroda.in",)),
|
||||||
|
BankIdentity("Bank of India", "Public Sector Bank", ("BANK OF INDIA",), ("BKID",), ("bankofindia.co.in",)),
|
||||||
|
BankIdentity("Bank of Maharashtra", "Public Sector Bank", ("BANK OF MAHARASHTRA",), ("MAHB",), ("bankofmaharashtra.in",)),
|
||||||
|
BankIdentity("Canara Bank", "Public Sector Bank", ("CANARA BANK",), ("CNRB",), ("canarabank.com",)),
|
||||||
|
BankIdentity("Central Bank of India", "Public Sector Bank", ("CENTRAL BANK OF INDIA",), ("CBIN",), ("centralbankofindia.co.in",)),
|
||||||
|
BankIdentity("Indian Bank", "Public Sector Bank", ("INDIAN BANK",), ("IDIB",), ("indianbank.in",)),
|
||||||
|
BankIdentity("Indian Overseas Bank", "Public Sector Bank", ("INDIAN OVERSEAS BANK", "IOB"), ("IOBA",), ("iob.in",)),
|
||||||
|
BankIdentity("Punjab & Sind Bank", "Public Sector Bank", ("PUNJAB & SIND BANK", "PUNJAB AND SIND BANK"), ("PSIB",), ("punjabandsindbank.co.in",)),
|
||||||
|
BankIdentity("Punjab National Bank", "Public Sector Bank", ("PUNJAB NATIONAL BANK", "PNB"), ("PUNB",), ("pnbindia.in",)),
|
||||||
|
BankIdentity("UCO Bank", "Public Sector Bank", ("UCO BANK",), ("UCBA",), ("ucobank.com",)),
|
||||||
|
BankIdentity("Union Bank of India", "Public Sector Bank", ("UNION BANK OF INDIA",), ("UBIN",), ("unionbankofindia.co.in",)),
|
||||||
|
|
||||||
|
# Indian private sector banks
|
||||||
|
BankIdentity("Axis Bank", "Private Sector Bank", ("AXIS BANK",), ("UTIB",), ("axisbank.com",)),
|
||||||
|
BankIdentity("Bandhan Bank", "Private Sector Bank", ("BANDHAN BANK",), ("BDBL",), ("bandhanbank.com",)),
|
||||||
|
BankIdentity("CSB Bank", "Private Sector Bank", ("CSB BANK", "CATHOLIC SYRIAN BANK"), ("CSBK",), ("csb.co.in",)),
|
||||||
|
BankIdentity("City Union Bank", "Private Sector Bank", ("CITY UNION BANK",), ("CIUB",), ("cityunionbank.com",)),
|
||||||
|
BankIdentity("DCB Bank", "Private Sector Bank", ("DCB BANK", "DEVELOPMENT CREDIT BANK"), ("DCBL",), ("dcbbank.com",)),
|
||||||
|
BankIdentity("Dhanlaxmi Bank", "Private Sector Bank", ("DHANLAXMI BANK", "DHANALAKSHMI BANK"), ("DLXB",), ("dhanbank.com",)),
|
||||||
|
BankIdentity("Federal Bank", "Private Sector Bank", ("FEDERAL BANK",), ("FDRL",), ("federalbank.co.in",)),
|
||||||
|
BankIdentity("HDFC Bank", "Private Sector Bank", ("HDFC BANK",), ("HDFC",), ("hdfcbank.com",)),
|
||||||
|
BankIdentity("ICICI Bank", "Private Sector Bank", ("ICICI BANK",), ("ICIC",), ("icicibank.com",)),
|
||||||
|
BankIdentity("IDBI Bank", "Private Sector Bank", ("IDBI BANK",), ("IBKL",), ("idbibank.in",)),
|
||||||
|
BankIdentity("IDFC FIRST Bank", "Private Sector Bank", ("IDFC FIRST BANK", "IDFC BANK"), ("IDFB",), ("idfcfirstbank.com",)),
|
||||||
|
BankIdentity("IndusInd Bank", "Private Sector Bank", ("INDUSIND BANK",), ("INDB",), ("indusind.com",)),
|
||||||
|
BankIdentity("Jammu & Kashmir Bank", "Private Sector Bank", ("JAMMU & KASHMIR BANK", "JAMMU AND KASHMIR BANK", "J&K BANK"), ("JAKA",), ("jkb.bank.in", "jkbank.com")),
|
||||||
|
BankIdentity("Karnataka Bank", "Private Sector Bank", ("KARNATAKA BANK",), ("KARB",), ("karnatakabank.com",)),
|
||||||
|
BankIdentity("Karur Vysya Bank", "Private Sector Bank", ("KARUR VYSYA BANK", "KVB"), ("KVBL",), ("kvb.co.in",)),
|
||||||
|
BankIdentity("Kotak Mahindra Bank", "Private Sector Bank", ("KOTAK MAHINDRA BANK", "KOTAK BANK"), ("KKBK",), ("kotak.com",)),
|
||||||
|
BankIdentity("Nainital Bank", "Private Sector Bank", ("NAINITAL BANK",), ("NTBL",), ("nainitalbank.co.in",)),
|
||||||
|
BankIdentity("RBL Bank", "Private Sector Bank", ("RBL BANK", "RATNAKAR BANK"), ("RATN",), ("rblbank.com",)),
|
||||||
|
BankIdentity("South Indian Bank", "Private Sector Bank", ("SOUTH INDIAN BANK",), ("SIBL",), ("southindianbank.com",)),
|
||||||
|
BankIdentity("Tamilnad Mercantile Bank", "Private Sector Bank", ("TAMILNAD MERCANTILE BANK", "TMB"), ("TMBL",), ("tmb.in",)),
|
||||||
|
BankIdentity("YES Bank", "Private Sector Bank", ("YES BANK",), ("YESB",), ("yesbank.in",)),
|
||||||
|
|
||||||
|
# Small finance banks
|
||||||
|
BankIdentity("AU Small Finance Bank", "Small Finance Bank", ("AU SMALL FINANCE BANK",), ("AUBL",), ("aubank.in",)),
|
||||||
|
BankIdentity("Capital Small Finance Bank", "Small Finance Bank", ("CAPITAL SMALL FINANCE BANK",), ("CLBL",), ("capitalbank.co.in",)),
|
||||||
|
BankIdentity("Equitas Small Finance Bank", "Small Finance Bank", ("EQUITAS SMALL FINANCE BANK",), ("ESFB",), ("equitasbank.com",)),
|
||||||
|
BankIdentity("ESAF Small Finance Bank", "Small Finance Bank", ("ESAF SMALL FINANCE BANK",), ("ESMF",), ("esafbank.com",)),
|
||||||
|
BankIdentity("Jana Small Finance Bank", "Small Finance Bank", ("JANA SMALL FINANCE BANK",), ("JSFB",), ("janabank.com",)),
|
||||||
|
BankIdentity("Shivalik Small Finance Bank", "Small Finance Bank", ("SHIVALIK SMALL FINANCE BANK",), ("SMCB",), ("shivalikbank.com",)),
|
||||||
|
BankIdentity("slice Small Finance Bank", "Small Finance Bank", ("SLICE SMALL FINANCE BANK", "NORTH EAST SMALL FINANCE BANK"), ("NESF",), ("slice.bank",)),
|
||||||
|
BankIdentity("Suryoday Small Finance Bank", "Small Finance Bank", ("SURYODAY SMALL FINANCE BANK",), ("SURY",), ("suryodaybank.com",)),
|
||||||
|
BankIdentity("Ujjivan Small Finance Bank", "Small Finance Bank", ("UJJIVAN SMALL FINANCE BANK",), ("UJVN",), ("ujjivansfb.in",)),
|
||||||
|
BankIdentity("Unity Small Finance Bank", "Small Finance Bank", ("UNITY SMALL FINANCE BANK",), ("UNSF",), ("theunitybank.com",)),
|
||||||
|
BankIdentity("Utkarsh Small Finance Bank", "Small Finance Bank", ("UTKARSH SMALL FINANCE BANK",), ("UTKS",), ("utkarsh.bank",)),
|
||||||
|
|
||||||
|
# Payments banks
|
||||||
|
BankIdentity("Airtel Payments Bank", "Payments Bank", ("AIRTEL PAYMENTS BANK",), ("AIRP",), ("airtel.in/bank",)),
|
||||||
|
BankIdentity("Fino Payments Bank", "Payments Bank", ("FINO PAYMENTS BANK",), ("FINO",), ("finobank.com",)),
|
||||||
|
BankIdentity("India Post Payments Bank", "Payments Bank", ("INDIA POST PAYMENTS BANK", "IPPB"), ("IPOS",), ("ippbonline.com",)),
|
||||||
|
BankIdentity("Jio Payments Bank", "Payments Bank", ("JIO PAYMENTS BANK",), ("JIOP",), ("jiopaymentsbank.com",)),
|
||||||
|
BankIdentity("NSDL Payments Bank", "Payments Bank", ("NSDL PAYMENTS BANK",), ("NSPB",), ("nsdlpayments.bank",)),
|
||||||
|
BankIdentity("Paytm Payments Bank", "Payments Bank", ("PAYTM PAYMENTS BANK",), ("PYTM",), ("paytmbank.com",)),
|
||||||
|
|
||||||
|
# Regional Rural Banks after One-State-One-RRB (2025)
|
||||||
|
BankIdentity("Andhra Pradesh Grameena Bank", "Regional Rural Bank", ("ANDHRA PRADESH GRAMEENA BANK", "ANDHRA PRAGATHI GRAMEENA BANK", "CHAITANYA GODAVARI GRAMEENA BANK", "SAPTAGIRI GRAMEENA BANK"), ("UBIN0CG",), ("corebanking.apgb.in", "apgb.in")),
|
||||||
|
BankIdentity("Arunachal Pradesh Rural Bank", "Regional Rural Bank", ("ARUNACHAL PRADESH RURAL BANK",), (), ("apruralbank.com",)),
|
||||||
|
BankIdentity("Assam Gramin Bank", "Regional Rural Bank", ("ASSAM GRAMIN BANK", "ASSAM GRAMIN VIKASH BANK"), (), ("agvbank.co.in",)),
|
||||||
|
BankIdentity("Bihar Gramin Bank", "Regional Rural Bank", ("BIHAR GRAMIN BANK", "DAKSHIN BIHAR GRAMIN BANK", "UTTAR BIHAR GRAMIN BANK"), (), ("dbgb.in", "ubgb.in")),
|
||||||
|
BankIdentity("Chhattisgarh Gramin Bank", "Regional Rural Bank", ("CHHATTISGARH GRAMIN BANK", "CHHATTISGARH RAJYA GRAMIN BANK"), (), ("cgbank.in",)),
|
||||||
|
BankIdentity("Gujarat Gramin Bank", "Regional Rural Bank", ("GUJARAT GRAMIN BANK", "BARODA GUJARAT GRAMIN BANK", "SAURASHTRA GRAMIN BANK"), (), ("bggb.in", "sgbrrb.org")),
|
||||||
|
BankIdentity("Haryana Gramin Bank", "Regional Rural Bank", ("HARYANA GRAMIN BANK", "SARVA HARYANA GRAMIN BANK"), (), ("shgb.co.in",)),
|
||||||
|
BankIdentity("Himachal Pradesh Gramin Bank", "Regional Rural Bank", ("HIMACHAL PRADESH GRAMIN BANK",), (), ("hpgb.in",)),
|
||||||
|
BankIdentity("Jammu and Kashmir Grameen Bank", "Regional Rural Bank", ("JAMMU AND KASHMIR GRAMEEN BANK", "J&K GRAMEEN BANK", "ELLAQUAI DEHATI BANK"), (), ("jkgb.in",)),
|
||||||
|
BankIdentity("Jharkhand Rajya Gramin Bank", "Regional Rural Bank", ("JHARKHAND RAJYA GRAMIN BANK",), (), ("jrgb.in",)),
|
||||||
|
BankIdentity("Karnataka Grameena Bank", "Regional Rural Bank", ("KARNATAKA GRAMEENA BANK", "KARNATAKA GRAMIN BANK", "KARNATAKA VIKAS GRAMEENA BANK"), (), ("karnatakagraminbank.com", "kvgbank.com")),
|
||||||
|
BankIdentity("Kerala Gramin Bank", "Regional Rural Bank", ("KERALA GRAMIN BANK",), (), ("keralagbank.com",)),
|
||||||
|
BankIdentity("Madhya Pradesh Gramin Bank", "Regional Rural Bank", ("MADHYA PRADESH GRAMIN BANK", "MADHYANCHAL GRAMIN BANK"), (), ("mpgb.co.in",)),
|
||||||
|
BankIdentity("Maharashtra Gramin Bank", "Regional Rural Bank", ("MAHARASHTRA GRAMIN BANK", "VIDHARBHA KONKAN GRAMIN BANK"), (), ("mahagramin.in", "vkgb.co.in")),
|
||||||
|
BankIdentity("Manipur Rural Bank", "Regional Rural Bank", ("MANIPUR RURAL BANK",), (), ("manipurruralbank.com",)),
|
||||||
|
BankIdentity("Meghalaya Rural Bank", "Regional Rural Bank", ("MEGHALAYA RURAL BANK",), (), ("meghalayaruralbank.co.in",)),
|
||||||
|
BankIdentity("Mizoram Rural Bank", "Regional Rural Bank", ("MIZORAM RURAL BANK",), (), ("mizoramruralbank.in",)),
|
||||||
|
BankIdentity("Nagaland Rural Bank", "Regional Rural Bank", ("NAGALAND RURAL BANK",), (), ("nagalandruralbank.com",)),
|
||||||
|
BankIdentity("Odisha Grameen Bank", "Regional Rural Bank", ("ODISHA GRAMEEN BANK", "ODISHA GRAMYA BANK", "UTKAL GRAMEEN BANK"), (), ("odishabank.in", "odishagramyabank.in")),
|
||||||
|
BankIdentity("Puducherry Grama Bank", "Regional Rural Bank", ("PUDUCHERRY GRAMA BANK", "PUDUVAI BHARATHIAR GRAMA BANK"), (), ("puduvaibharathiargramabank.in",)),
|
||||||
|
BankIdentity("Punjab Gramin Bank", "Regional Rural Bank", ("PUNJAB GRAMIN BANK",), (), ("pgb.org.in",)),
|
||||||
|
BankIdentity("Rajasthan Gramin Bank", "Regional Rural Bank", ("RAJASTHAN GRAMIN BANK", "RAJASTHAN MARUDHARA GRAMIN BANK", "BARODA RAJASTHAN KSHETRIYA GRAMIN BANK"), (), ("rmgb.in", "brkgb.com")),
|
||||||
|
BankIdentity("Tamil Nadu Grama Bank", "Regional Rural Bank", ("TAMIL NADU GRAMA BANK",), (), ("tamilnadugramabank.com",)),
|
||||||
|
BankIdentity("Telangana Grameena Bank", "Regional Rural Bank", ("TELANGANA GRAMEENA BANK", "ANDHRA PRADESH GRAMEENA VIKAS BANK"), (), ("tgbhyd.in", "apgvbank.in")),
|
||||||
|
BankIdentity("Tripura Gramin Bank", "Regional Rural Bank", ("TRIPURA GRAMIN BANK",), (), ("tripuragraminbank.org",)),
|
||||||
|
BankIdentity("Uttar Pradesh Gramin Bank", "Regional Rural Bank", ("UTTAR PRADESH GRAMIN BANK", "ARYAVART BANK", "BARODA U.P. BANK", "BARODA UP BANK", "PRATHAMA U.P. GRAMIN BANK"), (), ("upgbank.com", "aryavart-rrb.com", "barodaupbank.in", "prathamaupbank.com")),
|
||||||
|
BankIdentity("Uttarakhand Gramin Bank", "Regional Rural Bank", ("UTTARAKHAND GRAMIN BANK",), (), ("uttarakhandgraminbank.com",)),
|
||||||
|
BankIdentity("West Bengal Gramin Bank", "Regional Rural Bank", ("WEST BENGAL GRAMIN BANK", "BANGIYA GRAMIN VIKASH BANK", "PASCHIM BANGA GRAMIN BANK", "UTTARBANGA KSHETRIYA GRAMIN BANK"), (), ("bgvb.in", "pbgbank.com", "ubkgb.org")),
|
||||||
|
|
||||||
|
# Foreign banks commonly issuing Indian account statements
|
||||||
|
BankIdentity("HSBC India", "Foreign Bank", ("HSBC BANK", "THE HONGKONG AND SHANGHAI BANKING CORPORATION"), ("HSBC",), ("hsbc.co.in",)),
|
||||||
|
BankIdentity("Standard Chartered Bank India", "Foreign Bank", ("STANDARD CHARTERED BANK",), ("SCBL",), ("sc.com/in",)),
|
||||||
|
BankIdentity("DBS Bank India", "Foreign Bank", ("DBS BANK INDIA", "DBS BANK"), ("DBSS",), ("dbs.com/in",)),
|
||||||
|
BankIdentity("Deutsche Bank India", "Foreign Bank", ("DEUTSCHE BANK",), ("DEUT",), ("deutschebank.co.in",)),
|
||||||
|
BankIdentity("Citibank India", "Foreign Bank", ("CITIBANK", "CITI BANK"), ("CITI",), ("citibank.co.in",)),
|
||||||
|
BankIdentity("Bank of America India", "Foreign Bank", ("BANK OF AMERICA",), ("BOFA",), ("bankofamerica.com",)),
|
||||||
|
BankIdentity("Barclays Bank India", "Foreign Bank", ("BARCLAYS BANK",), ("BARC",), ("barclays.in",)),
|
||||||
|
BankIdentity("BNP Paribas India", "Foreign Bank", ("BNP PARIBAS",), ("BNPA",), ("apac.bnpparibas",)),
|
||||||
|
BankIdentity("MUFG Bank India", "Foreign Bank", ("MUFG BANK", "BANK OF TOKYO-MITSUBISHI UFJ"), ("BOTM",), ("bk.mufg.jp",)),
|
||||||
|
BankIdentity("Mizuho Bank India", "Foreign Bank", ("MIZUHO BANK",), ("MHCB",), ("mizuhogroup.com",)),
|
||||||
|
BankIdentity("Sumitomo Mitsui Banking Corporation India", "Foreign Bank", ("SUMITOMO MITSUI BANKING CORPORATION", "SMBC"), ("SMBC",), ("smbc.co.jp",)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalise(value: str) -> str:
|
||||||
|
return re.sub(r"[^A-Z0-9.&]+", " ", str(value or "").upper()).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _alias_present(text_upper: str, alias: str) -> bool:
|
||||||
|
normalised_alias = _normalise(alias)
|
||||||
|
if not normalised_alias:
|
||||||
|
return False
|
||||||
|
return re.search(rf"(?<![A-Z0-9]){re.escape(normalised_alias)}(?![A-Z0-9])", text_upper) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def detect_bank_identity(
|
||||||
|
text: str,
|
||||||
|
*,
|
||||||
|
ifsc: str = "",
|
||||||
|
source_file: str = "",
|
||||||
|
) -> BankIdentityMatch | None:
|
||||||
|
raw_text = str(text or "")
|
||||||
|
text_upper = _normalise(raw_text)
|
||||||
|
ifsc_upper = re.sub(r"[^A-Z0-9]", "", str(ifsc or "").upper())
|
||||||
|
file_upper = _normalise(Path(source_file or "").stem)
|
||||||
|
|
||||||
|
best: BankIdentityMatch | None = None
|
||||||
|
for bank in BANK_IDENTITIES:
|
||||||
|
score = 0.0
|
||||||
|
evidence: list[str] = []
|
||||||
|
|
||||||
|
# Longest/special IFSC prefixes carry the strongest identity signal.
|
||||||
|
matching_prefixes = [prefix for prefix in bank.ifsc_prefixes if ifsc_upper.startswith(prefix.upper())]
|
||||||
|
if matching_prefixes:
|
||||||
|
prefix = max(matching_prefixes, key=len)
|
||||||
|
score += 0.72 if len(prefix) > 4 else 0.58
|
||||||
|
evidence.append(f"IFSC prefix {prefix}")
|
||||||
|
|
||||||
|
for domain in bank.domains:
|
||||||
|
if domain.lower() in raw_text.lower():
|
||||||
|
score += 0.72
|
||||||
|
evidence.append(f"domain {domain}")
|
||||||
|
break
|
||||||
|
|
||||||
|
alias_hits = [alias for alias in bank.aliases if _alias_present(text_upper, alias)]
|
||||||
|
if alias_hits:
|
||||||
|
longest = max(alias_hits, key=len)
|
||||||
|
# Long formal headings are stronger than abbreviations such as SBI.
|
||||||
|
score += 0.72 if len(_normalise(longest)) >= 12 else 0.36
|
||||||
|
evidence.append(f"name {longest}")
|
||||||
|
|
||||||
|
if any(_alias_present(file_upper, alias) for alias in bank.aliases):
|
||||||
|
score += 0.10
|
||||||
|
evidence.append("filename")
|
||||||
|
|
||||||
|
confidence = min(score, 0.99)
|
||||||
|
if confidence < 0.50:
|
||||||
|
continue
|
||||||
|
candidate = BankIdentityMatch(bank.name, bank.category, confidence, tuple(dict.fromkeys(evidence)))
|
||||||
|
if best is None or candidate.confidence > best.confidence:
|
||||||
|
best = candidate
|
||||||
|
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
def apply_detected_bank_identity(meta, df, text: str):
|
||||||
|
match = detect_bank_identity(
|
||||||
|
text,
|
||||||
|
ifsc=str(getattr(meta, "ifsc", "") or ""),
|
||||||
|
source_file=str(getattr(meta, "source_file", "") or ""),
|
||||||
|
)
|
||||||
|
if match is None:
|
||||||
|
return meta, df
|
||||||
|
|
||||||
|
# Do not let a reused layout/parser brand the statement as another bank.
|
||||||
|
# A high-confidence independent identity always wins.
|
||||||
|
if match.confidence >= 0.68:
|
||||||
|
meta.bank_name = match.name
|
||||||
|
if df is not None and not df.empty:
|
||||||
|
df = df.copy()
|
||||||
|
df["bank_name"] = match.name
|
||||||
|
return meta, df
|
||||||
@@ -1,9 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
from .idfc import IDFCFirstParser
|
from .idfc import IDFCFirstParser
|
||||||
from .axis import AxisParser
|
from .axis import AxisParser
|
||||||
from .hdfc import HDFCParser
|
from .hdfc import HDFCParser
|
||||||
@@ -18,12 +14,15 @@ from .yes_bank import YesBankParser
|
|||||||
from .city_union_bank import CityUnionBankParser
|
from .city_union_bank import CityUnionBankParser
|
||||||
from .bank_of_baroda import BankOfBarodaParser
|
from .bank_of_baroda import BankOfBarodaParser
|
||||||
from .rbl_bank import RBLBankParser
|
from .rbl_bank import RBLBankParser
|
||||||
|
from .andhra_pradesh_grameena_bank import AndhraPradeshGrameenaBankParser
|
||||||
|
from .bank_identity import apply_detected_bank_identity
|
||||||
from .base import extract_text
|
from .base import extract_text
|
||||||
from .template_engine import parse_with_template
|
|
||||||
|
|
||||||
PARSERS = [
|
PARSERS = [
|
||||||
IDFCFirstParser,
|
IDFCFirstParser,
|
||||||
AxisParser,
|
AxisParser,
|
||||||
|
AndhraPradeshGrameenaBankParser,
|
||||||
CentralBankOfIndiaParser,
|
CentralBankOfIndiaParser,
|
||||||
YesBankParser,
|
YesBankParser,
|
||||||
CityUnionBankParser,
|
CityUnionBankParser,
|
||||||
@@ -43,6 +42,7 @@ PARSERS = [
|
|||||||
BANK_OPTIONS = [
|
BANK_OPTIONS = [
|
||||||
("auto", "Auto Detect"),
|
("auto", "Auto Detect"),
|
||||||
("axis", "Axis Bank"),
|
("axis", "Axis Bank"),
|
||||||
|
("andhra_pradesh_grameena_bank", "Andhra Pradesh Grameena Bank"),
|
||||||
("central_bank_of_india", "Central Bank of India"),
|
("central_bank_of_india", "Central Bank of India"),
|
||||||
("yes_bank", "YES Bank"),
|
("yes_bank", "YES Bank"),
|
||||||
("city_union_bank", "City Union Bank"),
|
("city_union_bank", "City Union Bank"),
|
||||||
@@ -60,6 +60,7 @@ BANK_OPTIONS = [
|
|||||||
|
|
||||||
BANK_PARSERS = {
|
BANK_PARSERS = {
|
||||||
"axis": [AxisParser],
|
"axis": [AxisParser],
|
||||||
|
"andhra_pradesh_grameena_bank": [AndhraPradeshGrameenaBankParser],
|
||||||
"central_bank_of_india": [CentralBankOfIndiaParser],
|
"central_bank_of_india": [CentralBankOfIndiaParser],
|
||||||
"yes_bank": [YesBankParser],
|
"yes_bank": [YesBankParser],
|
||||||
"city_union_bank": [CityUnionBankParser],
|
"city_union_bank": [CityUnionBankParser],
|
||||||
@@ -77,7 +78,11 @@ BANK_PARSERS = {
|
|||||||
|
|
||||||
|
|
||||||
def detect_parser(text):
|
def detect_parser(text):
|
||||||
scored = sorted(((parser.detect(text), parser) for parser in PARSERS), key=lambda item: item[0], reverse=True)
|
scored = sorted(
|
||||||
|
((parser.detect(text), parser) for parser in PARSERS),
|
||||||
|
key=lambda item: item[0],
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
if not scored or scored[0][0] <= 0:
|
if not scored or scored[0][0] <= 0:
|
||||||
return None, 0
|
return None, 0
|
||||||
return scored[0][1](), scored[0][0]
|
return scored[0][1](), scored[0][0]
|
||||||
@@ -87,115 +92,50 @@ def _selected_parser(bank_key: str, text: str):
|
|||||||
candidates = BANK_PARSERS.get(bank_key, [])
|
candidates = BANK_PARSERS.get(bank_key, [])
|
||||||
if not candidates:
|
if not candidates:
|
||||||
return None, 0
|
return None, 0
|
||||||
scored = sorted(((parser.detect(text), parser) for parser in candidates), key=lambda item: item[0], reverse=True)
|
scored = sorted(
|
||||||
|
((parser.detect(text), parser) for parser in candidates),
|
||||||
|
key=lambda item: item[0],
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
if scored and scored[0][0] > 0:
|
if scored and scored[0][0] > 0:
|
||||||
return scored[0][1](), scored[0][0]
|
return scored[0][1](), scored[0][0]
|
||||||
return None, 0
|
return None, 0
|
||||||
|
|
||||||
|
|
||||||
def _usable_result(result) -> bool:
|
def _parse_and_identify(parser, path, text: str):
|
||||||
if result is None:
|
meta, df = parser.parse(path, text)
|
||||||
return False
|
return apply_detected_bank_identity(meta, df, text)
|
||||||
try:
|
|
||||||
_meta, frame = result
|
|
||||||
return frame is not None and not frame.empty
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _attempt(parser, path, text: str, route: str):
|
|
||||||
try:
|
|
||||||
result = parser.parse(path, text)
|
|
||||||
if _usable_result(result):
|
|
||||||
meta, frame = result
|
|
||||||
logger.info(
|
|
||||||
"Bank analyzer route accepted: route=%s parser=%s rows=%s file=%s",
|
|
||||||
route, getattr(meta, "parser_name", parser.__class__.__name__), len(frame), path,
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
logger.warning(
|
|
||||||
"Bank analyzer route returned no rows: route=%s parser=%s file=%s",
|
|
||||||
route, parser.__class__.__name__, path,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning(
|
|
||||||
"Bank analyzer route failed: route=%s parser=%s file=%s error=%s",
|
|
||||||
route, parser.__class__.__name__, path, exc, exc_info=True,
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _template_first(path, text: str, hint: str):
|
|
||||||
try:
|
|
||||||
result = parse_with_template(path, text=text, bank_hint=hint)
|
|
||||||
if _usable_result(result):
|
|
||||||
meta, frame = result
|
|
||||||
logger.info(
|
|
||||||
"Bank analyzer template accepted: parser=%s rows=%s file=%s",
|
|
||||||
getattr(meta, "parser_name", "TemplateBasedStatementParser"), len(frame), path,
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
logger.warning("Bank analyzer template returned no rows: file=%s", path)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning(
|
|
||||||
"Bank analyzer template rejected: file=%s error=%s", path, exc, exc_info=True,
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _ordered_bank_candidates(text: str):
|
|
||||||
scored = sorted(
|
|
||||||
((parser.detect(text), parser) for parser in PARSERS),
|
|
||||||
key=lambda item: item[0],
|
|
||||||
reverse=True,
|
|
||||||
)
|
|
||||||
return [parser for score, parser in scored if score > 0]
|
|
||||||
|
|
||||||
|
|
||||||
def parse_pdf(path, bank_hint: str | None = None):
|
def parse_pdf(path, bank_hint: str | None = None):
|
||||||
hint = (bank_hint or "auto").strip().lower()
|
hint = (bank_hint or "auto").strip().lower()
|
||||||
text = extract_text(path)
|
text = extract_text(path)
|
||||||
|
|
||||||
template_result = _template_first(path, text, hint)
|
if hint == "hsbc":
|
||||||
if template_result is not None:
|
meta, df = HSBCParser().parse(path, text)
|
||||||
return template_result
|
return apply_detected_bank_identity(meta, df, text)
|
||||||
|
|
||||||
if hint and hint != "auto":
|
if hint and hint != "auto":
|
||||||
candidates = BANK_PARSERS.get(hint, [])
|
parser, score = _selected_parser(hint, text)
|
||||||
for parser_class in sorted(candidates, key=lambda cls: cls.detect(text), reverse=True):
|
if parser is None:
|
||||||
result = _attempt(parser_class(), path, text, f"selected:{hint}")
|
|
||||||
if result is not None:
|
|
||||||
return result
|
|
||||||
label = dict(BANK_OPTIONS).get(hint, "the selected bank")
|
label = dict(BANK_OPTIONS).get(hint, "the selected bank")
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"The uploaded statement could not be parsed by a validated layout template or the "
|
f"The uploaded statement does not match the selected {label} format. "
|
||||||
f"selected {label} parser. Please verify that the PDF is readable and supported."
|
"Please verify the selected bank or choose Auto Detect."
|
||||||
)
|
)
|
||||||
|
return _parse_and_identify(parser, path, text)
|
||||||
|
|
||||||
attempted: set[type] = set()
|
parser, score = detect_parser(text)
|
||||||
for parser_class in _ordered_bank_candidates(text):
|
if parser is None and not (text or "").strip():
|
||||||
attempted.add(parser_class)
|
# Image-only statements cannot be identified by pdftotext. HSBCParser
|
||||||
result = _attempt(parser_class(), path, text, "auto-detected")
|
# performs its own OCR identification and raises a precise mismatch error.
|
||||||
if result is not None:
|
try:
|
||||||
return result
|
meta, df = HSBCParser().parse(path, text)
|
||||||
|
return apply_detected_bank_identity(meta, df, text)
|
||||||
if not (text or "").strip():
|
except ValueError:
|
||||||
result = _attempt(HSBCParser(), path, text, "image-only-ocr")
|
pass
|
||||||
if result is not None:
|
if parser is None:
|
||||||
return result
|
|
||||||
|
|
||||||
# Last-resort compatibility pass: a detector may miss a new layout even
|
|
||||||
# though the bank-specific parser can parse its visual table correctly.
|
|
||||||
for parser_class in PARSERS:
|
|
||||||
if parser_class in attempted:
|
|
||||||
continue
|
|
||||||
result = _attempt(parser_class(), path, text, "compatibility-fallback")
|
|
||||||
if result is not None:
|
|
||||||
return result
|
|
||||||
|
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"The bank statement format was identified, but no transaction rows could be extracted. "
|
"Unsupported statement format. Select the bank manually or add a bank-specific parser for this statement layout."
|
||||||
"The template engine and all bank-specific parsers were attempted. "
|
|
||||||
"Please verify that the PDF is text-readable and that this statement layout is supported."
|
|
||||||
)
|
)
|
||||||
|
return _parse_and_identify(parser, path, text)
|
||||||
|
|||||||
Reference in New Issue
Block a user