Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
Credit-limit detection: inline + positional + OCR fallback (Kotak/Axis/IDFC/HDFC)
Browse files- Dockerfile +4 -1
- app/card_catalogue.py +2 -2
- app/demo_data.py +1 -1
- app/llm.py +3 -3
- app/main.py +10 -2
- app/merchants.py +3 -3
- app/offers.py +2 -2
- app/places.py +1 -1
- app/scoring_engine.py +4 -4
- app/statement_parser.py +155 -5
- requirements.txt +3 -0
Dockerfile
CHANGED
|
@@ -1,9 +1,12 @@
|
|
| 1 |
# RewardPilot API — Hugging Face Docker Space
|
| 2 |
FROM python:3.11-slim
|
| 3 |
|
| 4 |
-
#
|
|
|
|
| 5 |
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 6 |
build-essential \
|
|
|
|
|
|
|
| 7 |
&& rm -rf /var/lib/apt/lists/*
|
| 8 |
|
| 9 |
WORKDIR /app
|
|
|
|
| 1 |
# RewardPilot API — Hugging Face Docker Space
|
| 2 |
FROM python:3.11-slim
|
| 3 |
|
| 4 |
+
# build tools + Tesseract OCR & Poppler (for reading the credit limit off image-only
|
| 5 |
+
# statement summaries like HDFC's)
|
| 6 |
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 7 |
build-essential \
|
| 8 |
+
tesseract-ocr \
|
| 9 |
+
poppler-utils \
|
| 10 |
&& rm -rf /var/lib/apt/lists/*
|
| 11 |
|
| 12 |
WORKDIR /app
|
app/card_catalogue.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
"""
|
| 2 |
-
RewardPilot
|
| 3 |
============================
|
| 4 |
Canonical, curated economics for major Indian consumer credit cards.
|
| 5 |
|
|
@@ -526,7 +526,7 @@ CATALOGUE_BY_ID = {c.id: c for c in CATALOGUE}
|
|
| 526 |
|
| 527 |
# Per-card economics for the "should I apply" decision (mirrors app CARD_ECON):
|
| 528 |
# annual_fee, fee_waiver_spend, welcome_bonus_inr, welcome_bonus_min_spend, milestones.
|
| 529 |
-
# Curated 2025-26 estimates
|
| 530 |
CARD_ECON = {
|
| 531 |
"hdfc_infinia": {"annual_fee": 12500, "fee_waiver_spend": 1000000, "welcome_bonus_inr": 12500, "welcome_bonus_min_spend": 0, "milestones": []},
|
| 532 |
"hdfc_regalia_gold": {"annual_fee": 2500, "fee_waiver_spend": 400000, "welcome_bonus_inr": 2500, "welcome_bonus_min_spend": 0, "milestones": [{"spend": 400000, "value": 5000}]},
|
|
|
|
| 1 |
"""
|
| 2 |
+
RewardPilot - Card Catalogue
|
| 3 |
============================
|
| 4 |
Canonical, curated economics for major Indian consumer credit cards.
|
| 5 |
|
|
|
|
| 526 |
|
| 527 |
# Per-card economics for the "should I apply" decision (mirrors app CARD_ECON):
|
| 528 |
# annual_fee, fee_waiver_spend, welcome_bonus_inr, welcome_bonus_min_spend, milestones.
|
| 529 |
+
# Curated 2025-26 estimates - validate against live T&C.
|
| 530 |
CARD_ECON = {
|
| 531 |
"hdfc_infinia": {"annual_fee": 12500, "fee_waiver_spend": 1000000, "welcome_bonus_inr": 12500, "welcome_bonus_min_spend": 0, "milestones": []},
|
| 532 |
"hdfc_regalia_gold": {"annual_fee": 2500, "fee_waiver_spend": 400000, "welcome_bonus_inr": 2500, "welcome_bonus_min_spend": 0, "milestones": [{"spend": 400000, "value": 5000}]},
|
app/demo_data.py
CHANGED
|
@@ -4,7 +4,7 @@ DEMO_WALLET = ["hdfc_millennia", "sbi_cashback", "axis_ace", "amex_mrcc", "tatan
|
|
| 4 |
|
| 5 |
DEMO_PERSONA = ["young_professional", "online_shopper", "traveller"]
|
| 6 |
|
| 7 |
-
# 3 months of realistic transactions, some optimal, some not
|
| 8 |
# "you used the wrong card" analysis.
|
| 9 |
DEMO_TRANSACTIONS = [
|
| 10 |
{"id": "t1", "date": "2026-06-22", "merchant": "amazon", "description": "Amazon - Electronics", "category": "online_shopping", "amount": 42000, "brand_key": "amazon", "card_id_used": "amex_mrcc"},
|
|
|
|
| 4 |
|
| 5 |
DEMO_PERSONA = ["young_professional", "online_shopper", "traveller"]
|
| 6 |
|
| 7 |
+
# 3 months of realistic transactions, some optimal, some not - to showcase the
|
| 8 |
# "you used the wrong card" analysis.
|
| 9 |
DEMO_TRANSACTIONS = [
|
| 10 |
{"id": "t1", "date": "2026-06-22", "merchant": "amazon", "description": "Amazon - Electronics", "category": "online_shopping", "amount": 42000, "brand_key": "amazon", "card_id_used": "amex_mrcc"},
|
app/llm.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
"""
|
| 2 |
-
RewardPilot
|
| 3 |
========================================================
|
| 4 |
-
The model NEVER decides which card wins
|
| 5 |
The LLM only:
|
| 6 |
1. turns a spoken/typed phrase into a structured intent (merchant, category, amount)
|
| 7 |
2. writes the human "why this card" narration around the engine's numbers
|
|
@@ -152,7 +152,7 @@ _SYSTEM = (
|
|
| 152 |
"explanation. Hard rules, never break them:\n"
|
| 153 |
"1. Use ONLY the card names and facts provided. Never mention a card, benefit, "
|
| 154 |
"offer, fee or number that is not in the input.\n"
|
| 155 |
-
"2. Do NOT state specific rupee amounts or percentages
|
| 156 |
"user separately. Refer to value qualitatively (e.g. 'clearly ahead', 'a bit more').\n"
|
| 157 |
"3. 2-3 sentences. Say which card to use and why, then the runner-up in a few words, "
|
| 158 |
"then one line on the better card to consider if one is provided.\n"
|
|
|
|
| 1 |
"""
|
| 2 |
+
RewardPilot - LLM personalization & speech-to-text layer
|
| 3 |
========================================================
|
| 4 |
+
The model NEVER decides which card wins - the deterministic engine does that.
|
| 5 |
The LLM only:
|
| 6 |
1. turns a spoken/typed phrase into a structured intent (merchant, category, amount)
|
| 7 |
2. writes the human "why this card" narration around the engine's numbers
|
|
|
|
| 152 |
"explanation. Hard rules, never break them:\n"
|
| 153 |
"1. Use ONLY the card names and facts provided. Never mention a card, benefit, "
|
| 154 |
"offer, fee or number that is not in the input.\n"
|
| 155 |
+
"2. Do NOT state specific rupee amounts or percentages - they are shown to the "
|
| 156 |
"user separately. Refer to value qualitatively (e.g. 'clearly ahead', 'a bit more').\n"
|
| 157 |
"3. 2-3 sentences. Say which card to use and why, then the runner-up in a few words, "
|
| 158 |
"then one line on the better card to consider if one is provided.\n"
|
app/main.py
CHANGED
|
@@ -162,9 +162,10 @@ async def parse_statements(
|
|
| 162 |
files: List[UploadFile] = File(...),
|
| 163 |
password: Optional[str] = Form(None),
|
| 164 |
):
|
| 165 |
-
from statement_parser import parse_statement
|
| 166 |
all_txns = []
|
| 167 |
errors = []
|
|
|
|
| 168 |
for f in files:
|
| 169 |
try:
|
| 170 |
content = await f.read()
|
|
@@ -172,9 +173,16 @@ async def parse_statements(
|
|
| 172 |
for t in txns:
|
| 173 |
t["source_file"] = f.filename
|
| 174 |
all_txns.extend(txns)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
except Exception as e:
|
| 176 |
errors.append({"file": f.filename, "error": str(e)})
|
| 177 |
-
return {"transactions": all_txns, "count": len(all_txns), "errors": errors
|
|
|
|
| 178 |
|
| 179 |
|
| 180 |
@app.post("/transactions/analyze")
|
|
|
|
| 162 |
files: List[UploadFile] = File(...),
|
| 163 |
password: Optional[str] = Form(None),
|
| 164 |
):
|
| 165 |
+
from statement_parser import parse_statement, detect_credit_limit
|
| 166 |
all_txns = []
|
| 167 |
errors = []
|
| 168 |
+
limits = []
|
| 169 |
for f in files:
|
| 170 |
try:
|
| 171 |
content = await f.read()
|
|
|
|
| 173 |
for t in txns:
|
| 174 |
t["source_file"] = f.filename
|
| 175 |
all_txns.extend(txns)
|
| 176 |
+
try:
|
| 177 |
+
lim = detect_credit_limit(f.filename, content, password=password)
|
| 178 |
+
if lim:
|
| 179 |
+
limits.append(lim)
|
| 180 |
+
except Exception:
|
| 181 |
+
pass # credit-limit detection is best-effort, never fails the parse
|
| 182 |
except Exception as e:
|
| 183 |
errors.append({"file": f.filename, "error": str(e)})
|
| 184 |
+
return {"transactions": all_txns, "count": len(all_txns), "errors": errors,
|
| 185 |
+
"credit_limit": max(limits) if limits else None}
|
| 186 |
|
| 187 |
|
| 188 |
@app.post("/transactions/analyze")
|
app/merchants.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
"""
|
| 2 |
-
RewardPilot
|
| 3 |
=======================================
|
| 4 |
Maps a free-text merchant / spoken phrase to a canonical category and detects
|
| 5 |
live brand offers. In production this is backed by a continuously curated
|
|
@@ -127,7 +127,7 @@ MERCHANT_MAP: Dict[str, Tuple[str, Optional[str]]] = {
|
|
| 127 |
"figma": ("online_shopping", None),
|
| 128 |
"vercel": ("online_shopping", None),
|
| 129 |
"digitalocean": ("online_shopping", None),
|
| 130 |
-
# test prep / education (correct category
|
| 131 |
"gmatclub": ("education", None),
|
| 132 |
"gmat": ("education", None),
|
| 133 |
"targettestprep": ("education", None),
|
|
@@ -174,7 +174,7 @@ LIVE_OFFERS: Dict[str, List[Dict]] = {
|
|
| 174 |
}
|
| 175 |
|
| 176 |
|
| 177 |
-
# Retailers / merchants
|
| 178 |
# resolves the merchant to Croma, not iPhone.
|
| 179 |
STORE_KEYWORDS = {
|
| 180 |
"reliance digital", "vijay sales", "croma", "amazon", "flipkart", "myntra", "ajio",
|
|
|
|
| 1 |
"""
|
| 2 |
+
RewardPilot - Merchant & MCC resolution
|
| 3 |
=======================================
|
| 4 |
Maps a free-text merchant / spoken phrase to a canonical category and detects
|
| 5 |
live brand offers. In production this is backed by a continuously curated
|
|
|
|
| 127 |
"figma": ("online_shopping", None),
|
| 128 |
"vercel": ("online_shopping", None),
|
| 129 |
"digitalocean": ("online_shopping", None),
|
| 130 |
+
# test prep / education (correct category - GMAT/MBA prep is education spend)
|
| 131 |
"gmatclub": ("education", None),
|
| 132 |
"gmat": ("education", None),
|
| 133 |
"targettestprep": ("education", None),
|
|
|
|
| 174 |
}
|
| 175 |
|
| 176 |
|
| 177 |
+
# Retailers / merchants - preferred over product words so "iPhone at Croma"
|
| 178 |
# resolves the merchant to Croma, not iPhone.
|
| 179 |
STORE_KEYWORDS = {
|
| 180 |
"reliance digital", "vijay sales", "croma", "amazon", "flipkart", "myntra", "ajio",
|
app/offers.py
CHANGED
|
@@ -1,9 +1,9 @@
|
|
| 1 |
"""
|
| 2 |
-
RewardPilot
|
| 3 |
==========================
|
| 4 |
Structured, dated, source-attributed card offers. This is the source of truth
|
| 5 |
the engine reads at recommend time. It is fed by the ingestion connectors
|
| 6 |
-
(issuer scrapers, CLO networks, affiliate feeds) after human QA
|
| 7 |
connectors/ingest.py. Until a live feed is wired, it ships a curated seed.
|
| 8 |
|
| 9 |
Each offer is structured (not free text), so the engine can value it exactly:
|
|
|
|
| 1 |
"""
|
| 2 |
+
RewardPilot - Offers store
|
| 3 |
==========================
|
| 4 |
Structured, dated, source-attributed card offers. This is the source of truth
|
| 5 |
the engine reads at recommend time. It is fed by the ingestion connectors
|
| 6 |
+
(issuer scrapers, CLO networks, affiliate feeds) after human QA - see
|
| 7 |
connectors/ingest.py. Until a live feed is wired, it ships a curated seed.
|
| 8 |
|
| 9 |
Each offer is structured (not free text), so the engine can value it exactly:
|
app/places.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
"""
|
| 2 |
-
RewardPilot
|
| 3 |
==========================
|
| 4 |
Turns GPS coordinates into known-merchant candidates. The provider key (Google
|
| 5 |
Places / Foursquare) is held SERVER-SIDE (never in the app), mirroring how the
|
|
|
|
| 1 |
"""
|
| 2 |
+
RewardPilot - Places proxy
|
| 3 |
==========================
|
| 4 |
Turns GPS coordinates into known-merchant candidates. The provider key (Google
|
| 5 |
Places / Foursquare) is held SERVER-SIDE (never in the app), mirroring how the
|
app/scoring_engine.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
"""
|
| 2 |
-
RewardPilot
|
| 3 |
==========================================
|
| 4 |
The engine sets the number; the LLM only narrates it.
|
| 5 |
|
|
@@ -91,7 +91,7 @@ class CardScore:
|
|
| 91 |
reward_value_inr: float # rewards earned on this txn (capped)
|
| 92 |
instant_offer_inr: float # instant discount from live offers
|
| 93 |
total_value_inr: float # reward + instant offer (rounded for display)
|
| 94 |
-
raw_total: float # unrounded total
|
| 95 |
effective_rate_pct: float # total value as % of spend
|
| 96 |
capped: bool # whether monthly cap limited the reward
|
| 97 |
reasons: List[str]
|
|
@@ -243,7 +243,7 @@ def score_transaction(
|
|
| 243 |
)
|
| 244 |
if offer_inr > 0 and offer_note:
|
| 245 |
reasons.append(f"Live offer: {offer_note} (~₹{offer_inr:,.0f}).")
|
| 246 |
-
# Portal-only elevated rate (HDFC SmartBuy, Axis Travel Edge)
|
| 247 |
# NOT scored, since it isn't earned on a direct merchant booking.
|
| 248 |
portal_rate = card.portal_rates.get(ctx.category) if not upi_block else None
|
| 249 |
if portal_rate and portal_rate > rate and ctx.category not in card.excluded_categories:
|
|
@@ -284,7 +284,7 @@ def score_transaction(
|
|
| 284 |
|
| 285 |
# sort best-first by the unrounded total (display rounding must not flip ties).
|
| 286 |
# On the UPI rail a non-RuPay card can't be linked at all, so it must never rank above a
|
| 287 |
-
# UPI-eligible card of equal value (e.g. a sub-₹2,000 UPI spend earns everyone ₹0
|
| 288 |
# pick should still be a card you can actually pay with on UPI).
|
| 289 |
_upi_ok = {c.id for c in all_cards() if getattr(c, "upi_eligible", False)}
|
| 290 |
def _rank_key(s):
|
|
|
|
| 1 |
"""
|
| 2 |
+
RewardPilot - Deterministic Scoring Engine
|
| 3 |
==========================================
|
| 4 |
The engine sets the number; the LLM only narrates it.
|
| 5 |
|
|
|
|
| 91 |
reward_value_inr: float # rewards earned on this txn (capped)
|
| 92 |
instant_offer_inr: float # instant discount from live offers
|
| 93 |
total_value_inr: float # reward + instant offer (rounded for display)
|
| 94 |
+
raw_total: float # unrounded total - used for ranking
|
| 95 |
effective_rate_pct: float # total value as % of spend
|
| 96 |
capped: bool # whether monthly cap limited the reward
|
| 97 |
reasons: List[str]
|
|
|
|
| 243 |
)
|
| 244 |
if offer_inr > 0 and offer_note:
|
| 245 |
reasons.append(f"Live offer: {offer_note} (~₹{offer_inr:,.0f}).")
|
| 246 |
+
# Portal-only elevated rate (HDFC SmartBuy, Axis Travel Edge) - informational,
|
| 247 |
# NOT scored, since it isn't earned on a direct merchant booking.
|
| 248 |
portal_rate = card.portal_rates.get(ctx.category) if not upi_block else None
|
| 249 |
if portal_rate and portal_rate > rate and ctx.category not in card.excluded_categories:
|
|
|
|
| 284 |
|
| 285 |
# sort best-first by the unrounded total (display rounding must not flip ties).
|
| 286 |
# On the UPI rail a non-RuPay card can't be linked at all, so it must never rank above a
|
| 287 |
+
# UPI-eligible card of equal value (e.g. a sub-₹2,000 UPI spend earns everyone ₹0 - the top
|
| 288 |
# pick should still be a card you can actually pay with on UPI).
|
| 289 |
_upi_ok = {c.id for c in all_cards() if getattr(c, "upi_eligible", False)}
|
| 290 |
def _rank_key(s):
|
app/statement_parser.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
"""
|
| 2 |
-
RewardPilot
|
| 3 |
==============================
|
| 4 |
Parses uploaded credit card statements (PDF or CSV) into normalized
|
| 5 |
transactions. Real PDF text extraction via pdfplumber; CSV via stdlib.
|
|
@@ -25,8 +25,8 @@ DATE_PATTERNS = [
|
|
| 25 |
"%Y-%m-%d", "%m/%d/%Y", "%d.%m.%Y", "%d-%b-%y", "%d %b %y",
|
| 26 |
]
|
| 27 |
|
| 28 |
-
AMOUNT_RE = re.compile(r"(-?\d[\d,]*\.\d{2})") # strict (decimals)
|
| 29 |
-
_AMOUNT_LENIENT_RE = re.compile(r"(-?\d[\d,]*(?:\.\d{1,2})?)") # integer-or-decimal
|
| 30 |
|
| 31 |
|
| 32 |
def _parse_amount_cell(s: str):
|
|
@@ -245,7 +245,7 @@ def parse_csv(content: bytes) -> List[Dict]:
|
|
| 245 |
amount = _parse_amount_cell(r[ai]) if 0 <= ai < len(r) else None
|
| 246 |
if amount is None and ai < 0:
|
| 247 |
# ONLY when no amount/debit column exists: scan the row. If a debit column
|
| 248 |
-
# exists but is empty, the row is a credit/refund
|
| 249 |
# grab the Credit-column value and count it as a spend).
|
| 250 |
m = AMOUNT_RE.search(" ".join(r))
|
| 251 |
amount = float(m.group(1).replace(",", "")) if m else None
|
|
@@ -260,7 +260,7 @@ def parse_csv(content: bytes) -> List[Dict]:
|
|
| 260 |
out.append(_normalize_row(iso, desc, amount))
|
| 261 |
return out # header found: trust it (even if every row was a credit/payment)
|
| 262 |
|
| 263 |
-
# fallback: no recognizable header
|
| 264 |
for r in rows:
|
| 265 |
date_field = next((c for c in r if _parse_date(c)), None)
|
| 266 |
if not date_field:
|
|
@@ -467,6 +467,156 @@ def parse_pdf(content: bytes, password: Optional[str] = None) -> List[Dict]:
|
|
| 467 |
return out
|
| 468 |
|
| 469 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 470 |
def parse_statement(filename: str, content: bytes, password: Optional[str] = None) -> List[Dict]:
|
| 471 |
name = filename.lower()
|
| 472 |
if name.endswith(".csv"):
|
|
|
|
| 1 |
"""
|
| 2 |
+
RewardPilot - Statement Parser
|
| 3 |
==============================
|
| 4 |
Parses uploaded credit card statements (PDF or CSV) into normalized
|
| 5 |
transactions. Real PDF text extraction via pdfplumber; CSV via stdlib.
|
|
|
|
| 25 |
"%Y-%m-%d", "%m/%d/%Y", "%d.%m.%Y", "%d-%b-%y", "%d %b %y",
|
| 26 |
]
|
| 27 |
|
| 28 |
+
AMOUNT_RE = re.compile(r"(-?\d[\d,]*\.\d{2})") # strict (decimals) - safe to scan whole rows
|
| 29 |
+
_AMOUNT_LENIENT_RE = re.compile(r"(-?\d[\d,]*(?:\.\d{1,2})?)") # integer-or-decimal - dedicated amount column only
|
| 30 |
|
| 31 |
|
| 32 |
def _parse_amount_cell(s: str):
|
|
|
|
| 245 |
amount = _parse_amount_cell(r[ai]) if 0 <= ai < len(r) else None
|
| 246 |
if amount is None and ai < 0:
|
| 247 |
# ONLY when no amount/debit column exists: scan the row. If a debit column
|
| 248 |
+
# exists but is empty, the row is a credit/refund - do NOT scan (that would
|
| 249 |
# grab the Credit-column value and count it as a spend).
|
| 250 |
m = AMOUNT_RE.search(" ".join(r))
|
| 251 |
amount = float(m.group(1).replace(",", "")) if m else None
|
|
|
|
| 260 |
out.append(_normalize_row(iso, desc, amount))
|
| 261 |
return out # header found: trust it (even if every row was a credit/payment)
|
| 262 |
|
| 263 |
+
# fallback: no recognizable header - infer per row from date + amount tokens
|
| 264 |
for r in rows:
|
| 265 |
date_field = next((c for c in r if _parse_date(c)), None)
|
| 266 |
if not date_field:
|
|
|
|
| 467 |
return out
|
| 468 |
|
| 469 |
|
| 470 |
+
# "Credit Limit Rs. 11,30,000" appears in the statement header. We use it to estimate
|
| 471 |
+
# income when the user hasn't given one. Excludes "available" / "cash" limits, which are
|
| 472 |
+
# lower/different. `credit\s*limit` tolerates the glued "TotalCreditLimit" some PDFs render.
|
| 473 |
+
# Require a currency marker (Rs/INR/₹) right before the figure so we don't grab a stray
|
| 474 |
+
# year ("Credit Limit ... Jan 2026") when the label and value sit on different lines.
|
| 475 |
+
_CREDIT_LIMIT_RE = re.compile(
|
| 476 |
+
r"credit\s*limit\b[^0-9₹]{0,18}(?:rs\.?|inr|₹)\s*([1-9][\d,]{3,}(?:\.\d{1,2})?)", re.I)
|
| 477 |
+
# A word that is a rupee amount, possibly with an 'r'/Rs/₹ prefix (many PDFs render ₹ as 'r').
|
| 478 |
+
_LIMIT_AMT_WORD = re.compile(r"^(?:r|rs\.?|inr|₹)?\s*(\d[\d,]{4,}(?:\.\d{1,2})?)$", re.I)
|
| 479 |
+
|
| 480 |
+
|
| 481 |
+
def _plausible_limit(v: float) -> bool:
|
| 482 |
+
return 25000 <= v <= 1e8 # real card limits; floor rejects years/small numbers
|
| 483 |
+
|
| 484 |
+
|
| 485 |
+
def _inline_credit_limit(text: str) -> Optional[float]:
|
| 486 |
+
"""Inline 'Credit Limit Rs 11,30,000' on a single line (Kotak, SBI, many issuers)."""
|
| 487 |
+
best = None
|
| 488 |
+
for m in _CREDIT_LIMIT_RE.finditer(text or ""):
|
| 489 |
+
pre = text[max(0, m.start() - 18):m.start()].lower()
|
| 490 |
+
if "available" in pre or "cash" in pre:
|
| 491 |
+
continue
|
| 492 |
+
try:
|
| 493 |
+
v = float(m.group(1).replace(",", ""))
|
| 494 |
+
except ValueError:
|
| 495 |
+
continue
|
| 496 |
+
if _plausible_limit(v) and (best is None or v > best):
|
| 497 |
+
best = v
|
| 498 |
+
return best
|
| 499 |
+
|
| 500 |
+
|
| 501 |
+
def _credit_limit_from_words(words: List[dict]) -> Optional[float]:
|
| 502 |
+
"""Given positioned words ({text,x0,x1,top}) from a page or OCR, find the total credit
|
| 503 |
+
limit near a (non-available) 'Credit Limit' label: prefer a rupee amount to its right or
|
| 504 |
+
directly beneath it; failing that, take the LARGEST plausible amount in the summary band
|
| 505 |
+
around the label - a credit limit is always the biggest figure in its box (dues can never
|
| 506 |
+
exceed it), which reliably picks it out of statements like HDFC where labels and values
|
| 507 |
+
don't align in a column."""
|
| 508 |
+
best = None
|
| 509 |
+
for i in range(len(words) - 1):
|
| 510 |
+
if words[i]["text"].lower() != "credit" or words[i + 1]["text"].lower() != "limit":
|
| 511 |
+
continue
|
| 512 |
+
if abs(words[i + 1]["top"] - words[i]["top"]) > 4:
|
| 513 |
+
continue
|
| 514 |
+
pre = words[i - 1]["text"].lower() if i > 0 else ""
|
| 515 |
+
if pre in ("available", "cash") or "avl" in pre:
|
| 516 |
+
continue
|
| 517 |
+
lx0, lx1, ltop = words[i]["x0"], words[i + 1]["x1"], words[i]["top"]
|
| 518 |
+
aligned, band = [], []
|
| 519 |
+
for a in words:
|
| 520 |
+
m = _LIMIT_AMT_WORD.match(a["text"].replace(" ", ""))
|
| 521 |
+
if not m:
|
| 522 |
+
continue
|
| 523 |
+
try:
|
| 524 |
+
v = float(m.group(1).replace(",", ""))
|
| 525 |
+
except ValueError:
|
| 526 |
+
continue
|
| 527 |
+
if not _plausible_limit(v):
|
| 528 |
+
continue
|
| 529 |
+
if (abs(a["top"] - ltop) <= 4 and lx1 < a["x0"] < lx1 + 130) or \
|
| 530 |
+
(ltop < a["top"] <= ltop + 70 and a["x0"] < lx1 + 30 and a["x1"] > lx0 - 30):
|
| 531 |
+
aligned.append(v)
|
| 532 |
+
if ltop - 55 <= a["top"] <= ltop + 80:
|
| 533 |
+
band.append(v)
|
| 534 |
+
cand = max(aligned) if aligned else (max(band) if band else None)
|
| 535 |
+
if cand is not None and (best is None or cand > best):
|
| 536 |
+
best = cand
|
| 537 |
+
return best
|
| 538 |
+
|
| 539 |
+
|
| 540 |
+
def _positional_credit_limit(pdf) -> Optional[float]:
|
| 541 |
+
best = None
|
| 542 |
+
try:
|
| 543 |
+
pages = pdf.pages[:3]
|
| 544 |
+
except Exception:
|
| 545 |
+
return None
|
| 546 |
+
for page in pages:
|
| 547 |
+
try:
|
| 548 |
+
v = _credit_limit_from_words(page.extract_words())
|
| 549 |
+
except Exception:
|
| 550 |
+
continue
|
| 551 |
+
if v and (best is None or v > best):
|
| 552 |
+
best = v
|
| 553 |
+
return best
|
| 554 |
+
|
| 555 |
+
|
| 556 |
+
def _ocr_credit_limit(content: bytes, password: Optional[str] = None) -> Optional[float]:
|
| 557 |
+
"""Last-resort OCR for statements whose summary is a rendered image / has no usable text
|
| 558 |
+
layer (HDFC). Rasterises the first two pages and reuses the positional logic on the OCR'd
|
| 559 |
+
words. All heavy deps are lazily imported so a Space without them just skips this."""
|
| 560 |
+
try:
|
| 561 |
+
import io as _io
|
| 562 |
+
import pdf2image
|
| 563 |
+
import pytesseract
|
| 564 |
+
from pypdf import PdfReader, PdfWriter
|
| 565 |
+
except Exception:
|
| 566 |
+
return None
|
| 567 |
+
try:
|
| 568 |
+
reader = PdfReader(_io.BytesIO(content))
|
| 569 |
+
if reader.is_encrypted:
|
| 570 |
+
reader.decrypt(password or "")
|
| 571 |
+
writer = PdfWriter()
|
| 572 |
+
for p in reader.pages[:2]:
|
| 573 |
+
writer.add_page(p)
|
| 574 |
+
buf = _io.BytesIO(); writer.write(buf)
|
| 575 |
+
images = pdf2image.convert_from_bytes(buf.getvalue(), dpi=200)
|
| 576 |
+
except Exception:
|
| 577 |
+
return None
|
| 578 |
+
best = None
|
| 579 |
+
scale = 72.0 / 200.0 # normalise OCR pixels to PDF points so the same tolerances apply
|
| 580 |
+
for img in images[:2]:
|
| 581 |
+
try:
|
| 582 |
+
d = pytesseract.image_to_data(img, output_type=pytesseract.Output.DICT)
|
| 583 |
+
except Exception:
|
| 584 |
+
continue
|
| 585 |
+
words = []
|
| 586 |
+
for j in range(len(d["text"])):
|
| 587 |
+
t = (d["text"][j] or "").strip()
|
| 588 |
+
if t:
|
| 589 |
+
words.append({"text": t, "x0": d["left"][j] * scale,
|
| 590 |
+
"x1": (d["left"][j] + d["width"][j]) * scale, "top": d["top"][j] * scale})
|
| 591 |
+
text = " ".join(w["text"] for w in words)
|
| 592 |
+
v = _inline_credit_limit(text) or _credit_limit_from_words(words)
|
| 593 |
+
if v and (best is None or v > best):
|
| 594 |
+
best = v
|
| 595 |
+
return best
|
| 596 |
+
|
| 597 |
+
|
| 598 |
+
def detect_credit_limit(filename: str, content: bytes, password: Optional[str] = None) -> Optional[float]:
|
| 599 |
+
"""The card's total credit limit from the statement, ignoring available/cash limits.
|
| 600 |
+
Tiered: inline text, then column-aligned positional, then OCR (only if the text layer
|
| 601 |
+
yields nothing). Returns None (never a wrong guess) when nothing plausible is found."""
|
| 602 |
+
name = (filename or "").lower()
|
| 603 |
+
if name.endswith(".pdf"):
|
| 604 |
+
try:
|
| 605 |
+
import pdfplumber
|
| 606 |
+
with pdfplumber.open(io.BytesIO(content), password=password or "") as pdf:
|
| 607 |
+
text = "\n".join((p.extract_text() or "") for p in pdf.pages[:3])
|
| 608 |
+
cands = [x for x in (_inline_credit_limit(text), _positional_credit_limit(pdf)) if x]
|
| 609 |
+
if cands:
|
| 610 |
+
return max(cands)
|
| 611 |
+
except Exception:
|
| 612 |
+
pass
|
| 613 |
+
return _ocr_credit_limit(content, password) # image-only statements (HDFC)
|
| 614 |
+
try:
|
| 615 |
+
return _inline_credit_limit(content.decode("utf-8-sig", errors="ignore"))
|
| 616 |
+
except Exception:
|
| 617 |
+
return None
|
| 618 |
+
|
| 619 |
+
|
| 620 |
def parse_statement(filename: str, content: bytes, password: Optional[str] = None) -> List[Dict]:
|
| 621 |
name = filename.lower()
|
| 622 |
if name.endswith(".csv"):
|
requirements.txt
CHANGED
|
@@ -2,6 +2,9 @@ fastapi==0.111.0
|
|
| 2 |
uvicorn[standard]==0.30.1
|
| 3 |
python-multipart==0.0.9
|
| 4 |
pdfplumber==0.11.0
|
|
|
|
|
|
|
|
|
|
| 5 |
pydantic==2.7.4
|
| 6 |
openai==1.35.7
|
| 7 |
anthropic==0.30.1
|
|
|
|
| 2 |
uvicorn[standard]==0.30.1
|
| 3 |
python-multipart==0.0.9
|
| 4 |
pdfplumber==0.11.0
|
| 5 |
+
pypdf==4.2.0
|
| 6 |
+
pdf2image==1.17.0
|
| 7 |
+
pytesseract==0.3.10
|
| 8 |
pydantic==2.7.4
|
| 9 |
openai==1.35.7
|
| 10 |
anthropic==0.30.1
|