Commit Β·
44e159e
1
Parent(s): 305c025
feat: enhance UI, fix timezone, improve vision agent and email extraction, update README
Browse files- .gitignore +2 -0
- README.md +11 -0
- app.py +11 -13
- classifier_agent.py +84 -61
- config.py +1 -1
- email_agent.py +8 -1
- 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 |
+
[](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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 930 |
-
max_lines=
|
| 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"
|
| 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 |
-
#
|
| 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 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"
|
| 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
|
| 1040 |
summary = db.get_spending_summary()
|
| 1041 |
daywise = summary.get("daywise", [])
|
| 1042 |
|
| 1043 |
if not daywise:
|
| 1044 |
return None
|
| 1045 |
|
| 1046 |
-
|
| 1047 |
-
|
| 1048 |
-
|
|
|
|
| 1049 |
|
| 1050 |
fig = go.Figure()
|
| 1051 |
|
| 1052 |
# Debits trace
|
| 1053 |
fig.add_trace(go.Scatter(
|
| 1054 |
-
x=
|
| 1055 |
mode="lines+markers",
|
| 1056 |
name="Debits",
|
| 1057 |
-
line=dict(color=PLOT_ACCENT_COLORS[1], width=
|
| 1058 |
-
marker=dict(size=
|
| 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=
|
| 1068 |
mode="lines+markers",
|
| 1069 |
name="Credits",
|
| 1070 |
-
line=dict(color=PLOT_ACCENT_COLORS[2], width=
|
| 1071 |
-
marker=dict(size=
|
| 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
|
| 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=
|
| 1088 |
-
dict(count=
|
| 1089 |
-
dict(count=6, label="
|
| 1090 |
-
dict(count=1, label="
|
| 1091 |
-
dict(step="
|
|
|
|
| 1092 |
]),
|
| 1093 |
bgcolor="rgba(26,26,46,0.8)",
|
| 1094 |
-
|
| 1095 |
-
|
| 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=
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 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 |
-
|
| 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
|
| 64 |
-
VISION_OCR_PROMPT = """
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
Extract ALL text you can see in the image exactly as written
|
| 68 |
-
1.
|
| 69 |
-
2.
|
| 70 |
-
3.
|
| 71 |
-
4.
|
| 72 |
-
|
| 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
|
| 79 |
-
|
| 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).
|