Add independent bank identity detection and APGB parser

This commit is contained in:
A R R R Associates
2026-08-04 13:50:52 +05:30
parent 79bfc808d3
commit 0f6e4126e8
3 changed files with 420 additions and 103 deletions
@@ -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
import logging
logger = logging.getLogger(__name__)
from .idfc import IDFCFirstParser
from .axis import AxisParser
from .hdfc import HDFCParser
@@ -18,12 +14,15 @@ from .yes_bank import YesBankParser
from .city_union_bank import CityUnionBankParser
from .bank_of_baroda import BankOfBarodaParser
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 .template_engine import parse_with_template
PARSERS = [
IDFCFirstParser,
AxisParser,
AndhraPradeshGrameenaBankParser,
CentralBankOfIndiaParser,
YesBankParser,
CityUnionBankParser,
@@ -43,6 +42,7 @@ PARSERS = [
BANK_OPTIONS = [
("auto", "Auto Detect"),
("axis", "Axis Bank"),
("andhra_pradesh_grameena_bank", "Andhra Pradesh Grameena Bank"),
("central_bank_of_india", "Central Bank of India"),
("yes_bank", "YES Bank"),
("city_union_bank", "City Union Bank"),
@@ -60,6 +60,7 @@ BANK_OPTIONS = [
BANK_PARSERS = {
"axis": [AxisParser],
"andhra_pradesh_grameena_bank": [AndhraPradeshGrameenaBankParser],
"central_bank_of_india": [CentralBankOfIndiaParser],
"yes_bank": [YesBankParser],
"city_union_bank": [CityUnionBankParser],
@@ -77,7 +78,11 @@ BANK_PARSERS = {
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:
return None, 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, [])
if not candidates:
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:
return scored[0][1](), scored[0][0]
return None, 0
def _usable_result(result) -> bool:
if result is None:
return False
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_and_identify(parser, path, text: str):
meta, df = parser.parse(path, text)
return apply_detected_bank_identity(meta, df, text)
def parse_pdf(path, bank_hint: str | None = None):
hint = (bank_hint or "auto").strip().lower()
text = extract_text(path)
template_result = _template_first(path, text, hint)
if template_result is not None:
return template_result
if hint == "hsbc":
meta, df = HSBCParser().parse(path, text)
return apply_detected_bank_identity(meta, df, text)
if hint and hint != "auto":
candidates = BANK_PARSERS.get(hint, [])
for parser_class in sorted(candidates, key=lambda cls: cls.detect(text), reverse=True):
result = _attempt(parser_class(), path, text, f"selected:{hint}")
if result is not None:
return result
parser, score = _selected_parser(hint, text)
if parser is None:
label = dict(BANK_OPTIONS).get(hint, "the selected bank")
raise ValueError(
f"The uploaded statement could not be parsed by a validated layout template or the "
f"selected {label} parser. Please verify that the PDF is readable and supported."
f"The uploaded statement does not match the selected {label} format. "
"Please verify the selected bank or choose Auto Detect."
)
return _parse_and_identify(parser, path, text)
attempted: set[type] = set()
for parser_class in _ordered_bank_candidates(text):
attempted.add(parser_class)
result = _attempt(parser_class(), path, text, "auto-detected")
if result is not None:
return result
if not (text or "").strip():
result = _attempt(HSBCParser(), path, text, "image-only-ocr")
if result is not 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
parser, score = detect_parser(text)
if parser is None and not (text or "").strip():
# Image-only statements cannot be identified by pdftotext. HSBCParser
# performs its own OCR identification and raises a precise mismatch error.
try:
meta, df = HSBCParser().parse(path, text)
return apply_detected_bank_identity(meta, df, text)
except ValueError:
pass
if parser is None:
raise ValueError(
"The bank statement format was identified, but no transaction rows could be extracted. "
"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."
"Unsupported statement format. Select the bank manually or add a bank-specific parser for this statement layout."
)
return _parse_and_identify(parser, path, text)