229 lines
7.3 KiB
Python
229 lines
7.3 KiB
Python
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
from .idfc import IDFCFirstParser
|
|
from .axis import AxisParser
|
|
from .hdfc import HDFCParser
|
|
from .icici import ICICIParser
|
|
from .hsbc import HSBCParser
|
|
from .indian_bank import IndianBankModernParser, IndianBankLegacyParser
|
|
from .indusind import IndusIndParser
|
|
from .kotak import KotakParser
|
|
from .sbi import SBIModernParser, SBIOtherParser
|
|
from .central_bank_of_india import CentralBankOfIndiaParser
|
|
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
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
PARSERS = [
|
|
IDFCFirstParser,
|
|
AxisParser,
|
|
AndhraPradeshGrameenaBankParser,
|
|
CentralBankOfIndiaParser,
|
|
YesBankParser,
|
|
CityUnionBankParser,
|
|
BankOfBarodaParser,
|
|
RBLBankParser,
|
|
HDFCParser,
|
|
ICICIParser,
|
|
HSBCParser,
|
|
IndianBankModernParser,
|
|
IndianBankLegacyParser,
|
|
IndusIndParser,
|
|
KotakParser,
|
|
SBIOtherParser,
|
|
SBIModernParser,
|
|
]
|
|
|
|
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"),
|
|
("bank_of_baroda", "Bank of Baroda"),
|
|
("rbl_bank", "RBL Bank"),
|
|
("hdfc", "HDFC Bank"),
|
|
("icici", "ICICI Bank"),
|
|
("hsbc", "HSBC Bank"),
|
|
("idfc", "IDFC FIRST Bank"),
|
|
("indian_bank", "Indian Bank"),
|
|
("indusind", "IndusInd Bank"),
|
|
("kotak", "Kotak Mahindra Bank"),
|
|
("sbi", "State Bank of India"),
|
|
]
|
|
|
|
BANK_PARSERS = {
|
|
"axis": [AxisParser],
|
|
"andhra_pradesh_grameena_bank": [AndhraPradeshGrameenaBankParser],
|
|
"central_bank_of_india": [CentralBankOfIndiaParser],
|
|
"yes_bank": [YesBankParser],
|
|
"city_union_bank": [CityUnionBankParser],
|
|
"bank_of_baroda": [BankOfBarodaParser],
|
|
"rbl_bank": [RBLBankParser],
|
|
"hdfc": [HDFCParser],
|
|
"icici": [ICICIParser],
|
|
"hsbc": [HSBCParser],
|
|
"idfc": [IDFCFirstParser],
|
|
"indian_bank": [IndianBankModernParser, IndianBankLegacyParser],
|
|
"indusind": [IndusIndParser],
|
|
"kotak": [KotakParser],
|
|
"sbi": [SBIOtherParser, SBIModernParser],
|
|
}
|
|
|
|
|
|
def detect_parser(text):
|
|
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]
|
|
|
|
|
|
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 _identified(result, text: str):
|
|
if not _usable_result(result):
|
|
return None
|
|
meta, frame = result
|
|
return apply_detected_bank_identity(meta, frame, text)
|
|
|
|
|
|
def _attempt(parser, path, text: str, route: str):
|
|
try:
|
|
result = parser.parse(path, text)
|
|
result = _identified(result, text)
|
|
if result is not None:
|
|
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)
|
|
result = _identified(result, text)
|
|
if result is not None:
|
|
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):
|
|
"""Parse a statement using a validated template first, then bank parsers.
|
|
|
|
Bank identity is resolved independently after extraction, so using a shared
|
|
layout never labels a statement as the bank whose parser happens to have a
|
|
similar table structure.
|
|
"""
|
|
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 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
|
|
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."
|
|
)
|
|
|
|
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
|
|
|
|
# A detector may miss a new bank layout even though an existing dedicated
|
|
# parser can still extract and reconcile it. Keep this compatibility pass.
|
|
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(
|
|
"The bank statement format was identified, but no transaction rows could be extracted. "
|
|
"The validated template engine and all bank-specific parsers were attempted. "
|
|
"Please verify that the PDF is text-readable and that this statement layout is supported."
|
|
)
|