sammy786 commited on
Commit
7f1c52e
·
1 Parent(s): 03c920c

Credit-limit detection: inline + positional + OCR fallback (Kotak/Axis/IDFC/HDFC)

Browse files
Dockerfile CHANGED
@@ -1,9 +1,12 @@
1
  # RewardPilot API — Hugging Face Docker Space
2
  FROM python:3.11-slim
3
 
4
- # small system deps some wheels expect
 
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 Card Catalogue
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 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}]},
 
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 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"},
 
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 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,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 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"
 
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 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,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 GMAT/MBA prep is education spend)
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 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",
 
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 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:
 
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 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
 
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 Deterministic Scoring Engine
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 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,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) 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,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 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):
 
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 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,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) 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,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 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,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 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,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