AdityaAmarnath commited on
Commit
44e159e
Β·
1 Parent(s): 305c025

feat: enhance UI, fix timezone, improve vision agent and email extraction, update README

Browse files
Files changed (7) hide show
  1. .gitignore +2 -0
  2. README.md +11 -0
  3. app.py +11 -13
  4. classifier_agent.py +84 -61
  5. config.py +1 -1
  6. email_agent.py +8 -1
  7. vision_agent.py +12 -24
.gitignore CHANGED
@@ -9,3 +9,5 @@ __pycache__/
9
  .env
10
  models/
11
  data/
 
 
 
9
  .env
10
  models/
11
  data/
12
+ fix_indent.py
13
+ test_extraction.py
README.md CHANGED
@@ -36,6 +36,17 @@ tags:
36
 
37
  ---
38
 
 
 
 
 
 
 
 
 
 
 
 
39
  ## 🎯 The Problem
40
 
41
  Indian consumers receive **hundreds** of banking emails monthly β€” transaction alerts from HDFC, SBI, ICICI, Kotak, IDFC First, Amex, and more. Buried in these emails are:
 
36
 
37
  ---
38
 
39
+ ## πŸ“Ί Video Demo & πŸ“– Full Write-up
40
+
41
+ Check out the deep dive into the architecture and the story behind the build:
42
+
43
+ πŸ“° **Read the full breakdown on Medium**: [Sorting my bank emails offline with small AI models](https://medium.com/@aa911mdccxxix/sorting-my-bank-emails-offline-with-small-ai-models-fec305c564f6)
44
+
45
+ πŸŽ₯ **Watch the Demo on YouTube:**
46
+ [![YouTube Demo](https://img.youtube.com/vi/s6H9iZR8PDs/maxresdefault.jpg)](https://youtu.be/s6H9iZR8PDs)
47
+
48
+ ---
49
+
50
  ## 🎯 The Problem
51
 
52
  Indian consumers receive **hundreds** of banking emails monthly β€” transaction alerts from HDFC, SBI, ICICI, Kotak, IDFC First, Amex, and more. Buried in these emails are:
app.py CHANGED
@@ -415,7 +415,13 @@ def get_dashboard_stats_html() -> str:
415
  """Generate HTML for dashboard statistics cards."""
416
  stats = db.get_dashboard_stats()
417
  latest = stats.get("latest_sync_date")
418
- latest_str = latest.strftime("%d %b %Y, %H:%M") if latest else "Never"
 
 
 
 
 
 
419
 
420
  return f"""
421
  <div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin: 10px 0;">
@@ -869,19 +875,11 @@ def create_app():
869
  type="password",
870
  placeholder="xxxx xxxx xxxx xxxx",
871
  elem_id="password-input",
 
872
  )
873
 
874
- gr.HTML("""
875
- <div style='font-size: 0.8em; color: #8888aa; margin-top: -10px; margin-bottom: 10px;'>
876
- Need an App Password?
877
- <a href='https://myaccount.google.com/apppasswords' target='_blank' style='color: #43E8D8;'>Gmail</a> |
878
- <a href='https://account.live.com/proofs/manage/additional' target='_blank' style='color: #43E8D8;'>Outlook</a> |
879
- <a href='https://login.yahoo.com/account/security' target='_blank' style='color: #43E8D8;'>Yahoo</a>
880
- </div>
881
- """)
882
-
883
  add_account_btn = gr.Button("Add Account", variant="secondary")
884
- accounts_display = gr.Markdown("No accounts added yet.", elem_classes=["markdown-text"])
885
 
886
  gr.HTML('<hr style="margin: 8px 0; border-color: #2a2a4a;">')
887
 
@@ -926,8 +924,8 @@ def create_app():
926
  )
927
  sync_log_output = gr.Textbox(
928
  label="Sync Progress",
929
- lines=20,
930
- max_lines=30,
931
  interactive=False,
932
  elem_classes=["log-output"],
933
  elem_id="sync-log",
 
415
  """Generate HTML for dashboard statistics cards."""
416
  stats = db.get_dashboard_stats()
417
  latest = stats.get("latest_sync_date")
418
+ if latest:
419
+ from datetime import timedelta
420
+ # Add 5 hours 30 mins to convert UTC to IST
421
+ latest_ist = latest + timedelta(hours=5, minutes=30)
422
+ latest_str = latest_ist.strftime("%d %b %Y, %H:%M IST")
423
+ else:
424
+ latest_str = "Never"
425
 
426
  return f"""
427
  <div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin: 10px 0;">
 
875
  type="password",
876
  placeholder="xxxx xxxx xxxx xxxx",
877
  elem_id="password-input",
878
+ info="Need an App Password? [Gmail](https://myaccount.google.com/apppasswords) | [Outlook](https://account.live.com/proofs/manage/additional) | [Yahoo](https://login.yahoo.com/account/security)"
879
  )
880
 
 
 
 
 
 
 
 
 
 
881
  add_account_btn = gr.Button("Add Account", variant="secondary")
882
+ accounts_display = gr.Markdown("No accounts added yet.", elem_classes=["markdown-text"], elem_id="accounts-display")
883
 
884
  gr.HTML('<hr style="margin: 8px 0; border-color: #2a2a4a;">')
885
 
 
924
  )
925
  sync_log_output = gr.Textbox(
926
  label="Sync Progress",
927
+ lines=12,
928
+ max_lines=18,
929
  interactive=False,
930
  elem_classes=["log-output"],
931
  elem_id="sync-log",
classifier_agent.py CHANGED
@@ -47,12 +47,6 @@ try:
47
  HAS_SPACES = True
48
  except ImportError:
49
  HAS_SPACES = False
50
- class spaces:
51
- @staticmethod
52
- def GPU(func=None, **kwargs):
53
- if func is None:
54
- return lambda f: f
55
- return func
56
 
57
  try:
58
  from openai import OpenAI
@@ -89,7 +83,7 @@ DEFINITIONS:
89
  INSTRUCTIONS:
90
  - Step 1: Read the email SUBJECT first. The subject is the STRONGEST signal and should be checked BEFORE analyzing the body.
91
  * Transaction subjects: "Debit Alert", "Transaction Alert", "Credit Alert", "Debit Card Transaction", "Fund Transfer".
92
- * Promotion subjects: "Pre Approved", "Upgrade", "Rewards Points", "Membership", "Cashback Offer", "Exclusive Offer", "Special Offer", "Congratulations", "Limited Period", "wish list", "personal loan", "loan". If the subject contains any of these promotion keywords, classify as CREDIT_LOAN_PROMOTION or CREDIT_LIMIT_CARD_MANAGEMENT β€” do NOT classify as a transaction even if the body mentions amounts.
93
  - Step 2: Only if the subject is ambiguous, read the body. Note Indian financial terminology (INR, Rs., amount/-, Lakh, Cr).
94
  - Step 3: Determine the primary trigger. Pay strict attention to the direction of cash flow (Credited vs. Debited).
95
  - Step 4: CRITICAL: For FUNDS_CREDITED_ALERT or FUNDS_DEBITED_ALERT, the email MUST contain a specific account number (A/c XX1234) or card number (ending 5678) AND use explicit debit/credit language ("debited from", "credited to"). If the email mentions amounts alongside promotional language (reward points, upgrade, membership, cashback offer, discount, limit) without a specific card/account number being debited/credited, it is a PROMOTION, not a transaction.
@@ -167,16 +161,6 @@ Output:
167
  "amount_inr": null
168
  }}
169
 
170
- Subject: Your wish list calling? β‚Ή50,000 will be credited to your account!
171
- Body: Dear Customer, get a Kotak Personal Loan of β‚Ή50,000 instantly. The amount will be credited to your account in 3 seconds. Apply now!
172
- Output:
173
- {{
174
- "thought_process": "Although the email mentions 'will be credited' and an amount, it is an offer for a personal loan ('wish list calling', 'Personal Loan'). It is a future marketing offer, not an actual completed transaction.",
175
- "category": "CREDIT_LOAN_PROMOTION",
176
- "bank_or_platform": "Kotak Mahindra Bank",
177
- "amount_inr": 50000.00
178
- }}
179
-
180
  Subject: {subject}
181
  Body: {body}
182
  Output:"""
@@ -378,11 +362,36 @@ class ClassifierAgent:
378
  base_url=LMSTUDIO_BASE_URL,
379
  api_key="lm-studio",
380
  )
381
- # Quick connectivity check
382
- self._openai_client.models.list()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
383
  self._loaded = True
384
  log(f"[OK] Connected to LMStudio at {LMSTUDIO_BASE_URL}")
385
- log(f" Model: {LMSTUDIO_MODEL}")
386
  except Exception as e:
387
  log(f"[ERROR] LMStudio connection failed: {e}")
388
  log("[WARN] Falling back to rule-based classification")
@@ -469,23 +478,29 @@ class ClassifierAgent:
469
  """Generate text via LMStudio's OpenAI-compatible API."""
470
  if not self._openai_client:
471
  return ""
472
- try:
473
- response = self._openai_client.chat.completions.create(
474
- model=LMSTUDIO_MODEL,
475
- messages=[
476
- {"role": "system", "content": system_prompt},
477
- {"role": "user", "content": prompt},
478
- ],
479
- max_tokens=max_tokens,
480
- temperature=QWEN_TEMPERATURE,
481
- top_p=0.9,
482
- )
483
- return response.choices[0].message.content.strip()
484
- except Exception as e:
485
- logger.warning(f"LMStudio generation failed: {e}")
486
- return ""
 
 
 
 
 
 
 
487
 
488
- @spaces.GPU
489
  def _generate_transformers(self, prompt: str, max_tokens: int, system_prompt: str) -> str:
490
  """Generate text using loaded transformers model (ZeroGPU or CPU)."""
491
  if not self._loaded or self.model is None:
@@ -533,7 +548,7 @@ class ClassifierAgent:
533
  "FUNDS_CREDITED_ALERT": sum(1 for kw in ["credited", "received", "added", "refund"] if kw in text),
534
  "FUNDS_DEBITED_ALERT": sum(1 for kw in ["debited", "spent", "paid", "withdrawn"] if kw in text),
535
  "OTP_SECURITY_ALERT": sum(1 for kw in ["otp", "login", "password", "security"] if kw in text),
536
- "CREDIT_LOAN_PROMOTION": sum(1 for kw in ["loan", "pre-approved", "pre approved", "cashback", "offer", "discount", "upgrade", "reward", "points", "membership", "wish list", "will be credited", "personal loan"] if kw in text),
537
  "REGULATORY_KYC_NOTICE": sum(1 for kw in ["kyc", "rbi", "pan", "aadhaar"] if kw in text),
538
  "ACCOUNT_STATEMENT_BILL": sum(1 for kw in ["statement", "due", "bill", "amb"] if kw in text),
539
  "CREDIT_SCORE_BUREAU_ALERT": sum(1 for kw in ["cibil", "experian", "score", "inquiry"] if kw in text),
@@ -1036,26 +1051,27 @@ By Category (sorted by spend):
1036
  return fig
1037
 
1038
  def plot_daywise_trend(self, db: BankingDatabase) -> Optional[go.Figure]:
1039
- """Generate an interactive Plotly line chart of daywise spending with drill down."""
1040
  summary = db.get_spending_summary()
1041
  daywise = summary.get("daywise", [])
1042
 
1043
  if not daywise:
1044
  return None
1045
 
1046
- days = [d["day"] for d in daywise]
1047
- debits = [d["debits"] for d in daywise]
1048
- credits_ = [d["credits"] for d in daywise]
 
1049
 
1050
  fig = go.Figure()
1051
 
1052
  # Debits trace
1053
  fig.add_trace(go.Scatter(
1054
- x=days, y=debits,
1055
  mode="lines+markers",
1056
  name="Debits",
1057
- line=dict(color=PLOT_ACCENT_COLORS[1], width=2),
1058
- marker=dict(size=4, color=PLOT_ACCENT_COLORS[1]),
1059
  fill="tozeroy",
1060
  fillcolor="rgba(255,101,132,0.1)",
1061
  hovertext=[f"Debits: {format_inr(d)}" for d in debits],
@@ -1064,11 +1080,11 @@ By Category (sorted by spend):
1064
 
1065
  # Credits trace
1066
  fig.add_trace(go.Scatter(
1067
- x=days, y=credits_,
1068
  mode="lines+markers",
1069
  name="Credits",
1070
- line=dict(color=PLOT_ACCENT_COLORS[2], width=2),
1071
- marker=dict(size=4, symbol="square", color=PLOT_ACCENT_COLORS[2]),
1072
  fill="tozeroy",
1073
  fillcolor="rgba(67,232,216,0.1)",
1074
  hovertext=[f"Credits: {format_inr(c)}" for c in credits_],
@@ -1076,25 +1092,26 @@ By Category (sorted by spend):
1076
  ))
1077
 
1078
  fig.update_layout(**self._plotly_layout(
1079
- "Transaction Trend (Daywise)",
1080
  xaxis=dict(
1081
  gridcolor=PLOT_GRID_COLOR,
1082
  gridwidth=0.5,
1083
  title="Date",
1084
  type="date",
 
1085
  rangeselector=dict(
1086
  buttons=list([
1087
- dict(count=1, label="1m", step="month", stepmode="backward"),
1088
- dict(count=3, label="3m", step="month", stepmode="backward"),
1089
- dict(count=6, label="6m", step="month", stepmode="backward"),
1090
- dict(count=1, label="1y", step="year", stepmode="backward"),
1091
- dict(step="all")
 
1092
  ]),
1093
  bgcolor="rgba(26,26,46,0.8)",
1094
- activecolor=PLOT_ACCENT_COLORS[0],
1095
- font=dict(color=PLOT_TEXT_COLOR)
1096
- ),
1097
- rangeslider=dict(visible=True, bgcolor="rgba(26,26,46,0.5)")
1098
  ),
1099
  yaxis=dict(gridcolor=PLOT_GRID_COLOR, gridwidth=0.5, title="Amount (β‚Ή)", zeroline=False),
1100
  legend=dict(
@@ -1104,7 +1121,7 @@ By Category (sorted by spend):
1104
  borderwidth=1,
1105
  ),
1106
  hovermode="x unified",
1107
- height=450,
1108
  ))
1109
 
1110
  return fig
@@ -1156,9 +1173,15 @@ By Category (sorted by spend):
1156
  "payment_mode": "Other",
1157
  "merchant": "Unknown"
1158
  }
 
1159
  amount_inr = email_result.get("amount_inr")
1160
- if amount_inr is not None:
1161
- txn["amount"] = amount_inr
 
 
 
 
 
1162
  txn["transaction_type"] = "credit" if email_type == "FUNDS_CREDITED_ALERT" else "debit"
1163
  txn["email_id"] = eid
1164
  if not txn.get("transaction_date"):
@@ -1176,7 +1199,7 @@ By Category (sorted by spend):
1176
  # Progress logging per email
1177
  desc = f"Classified {results['classified']}/{total_emails} emails... (Last: {email_type})"
1178
  log(desc)
1179
- if progress_bar:
1180
  progress_bar(results['classified'] / total_emails, desc=desc)
1181
 
1182
  if txn and txn.get("amount"):
 
47
  HAS_SPACES = True
48
  except ImportError:
49
  HAS_SPACES = False
 
 
 
 
 
 
50
 
51
  try:
52
  from openai import OpenAI
 
83
  INSTRUCTIONS:
84
  - Step 1: Read the email SUBJECT first. The subject is the STRONGEST signal and should be checked BEFORE analyzing the body.
85
  * Transaction subjects: "Debit Alert", "Transaction Alert", "Credit Alert", "Debit Card Transaction", "Fund Transfer".
86
+ * Promotion subjects: "Pre Approved", "Upgrade", "Rewards Points", "Membership", "Cashback Offer", "Exclusive Offer", "Special Offer", "Congratulations", "Limited Period". If the subject contains any of these promotion keywords, classify as CREDIT_LOAN_PROMOTION or CREDIT_LIMIT_CARD_MANAGEMENT β€” do NOT classify as a transaction even if the body mentions amounts.
87
  - Step 2: Only if the subject is ambiguous, read the body. Note Indian financial terminology (INR, Rs., amount/-, Lakh, Cr).
88
  - Step 3: Determine the primary trigger. Pay strict attention to the direction of cash flow (Credited vs. Debited).
89
  - Step 4: CRITICAL: For FUNDS_CREDITED_ALERT or FUNDS_DEBITED_ALERT, the email MUST contain a specific account number (A/c XX1234) or card number (ending 5678) AND use explicit debit/credit language ("debited from", "credited to"). If the email mentions amounts alongside promotional language (reward points, upgrade, membership, cashback offer, discount, limit) without a specific card/account number being debited/credited, it is a PROMOTION, not a transaction.
 
161
  "amount_inr": null
162
  }}
163
 
 
 
 
 
 
 
 
 
 
 
164
  Subject: {subject}
165
  Body: {body}
166
  Output:"""
 
362
  base_url=LMSTUDIO_BASE_URL,
363
  api_key="lm-studio",
364
  )
365
+ # Fetch currently loaded models
366
+ models = self._openai_client.models.list()
367
+ model_ids = [m.id for m in models.data]
368
+
369
+ # Prioritize Qwen models first (as per guidelines)
370
+ matched_model = None
371
+ for m in model_ids:
372
+ if "qwen" in m.lower():
373
+ matched_model = m
374
+ break
375
+
376
+ # If Qwen isn't found, find any text model that isn't a vision model
377
+ if not matched_model:
378
+ for m in model_ids:
379
+ if "moondream" not in m.lower() and "vision" not in m.lower() and "vl" not in m.lower():
380
+ matched_model = m
381
+ break
382
+
383
+ # Fallback to the first model if we couldn't find a clear text model, but prefer the matched one
384
+ if matched_model:
385
+ self._active_lmstudio_model = matched_model
386
+ log(f" Model detected: {self._active_lmstudio_model}")
387
+ elif model_ids:
388
+ self._active_lmstudio_model = model_ids[0]
389
+ log(f" Model detected (fallback): {self._active_lmstudio_model}")
390
+ else:
391
+ self._active_lmstudio_model = LMSTUDIO_MODEL
392
+ log(f" Model: {LMSTUDIO_MODEL}")
393
  self._loaded = True
394
  log(f"[OK] Connected to LMStudio at {LMSTUDIO_BASE_URL}")
 
395
  except Exception as e:
396
  log(f"[ERROR] LMStudio connection failed: {e}")
397
  log("[WARN] Falling back to rule-based classification")
 
478
  """Generate text via LMStudio's OpenAI-compatible API."""
479
  if not self._openai_client:
480
  return ""
481
+
482
+ for attempt in range(3):
483
+ try:
484
+ response = self._openai_client.chat.completions.create(
485
+ model=getattr(self, "_active_lmstudio_model", LMSTUDIO_MODEL),
486
+ messages=[
487
+ {"role": "system", "content": system_prompt},
488
+ {"role": "user", "content": prompt},
489
+ ],
490
+ max_tokens=max_tokens,
491
+ temperature=QWEN_TEMPERATURE,
492
+ top_p=0.9,
493
+ )
494
+ return response.choices[0].message.content.strip()
495
+ except Exception as e:
496
+ if attempt < 2:
497
+ import time
498
+ logger.debug(f"LMStudio generation failed (attempt {attempt+1}): {e}. Retrying...")
499
+ time.sleep(3)
500
+ else:
501
+ logger.warning(f"LMStudio generation failed after 3 attempts: {e}")
502
+ return ""
503
 
 
504
  def _generate_transformers(self, prompt: str, max_tokens: int, system_prompt: str) -> str:
505
  """Generate text using loaded transformers model (ZeroGPU or CPU)."""
506
  if not self._loaded or self.model is None:
 
548
  "FUNDS_CREDITED_ALERT": sum(1 for kw in ["credited", "received", "added", "refund"] if kw in text),
549
  "FUNDS_DEBITED_ALERT": sum(1 for kw in ["debited", "spent", "paid", "withdrawn"] if kw in text),
550
  "OTP_SECURITY_ALERT": sum(1 for kw in ["otp", "login", "password", "security"] if kw in text),
551
+ "CREDIT_LOAN_PROMOTION": sum(1 for kw in ["loan", "pre-approved", "pre approved", "cashback", "offer", "discount", "upgrade", "reward", "points", "membership"] if kw in text),
552
  "REGULATORY_KYC_NOTICE": sum(1 for kw in ["kyc", "rbi", "pan", "aadhaar"] if kw in text),
553
  "ACCOUNT_STATEMENT_BILL": sum(1 for kw in ["statement", "due", "bill", "amb"] if kw in text),
554
  "CREDIT_SCORE_BUREAU_ALERT": sum(1 for kw in ["cibil", "experian", "score", "inquiry"] if kw in text),
 
1051
  return fig
1052
 
1053
  def plot_daywise_trend(self, db: BankingDatabase) -> Optional[go.Figure]:
1054
+ """Generate an interactive Plotly line chart of daily spending and credits."""
1055
  summary = db.get_spending_summary()
1056
  daywise = summary.get("daywise", [])
1057
 
1058
  if not daywise:
1059
  return None
1060
 
1061
+ display_days = [m["day"] for m in daywise]
1062
+
1063
+ debits = [m["debits"] for m in daywise]
1064
+ credits_ = [m["credits"] for m in daywise]
1065
 
1066
  fig = go.Figure()
1067
 
1068
  # Debits trace
1069
  fig.add_trace(go.Scatter(
1070
+ x=display_days, y=debits,
1071
  mode="lines+markers",
1072
  name="Debits",
1073
+ line=dict(color=PLOT_ACCENT_COLORS[1], width=3),
1074
+ marker=dict(size=8, color=PLOT_ACCENT_COLORS[1]),
1075
  fill="tozeroy",
1076
  fillcolor="rgba(255,101,132,0.1)",
1077
  hovertext=[f"Debits: {format_inr(d)}" for d in debits],
 
1080
 
1081
  # Credits trace
1082
  fig.add_trace(go.Scatter(
1083
+ x=display_days, y=credits_,
1084
  mode="lines+markers",
1085
  name="Credits",
1086
+ line=dict(color=PLOT_ACCENT_COLORS[2], width=3),
1087
+ marker=dict(size=8, symbol="square", color=PLOT_ACCENT_COLORS[2]),
1088
  fill="tozeroy",
1089
  fillcolor="rgba(67,232,216,0.1)",
1090
  hovertext=[f"Credits: {format_inr(c)}" for c in credits_],
 
1092
  ))
1093
 
1094
  fig.update_layout(**self._plotly_layout(
1095
+ "Day-wise Transaction Trend",
1096
  xaxis=dict(
1097
  gridcolor=PLOT_GRID_COLOR,
1098
  gridwidth=0.5,
1099
  title="Date",
1100
  type="date",
1101
+ rangeslider=dict(visible=True, bgcolor="#0f0f1a", bordercolor=PLOT_GRID_COLOR, borderwidth=1),
1102
  rangeselector=dict(
1103
  buttons=list([
1104
+ dict(count=7, label="1W", step="day", stepmode="backward"),
1105
+ dict(count=1, label="1M", step="month", stepmode="backward"),
1106
+ dict(count=6, label="6M", step="month", stepmode="backward"),
1107
+ dict(count=1, label="YTD", step="year", stepmode="todate"),
1108
+ dict(count=1, label="1Y", step="year", stepmode="backward"),
1109
+ dict(step="all", label="All")
1110
  ]),
1111
  bgcolor="rgba(26,26,46,0.8)",
1112
+ font=dict(color=PLOT_TEXT_COLOR, size=11),
1113
+ activecolor="#3b3b5c",
1114
+ )
 
1115
  ),
1116
  yaxis=dict(gridcolor=PLOT_GRID_COLOR, gridwidth=0.5, title="Amount (β‚Ή)", zeroline=False),
1117
  legend=dict(
 
1121
  borderwidth=1,
1122
  ),
1123
  hovermode="x unified",
1124
+ height=400,
1125
  ))
1126
 
1127
  return fig
 
1173
  "payment_mode": "Other",
1174
  "merchant": "Unknown"
1175
  }
1176
+ # ONLY use amount_inr from initial classification if we didn't extract a valid one!
1177
  amount_inr = email_result.get("amount_inr")
1178
+ if amount_inr is not None and str(amount_inr).lower() != "null":
1179
+ try:
1180
+ parsed_amount = float(str(amount_inr).replace(",", ""))
1181
+ if parsed_amount > 0 and not txn.get("amount"):
1182
+ txn["amount"] = parsed_amount
1183
+ except ValueError:
1184
+ pass
1185
  txn["transaction_type"] = "credit" if email_type == "FUNDS_CREDITED_ALERT" else "debit"
1186
  txn["email_id"] = eid
1187
  if not txn.get("transaction_date"):
 
1199
  # Progress logging per email
1200
  desc = f"Classified {results['classified']}/{total_emails} emails... (Last: {email_type})"
1201
  log(desc)
1202
+ if progress_bar is not None:
1203
  progress_bar(results['classified'] / total_emails, desc=desc)
1204
 
1205
  if txn and txn.get("amount"):
config.py CHANGED
@@ -66,7 +66,7 @@ IMAP_FOLDERS_OUTLOOK = ["INBOX", "Promotions", "Updates"]
66
  IMAP_FOLDERS_YAHOO = ["INBOX", "Bulk Mail"]
67
 
68
  # ─── Delta Sync ──────────────────────────────────────────────────────────────
69
- INITIAL_SYNC_DAYS = 7 # How far back on first sync (demo: 7 days)
70
 
71
  # ─── Indian Bank Domain Mapping ──────────────────────────────────────────────
72
  INDIAN_BANK_DOMAINS = {
 
66
  IMAP_FOLDERS_YAHOO = ["INBOX", "Bulk Mail"]
67
 
68
  # ─── Delta Sync ──────────────────────────────────────────────────────────────
69
+ INITIAL_SYNC_DAYS = 3 # How far back on first sync (demo: 90 days)
70
 
71
  # ─── Indian Bank Domain Mapping ──────────────────────────────────────────────
72
  INDIAN_BANK_DOMAINS = {
email_agent.py CHANGED
@@ -189,8 +189,15 @@ class EmailAgent:
189
  continue
190
 
191
  # Extract email content
192
- body_text = self._clean_text(msg.text or "")
193
  body_html = msg.html or ""
 
 
 
 
 
 
 
194
  msg_date = msg.date
195
  if msg_date:
196
  # Strip timezone info β€” SQLite strftime only
 
189
  continue
190
 
191
  # Extract email content
192
+ raw_text = msg.text or ""
193
  body_html = msg.html or ""
194
+ if not raw_text.strip() and body_html:
195
+ try:
196
+ soup = BeautifulSoup(body_html, "html.parser")
197
+ raw_text = soup.get_text(separator=" ", strip=True)
198
+ except Exception:
199
+ pass
200
+ body_text = self._clean_text(raw_text)
201
  msg_date = msg.date
202
  if msg_date:
203
  # Strip timezone info β€” SQLite strftime only
vision_agent.py CHANGED
@@ -42,12 +42,6 @@ try:
42
  HAS_SPACES = True
43
  except ImportError:
44
  HAS_SPACES = False
45
- class spaces:
46
- @staticmethod
47
- def GPU(func=None, **kwargs):
48
- if func is None:
49
- return lambda f: f
50
- return func
51
 
52
  try:
53
  from openai import OpenAI
@@ -60,25 +54,20 @@ except ImportError:
60
  # VISION PROMPT TEMPLATES β€” Multi-Pass Strategy
61
  # ══════════════════════════════════════════════════════════════════════════════
62
 
63
- # OCR Extraction β€” get ALL text from the image (no classification)
64
- VISION_OCR_PROMPT = """Look at this banking promotional banner image carefully.
65
- Your task is to perform an accurate OCR (Optical Character Recognition) extraction.
66
-
67
- Extract ALL text you can see in the image exactly as written, including:
68
- 1. Headlines and main promotional text
69
- 2. Specific numbers: Percentages (%), flat discount amounts (Rs./β‚Ή), minimum spend criteria
70
- 3. Bank names, card names, and merchant/app names (e.g., Swiggy, Amazon, Zomato)
71
- 4. Validity dates (e.g., "Valid till 30 June", "Offer ends today")
72
- 5. Promo codes or coupon codes
73
- 6. Fine print and terms & conditions (T&C)
74
-
75
- Output the extracted text clearly, preserving the original formatting and hierarchy as much as possible. Do not summarize; just transcribe the text."""
76
 
77
  # LMStudio vision system prompt (OCR-focused)
78
- VISION_SYSTEM_PROMPT = """You are a highly accurate OCR assistant specializing in Indian banking and financial promotional banners.
79
- You can read text in English and Hindi from images.
80
- Pay extremely close attention to numbers, currencies (Rs, INR, β‚Ή), percentages (%), and dates.
81
- Transcribe all visible text accurately without adding your own commentary or summarizing."""
82
 
83
 
84
  class VisionAgent:
@@ -349,7 +338,6 @@ class VisionAgent:
349
  logger.warning(f"LMStudio vision OCR failed: {e}")
350
  return ""
351
 
352
- @spaces.GPU
353
  def _extract_moondream(self, image: Image.Image) -> str:
354
  """
355
  OCR text extraction using Moondream2 (transformers).
 
42
  HAS_SPACES = True
43
  except ImportError:
44
  HAS_SPACES = False
 
 
 
 
 
 
45
 
46
  try:
47
  from openai import OpenAI
 
54
  # VISION PROMPT TEMPLATES β€” Multi-Pass Strategy
55
  # ══════════════════════════════════════════════════════════════════════════════
56
 
57
+ # OCR Extraction β€” get ALL text from the image using RECAT Strategy
58
+ VISION_OCR_PROMPT = """**Role**: You are a highly accurate Optical Character Recognition (OCR) assistant.
59
+ **Expertise**: You specialize in analyzing Indian banking and financial images, including transaction alerts, e-statements, and promotional banners.
60
+ **Context**: You are analyzing an image extracted from a banking email. The image may contain critical transaction details (money sent/received) OR promotional offers.
61
+ **Action**: Extract ALL text you can see in the image exactly as written. Pay extremely close attention to:
62
+ 1. Transaction details: Amounts (Rs./β‚Ή/INR), "debited from" / "credited to", merchant names, and transaction IDs/references.
63
+ 2. Account details: Last 4 digits of cards or accounts.
64
+ 3. Promotional details: Percentages (%), flat discounts, promo codes.
65
+ 4. Dates and timestamps.
66
+ **Tone**: Be purely objective, accurate, and precise. Do not summarize or interpret; simply transcribe the text clearly and preserve the visual hierarchy."""
 
 
 
67
 
68
  # LMStudio vision system prompt (OCR-focused)
69
+ VISION_SYSTEM_PROMPT = """You are a highly accurate OCR assistant specializing in Indian banking.
70
+ Extract all visible text accurately without adding commentary. Pay extremely close attention to transaction amounts, currency symbols (Rs, INR, β‚Ή), merchant names, card numbers, percentages, and dates."""
 
 
71
 
72
 
73
  class VisionAgent:
 
338
  logger.warning(f"LMStudio vision OCR failed: {e}")
339
  return ""
340
 
 
341
  def _extract_moondream(self, image: Image.Image) -> str:
342
  """
343
  OCR text extraction using Moondream2 (transformers).