|
|
from typing import Dict, Any, Optional, Tuple, List |
|
|
import traceback |
|
|
import json |
|
|
import os |
|
|
from datetime import date, datetime |
|
|
import calendar |
|
|
import gradio as gr |
|
|
import plotly.graph_objects as go |
|
|
import httpx |
|
|
import logging |
|
|
from utils.llamaindex_rag import get_card_benefits_rag, initialize_rag |
|
|
|
|
|
logging.basicConfig(level=logging.INFO) |
|
|
logger = logging.getLogger(__name__) |
|
|
|
|
|
from config import ( |
|
|
APP_TITLE, APP_DESCRIPTION, THEME, |
|
|
MCC_CATEGORIES, SAMPLE_USERS, |
|
|
MERCHANTS_BY_CATEGORY) |
|
|
from utils.api_client import RewardPilotClient |
|
|
from utils.formatters import ( |
|
|
format_full_recommendation, |
|
|
format_comparison_table, |
|
|
format_analytics_metrics, |
|
|
create_spending_chart, |
|
|
create_rewards_pie_chart, |
|
|
create_optimization_gauge, |
|
|
create_trend_line_chart, |
|
|
create_card_performance_chart) |
|
|
from utils.llm_explainer import get_llm_explainer |
|
|
from utils.gemini_explainer import get_gemini_explainer |
|
|
|
|
|
import config |
|
|
from openai import OpenAI |
|
|
import os |
|
|
|
|
|
|
|
|
openai_client = OpenAI(api_key=os.getenv("OPENAI_API_KEY")) |
|
|
|
|
|
CARDS_FILE = os.path.join(os.path.dirname(__file__), "data", "cards.json") |
|
|
|
|
|
def safe_get(data: Dict, key: str, default: Any = None) -> Any: |
|
|
"""Safely get value from dictionary with fallback""" |
|
|
try: |
|
|
return data.get(key, default) |
|
|
except: |
|
|
return default |
|
|
|
|
|
def normalize_recommendation_data(data: Dict) -> Dict: |
|
|
""" |
|
|
Normalize API response to ensure all required fields exist. |
|
|
Handles both orchestrator format and mock data format. |
|
|
""" |
|
|
|
|
|
if data.get('mock_data'): |
|
|
return { |
|
|
'recommended_card': safe_get(data, 'recommended_card', 'Unknown Card'), |
|
|
'rewards_earned': float(safe_get(data, 'rewards_earned', 0)), |
|
|
'rewards_rate': safe_get(data, 'rewards_rate', 'N/A'), |
|
|
'merchant': safe_get(data, 'merchant', 'Unknown Merchant'), |
|
|
'category': safe_get(data, 'category', 'Unknown'), |
|
|
'amount': float(safe_get(data, 'amount', 0)), |
|
|
'annual_potential': float(safe_get(data, 'annual_potential', 0)), |
|
|
'optimization_score': int(safe_get(data, 'optimization_score', 85)), |
|
|
'reasoning': safe_get(data, 'reasoning', 'Optimal choice'), |
|
|
'warnings': safe_get(data, 'warnings', []), |
|
|
'alternatives': safe_get(data, 'alternatives', []), |
|
|
'mock_data': True |
|
|
} |
|
|
|
|
|
recommended_card = safe_get(data, 'recommended_card', {}) |
|
|
if isinstance(recommended_card, dict): |
|
|
card_name = safe_get(recommended_card, 'card_name', 'Unknown Card') |
|
|
reward_amount = float(safe_get(recommended_card, 'reward_amount', 0)) |
|
|
reward_rate = float(safe_get(recommended_card, 'reward_rate', 0)) |
|
|
category = safe_get(recommended_card, 'category', 'Unknown') |
|
|
reasoning = safe_get(recommended_card, 'reasoning', 'Optimal choice') |
|
|
|
|
|
if reward_rate > 0: |
|
|
rewards_rate_str = f"{reward_rate}x points" |
|
|
else: |
|
|
rewards_rate_str = "N/A" |
|
|
else: |
|
|
card_name = str(recommended_card) if recommended_card else 'Unknown Card' |
|
|
reward_amount = float(safe_get(data, 'rewards_earned', 0)) |
|
|
reward_rate = 0 |
|
|
rewards_rate_str = safe_get(data, 'rewards_rate', 'N/A') |
|
|
category = safe_get(data, 'category', 'Unknown') |
|
|
reasoning = safe_get(data, 'reasoning', 'Optimal choice') |
|
|
|
|
|
merchant = safe_get(data, 'merchant', 'Unknown Merchant') |
|
|
amount = float(safe_get(data, 'amount_usd', safe_get(data, 'amount', 0))) |
|
|
annual_potential = reward_amount * 12 if reward_amount > 0 else 0 |
|
|
|
|
|
alternatives = [] |
|
|
alt_cards = safe_get(data, 'alternative_cards', safe_get(data, 'alternatives', [])) |
|
|
|
|
|
for alt in alt_cards[:3]: |
|
|
if isinstance(alt, dict): |
|
|
alt_name = safe_get(alt, 'card_name', safe_get(alt, 'card', 'Unknown')) |
|
|
alt_reward = float(safe_get(alt, 'reward_amount', safe_get(alt, 'rewards', 0))) |
|
|
alt_rate = safe_get(alt, 'reward_rate', safe_get(alt, 'rate', 0)) |
|
|
|
|
|
if isinstance(alt_rate, (int, float)) and alt_rate > 0: |
|
|
alt_rate_str = f"{alt_rate}x points" |
|
|
else: |
|
|
alt_rate_str = str(alt_rate) if alt_rate else "N/A" |
|
|
|
|
|
alternatives.append({ |
|
|
'card': alt_name, |
|
|
'rewards': alt_reward, |
|
|
'rate': alt_rate_str |
|
|
}) |
|
|
|
|
|
warnings = safe_get(data, 'warnings', []) |
|
|
forecast_warning = safe_get(data, 'forecast_warning') |
|
|
if forecast_warning and isinstance(forecast_warning, dict): |
|
|
warning_msg = safe_get(forecast_warning, 'warning_message') |
|
|
if warning_msg: |
|
|
warnings.append(warning_msg) |
|
|
|
|
|
normalized = { |
|
|
'recommended_card': card_name, |
|
|
'rewards_earned': round(reward_amount, 2), |
|
|
'rewards_rate': rewards_rate_str, |
|
|
'merchant': merchant, |
|
|
'category': category, |
|
|
'amount': amount, |
|
|
'annual_potential': round(annual_potential, 2), |
|
|
'optimization_score': int(safe_get(data, 'optimization_score', 75)), |
|
|
'reasoning': reasoning, |
|
|
'warnings': warnings, |
|
|
'alternatives': alternatives, |
|
|
'mock_data': safe_get(data, 'mock_data', False) |
|
|
} |
|
|
|
|
|
return normalized |
|
|
|
|
|
def create_loading_state(): |
|
|
"""Create loading indicator message""" |
|
|
return "⏳ **Loading...** Please wait while we fetch your recommendation.", None |
|
|
|
|
|
def load_card_database() -> dict: |
|
|
"""Load card database from local cards.json""" |
|
|
try: |
|
|
with open(CARDS_FILE, 'r') as f: |
|
|
cards = json.load(f) |
|
|
print(f"✅ Loaded {len(cards)} cards from database") |
|
|
return cards |
|
|
except FileNotFoundError: |
|
|
print(f"⚠️ cards.json not found at {CARDS_FILE}") |
|
|
return {} |
|
|
except json.JSONDecodeError as e: |
|
|
print(f"❌ Error parsing cards.json: {e}") |
|
|
return {} |
|
|
|
|
|
CARD_DATABASE = load_card_database() |
|
|
|
|
|
def get_card_details(card_id: str, mcc: str = None) -> dict: |
|
|
""" |
|
|
Get card details from database with enhanced MCC matching |
|
|
|
|
|
Args: |
|
|
card_id: Card identifier (e.g., "c_citi_custom_cash") |
|
|
mcc: Optional MCC code to get specific reward rate |
|
|
|
|
|
Returns: |
|
|
dict: Card details including name, reward rate, caps, etc. |
|
|
""" |
|
|
|
|
|
print(f"🔍 get_card_details called with card_id='{card_id}', mcc='{mcc}'") |
|
|
|
|
|
if card_id not in CARD_DATABASE: |
|
|
print(f"⚠️ Card {card_id} not found in database, using fallback") |
|
|
return { |
|
|
"name": card_id.replace("c_", "").replace("_", " ").title(), |
|
|
"issuer": "Unknown", |
|
|
"reward_rate": 1.0, |
|
|
"base_rate": 1.0, |
|
|
"annual_fee": 0, |
|
|
"spending_caps": {}, |
|
|
"benefits": [] |
|
|
} |
|
|
|
|
|
card = CARD_DATABASE[card_id] |
|
|
reward_rate = 1.0 |
|
|
base_rate = 1.0 |
|
|
|
|
|
print(f"✅ Found card in database: {card.get('name', 'Unknown')}") |
|
|
|
|
|
if "reward_structure" in card: |
|
|
reward_structure = card["reward_structure"] |
|
|
print(f"📋 Reward structure keys: {list(reward_structure.keys())}") |
|
|
|
|
|
|
|
|
base_rate = reward_structure.get("default", 1.0) |
|
|
print(f" Base rate (default): {base_rate}%") |
|
|
|
|
|
|
|
|
if mcc: |
|
|
|
|
|
if str(mcc) in reward_structure: |
|
|
reward_rate = reward_structure[str(mcc)] |
|
|
print(f" ✅ Found exact MCC match '{mcc}': {reward_rate}%") |
|
|
|
|
|
|
|
|
elif int(mcc) in reward_structure: |
|
|
reward_rate = reward_structure[int(mcc)] |
|
|
print(f" ✅ Found exact MCC match {mcc}: {reward_rate}%") |
|
|
|
|
|
|
|
|
else: |
|
|
try: |
|
|
mcc_int = int(mcc) |
|
|
for key, rate in reward_structure.items(): |
|
|
if isinstance(key, str) and "-" in key: |
|
|
try: |
|
|
start, end = key.split("-") |
|
|
if int(start) <= mcc_int <= int(end): |
|
|
reward_rate = rate |
|
|
print(f" ✅ Found MCC range match '{key}' for {mcc}: {reward_rate}%") |
|
|
break |
|
|
except (ValueError, AttributeError) as e: |
|
|
print(f" ⚠️ Error parsing range '{key}': {e}") |
|
|
continue |
|
|
|
|
|
|
|
|
if reward_rate == 1.0: |
|
|
reward_rate = base_rate |
|
|
print(f" ⚠️ No MCC match found, using base rate: {reward_rate}%") |
|
|
|
|
|
except (ValueError, AttributeError) as e: |
|
|
print(f" ⚠️ Error processing MCC: {e}") |
|
|
reward_rate = base_rate |
|
|
else: |
|
|
|
|
|
reward_rate = base_rate |
|
|
print(f" ℹ️ No MCC provided, using base rate: {reward_rate}%") |
|
|
else: |
|
|
print(f" ⚠️ No reward_structure found in card data") |
|
|
|
|
|
|
|
|
spending_caps = card.get("spending_caps", {}) |
|
|
cap_info = {} |
|
|
|
|
|
if "monthly_bonus" in spending_caps: |
|
|
cap_info = { |
|
|
"type": "monthly", |
|
|
"limit": spending_caps["monthly_bonus"], |
|
|
"display": f"${spending_caps['monthly_bonus']}/month" |
|
|
} |
|
|
elif "quarterly_bonus" in spending_caps: |
|
|
cap_info = { |
|
|
"type": "quarterly", |
|
|
"limit": spending_caps["quarterly_bonus"], |
|
|
"display": f"${spending_caps['quarterly_bonus']}/quarter" |
|
|
} |
|
|
elif "annual_bonus" in spending_caps: |
|
|
cap_info = { |
|
|
"type": "annual", |
|
|
"limit": spending_caps["annual_bonus"], |
|
|
"display": f"${spending_caps['annual_bonus']}/year" |
|
|
} |
|
|
|
|
|
result = { |
|
|
"name": card.get("name", "Unknown Card"), |
|
|
"issuer": card.get("issuer", "Unknown"), |
|
|
"reward_rate": reward_rate, |
|
|
"base_rate": base_rate, |
|
|
"annual_fee": card.get("annual_fee", 0), |
|
|
"spending_caps": cap_info, |
|
|
"benefits": card.get("benefits", []) |
|
|
} |
|
|
|
|
|
print(f"📤 Returning: reward_rate={reward_rate}%, base_rate={base_rate}%") |
|
|
print("=" * 60) |
|
|
|
|
|
return result |
|
|
|
|
|
|
|
|
def get_recommendation_with_agent(user_id, merchant, category, amount): |
|
|
import time |
|
|
|
|
|
|
|
|
yield """ |
|
|
<div style="padding: 20px;"> |
|
|
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px;"> |
|
|
<span style="color: #667eea;">⏳</span> |
|
|
<span style="color: #667eea; font-weight: 500;">AI Agent is thinking...</span> |
|
|
</div> |
|
|
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px; opacity: 0.3;"> |
|
|
<span>⏳</span> |
|
|
<span>Analyzing your wallet...</span> |
|
|
</div> |
|
|
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px; opacity: 0.3;"> |
|
|
<span>⏳</span> |
|
|
<span>Comparing reward rates...</span> |
|
|
</div> |
|
|
<div style="display: flex; align-items: center; gap: 10px; opacity: 0.3;"> |
|
|
<span>⏳</span> |
|
|
<span>Calculating optimal strategy...</span> |
|
|
</div> |
|
|
</div> |
|
|
""", None |
|
|
|
|
|
time.sleep(0.8) |
|
|
|
|
|
try: |
|
|
transaction = { |
|
|
"user_id": user_id, |
|
|
"merchant": merchant, |
|
|
"category": category, |
|
|
"mcc": MCC_CATEGORIES.get(category, "5999"), |
|
|
"amount_usd": float(amount) |
|
|
} |
|
|
|
|
|
print("=" * 80) |
|
|
print(f"🚀 REQUEST: {config.ORCHESTRATOR_URL}/recommend") |
|
|
print(f"PAYLOAD: {json.dumps(transaction, indent=2)}") |
|
|
|
|
|
|
|
|
yield """ |
|
|
<div style="padding: 20px;"> |
|
|
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px;"> |
|
|
<span style="color: #4caf50;">✅</span> |
|
|
<span style="color: #4caf50; font-weight: 500;">AI Agent is thinking</span> |
|
|
</div> |
|
|
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px;"> |
|
|
<span style="color: #667eea;">⏳</span> |
|
|
<span style="color: #667eea; font-weight: 500;">Analyzing your wallet...</span> |
|
|
</div> |
|
|
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px; opacity: 0.3;"> |
|
|
<span>⏳</span> |
|
|
<span>Comparing reward rates...</span> |
|
|
</div> |
|
|
<div style="display: flex; align-items: center; gap: 10px; opacity: 0.3;"> |
|
|
<span>⏳</span> |
|
|
<span>Calculating optimal strategy...</span> |
|
|
</div> |
|
|
</div> |
|
|
""", None |
|
|
|
|
|
time.sleep(0.6) |
|
|
|
|
|
response = httpx.post( |
|
|
f"{config.ORCHESTRATOR_URL}/recommend", |
|
|
json=transaction, |
|
|
timeout=60.0 |
|
|
) |
|
|
|
|
|
|
|
|
yield """ |
|
|
<div style="padding: 20px;"> |
|
|
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px;"> |
|
|
<span style="color: #4caf50;">✅</span> |
|
|
<span style="color: #4caf50; font-weight: 500;">AI Agent is thinking</span> |
|
|
</div> |
|
|
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px;"> |
|
|
<span style="color: #4caf50;">✅</span> |
|
|
<span style="color: #4caf50; font-weight: 500;">Analyzing your wallet</span> |
|
|
</div> |
|
|
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px;"> |
|
|
<span style="color: #667eea;">⏳</span> |
|
|
<span style="color: #667eea; font-weight: 500;">Comparing reward rates...</span> |
|
|
</div> |
|
|
<div style="display: flex; align-items: center; gap: 10px; opacity: 0.3;"> |
|
|
<span>⏳</span> |
|
|
<span>Calculating optimal strategy...</span> |
|
|
</div> |
|
|
</div> |
|
|
""", None |
|
|
|
|
|
time.sleep(0.6) |
|
|
|
|
|
print(f"📥 STATUS: {response.status_code}") |
|
|
print(f"📦 RESPONSE: {response.text[:2000]}") |
|
|
print("=" * 80) |
|
|
|
|
|
if response.status_code != 200: |
|
|
yield f"❌ Error: API returned status {response.status_code}", None |
|
|
return |
|
|
|
|
|
result = response.json() |
|
|
|
|
|
print("=" * 80) |
|
|
print("🔍 DEBUG: Response Structure Analysis") |
|
|
print(f"Response type: {type(result)}") |
|
|
print(f"Top-level keys: {list(result.keys()) if isinstance(result, dict) else 'Not a dict'}") |
|
|
|
|
|
|
|
|
recommendation = None |
|
|
|
|
|
|
|
|
yield """ |
|
|
<div style="padding: 20px;"> |
|
|
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px;"> |
|
|
<span style="color: #4caf50;">✅</span> |
|
|
<span style="color: #4caf50; font-weight: 500;">AI Agent is thinking</span> |
|
|
</div> |
|
|
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px;"> |
|
|
<span style="color: #4caf50;">✅</span> |
|
|
<span style="color: #4caf50; font-weight: 500;">Analyzing your wallet</span> |
|
|
</div> |
|
|
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px;"> |
|
|
<span style="color: #4caf50;">✅</span> |
|
|
<span style="color: #4caf50; font-weight: 500;">Comparing reward rates</span> |
|
|
</div> |
|
|
<div style="display: flex; align-items: center; gap: 10px;"> |
|
|
<span style="color: #667eea;">⏳</span> |
|
|
<span style="color: #667eea; font-weight: 500;">Calculating optimal strategy...</span> |
|
|
</div> |
|
|
</div> |
|
|
""", None |
|
|
|
|
|
time.sleep(0.5) |
|
|
|
|
|
if not isinstance(result, dict): |
|
|
yield f"❌ Invalid response type: {type(result)}", None |
|
|
return |
|
|
|
|
|
print(f"🔍 KEYS: {list(result.keys())}") |
|
|
|
|
|
|
|
|
if isinstance(result, dict) and 'recommendation' in result: |
|
|
recommendation = result['recommendation'] |
|
|
print("✅ Found 'recommendation' key (Format 1: Nested)") |
|
|
|
|
|
|
|
|
elif isinstance(result, dict) and 'recommended_card' in result: |
|
|
recommendation = result |
|
|
print("✅ Using direct response (Format 2: Flat)") |
|
|
|
|
|
|
|
|
elif isinstance(result, dict) and 'data' in result: |
|
|
data = result['data'] |
|
|
if isinstance(data, dict) and 'recommendation' in data: |
|
|
recommendation = data['recommendation'] |
|
|
print("✅ Found in 'data.recommendation' (Format 3)") |
|
|
elif isinstance(data, dict) and 'recommended_card' in data: |
|
|
recommendation = data |
|
|
print("✅ Found in 'data' directly (Format 3b)") |
|
|
|
|
|
if not recommendation: |
|
|
print(f"❌ ERROR: Could not find recommendation in response") |
|
|
print(f"Available keys: {list(result.keys())}") |
|
|
print(f"Full response (first 1000 chars): {str(result)[:1000]}") |
|
|
|
|
|
yield f"❌ Invalid response: No recommendation found", None |
|
|
return |
|
|
|
|
|
|
|
|
|
|
|
card_id = recommendation.get('recommended_card', 'Unknown') |
|
|
|
|
|
|
|
|
if card_id != 'Unknown' and not card_id.startswith('c_'): |
|
|
card_id = f'c_{card_id}' |
|
|
print(f"✅ Normalized card_id to: {card_id}") |
|
|
|
|
|
card_name = recommendation.get('card_name', card_id.replace('c_', '').replace('_', ' ').title()) |
|
|
|
|
|
rewards_earned = float(recommendation.get('rewards_earned', 0)) |
|
|
rewards_rate = recommendation.get('rewards_rate', 'N/A') |
|
|
confidence = float(recommendation.get('confidence', 0)) |
|
|
reasoning = recommendation.get('reasoning', 'No reasoning provided') |
|
|
alternatives = recommendation.get('alternative_options', []) |
|
|
warnings = recommendation.get('warnings', []) |
|
|
|
|
|
|
|
|
annual_impact = recommendation.get('annual_impact', {}) |
|
|
annual_potential = annual_impact.get('potential_savings', 0) |
|
|
optimization_score = annual_impact.get('optimization_score', 0) |
|
|
frequency = annual_impact.get('transaction_frequency', 12) |
|
|
annual_spending = annual_impact.get('annual_spending', 0) |
|
|
frequency_label = annual_impact.get('frequency_assumption', f'{frequency}x per year') |
|
|
|
|
|
|
|
|
|
|
|
transaction_mcc = transaction.get('mcc', MCC_CATEGORIES.get(category, "5999")) |
|
|
card_details = get_card_details(card_id, transaction_mcc) |
|
|
|
|
|
|
|
|
reward_rate_value = card_details.get('reward_rate', 1.0) |
|
|
annual_fee = card_details.get('annual_fee', 0) |
|
|
spending_caps = card_details.get('spending_caps', {}) |
|
|
|
|
|
|
|
|
base_rate = 1.0 |
|
|
if card_id in CARD_DATABASE: |
|
|
reward_structure = CARD_DATABASE[card_id].get('reward_structure', {}) |
|
|
base_rate = reward_structure.get('default', 1.0) |
|
|
|
|
|
print(f"✅ Card: {card_id}") |
|
|
print(f" Category rate: {reward_rate_value}%") |
|
|
print(f" Base rate: {base_rate}%") |
|
|
print(f" Annual fee: ${annual_fee}") |
|
|
|
|
|
llamaindex_context = None |
|
|
spending_warnings = None |
|
|
|
|
|
if card_rag.enabled: |
|
|
logger.info("📚 Fetching RAG context...") |
|
|
|
|
|
|
|
|
llamaindex_context = card_rag.get_card_context( |
|
|
card_name=card_name, |
|
|
merchant=merchant, |
|
|
category=category |
|
|
) |
|
|
|
|
|
|
|
|
spending_warnings = card_rag.get_spending_warnings( |
|
|
card_name=card_name, |
|
|
category=category, |
|
|
amount=amount |
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
baseline_rewards = annual_spending * 0.01 |
|
|
net_rewards = annual_potential - annual_fee |
|
|
net_benefit = net_rewards - baseline_rewards |
|
|
|
|
|
|
|
|
if spending_caps and spending_caps.get('limit'): |
|
|
cap_limit = spending_caps['limit'] |
|
|
cap_type = spending_caps.get('type', 'monthly') |
|
|
|
|
|
if cap_type == 'monthly': |
|
|
cap_annual = cap_limit * 12 |
|
|
elif cap_type == 'quarterly': |
|
|
cap_annual = cap_limit * 4 |
|
|
else: |
|
|
cap_annual = cap_limit |
|
|
|
|
|
if annual_spending <= cap_annual: |
|
|
high_rate_spend = annual_spending |
|
|
low_rate_spend = 0 |
|
|
else: |
|
|
high_rate_spend = cap_annual |
|
|
low_rate_spend = annual_spending - cap_annual |
|
|
|
|
|
high_rate_rewards = high_rate_spend * (reward_rate_value / 100) |
|
|
low_rate_rewards = low_rate_spend * (base_rate / 100) |
|
|
total_calculated_rewards = high_rate_rewards + low_rate_rewards |
|
|
|
|
|
|
|
|
display_rewards = annual_potential if annual_potential > 0 else total_calculated_rewards |
|
|
|
|
|
calc_table = f"""| Spending Tier | Annual Amount | Rate | Rewards | |
|
|
|---------------|---------------|------|---------| |
|
|
| First ${spending_caps['display']} | ${high_rate_spend:.2f} | **{reward_rate_value}%** | ${high_rate_rewards:.2f} | |
|
|
| Remaining spend | ${low_rate_spend:.2f} | {base_rate}% | ${low_rate_rewards:.2f} | |
|
|
| **Subtotal** | **${annual_spending:.2f}** | - | **${display_rewards:.2f}** | |
|
|
| Annual fee | - | - | -${annual_fee:.2f} | |
|
|
| **Net Rewards** | - | - | **${display_rewards - annual_fee:.2f}** |""" |
|
|
else: |
|
|
|
|
|
calc_table = f"""| Spending Tier | Annual Amount | Rate | Rewards | |
|
|
|---------------|---------------|------|---------| |
|
|
| All spending | ${annual_spending:.2f} | **{reward_rate_value}%** | ${annual_potential:.2f} | |
|
|
| Annual fee | - | - | -${annual_fee:.2f} | |
|
|
| **Net Rewards** | - | - | **${annual_potential - annual_fee:.2f}** |""" |
|
|
|
|
|
|
|
|
def format_reasoning(text): |
|
|
if text.strip().startswith(('-', '•', '*', '1.', '2.')): |
|
|
return text |
|
|
sentences = text.replace('\n', ' ').split('. ') |
|
|
bullets = [] |
|
|
for sentence in sentences[:4]: |
|
|
sentence = sentence.strip() |
|
|
if sentence and len(sentence) > 20: |
|
|
if not sentence.endswith('.'): |
|
|
sentence += '.' |
|
|
bullets.append(f"- {sentence}") |
|
|
return '\n'.join(bullets) if bullets else f"- {text}" |
|
|
|
|
|
reasoning_bullets = format_reasoning(reasoning) |
|
|
|
|
|
|
|
|
output = f"""## 🎯 Recommended: **{card_name}** |
|
|
|
|
|
| Metric | Value | |
|
|
|--------|-------| |
|
|
| 💰 **Rewards Earned** | ${rewards_earned:.2f} ({rewards_rate}) | |
|
|
| 📊 **Confidence** | {confidence*100:.0f}% | |
|
|
| 📈 **Annual Potential** | ${annual_potential:.2f}/year | |
|
|
| ⭐ **Optimization Score** | {optimization_score}/100 | |
|
|
|
|
|
--- |
|
|
|
|
|
### 🧠 Why This Card? |
|
|
|
|
|
{reasoning_bullets} |
|
|
|
|
|
--- |
|
|
""" |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if spending_warnings: |
|
|
output += f""" |
|
|
|
|
|
### ⚠️ Important to Know |
|
|
{spending_warnings} |
|
|
""" |
|
|
if alternatives: |
|
|
output += "\n### 🔄 Alternative Options\n\n" |
|
|
output += "| Rank | Card | Rewards | Rate | Why Ranked Here? |\n" |
|
|
output += "|------|------|---------|------|------------------|\n" |
|
|
for idx, alt in enumerate(alternatives[:3], start=2): |
|
|
alt_card_name = alt.get('card_name', alt.get('card', 'Unknown')) |
|
|
alt_rewards = float(alt.get('rewards_earned', 0)) |
|
|
alt_rate = alt.get('rewards_rate', 'N/A') |
|
|
alt_reason = alt.get('reason', 'Good alternative') |
|
|
|
|
|
|
|
|
alt_reason_short = alt_reason.split('.')[0].strip() |
|
|
if not alt_reason_short.endswith('.'): |
|
|
alt_reason_short += '.' |
|
|
|
|
|
|
|
|
if abs(alt_rewards - rewards_earned) < 0.01: |
|
|
ranking_note = " ⚠️ Same rewards, but requires activation or has limitations" |
|
|
elif alt_rewards > rewards_earned: |
|
|
ranking_note = " ⚠️ Higher rewards but may have restrictions" |
|
|
else: |
|
|
ranking_note = "" |
|
|
|
|
|
output += f"| #{idx} | {alt_card_name} | ${alt_rewards:.2f} | {alt_rate} | {alt_reason_short}{ranking_note} |\n" |
|
|
|
|
|
output += "\n---\n" |
|
|
|
|
|
|
|
|
if warnings: |
|
|
output += "\n### ⚠️ Alerts\n\n" |
|
|
for warning in warnings: |
|
|
output += f"- {warning}\n" |
|
|
output += "\n---\n" |
|
|
|
|
|
|
|
|
output += f"""<details> |
|
|
<summary>📊 <b>Annual Impact Calculation</b> (Click to expand)</summary> |
|
|
|
|
|
<br> |
|
|
|
|
|
**Assumptions:** |
|
|
- Transaction: ${float(amount):.2f} at {merchant} ({category}) |
|
|
- Frequency: {frequency_label} → ${annual_spending:.2f}/year |
|
|
|
|
|
**Rewards Breakdown:** |
|
|
|
|
|
{calc_table} |
|
|
|
|
|
**Comparison Analysis:** |
|
|
|
|
|
| Scenario | Annual Rewards | Explanation | |
|
|
|----------|---------------|-------------| |
|
|
| **Recommended Card** | ${annual_potential:.2f} | Using {card_name} with {reward_rate_value}% on {category} | |
|
|
| **Baseline (1% card)** | ${baseline_rewards:.2f} | Using any basic 1% cashback card (e.g., Citi Double Cash) | |
|
|
| **Net Benefit** | **${net_benefit:+.2f}** {"🎉" if net_benefit > 0 else "⚠️"} | Extra rewards by using optimal card | |
|
|
|
|
|
**Why compare to 1% baseline?** |
|
|
- Industry standard for basic cashback cards |
|
|
- Shows the value of strategic card selection |
|
|
- Demonstrates ROI of using this AI recommendation system |
|
|
|
|
|
**Card Details:** {reward_rate_value}% on {category} | Cap: {"$" + str(spending_caps.get('limit', 'None')) if spending_caps else "None"} | Fee: ${annual_fee} |
|
|
|
|
|
<br> |
|
|
</details> |
|
|
""" |
|
|
|
|
|
|
|
|
try: |
|
|
similar_merchants = find_similar_merchants_openai(merchant, user_id) |
|
|
if similar_merchants: |
|
|
output += "\n\n### 🔍 Similar Merchants You've Shopped At\n\n" |
|
|
for merch, score in similar_merchants: |
|
|
output += f"- **{merch}** (similarity: {score*100:.0f}%)\n" |
|
|
output += "\n*Use the same card strategy for these merchants!*\n" |
|
|
except Exception as e: |
|
|
print(f"Similar merchants error: {e}") |
|
|
|
|
|
|
|
|
chart = create_agent_recommendation_chart_enhanced(recommendation) |
|
|
if llamaindex_context or spending_warnings: |
|
|
output += f""" |
|
|
|
|
|
--- |
|
|
|
|
|
*🤖 Enhanced with AI-powered knowledge retrieval using LlamaIndex + OpenAI Embeddings* |
|
|
""" |
|
|
|
|
|
yield output, chart |
|
|
|
|
|
except Exception as e: |
|
|
print(f"❌ ERROR: {traceback.format_exc()}") |
|
|
yield f"❌ **Error:** {str(e)}", None |
|
|
def create_agent_recommendation_chart_enhanced(result: Dict) -> go.Figure: |
|
|
try: |
|
|
rec_name_map = { |
|
|
'c_citi_custom_cash': 'Citi Custom Cash', |
|
|
'c_amex_gold': 'Amex Gold', |
|
|
'c_chase_sapphire_reserve': 'Sapphire Reserve', |
|
|
'c_chase_freedom_unlimited': 'Freedom Unlimited', |
|
|
'c_chase_freedom_flex': 'Freedom Flex' |
|
|
} |
|
|
|
|
|
rec_id = result.get('recommended_card', '') |
|
|
rec_name = rec_name_map.get(rec_id, rec_id.replace('c_', '').replace('_', ' ').title()) |
|
|
rec_reward = float(result.get('rewards_earned', 0)) |
|
|
|
|
|
cards = [rec_name] |
|
|
rewards = [rec_reward] |
|
|
colors = ['#667eea'] |
|
|
|
|
|
alternatives = result.get('alternative_options', []) |
|
|
for alt in alternatives[:3]: |
|
|
alt_id = alt.get('card', '') |
|
|
alt_name = rec_name_map.get(alt_id, alt_id.replace('c_', '').replace('_', ' ').title()) |
|
|
|
|
|
alt_reward = float(alt.get('rewards_earned', 0)) |
|
|
|
|
|
if alt_reward == 0: |
|
|
alt_reward = rec_reward * 0.8 |
|
|
print(f"⚠️ Using fallback reward for {alt_name}: ${alt_reward:.2f}") |
|
|
else: |
|
|
print(f"✅ Using backend reward for {alt_name}: ${alt_reward:.2f}") |
|
|
|
|
|
cards.append(alt_name) |
|
|
rewards.append(alt_reward) |
|
|
colors.append('#cbd5e0') |
|
|
|
|
|
fig = go.Figure(data=[ |
|
|
go.Bar( |
|
|
x=cards, |
|
|
y=rewards, |
|
|
marker=dict(color=colors, line=dict(color='white', width=2)), |
|
|
text=[f'${r:.2f}' for r in rewards], |
|
|
textposition='outside' |
|
|
) |
|
|
]) |
|
|
|
|
|
fig.update_layout( |
|
|
title='🎯 Card Comparison', |
|
|
xaxis_title='Credit Card', |
|
|
yaxis_title='Rewards ($)', |
|
|
template='plotly_white', |
|
|
height=400, |
|
|
showlegend=False |
|
|
) |
|
|
|
|
|
return fig |
|
|
|
|
|
except Exception as e: |
|
|
print(f"Chart error: {e}") |
|
|
fig = go.Figure() |
|
|
fig.add_annotation(text="Chart unavailable", xref="paper", yref="paper", x=0.5, y=0.5, showarrow=False) |
|
|
fig.update_layout(height=400, template='plotly_white') |
|
|
return fig |
|
|
|
|
|
client = RewardPilotClient(config.ORCHESTRATOR_URL) |
|
|
|
|
|
gemini = get_gemini_explainer() |
|
|
|
|
|
logger.info("🚀 Initializing LlamaIndex RAG...") |
|
|
card_rag = initialize_rag() |
|
|
if card_rag.enabled: |
|
|
logger.info("✅ RAG system ready") |
|
|
else: |
|
|
logger.warning("⚠️ RAG system not available") |
|
|
|
|
|
def get_recommendation( |
|
|
user_id: str, |
|
|
merchant: str, |
|
|
category: str, |
|
|
amount: float, |
|
|
use_custom_mcc: bool, |
|
|
custom_mcc: str, |
|
|
transaction_date: Optional[str]) -> tuple: |
|
|
"""Get card recommendation and format response""" |
|
|
if not user_id or not merchant or amount <= 0: |
|
|
return ( |
|
|
"❌ **Error:** Please fill in all required fields.", |
|
|
None, |
|
|
None, |
|
|
) |
|
|
|
|
|
if use_custom_mcc and custom_mcc: |
|
|
mcc = custom_mcc |
|
|
else: |
|
|
mcc = MCC_CATEGORIES.get(category, "5999") |
|
|
|
|
|
if not transaction_date: |
|
|
transaction_date = str(date.today()) |
|
|
|
|
|
response: Dict[str, Any] = client.get_recommendation_sync( |
|
|
user_id=user_id, |
|
|
merchant=merchant, |
|
|
mcc=mcc, |
|
|
amount_usd=amount, |
|
|
transaction_date=transaction_date, |
|
|
) |
|
|
|
|
|
formatted_text = format_full_recommendation(response) |
|
|
|
|
|
comparison_table: Optional[str] |
|
|
stats: Optional[str] |
|
|
if not response.get("error"): |
|
|
recommended = response.get("recommended_card", {}) or {} |
|
|
alternatives: List[Dict[str, Any]] = response.get("alternative_cards", []) or [] |
|
|
all_cards = [c for c in ([recommended] + alternatives) if c] |
|
|
comparison_table = format_comparison_table(all_cards) if all_cards else None |
|
|
|
|
|
total_analyzed = response.get("total_cards_analyzed", len(all_cards)) |
|
|
best_reward = (recommended.get("reward_amount") or 0.0) |
|
|
services_used = response.get("services_used", []) |
|
|
stats = f"""**Cards Analyzed:** {total_analyzed} |
|
|
**Best Reward:** ${best_reward:.2f} |
|
|
**Services Used:** {', '.join(services_used)}""".strip() |
|
|
else: |
|
|
comparison_table = None |
|
|
stats = None |
|
|
|
|
|
return formatted_text, comparison_table, stats |
|
|
|
|
|
def get_recommendation_with_ai(user_id, merchant, category, amount): |
|
|
"""Get card recommendation with LLM-powered explanation""" |
|
|
|
|
|
logger.info("=" * 80) |
|
|
logger.info("🔍 GEMINI DEBUG INFO:") |
|
|
logger.info(f" config.USE_GEMINI = {config.USE_GEMINI}") |
|
|
logger.info(f" gemini.enabled = {gemini.enabled}") |
|
|
logger.info(f" config.GEMINI_API_KEY exists = {bool(config.GEMINI_API_KEY)}") |
|
|
logger.info(f" config.GEMINI_MODEL = {config.GEMINI_MODEL}") |
|
|
logger.info("=" * 80) |
|
|
|
|
|
if not merchant or not merchant.strip(): |
|
|
return "❌ Please enter a merchant name.", None |
|
|
|
|
|
if amount <= 0: |
|
|
return "❌ Please enter a valid amount greater than $0.", None |
|
|
|
|
|
yield "⏳ **Loading recommendation...** Analyzing your cards and transaction...", None |
|
|
|
|
|
try: |
|
|
result = client.get_recommendation( |
|
|
user_id=user_id, |
|
|
merchant=merchant, |
|
|
category=category, |
|
|
amount=float(amount), |
|
|
mcc=None |
|
|
) |
|
|
|
|
|
if not result.get('success'): |
|
|
error_msg = result.get('error', 'Unknown error') |
|
|
yield f"❌ Error: {error_msg}", None |
|
|
return |
|
|
|
|
|
data = normalize_recommendation_data(result.get('data', {})) |
|
|
|
|
|
ai_explanation = "" |
|
|
if config.USE_GEMINI and gemini.enabled: |
|
|
try: |
|
|
ai_explanation = gemini.explain_recommendation( |
|
|
card=data['recommended_card'], |
|
|
rewards=data['rewards_earned'], |
|
|
rewards_rate=data['rewards_rate'], |
|
|
merchant=merchant, |
|
|
category=category, |
|
|
amount=float(amount), |
|
|
warnings=data['warnings'] if data['warnings'] else None, |
|
|
annual_potential=data['annual_potential'], |
|
|
alternatives=data['alternatives'] |
|
|
) |
|
|
ai_explanation = f"🤖 **Powered by Google Gemini**\n\n{ai_explanation}" |
|
|
except Exception as e: |
|
|
logger.info(f"Gemini explanation failed: {e}") |
|
|
ai_explanation = "" |
|
|
|
|
|
output = f""" |
|
|
## 🎯 Recommendation for ${amount:.2f} at {merchant} |
|
|
|
|
|
### 💳 Best Card: **{data['recommended_card']}** |
|
|
|
|
|
**Rewards Earned:** ${data['rewards_earned']:.2f} ({data['rewards_rate']}) |
|
|
""" |
|
|
|
|
|
if data.get('mock_data'): |
|
|
output += """ |
|
|
> ⚠️ **Demo Mode:** Using sample data. Connect to orchestrator for real recommendations. |
|
|
""" |
|
|
|
|
|
if ai_explanation: |
|
|
output += f""" |
|
|
### 🤖 AI Insight |
|
|
|
|
|
{ai_explanation} |
|
|
|
|
|
--- |
|
|
""" |
|
|
|
|
|
output += f""" |
|
|
### 📊 Breakdown |
|
|
|
|
|
- **Category:** {data['category']} |
|
|
- **Merchant:** {data['merchant']} |
|
|
- **Reasoning:** {data['reasoning']} |
|
|
- **Annual Potential:** ${data['annual_potential']:.2f} |
|
|
- **Optimization Score:** {data['optimization_score']}/100 |
|
|
""" |
|
|
|
|
|
if data['warnings']: |
|
|
output += "\n\n### ⚠️ Important Warnings\n\n" |
|
|
for warning in data['warnings']: |
|
|
output += f"- {warning}\n" |
|
|
|
|
|
if data['alternatives']: |
|
|
output += "\n\n### 🔄 Alternative Options\n\n" |
|
|
for alt in data['alternatives']: |
|
|
output += f"- **{alt['card']}:** ${alt['rewards']:.2f} ({alt['rate']})\n" |
|
|
|
|
|
chart = create_rewards_comparison_chart(data) |
|
|
|
|
|
yield output, chart |
|
|
|
|
|
except Exception as e: |
|
|
error_details = traceback.format_exc() |
|
|
print(f"Recommendation error: {error_details}") |
|
|
yield f"❌ Error: {str(e)}\n\nPlease check your API connection or try again.", None |
|
|
|
|
|
def create_rewards_comparison_chart(data): |
|
|
"""Create dual chart: current transaction + annual projection""" |
|
|
|
|
|
try: |
|
|
import matplotlib.pyplot as plt |
|
|
import matplotlib |
|
|
matplotlib.use('Agg') |
|
|
|
|
|
|
|
|
primary_card = data.get('recommended_card', 'Unknown') |
|
|
primary_rewards = data.get('rewards_earned', 0) |
|
|
annual_potential = data.get('annual_potential', 0) |
|
|
|
|
|
|
|
|
transaction_amount = primary_rewards / 0.02 |
|
|
|
|
|
|
|
|
cards = [primary_card] |
|
|
current_rewards = [primary_rewards] |
|
|
annual_rewards = [annual_potential] |
|
|
colors = ['#2ecc71'] |
|
|
|
|
|
|
|
|
alternatives = data.get('alternatives', []) |
|
|
|
|
|
if alternatives and len(alternatives) > 0: |
|
|
for alt in alternatives[:3]: |
|
|
cards.append(alt.get('card', 'Unknown')) |
|
|
current_rewards.append(alt.get('rewards', 0)) |
|
|
|
|
|
annual_est = (alt.get('rewards', 0) / primary_rewards) * annual_potential if primary_rewards > 0 else 0 |
|
|
annual_rewards.append(annual_est) |
|
|
colors.append('#3498db') |
|
|
|
|
|
|
|
|
if len(cards) == 1: |
|
|
cards.extend(['Citi Double Cash', 'Chase Freedom Unlimited', 'Baseline (1%)']) |
|
|
current_rewards.extend([ |
|
|
transaction_amount * 0.02, |
|
|
transaction_amount * 0.015, |
|
|
transaction_amount * 0.01 |
|
|
]) |
|
|
annual_rewards.extend([ |
|
|
annual_potential * 1.0, |
|
|
annual_potential * 0.75, |
|
|
annual_potential * 0.5 |
|
|
]) |
|
|
colors.extend(['#3498db', '#3498db', '#95a5a6']) |
|
|
|
|
|
|
|
|
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(16, 6)) |
|
|
|
|
|
|
|
|
y_pos = range(len(cards)) |
|
|
bars1 = ax1.barh(y_pos, current_rewards, color=colors, edgecolor='black', linewidth=1.5, alpha=0.85) |
|
|
|
|
|
ax1.set_yticks(y_pos) |
|
|
ax1.set_yticklabels(cards, fontsize=11, fontweight='bold') |
|
|
ax1.set_xlabel('Rewards ($)', fontsize=12, fontweight='bold') |
|
|
ax1.set_title(f'💳 This Transaction (${transaction_amount:.2f})', fontsize=13, fontweight='bold') |
|
|
ax1.grid(axis='x', alpha=0.3, linestyle='--') |
|
|
ax1.set_axisbelow(True) |
|
|
|
|
|
|
|
|
for i, (bar, reward) in enumerate(zip(bars1, current_rewards)): |
|
|
label = f'⭐ ${reward:.2f}' if i == 0 else f'${reward:.2f}' |
|
|
ax1.text(reward + max(current_rewards)*0.02, bar.get_y() + bar.get_height()/2, |
|
|
label, va='center', fontsize=10, fontweight='bold') |
|
|
|
|
|
|
|
|
bars2 = ax2.barh(y_pos, annual_rewards, color=colors, edgecolor='black', linewidth=1.5, alpha=0.85) |
|
|
|
|
|
ax2.set_yticks(y_pos) |
|
|
ax2.set_yticklabels([''] * len(cards)) |
|
|
ax2.set_xlabel('Annual Rewards ($)', fontsize=12, fontweight='bold') |
|
|
ax2.set_title('📊 Estimated Annual Value', fontsize=13, fontweight='bold') |
|
|
ax2.grid(axis='x', alpha=0.3, linestyle='--') |
|
|
ax2.set_axisbelow(True) |
|
|
|
|
|
|
|
|
for i, (bar, reward) in enumerate(zip(bars2, annual_rewards)): |
|
|
label = f'⭐ ${reward:.2f}' if i == 0 else f'${reward:.2f}' |
|
|
ax2.text(reward + max(annual_rewards)*0.02, bar.get_y() + bar.get_height()/2, |
|
|
label, va='center', fontsize=10, fontweight='bold') |
|
|
|
|
|
|
|
|
ax1.set_xlim(0, max(current_rewards) * 1.15) |
|
|
ax2.set_xlim(0, max(annual_rewards) * 1.15) |
|
|
|
|
|
|
|
|
from matplotlib.patches import Patch |
|
|
legend_elements = [ |
|
|
Patch(facecolor='#2ecc71', edgecolor='black', label='⭐ Recommended'), |
|
|
Patch(facecolor='#3498db', edgecolor='black', label='Alternatives') |
|
|
] |
|
|
fig.legend(handles=legend_elements, loc='lower center', ncol=2, fontsize=11, framealpha=0.9) |
|
|
|
|
|
plt.tight_layout() |
|
|
plt.subplots_adjust(bottom=0.1) |
|
|
|
|
|
return fig |
|
|
|
|
|
except Exception as e: |
|
|
print(f"Chart creation error: {e}") |
|
|
import traceback |
|
|
traceback.print_exc() |
|
|
return None |
|
|
|
|
|
|
|
|
def get_analytics_with_insights(user_id): |
|
|
"""Get analytics with LLM-generated insights""" |
|
|
|
|
|
try: |
|
|
result = client.get_user_analytics(user_id) |
|
|
|
|
|
if not result.get('success'): |
|
|
return f"❌ Error: {result.get('error', 'Unknown error')}", None, None, None |
|
|
|
|
|
data = result['data'] |
|
|
|
|
|
ai_insights = "" |
|
|
if config.LLM_ENABLED: |
|
|
try: |
|
|
ai_insights = llm.generate_spending_insights( |
|
|
user_id=user_id, |
|
|
total_spending=data['total_spending'], |
|
|
total_rewards=data['total_rewards'], |
|
|
optimization_score=data['optimization_score'], |
|
|
top_categories=data.get('category_breakdown', []), |
|
|
recommendations_count=data.get('optimized_count', 0) |
|
|
) |
|
|
except Exception as e: |
|
|
print(f"AI insights generation failed: {e}") |
|
|
ai_insights = "" |
|
|
|
|
|
metrics = f""" |
|
|
## 📊 Your Rewards Analytics |
|
|
|
|
|
### Key Metrics |
|
|
|
|
|
- **💰 Total Rewards:** ${data['total_rewards']:.2f} |
|
|
- **📈 Potential Savings:** ${data['potential_savings']:.2f}/year |
|
|
- **⭐ Optimization Score:** {data['optimization_score']}/100 |
|
|
- **✅ Optimized Transactions:** {data.get('optimized_count', 0)} |
|
|
""" |
|
|
|
|
|
if ai_insights: |
|
|
metrics += f""" |
|
|
### 🤖 Personalized Insights |
|
|
|
|
|
{ai_insights} |
|
|
|
|
|
--- |
|
|
""" |
|
|
|
|
|
spending_chart = create_spending_chart(data) |
|
|
rewards_chart = create_rewards_distribution_chart(data) |
|
|
optimization_chart = create_optimization_gauge(data['optimization_score']) |
|
|
|
|
|
return metrics, spending_chart, rewards_chart, optimization_chart |
|
|
|
|
|
except Exception as e: |
|
|
return f"❌ Error: {str(e)}", None, None, None |
|
|
|
|
|
EXAMPLES = [ |
|
|
["u_alice", "Groceries", "Whole Foods", 125.50, False, "", "2025-01-15"], |
|
|
["u_bob", "Restaurants", "Olive Garden", 65.75, False, "", "2025-01-15"], |
|
|
["u_charlie", "Airlines", "United Airlines", 450.00, False, "", "2025-01-15"], |
|
|
["u_alice", "Fast Food", "Starbucks", 15.75, False, "", ""], |
|
|
["u_bob", "Gas Stations", "Shell", 45.00, False, "", ""], |
|
|
] |
|
|
|
|
|
def create_empty_chart(message: str) -> go.Figure: |
|
|
"""Helper to create empty chart with message""" |
|
|
fig = go.Figure() |
|
|
fig.add_annotation( |
|
|
text=message, |
|
|
xref="paper", yref="paper", |
|
|
x=0.5, y=0.5, showarrow=False, |
|
|
font=dict(size=14, color="#666") |
|
|
) |
|
|
fig.update_layout(height=400, template='plotly_white') |
|
|
return fig |
|
|
|
|
|
|
|
|
def format_current_month_summary(analytics_data): |
|
|
"""Format current month warnings with clear styling (for Analytics tab)""" |
|
|
|
|
|
warnings = [] |
|
|
|
|
|
|
|
|
for card in analytics_data.get('card_usage', []): |
|
|
if card.get('cap_percentage', 0) > 90: |
|
|
warnings.append( |
|
|
f"⚠️ <strong>{card['name']}</strong>: " |
|
|
f"${card['current_spend']:.0f} / ${card['cap']:.0f} " |
|
|
f"({card['cap_percentage']:.0f}% used)" |
|
|
) |
|
|
|
|
|
|
|
|
days_elapsed = datetime.now().day |
|
|
days_in_month = calendar.monthrange(datetime.now().year, datetime.now().month)[1] |
|
|
projection_ratio = days_in_month / days_elapsed if days_elapsed > 0 else 1 |
|
|
|
|
|
projected_spending = analytics_data.get('total_spending', 0) * projection_ratio |
|
|
projected_rewards = analytics_data.get('total_rewards', 0) * projection_ratio |
|
|
|
|
|
warnings_html = "<br>".join(warnings) if warnings else "✅ No warnings - you're on track!" |
|
|
|
|
|
return f""" |
|
|
<div class="current-month-warning"> |
|
|
<h4>⚠️ This Month's Status (as of {datetime.now().strftime('%B %d')})</h4> |
|
|
|
|
|
<p><strong>Month-End Projection:</strong></p> |
|
|
<ul style="margin: 10px 0; padding-left: 20px;"> |
|
|
<li>Estimated Total Spending: <strong>${projected_spending:.2f}</strong></li> |
|
|
<li>Estimated Total Rewards: <strong>${projected_rewards:.2f}</strong></li> |
|
|
</ul> |
|
|
|
|
|
<p><strong>Spending Cap Alerts:</strong></p> |
|
|
<p style="margin: 10px 0;">{warnings_html}</p> |
|
|
|
|
|
<p style="margin-top: 15px; padding-top: 15px; border-top: 1px solid #ffcc80; font-size: 13px; color: #666;"> |
|
|
💡 <em>These are estimates based on your current month's activity. |
|
|
For detailed future predictions, visit the <strong>Forecast tab</strong>.</em> |
|
|
</p> |
|
|
</div> |
|
|
""" |
|
|
|
|
|
def update_analytics_with_charts(user_id: str): |
|
|
"""Fetch and format analytics with charts for selected user""" |
|
|
|
|
|
try: |
|
|
result = client.get_user_analytics(user_id) |
|
|
|
|
|
print("=" * 60) |
|
|
print(f"DEBUG: Analytics for {user_id}") |
|
|
print(f"Success: {result.get('success')}") |
|
|
if result.get('data'): |
|
|
print(f"Data keys: {result['data'].keys()}") |
|
|
print(f"Total spending: {result['data'].get('total_spending')}") |
|
|
print(f"Total rewards: {result['data'].get('total_rewards')}") |
|
|
print("=" * 60) |
|
|
|
|
|
if not result.get('success'): |
|
|
error_msg = result.get('error', 'Unknown error') |
|
|
empty_fig = create_empty_chart(f"Error: {error_msg}") |
|
|
return ( |
|
|
f"<p>❌ Error: {error_msg}</p>", |
|
|
empty_fig, empty_fig, empty_fig, empty_fig, empty_fig, |
|
|
"Error loading data", |
|
|
"Error loading data", |
|
|
f"<p>Error: {error_msg}</p>", |
|
|
f"*Error: {error_msg}*" |
|
|
) |
|
|
|
|
|
analytics_data = result.get('data', {}) |
|
|
|
|
|
if not analytics_data: |
|
|
empty_fig = create_empty_chart("No analytics data available") |
|
|
return ( |
|
|
"<p>No data available</p>", |
|
|
empty_fig, empty_fig, empty_fig, empty_fig, empty_fig, |
|
|
"No data", |
|
|
"No data", |
|
|
"<p>No data available</p>", |
|
|
"*No data available*" |
|
|
) |
|
|
|
|
|
metrics_html, table_md, insights_md, _ = format_analytics_metrics(analytics_data) |
|
|
|
|
|
|
|
|
current_month_html = format_current_month_summary(analytics_data) |
|
|
|
|
|
spending_fig = create_spending_chart(analytics_data) |
|
|
pie_fig = create_rewards_pie_chart(analytics_data) |
|
|
gauge_fig = create_optimization_gauge(analytics_data) |
|
|
trend_fig = create_trend_line_chart(analytics_data) |
|
|
performance_fig = create_card_performance_chart(analytics_data) |
|
|
|
|
|
status = f"*Analytics updated for {user_id} at {datetime.now().strftime('%I:%M %p')}*" |
|
|
|
|
|
return ( |
|
|
metrics_html, |
|
|
spending_fig, |
|
|
gauge_fig, |
|
|
pie_fig, |
|
|
performance_fig, |
|
|
trend_fig, |
|
|
table_md, |
|
|
insights_md, |
|
|
current_month_html, |
|
|
status |
|
|
) |
|
|
|
|
|
except Exception as e: |
|
|
error_details = traceback.format_exc() |
|
|
error_msg = f"❌ Error loading analytics: {str(e)}" |
|
|
print(error_msg) |
|
|
print(error_details) |
|
|
|
|
|
empty_fig = create_empty_chart("Error loading chart") |
|
|
|
|
|
return ( |
|
|
f"<p>{error_msg}</p>", |
|
|
empty_fig, empty_fig, empty_fig, empty_fig, empty_fig, |
|
|
"Error loading table", |
|
|
"Error loading insights", |
|
|
f"<p>{error_msg}</p>", |
|
|
f"*{error_msg}*" |
|
|
) |
|
|
|
|
|
def _toggle_custom_mcc(use_custom: bool): |
|
|
return gr.update(visible=use_custom, value="") |
|
|
|
|
|
|
|
|
def find_similar_merchants_openai(merchant_name, user_id): |
|
|
"""Use OpenAI embeddings to find similar merchants in user history""" |
|
|
|
|
|
try: |
|
|
|
|
|
historical_merchants = [ |
|
|
"Whole Foods", "Trader Joe's", "Safeway", "Costco", |
|
|
"Starbucks", "Chipotle", "Olive Garden", |
|
|
"Shell", "Chevron", "BP" |
|
|
] |
|
|
|
|
|
|
|
|
|
|
|
target_response = openai_client.embeddings.create( |
|
|
model="text-embedding-3-small", |
|
|
input=merchant_name |
|
|
) |
|
|
target_embedding = target_response.data[0].embedding |
|
|
|
|
|
|
|
|
similarities = [] |
|
|
for hist_merchant in historical_merchants: |
|
|
hist_response = openai_client.embeddings.create( |
|
|
model="text-embedding-3-small", |
|
|
input=hist_merchant |
|
|
) |
|
|
hist_embedding = hist_response.data[0].embedding |
|
|
|
|
|
|
|
|
import numpy as np |
|
|
similarity = np.dot(target_embedding, hist_embedding) / ( |
|
|
np.linalg.norm(target_embedding) * np.linalg.norm(hist_embedding) |
|
|
) |
|
|
|
|
|
similarities.append((hist_merchant, float(similarity))) |
|
|
|
|
|
|
|
|
similarities.sort(key=lambda x: x[0], reverse=True) |
|
|
return similarities[:3] |
|
|
|
|
|
except Exception as e: |
|
|
print(f"Embeddings error: {e}") |
|
|
return [] |
|
|
|
|
|
|
|
|
def load_user_wallet(user_id: str): |
|
|
"""Load and display user's credit card wallet""" |
|
|
try: |
|
|
|
|
|
|
|
|
wallet_data = { |
|
|
'u_alice': [ |
|
|
{'name': 'Amex Gold', 'issuer': 'American Express', 'status': 'Active', 'limit': '$25,000'}, |
|
|
{'name': 'Chase Sapphire Reserve', 'issuer': 'Chase', 'status': 'Active', 'limit': '$30,000'}, |
|
|
{'name': 'Citi Custom Cash', 'issuer': 'Citibank', 'status': 'Active', 'limit': '$15,000'}, |
|
|
], |
|
|
'u_bob': [ |
|
|
{'name': 'Chase Freedom Unlimited', 'issuer': 'Chase', 'status': 'Active', 'limit': '$20,000'}, |
|
|
{'name': 'Discover it', 'issuer': 'Discover', 'status': 'Active', 'limit': '$12,000'}, |
|
|
], |
|
|
'u_charlie': [ |
|
|
{'name': 'Capital One Venture', 'issuer': 'Capital One', 'status': 'Active', 'limit': '$35,000'}, |
|
|
{'name': 'Wells Fargo Active Cash', 'issuer': 'Wells Fargo', 'status': 'Active', 'limit': '$18,000'}, |
|
|
] |
|
|
} |
|
|
|
|
|
cards = wallet_data.get(user_id, []) |
|
|
|
|
|
if not cards: |
|
|
return "No cards found in wallet", create_empty_chart("No cards in wallet") |
|
|
|
|
|
output = f"## 💳 Your Credit Card Wallet ({len(cards)} cards)\n\n" |
|
|
|
|
|
for card in cards: |
|
|
output += f""" |
|
|
### {card['name']} |
|
|
- **Issuer:** {card['issuer']} |
|
|
- **Status:** {card['status']} |
|
|
- **Credit Limit:** {card['limit']} |
|
|
|
|
|
--- |
|
|
""" |
|
|
|
|
|
|
|
|
fig = go.Figure(data=[ |
|
|
go.Bar( |
|
|
x=[c['name'] for c in cards], |
|
|
y=[int(c['limit'].replace('$', '').replace(',', '')) for c in cards], |
|
|
marker=dict(color='#667eea'), |
|
|
text=[c['limit'] for c in cards], |
|
|
textposition='outside' |
|
|
) |
|
|
]) |
|
|
|
|
|
fig.update_layout( |
|
|
title='Credit Limits by Card', |
|
|
xaxis_title='Card', |
|
|
yaxis_title='Credit Limit ($)', |
|
|
template='plotly_white', |
|
|
height=400 |
|
|
) |
|
|
|
|
|
return output, fig |
|
|
|
|
|
except Exception as e: |
|
|
return f"❌ Error loading wallet: {str(e)}", create_empty_chart("Error") |
|
|
|
|
|
|
|
|
def load_user_forecast(user_id: str): |
|
|
"""Load and display spending forecast (comprehensive version for Forecast tab)""" |
|
|
try: |
|
|
|
|
|
forecast_data = { |
|
|
'next_month_spending': 3250.50, |
|
|
'predicted_rewards': 127.50, |
|
|
'confidence': 0.92, |
|
|
'top_categories': [ |
|
|
{'category': 'Groceries', 'predicted': 850.00, 'confidence': 0.92, 'emoji': '🛒'}, |
|
|
{'category': 'Restaurants', 'predicted': 650.00, 'confidence': 0.88, 'emoji': '🍽️'}, |
|
|
{'category': 'Gas', 'predicted': 450.00, 'confidence': 0.85, 'emoji': '⛽'}, |
|
|
], |
|
|
'recommendations': [ |
|
|
"Use Amex Gold for groceries (4x points)", |
|
|
"Approaching Citi Custom Cash $500 cap", |
|
|
"Travel spending predicted to increase" |
|
|
], |
|
|
'optimization_potential': 45.50 |
|
|
} |
|
|
|
|
|
confidence = forecast_data.get('confidence', 0.85) |
|
|
confidence_badge = "High" if confidence > 0.9 else "Medium" if confidence > 0.75 else "Low" |
|
|
confidence_color = '#4caf50' if confidence > 0.9 else '#ff9800' if confidence > 0.75 else '#f44336' |
|
|
|
|
|
|
|
|
output = f""" |
|
|
## 🔮 Next Month Forecast |
|
|
|
|
|
**Confidence:** {confidence*100:.0f}% ({confidence_badge}) |
|
|
|
|
|
### 📊 Summary |
|
|
|
|
|
| Metric | Amount | |
|
|
|--------|--------| |
|
|
| 💰 **Total Spending** | ${forecast_data['next_month_spending']:.2f} | |
|
|
| 🎁 **Expected Rewards** | ${forecast_data['predicted_rewards']:.2f} | |
|
|
| 📈 **Extra with Optimization** | +${forecast_data['optimization_potential']:.2f} | |
|
|
|
|
|
--- |
|
|
|
|
|
### 📊 Top Categories |
|
|
|
|
|
| Category | Predicted | Confidence | |
|
|
|----------|-----------|------------| |
|
|
""" |
|
|
|
|
|
for cat in forecast_data['top_categories']: |
|
|
output += f"| {cat['emoji']} {cat['category']} | ${cat['predicted']:.2f} | {cat['confidence']*100:.0f}% |\n" |
|
|
|
|
|
output += "\n---\n\n### 💡 Action Items\n\n" |
|
|
for i, rec in enumerate(forecast_data['recommendations'], 1): |
|
|
output += f"{i}. {rec}\n" |
|
|
|
|
|
output += """ |
|
|
--- |
|
|
|
|
|
**💡 Tip:** Check the Analytics tab to see your current spending patterns and optimization opportunities. |
|
|
""" |
|
|
|
|
|
fig = go.Figure() |
|
|
|
|
|
categories = [c['category'] for c in forecast_data['top_categories']] |
|
|
amounts = [c['predicted'] for c in forecast_data['top_categories']] |
|
|
confidences = [c['confidence'] for c in forecast_data['top_categories']] |
|
|
|
|
|
|
|
|
colors = ['#4caf50' if c > 0.9 else '#ff9800' if c > 0.8 else '#f44336' for c in confidences] |
|
|
|
|
|
fig.add_trace(go.Bar( |
|
|
x=categories, |
|
|
y=amounts, |
|
|
marker=dict(color=colors), |
|
|
text=[f'${a:.0f}<br>{c*100:.0f}% conf.' for a, c in zip(amounts, confidences)], |
|
|
textposition='outside', |
|
|
hovertemplate='<b>%{x}</b><br>Predicted: $%{y:.2f}<extra></extra>' |
|
|
)) |
|
|
|
|
|
fig.update_layout( |
|
|
title='Predicted Spending by Category (Next Month)', |
|
|
xaxis_title='Category', |
|
|
yaxis_title='Predicted Amount ($)', |
|
|
template='plotly_white', |
|
|
height=400, |
|
|
showlegend=False |
|
|
) |
|
|
|
|
|
return output, fig |
|
|
|
|
|
except Exception as e: |
|
|
return f"❌ Error loading forecast: {str(e)}", create_empty_chart("Error") |
|
|
|
|
|
|
|
|
|
|
|
with gr.Blocks( |
|
|
theme=THEME if isinstance(THEME, gr.themes.ThemeClass) else gr.themes.Soft(), |
|
|
title=APP_TITLE, |
|
|
css=""" |
|
|
/* ===== RESPONSIVE LAYOUT ===== */ |
|
|
.gradio-container { |
|
|
max-width: 100% !important; |
|
|
width: 100% !important; |
|
|
padding: 0 clamp(1rem, 2vw, 3rem) !important; |
|
|
margin: 0 auto !important; |
|
|
} |
|
|
|
|
|
/* Limit width on ultra-wide screens for readability */ |
|
|
@media (min-width: 2560px) { |
|
|
.gradio-container { |
|
|
max-width: 1800px !important; |
|
|
} |
|
|
} |
|
|
|
|
|
/* Tablet optimization */ |
|
|
@media (max-width: 1024px) { |
|
|
.gradio-container { |
|
|
padding: 0 1rem !important; |
|
|
} |
|
|
} |
|
|
|
|
|
/* Mobile optimization */ |
|
|
@media (max-width: 768px) { |
|
|
.gradio-container { |
|
|
padding: 0 0.5rem !important; |
|
|
} |
|
|
|
|
|
/* Stack hero stats vertically on mobile */ |
|
|
.impact-stats { |
|
|
grid-template-columns: 1fr !important; |
|
|
} |
|
|
} |
|
|
|
|
|
/* ===== HERO SECTION STYLES ===== */ |
|
|
.hero-section { |
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
|
|
padding: 50px 40px; |
|
|
border-radius: 20px; |
|
|
color: white; |
|
|
margin-bottom: 35px; |
|
|
box-shadow: 0 15px 40px rgba(102, 126, 234, 0.4); |
|
|
position: relative; |
|
|
overflow: hidden; |
|
|
} |
|
|
|
|
|
|
|
|
.hero-section::before { |
|
|
content: ''; |
|
|
position: absolute; |
|
|
top: -50%; |
|
|
right: -50%; |
|
|
width: 200%; |
|
|
height: 200%; |
|
|
background: radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 70%); |
|
|
animation: pulse 4s ease-in-out infinite; |
|
|
} |
|
|
|
|
|
@keyframes pulse { |
|
|
0%, 100% { transform: scale(1); opacity: 0.5; } |
|
|
50% { transform: scale(1.1); opacity: 0.8; } |
|
|
} |
|
|
|
|
|
.hero-title { |
|
|
font-size: 42px; |
|
|
font-weight: 800; |
|
|
margin-bottom: 15px; |
|
|
text-align: center; |
|
|
text-shadow: 0 2px 10px rgba(0,0,0,0.2); |
|
|
position: relative; |
|
|
z-index: 1; |
|
|
} |
|
|
|
|
|
.hero-subtitle { |
|
|
font-size: 22px; |
|
|
font-weight: 400; |
|
|
margin-bottom: 35px; |
|
|
text-align: center; |
|
|
opacity: 0.95; |
|
|
line-height: 1.6; |
|
|
position: relative; |
|
|
z-index: 1; |
|
|
} |
|
|
|
|
|
.impact-stats { |
|
|
display: grid; |
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); |
|
|
gap: 25px; |
|
|
margin-top: 35px; |
|
|
position: relative; |
|
|
z-index: 1; |
|
|
} |
|
|
|
|
|
.stat-card { |
|
|
background: rgba(255, 255, 255, 0.2); |
|
|
backdrop-filter: blur(10px); |
|
|
padding: 25px; |
|
|
border-radius: 15px; |
|
|
border: 2px solid rgba(255, 255, 255, 0.3); |
|
|
text-align: center; |
|
|
transition: all 0.3s ease; |
|
|
} |
|
|
|
|
|
.stat-card:hover { |
|
|
transform: translateY(-8px); |
|
|
background: rgba(255, 255, 255, 0.25); |
|
|
box-shadow: 0 10px 30px rgba(0,0,0,0.2); |
|
|
} |
|
|
|
|
|
.stat-number { |
|
|
font-size: 48px; |
|
|
font-weight: 800; |
|
|
margin-bottom: 10px; |
|
|
display: block; |
|
|
} |
|
|
|
|
|
.stat-label { |
|
|
font-size: 16px; |
|
|
opacity: 0.9; |
|
|
font-weight: 500; |
|
|
} |
|
|
|
|
|
/* ===== PROBLEM/SOLUTION BOXES ===== */ |
|
|
.problem-showcase { |
|
|
background: linear-gradient(to right, #fff3cd, #fff8e1); |
|
|
padding: 35px; |
|
|
border-radius: 16px; |
|
|
margin: 35px 0; |
|
|
border-left: 6px solid #ffc107; |
|
|
box-shadow: 0 5px 20px rgba(255, 193, 7, 0.2); |
|
|
} |
|
|
|
|
|
.solution-showcase { |
|
|
background: linear-gradient(to right, #d1ecf1, #e7f5f8); |
|
|
padding: 35px; |
|
|
border-radius: 16px; |
|
|
margin: 35px 0; |
|
|
border-left: 6px solid #17a2b8; |
|
|
box-shadow: 0 5px 20px rgba(23, 162, 184, 0.2); |
|
|
} |
|
|
|
|
|
.scenario-box { |
|
|
background: white; |
|
|
padding: 25px; |
|
|
border-radius: 12px; |
|
|
margin: 20px 0; |
|
|
box-shadow: 0 3px 15px rgba(0,0,0,0.1); |
|
|
} |
|
|
|
|
|
|
|
|
.recommendation-output { |
|
|
font-size: 16px; |
|
|
line-height: 1.6; |
|
|
} |
|
|
.metric-card { |
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
|
|
color: white; |
|
|
padding: 30px 20px; |
|
|
border-radius: 16px; |
|
|
text-align: center; |
|
|
box-shadow: 0 8px 24px rgba(102, 126, 234, 0.3); |
|
|
transition: transform 0.3s ease, box-shadow 0.3s ease; |
|
|
margin: 10px; |
|
|
} |
|
|
.metric-card:hover { |
|
|
transform: translateY(-5px); |
|
|
box-shadow: 0 12px 32px rgba(102, 126, 234, 0.4); |
|
|
} |
|
|
.metric-card h2 { |
|
|
font-size: 48px; |
|
|
font-weight: 700; |
|
|
margin: 0 0 10px 0; |
|
|
color: white; |
|
|
} |
|
|
.metric-card p { |
|
|
font-size: 16px; |
|
|
margin: 0; |
|
|
opacity: 0.9; |
|
|
color: white; |
|
|
} |
|
|
.metric-card-green { |
|
|
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%); |
|
|
} |
|
|
.metric-card-orange { |
|
|
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); |
|
|
} |
|
|
.metric-card-blue { |
|
|
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); |
|
|
} |
|
|
|
|
|
/* ===== ANALYTICS TAB - WARNING BOX ===== */ |
|
|
.current-month-warning { |
|
|
background: linear-gradient(135deg, #fff4e6 0%, #ffe8cc 100%); |
|
|
border-left: 4px solid #ff9800; |
|
|
padding: 15px 20px; |
|
|
border-radius: 8px; |
|
|
margin: 20px 0; |
|
|
box-shadow: 0 2px 8px rgba(255, 152, 0, 0.2); |
|
|
} |
|
|
|
|
|
.current-month-warning h4 { |
|
|
color: #e65100; |
|
|
margin: 0 0 10px 0; |
|
|
font-size: 18px; |
|
|
font-weight: 600; |
|
|
} |
|
|
|
|
|
.current-month-warning p { |
|
|
color: #5d4037; |
|
|
margin: 5px 0; |
|
|
font-size: 14px; |
|
|
} |
|
|
.thinking-dots { |
|
|
display: inline-flex; |
|
|
align-items: center; |
|
|
gap: 8px; |
|
|
padding: 20px; |
|
|
font-size: 18px; |
|
|
color: #667eea; |
|
|
font-weight: 500; |
|
|
} |
|
|
|
|
|
.thinking-dots::after { |
|
|
content: '●●●'; |
|
|
display: inline-block; |
|
|
letter-spacing: 4px; |
|
|
animation: thinking 1.4s infinite; |
|
|
color: #667eea; |
|
|
} |
|
|
|
|
|
@keyframes thinking { |
|
|
0%, 20% { |
|
|
content: '●○○'; |
|
|
} |
|
|
40% { |
|
|
content: '●●○'; |
|
|
} |
|
|
60%, 100% { |
|
|
content: '●●●'; |
|
|
} |
|
|
} |
|
|
|
|
|
/* Alternative bouncing dots animation */ |
|
|
.thinking-bounce { |
|
|
display: inline-flex; |
|
|
align-items: center; |
|
|
gap: 5px; |
|
|
} |
|
|
|
|
|
.thinking-bounce span { |
|
|
width: 10px; |
|
|
height: 10px; |
|
|
background: #667eea; |
|
|
border-radius: 50%; |
|
|
animation: bounce 1.4s infinite ease-in-out; |
|
|
} |
|
|
|
|
|
.thinking-bounce span:nth-child(1) { |
|
|
animation-delay: 0s; |
|
|
} |
|
|
|
|
|
.thinking-bounce span:nth-child(2) { |
|
|
animation-delay: 0.2s; |
|
|
} |
|
|
|
|
|
.thinking-bounce span:nth-child(3) { |
|
|
animation-delay: 0.4s; |
|
|
} |
|
|
|
|
|
@keyframes bounce { |
|
|
0%, 80%, 100% { |
|
|
transform: translateY(0); |
|
|
opacity: 0.5; |
|
|
} |
|
|
40% { |
|
|
transform: translateY(-10px); |
|
|
opacity: 1; |
|
|
} |
|
|
} |
|
|
|
|
|
/* Pulsing effect */ |
|
|
.thinking-pulse { |
|
|
display: inline-block; |
|
|
animation: pulse 1.5s ease-in-out infinite; |
|
|
} |
|
|
|
|
|
@keyframes pulse { |
|
|
0%, 100% { |
|
|
opacity: 1; |
|
|
} |
|
|
50% { |
|
|
opacity: 0.3; |
|
|
} |
|
|
} |
|
|
/* ===== FORECAST TAB - PREDICTION BOX ===== */ |
|
|
.forecast-prediction { |
|
|
background: linear-gradient(135deg, #e3f2fd 0%, #bbdefb 100%); |
|
|
border-left: 4px solid #2196f3; |
|
|
padding: 20px; |
|
|
border-radius: 8px; |
|
|
margin: 20px 0; |
|
|
box-shadow: 0 2px 8px rgba(33, 150, 243, 0.2); |
|
|
} |
|
|
|
|
|
.forecast-prediction h3 { |
|
|
color: #0d47a1; |
|
|
margin: 0 0 15px 0; |
|
|
font-size: 22px; |
|
|
font-weight: 700; |
|
|
} |
|
|
|
|
|
.forecast-prediction .confidence-badge { |
|
|
display: inline-block; |
|
|
padding: 4px 12px; |
|
|
background: #4caf50; |
|
|
color: white; |
|
|
border-radius: 12px; |
|
|
font-size: 12px; |
|
|
font-weight: 600; |
|
|
margin-left: 10px; |
|
|
} |
|
|
|
|
|
/* ===== SECTION DIVIDERS ===== */ |
|
|
.section-divider { |
|
|
border: 0; |
|
|
height: 2px; |
|
|
background: linear-gradient(to right, transparent, #ddd, transparent); |
|
|
margin: 30px 0; |
|
|
} |
|
|
|
|
|
/* ===== INFO BOXES ===== */ |
|
|
.info-box { |
|
|
background: #f5f5f5; |
|
|
border-radius: 8px; |
|
|
padding: 15px; |
|
|
margin: 15px 0; |
|
|
border-left: 3px solid #667eea; |
|
|
} |
|
|
|
|
|
.info-box-icon { |
|
|
font-size: 24px; |
|
|
margin-right: 10px; |
|
|
vertical-align: middle; |
|
|
} |
|
|
|
|
|
table { |
|
|
width: 100%; |
|
|
border-collapse: collapse; |
|
|
margin: 20px 0; |
|
|
background: white; |
|
|
border-radius: 8px; |
|
|
overflow: hidden; |
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1); |
|
|
} |
|
|
table th { |
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
|
|
color: white; |
|
|
padding: 12px; |
|
|
text-align: left; |
|
|
font-weight: 600; |
|
|
} |
|
|
table td { |
|
|
padding: 12px; |
|
|
border-bottom: 1px solid #f0f0f0; |
|
|
} |
|
|
table tr:last-child td { |
|
|
border-bottom: none; |
|
|
} |
|
|
table tr:hover { |
|
|
background: #f8f9fa; |
|
|
} |
|
|
""", |
|
|
) as app: |
|
|
|
|
|
|
|
|
gr.HTML(""" |
|
|
<div class="hero-section"> |
|
|
<h1 class="hero-title">🎯 Stop Losing Money at Every Purchase</h1> |
|
|
<p class="hero-subtitle"> |
|
|
You have <strong>5 credit cards</strong>. You're at checkout. Which one do you use?<br> |
|
|
Most people pick wrong and lose <strong>$400+ per year</strong>.<br> |
|
|
Our AI agent makes the optimal choice in <strong>2 seconds</strong>.<br> |
|
|
Powered by <strong>OpenAI GPT-4 + Gemini + Modal</strong><br> |
|
|
</p> |
|
|
|
|
|
<div class="impact-stats"> |
|
|
<div class="stat-card"> |
|
|
<span class="stat-number">2s</span> |
|
|
<span class="stat-label">Decision Time<br>(vs. 5 min manual)</span> |
|
|
</div> |
|
|
<div class="stat-card"> |
|
|
<span class="stat-number">35%</span> |
|
|
<span class="stat-label">More Rewards<br>Earned</span> |
|
|
</div> |
|
|
<div class="stat-card"> |
|
|
<span class="stat-number">$400+</span> |
|
|
<span class="stat-label">Saved Per Year<br>Per User</span> |
|
|
</div> |
|
|
<div class="stat-card"> |
|
|
<span class="stat-number">100%</span> |
|
|
<span class="stat-label">Optimal Choice<br>Every Time</span> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
""") |
|
|
|
|
|
|
|
|
gr.HTML(""" |
|
|
<div class="problem-showcase"> |
|
|
<h2 style="color: #856404; margin-top: 0; font-size: 28px;"> |
|
|
😰 The $400/Year Problem Nobody Talks About |
|
|
</h2> |
|
|
|
|
|
<div class="scenario-box"> |
|
|
<h3 style="color: #333; margin-top: 0;">📍 Real Scenario: Sunday Grocery Shopping</h3> |
|
|
<p style="font-size: 16px; line-height: 1.8; color: #555;"> |
|
|
You're at <strong>Whole Foods</strong> with <strong>$127.50</strong> of groceries. |
|
|
You pull out your wallet and see 5 credit cards... |
|
|
</p> |
|
|
|
|
|
<div style="background: #fff; padding: 20px; border-radius: 8px; margin-top: 20px; border-left: 4px solid #ff9800;"> |
|
|
<h4 style="color: #e65100; margin-top: 0;">⏰ You Have 10 Seconds to Decide...</h4> |
|
|
<p style="font-size: 15px; color: #666; margin: 0;"> |
|
|
❓ Which card gives best rewards?<br> |
|
|
❓ Have you hit spending caps this month?<br> |
|
|
❓ Is 4x points better than 5% cashback?<br> |
|
|
❓ People are waiting behind you... |
|
|
</p> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div style="background: #fff; padding: 25px; border-radius: 12px; margin-top: 25px; border: 3px dashed #f44336;"> |
|
|
<h3 style="color: #c62828; margin-top: 0;">❌ What Usually Happens:</h3> |
|
|
<p style="font-size: 17px; color: #555; line-height: 1.8;"> |
|
|
<strong>You panic and use your "default" card</strong><br> |
|
|
<strong>Money lost on this transaction:</strong> <span style="color: #f44336; font-size: 20px; font-weight: 700;">-$3.19</span> |
|
|
</p> |
|
|
<p style="font-size: 16px; color: #666; margin-top: 15px;"> |
|
|
This happens <strong>50+ times per month</strong>.<br> |
|
|
<strong>Annual loss:</strong> <span style="color: #f44336; font-size: 22px; font-weight: 700;">$400-600</span> |
|
|
</p> |
|
|
</div> |
|
|
</div> |
|
|
""") |
|
|
|
|
|
|
|
|
gr.HTML(""" |
|
|
<div class="solution-showcase"> |
|
|
<h2 style="color: #0c5460; margin-top: 0; font-size: 28px;"> |
|
|
✨ Our AI Solution: Your Personal Rewards Optimizer |
|
|
</h2> |
|
|
|
|
|
<div class="scenario-box"> |
|
|
<h3 style="color: #333; margin-top: 0;">🤖 Same Scenario, With AI Agent</h3> |
|
|
|
|
|
<div style="background: #e8f5e9; padding: 25px; border-radius: 12px; margin: 20px 0; border: 3px solid #4caf50;"> |
|
|
<h4 style="color: #2e7d32; margin-top: 0; font-size: 20px;">✅ Result:</h4> |
|
|
<div style="font-size: 18px; line-height: 2; color: #1b5e20;"> |
|
|
<strong>💳 Use: Amex Gold</strong><br> |
|
|
<strong>🎁 Earn: $5.10</strong> (vs. $1.91 with default card)<br> |
|
|
<strong>⚡ Decision time: 2 seconds</strong> (vs. 5 minutes)<br> |
|
|
<strong>💡 Confidence: 100%</strong> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div style="background: #fff; padding: 25px; border-radius: 12px; margin-top: 25px; box-shadow: 0 5px 20px rgba(76, 175, 80, 0.3);"> |
|
|
<p style="font-size: 20px; color: #2e7d32; font-weight: 700; text-align: center; margin: 0;"> |
|
|
💰 You just saved $3.19 in 2 seconds with ZERO mental effort |
|
|
</p> |
|
|
</div> |
|
|
</div> |
|
|
""") |
|
|
|
|
|
|
|
|
gr.Markdown(""" |
|
|
## 🌟 What Makes This a Winning Solution? |
|
|
|
|
|
| Traditional Approach | Our AI Solution | Impact | |
|
|
|---------------------|-----------------|---------| |
|
|
| 😰 Manual calculation (5 min) | ⚡ AI decision (2 sec) | **150x faster** | |
|
|
| 🤔 Mental math & guessing | 🎯 100% optimal choice | **35% more rewards** | |
|
|
| 📝 Manual cap tracking | 🤖 Automatic monitoring | **Zero effort** | |
|
|
| ❌ No explanations | 💡 Clear reasoning | **Build trust** | |
|
|
| 📊 Reactive only | 🔮 Predictive insights | **Proactive optimization** | |
|
|
|
|
|
--- |
|
|
|
|
|
### 💎 **Unique Differentiators** |
|
|
|
|
|
#### 1️⃣ **Real-Time Transaction Intelligence** |
|
|
- Not just a card comparison tool |
|
|
- **Context-aware recommendations** at point of purchase |
|
|
- Considers YOUR specific spending patterns and caps |
|
|
|
|
|
#### 2️⃣ **Multi-Agent AI Architecture** |
|
|
- Orchestrator coordinates multiple specialized agents |
|
|
- **Reasoning engine** explains every decision |
|
|
- Learns and adapts to user behavior |
|
|
|
|
|
#### 3️⃣ **Predictive Optimization** |
|
|
- Forecasts next month spending by category |
|
|
- **Warns before hitting caps** |
|
|
- Suggests optimal card rotation strategies |
|
|
|
|
|
#### 4️⃣ **Practical & Immediate Value** |
|
|
- Solves a **real pain point** everyone faces |
|
|
- **Measurable ROI**: $400+ saved per year |
|
|
- Works with existing cards (no signup needed) |
|
|
|
|
|
--- |
|
|
|
|
|
### 🚀 **Ready to Stop Losing Money?** |
|
|
|
|
|
⬇️ **Try the "Get Recommendation" tab below** to experience the magic yourself ⬇️ |
|
|
""") |
|
|
|
|
|
|
|
|
agent_status = """ |
|
|
🤖 **Autonomous Agent:** ✅ Active (Claude 3.5 Sonnet) |
|
|
📊 **Mode:** Dynamic Planning + Reasoning |
|
|
⚡ **Services:** Smart Wallet + RAG + Forecast |
|
|
""" |
|
|
gr.Markdown(agent_status) |
|
|
|
|
|
with gr.Tabs(): |
|
|
|
|
|
with gr.Tab("🎯 Get Recommendation"): |
|
|
|
|
|
|
|
|
gr.HTML(""" |
|
|
<div style="background: linear-gradient(to right, #667eea, #764ba2); |
|
|
padding: 30px; border-radius: 16px; color: white; margin-bottom: 25px; |
|
|
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.4);"> |
|
|
<h3 style="margin: 0 0 15px 0; font-size: 24px;"> |
|
|
💡 Experience the Magic: Real-Time AI Optimization |
|
|
</h3> |
|
|
<p style="margin: 0; font-size: 17px; line-height: 1.7; opacity: 0.95;"> |
|
|
<strong>Simulate a real transaction:</strong> You're about to make a purchase. |
|
|
Instead of spending 5 minutes calculating or guessing, let our AI agent analyze |
|
|
your entire wallet and recommend the optimal card in <strong>under 2 seconds</strong>. |
|
|
</p> |
|
|
<div style="background: rgba(255,255,255,0.15); padding: 20px; border-radius: 10px; margin-top: 20px;"> |
|
|
<p style="margin: 0; font-size: 16px; font-weight: 500;"> |
|
|
🎯 Try these scenarios: |
|
|
</p> |
|
|
<ul style="margin: 10px 0 0 20px; font-size: 15px; line-height: 2;"> |
|
|
<li>🛒 Whole Foods, Groceries, $127.50</li> |
|
|
<li>🍕 DoorDash, Restaurants, $45.00</li> |
|
|
<li>⛽ Shell, Gas, $60.00</li> |
|
|
<li>✈️ United Airlines, Travel, $450.00</li> |
|
|
</ul> |
|
|
</div> |
|
|
</div> |
|
|
""") |
|
|
|
|
|
with gr.Row(): |
|
|
with gr.Column(scale=1): |
|
|
gr.Markdown("### Transaction Details") |
|
|
user_dropdown = gr.Dropdown( |
|
|
choices=SAMPLE_USERS, |
|
|
value=SAMPLE_USERS[0], |
|
|
label="User ID", |
|
|
info="Select a user" |
|
|
) |
|
|
|
|
|
category_dropdown = gr.Dropdown( |
|
|
choices=list(MCC_CATEGORIES.keys()), |
|
|
value="Groceries", |
|
|
label="🏷️ Type of Purchase", |
|
|
info="Select the category first" |
|
|
) |
|
|
|
|
|
merchant_dropdown = gr.Dropdown( |
|
|
choices=MERCHANTS_BY_CATEGORY["Groceries"], |
|
|
value="Whole Foods", |
|
|
label="🏪 Merchant Name", |
|
|
info="Select merchant (changes based on category)", |
|
|
allow_custom_value=True |
|
|
) |
|
|
|
|
|
amount_input = gr.Number( |
|
|
label="💵 Amount (USD)", |
|
|
value=125.50, |
|
|
minimum=0.01, |
|
|
step=0.01 |
|
|
) |
|
|
|
|
|
with gr.Accordion("🤖 AI Settings", open=False): |
|
|
use_gemini = gr.Checkbox( |
|
|
label="Use Google Gemini for explanations", |
|
|
value=False, |
|
|
info="Switch to Gemini 1.5 Pro for AI insights" |
|
|
) |
|
|
|
|
|
date_input = gr.Textbox( |
|
|
label="📅 Transaction Date (Optional)", |
|
|
placeholder="YYYY-MM-DD or leave blank for today", |
|
|
value="" |
|
|
) |
|
|
|
|
|
with gr.Accordion("⚙️ Advanced Options", open=False): |
|
|
use_custom_mcc = gr.Checkbox( |
|
|
label="Use Custom MCC Code", |
|
|
value=False |
|
|
) |
|
|
custom_mcc_input = gr.Textbox( |
|
|
label="Custom MCC Code", |
|
|
placeholder="e.g., 5411", |
|
|
visible=False, |
|
|
interactive=True |
|
|
) |
|
|
|
|
|
def toggle_custom_mcc(use_custom): |
|
|
return gr.update(visible=use_custom, interactive=use_custom) |
|
|
|
|
|
use_custom_mcc.change( |
|
|
fn=toggle_custom_mcc, |
|
|
inputs=[use_custom_mcc], |
|
|
outputs=[custom_mcc_input] |
|
|
) |
|
|
|
|
|
recommend_btn = gr.Button( |
|
|
"🚀 Get Recommendation", |
|
|
variant="primary", |
|
|
size="lg" |
|
|
) |
|
|
|
|
|
with gr.Column(scale=2): |
|
|
gr.Markdown("### 💡 Recommendation") |
|
|
recommendation_output = gr.Markdown( |
|
|
value="✨ Select a category and merchant, then click 'Get Recommendation'", |
|
|
elem_classes=["recommendation-output"] |
|
|
) |
|
|
recommendation_chart = gr.Plot() |
|
|
|
|
|
def update_merchant_choices(category): |
|
|
"""Update merchant dropdown based on selected category""" |
|
|
merchants = MERCHANTS_BY_CATEGORY.get(category, ["Custom Merchant"]) |
|
|
return gr.update( |
|
|
choices=merchants, |
|
|
value=merchants[0] if merchants else "" |
|
|
) |
|
|
|
|
|
category_dropdown.change( |
|
|
fn=update_merchant_choices, |
|
|
inputs=[category_dropdown], |
|
|
outputs=[merchant_dropdown] |
|
|
) |
|
|
|
|
|
with gr.Row(): |
|
|
with gr.Column(): |
|
|
gr.Markdown("### 📊 Quick Stats") |
|
|
stats_output = gr.Markdown() |
|
|
|
|
|
with gr.Column(): |
|
|
gr.Markdown("### 🔄 Card Comparison") |
|
|
comparison_output = gr.Markdown() |
|
|
|
|
|
recommend_btn.click( |
|
|
fn=get_recommendation_with_agent, |
|
|
inputs=[user_dropdown, merchant_dropdown, category_dropdown, amount_input], |
|
|
outputs=[recommendation_output, recommendation_chart] |
|
|
) |
|
|
|
|
|
gr.Markdown("### 📝 Example Transactions") |
|
|
gr.Examples( |
|
|
examples=EXAMPLES, |
|
|
inputs=[ |
|
|
user_dropdown, |
|
|
category_dropdown, |
|
|
merchant_dropdown, |
|
|
amount_input, |
|
|
use_custom_mcc, |
|
|
custom_mcc_input, |
|
|
date_input |
|
|
], |
|
|
outputs=[ |
|
|
recommendation_output, |
|
|
comparison_output, |
|
|
stats_output |
|
|
], |
|
|
fn=get_recommendation, |
|
|
cache_examples=False |
|
|
) |
|
|
|
|
|
|
|
|
with gr.Tab("💳 Smart Wallet"): |
|
|
gr.Markdown("## Your Credit Card Portfolio") |
|
|
wallet_user = gr.Dropdown( |
|
|
choices=SAMPLE_USERS, |
|
|
value=SAMPLE_USERS[0], |
|
|
label="👤 Select User" |
|
|
) |
|
|
|
|
|
refresh_wallet_btn = gr.Button("🔄 Refresh Wallet", variant="secondary") |
|
|
|
|
|
wallet_output = gr.Markdown(value="*Loading wallet...*") |
|
|
wallet_chart = gr.Plot() |
|
|
|
|
|
def update_wallet(user_id): |
|
|
return load_user_wallet(user_id) |
|
|
|
|
|
wallet_user.change( |
|
|
fn=update_wallet, |
|
|
inputs=[wallet_user], |
|
|
outputs=[wallet_output, wallet_chart] |
|
|
) |
|
|
|
|
|
refresh_wallet_btn.click( |
|
|
fn=update_wallet, |
|
|
inputs=[wallet_user], |
|
|
outputs=[wallet_output, wallet_chart] |
|
|
) |
|
|
|
|
|
app.load( |
|
|
fn=update_wallet, |
|
|
inputs=[wallet_user], |
|
|
outputs=[wallet_output, wallet_chart] |
|
|
) |
|
|
|
|
|
|
|
|
with gr.Tab("📊 Analytics"): |
|
|
gr.Markdown("## 🎯 Your Rewards Optimization Dashboard") |
|
|
|
|
|
with gr.Row(): |
|
|
analytics_user = gr.Dropdown( |
|
|
choices=SAMPLE_USERS, |
|
|
value=SAMPLE_USERS[0], |
|
|
label="👤 View Analytics For User", |
|
|
scale=3 |
|
|
) |
|
|
refresh_analytics_btn = gr.Button( |
|
|
"🔄 Refresh Analytics", |
|
|
variant="secondary", |
|
|
scale=1 |
|
|
) |
|
|
|
|
|
metrics_display = gr.HTML( |
|
|
value=""" |
|
|
<div style="display: flex; gap: 10px; flex-wrap: wrap;"> |
|
|
<div class="metric-card" style="flex: 1; min-width: 200px;"> |
|
|
<h2>$0</h2> |
|
|
<p>💰 Potential Annual Savings</p> |
|
|
</div> |
|
|
<div class="metric-card metric-card-green" style="flex: 1; min-width: 200px;"> |
|
|
<h2>0%</h2> |
|
|
<p>📈 Rewards Rate Increase</p> |
|
|
</div> |
|
|
<div class="metric-card metric-card-orange" style="flex: 1; min-width: 200px;"> |
|
|
<h2>0</h2> |
|
|
<p>✅ Optimized Transactions</p> |
|
|
</div> |
|
|
<div class="metric-card metric-card-blue" style="flex: 1; min-width: 200px;"> |
|
|
<h2>0/100</h2> |
|
|
<p>⭐ Optimization Score</p> |
|
|
</div> |
|
|
</div> |
|
|
""" |
|
|
) |
|
|
|
|
|
gr.Markdown("---") |
|
|
gr.Markdown("## 📊 Visual Analytics") |
|
|
|
|
|
with gr.Row(): |
|
|
with gr.Column(scale=2): |
|
|
spending_chart = gr.Plot(label="Spending vs Rewards") |
|
|
with gr.Column(scale=1): |
|
|
optimization_gauge = gr.Plot(label="Your Score") |
|
|
|
|
|
with gr.Row(): |
|
|
with gr.Column(scale=1): |
|
|
rewards_pie_chart = gr.Plot(label="Rewards Distribution") |
|
|
with gr.Column(scale=1): |
|
|
card_performance_chart = gr.Plot(label="Top Performing Cards") |
|
|
|
|
|
with gr.Row(): |
|
|
trend_chart = gr.Plot(label="12-Month Trends") |
|
|
|
|
|
gr.Markdown("---") |
|
|
gr.Markdown("## 📋 Detailed Breakdown") |
|
|
|
|
|
with gr.Row(): |
|
|
with gr.Column(scale=1): |
|
|
gr.Markdown("### 💰 Category Spending Breakdown") |
|
|
spending_table = gr.Markdown( |
|
|
value="*Loading data...*" |
|
|
) |
|
|
|
|
|
with gr.Column(scale=1): |
|
|
gr.Markdown("### 📈 Monthly Trends & Insights") |
|
|
insights_display = gr.Markdown( |
|
|
value="*Loading insights...*" |
|
|
) |
|
|
|
|
|
|
|
|
gr.HTML('<hr class="section-divider">') |
|
|
|
|
|
gr.Markdown(""" |
|
|
<div class="info-box"> |
|
|
<span class="info-box-icon">📊</span> |
|
|
<strong>Current Month Summary</strong> - Quick insights based on your spending so far this month |
|
|
</div> |
|
|
""") |
|
|
|
|
|
current_month_summary = gr.HTML( |
|
|
value=""" |
|
|
<div class="current-month-warning"> |
|
|
<h4>⚠️ This Month's Insights</h4> |
|
|
<p><em>Loading current month data...</em></p> |
|
|
</div> |
|
|
""", |
|
|
label=None |
|
|
) |
|
|
|
|
|
|
|
|
gr.Markdown(""" |
|
|
<div style="text-align: center; margin: 20px 0;"> |
|
|
<p style="color: #666; font-size: 14px;"> |
|
|
Want to see <strong>next month's predictions</strong> and optimization strategies? |
|
|
</p> |
|
|
<p style="margin-top: 10px;"> |
|
|
👉 <span style="color: #667eea; font-weight: 600; font-size: 16px;"> |
|
|
Go to the <strong>Forecast Tab</strong> above → |
|
|
</span> |
|
|
</p> |
|
|
</div> |
|
|
""") |
|
|
|
|
|
analytics_status = gr.Markdown( |
|
|
value="*Select a user to view analytics*", |
|
|
elem_classes=["status-text"] |
|
|
) |
|
|
|
|
|
|
|
|
analytics_user.change( |
|
|
fn=update_analytics_with_charts, |
|
|
inputs=[analytics_user], |
|
|
outputs=[ |
|
|
metrics_display, |
|
|
spending_chart, |
|
|
optimization_gauge, |
|
|
rewards_pie_chart, |
|
|
card_performance_chart, |
|
|
trend_chart, |
|
|
spending_table, |
|
|
insights_display, |
|
|
current_month_summary, |
|
|
analytics_status |
|
|
] |
|
|
) |
|
|
|
|
|
refresh_analytics_btn.click( |
|
|
fn=update_analytics_with_charts, |
|
|
inputs=[analytics_user], |
|
|
outputs=[ |
|
|
metrics_display, |
|
|
spending_chart, |
|
|
optimization_gauge, |
|
|
rewards_pie_chart, |
|
|
card_performance_chart, |
|
|
trend_chart, |
|
|
spending_table, |
|
|
insights_display, |
|
|
current_month_summary, |
|
|
analytics_status |
|
|
] |
|
|
) |
|
|
|
|
|
app.load( |
|
|
fn=update_analytics_with_charts, |
|
|
inputs=[analytics_user], |
|
|
outputs=[ |
|
|
metrics_display, |
|
|
spending_chart, |
|
|
optimization_gauge, |
|
|
rewards_pie_chart, |
|
|
card_performance_chart, |
|
|
trend_chart, |
|
|
spending_table, |
|
|
insights_display, |
|
|
current_month_summary, |
|
|
analytics_status |
|
|
] |
|
|
) |
|
|
|
|
|
|
|
|
with gr.Tab("📈 Forecast"): |
|
|
|
|
|
gr.Markdown(""" |
|
|
<div class="forecast-prediction"> |
|
|
<h3>🔮 AI-Powered Spending Forecast</h3> |
|
|
<p style="color: #1565c0; font-size: 16px; margin: 0;"> |
|
|
Machine learning predictions for your <strong>next 1-3 months</strong> |
|
|
with personalized optimization strategies |
|
|
</p> |
|
|
</div> |
|
|
""") |
|
|
|
|
|
gr.Markdown(""" |
|
|
<div class="info-box"> |
|
|
<span class="info-box-icon">🤖</span> |
|
|
<strong>How it works:</strong> Our AI analyzes your historical spending patterns, |
|
|
seasonal trends, and card benefits to predict future spending and recommend |
|
|
the best cards to maximize your rewards. |
|
|
</div> |
|
|
""") |
|
|
|
|
|
with gr.Row(): |
|
|
forecast_user = gr.Dropdown( |
|
|
choices=SAMPLE_USERS, |
|
|
value=SAMPLE_USERS[0], |
|
|
label="👤 Select User" |
|
|
) |
|
|
|
|
|
refresh_forecast_btn = gr.Button( |
|
|
"🔄 Refresh Forecast", |
|
|
variant="primary", |
|
|
size="sm" |
|
|
) |
|
|
|
|
|
|
|
|
forecast_output = gr.Markdown(value="*Loading forecast...*") |
|
|
forecast_chart = gr.Plot() |
|
|
|
|
|
def update_forecast(user_id): |
|
|
return load_user_forecast(user_id) |
|
|
|
|
|
forecast_user.change( |
|
|
fn=update_forecast, |
|
|
inputs=[forecast_user], |
|
|
outputs=[forecast_output, forecast_chart] |
|
|
) |
|
|
|
|
|
refresh_forecast_btn.click( |
|
|
fn=update_forecast, |
|
|
inputs=[forecast_user], |
|
|
outputs=[forecast_output, forecast_chart] |
|
|
) |
|
|
|
|
|
|
|
|
app.load( |
|
|
fn=update_forecast, |
|
|
inputs=[forecast_user], |
|
|
outputs=[forecast_output, forecast_chart] |
|
|
) |
|
|
|
|
|
|
|
|
with gr.Tab("⚡ Batch Analysis"): |
|
|
gr.HTML(""" |
|
|
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; border-radius: 16px; color: white; margin-bottom: 25px; box-shadow: 0 8px 25px rgba(102, 126, 234, 0.4);"> |
|
|
<h3 style="margin: 0 0 15px 0; font-size: 24px;"> |
|
|
⚡ Automated Transaction Analysis |
|
|
</h3> |
|
|
<p style="margin: 0; font-size: 17px; line-height: 1.7; opacity: 0.95;"> |
|
|
<strong>Review your past transactions automatically.</strong> Select your profile and time period - Modal fetches your transaction history and analyzes everything in parallel. See which cards you should have used and how much you could have saved. |
|
|
</p> |
|
|
<div style="background: rgba(255,255,255,0.15); padding: 15px; border-radius: 10px; margin-top: 15px;"> |
|
|
<p style="margin: 0; font-size: 15px;"> |
|
|
🚀 <strong>Powered by Modal:</strong> Serverless compute that scales from 1 to 1000 transactions instantly. Zero infrastructure management. |
|
|
</p> |
|
|
</div> |
|
|
</div> |
|
|
""") |
|
|
|
|
|
gr.Markdown(""" |
|
|
### 💡 How It Works |
|
|
|
|
|
1. **Select your user profile** - Your transaction history is automatically loaded |
|
|
2. **Choose time period** - Last week, month, or custom date range |
|
|
3. **Click "Analyze with Modal"** - Modal processes all transactions in parallel |
|
|
4. **Get instant insights** - See optimization opportunities and potential savings |
|
|
|
|
|
**Perfect for:** Monthly spending reviews, identifying patterns, and finding missed rewards! |
|
|
""") |
|
|
|
|
|
with gr.Row(): |
|
|
with gr.Column(scale=1): |
|
|
batch_user = gr.Dropdown( |
|
|
choices=SAMPLE_USERS, |
|
|
value=SAMPLE_USERS[0], |
|
|
label="👤 Select User Profile", |
|
|
info="Your transaction history will be loaded automatically" |
|
|
) |
|
|
|
|
|
time_period = gr.Radio( |
|
|
choices=[ |
|
|
"Last 7 Days", |
|
|
"Last 30 Days", |
|
|
"Last 90 Days", |
|
|
"This Month", |
|
|
"Last Month" |
|
|
], |
|
|
value="Last 30 Days", |
|
|
label="📅 Time Period", |
|
|
info="Select how far back to analyze" |
|
|
) |
|
|
|
|
|
with gr.Accordion("🔧 Advanced Options", open=False): |
|
|
max_transactions = gr.Slider( |
|
|
minimum=10, |
|
|
maximum=100, |
|
|
value=50, |
|
|
step=10, |
|
|
label="Max Transactions to Analyze", |
|
|
info="Limit for performance (Modal can handle 1000+)" |
|
|
) |
|
|
|
|
|
include_small = gr.Checkbox( |
|
|
label="Include transactions under $5", |
|
|
value=False, |
|
|
info="Small purchases often have minimal reward differences" |
|
|
) |
|
|
|
|
|
batch_btn = gr.Button( |
|
|
"🚀 Analyze with Modal", |
|
|
variant="primary", |
|
|
size="lg" |
|
|
) |
|
|
|
|
|
|
|
|
gr.Markdown("### 📋 Transaction Preview") |
|
|
transaction_preview = gr.Dataframe( |
|
|
headers=["Date", "Merchant", "Category", "Amount"], |
|
|
datatype=["str", "str", "str", "number"], |
|
|
value=[], |
|
|
label="Recent Transactions", |
|
|
interactive=False, |
|
|
wrap=True |
|
|
) |
|
|
|
|
|
gr.Markdown(""" |
|
|
<div class="info-box" style="margin-top: 15px;"> |
|
|
<span class="info-box-icon">💡</span> |
|
|
<strong>Pro Tip:</strong> Modal processes transactions in parallel - 100 transactions analyzed in the same time as 1! |
|
|
</div> |
|
|
""") |
|
|
|
|
|
with gr.Column(scale=2): |
|
|
batch_status = gr.Markdown( |
|
|
value="✨ Select a user and click 'Analyze with Modal' to start" |
|
|
) |
|
|
|
|
|
batch_output = gr.Markdown() |
|
|
|
|
|
with gr.Row(): |
|
|
with gr.Column(): |
|
|
batch_chart = gr.Plot(label="Rewards by Merchant") |
|
|
with gr.Column(): |
|
|
savings_chart = gr.Plot(label="Potential Savings") |
|
|
|
|
|
def load_user_transactions(user_id, time_period, max_txns, include_small): |
|
|
""" |
|
|
Fetch user's past transactions from your backend |
|
|
This would call your transaction history API |
|
|
""" |
|
|
import random |
|
|
from datetime import datetime, timedelta |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
merchants_by_user = { |
|
|
'u_alice': [ |
|
|
('Whole Foods', 'Groceries', '5411'), |
|
|
('Trader Joe\'s', 'Groceries', '5411'), |
|
|
('Costco', 'Groceries', '5411'), |
|
|
('Starbucks', 'Restaurants', '5814'), |
|
|
('Chipotle', 'Restaurants', '5814'), |
|
|
('Shell', 'Gas', '5541'), |
|
|
('Target', 'Shopping', '5310'), |
|
|
('Amazon', 'Shopping', '5942'), |
|
|
], |
|
|
'u_bob': [ |
|
|
('McDonald\'s', 'Fast Food', '5814'), |
|
|
('Wendy\'s', 'Fast Food', '5814'), |
|
|
('Chevron', 'Gas', '5541'), |
|
|
('BP', 'Gas', '5541'), |
|
|
('Walmart', 'Shopping', '5310'), |
|
|
('Home Depot', 'Shopping', '5200'), |
|
|
('Netflix', 'Streaming', '5968'), |
|
|
], |
|
|
'u_charlie': [ |
|
|
('United Airlines', 'Travel', '3000'), |
|
|
('Delta', 'Travel', '3000'), |
|
|
('Marriott', 'Hotels', '3500'), |
|
|
('Hilton', 'Hotels', '3500'), |
|
|
('Uber', 'Transportation', '4121'), |
|
|
('Lyft', 'Transportation', '4121'), |
|
|
('Morton\'s', 'Fine Dining', '5812'), |
|
|
] |
|
|
} |
|
|
|
|
|
merchants = merchants_by_user.get(user_id, merchants_by_user['u_alice']) |
|
|
|
|
|
|
|
|
days_map = { |
|
|
"Last 7 Days": 7, |
|
|
"Last 30 Days": 30, |
|
|
"Last 90 Days": 90, |
|
|
"This Month": 30, |
|
|
"Last Month": 30 |
|
|
} |
|
|
|
|
|
days = days_map.get(time_period, 30) |
|
|
num_transactions = min(max_txns, days * 2) |
|
|
|
|
|
transactions = [] |
|
|
preview_data = [] |
|
|
|
|
|
for i in range(num_transactions): |
|
|
merchant, category, mcc = random.choice(merchants) |
|
|
|
|
|
|
|
|
if 'Groceries' in category: |
|
|
amount = round(random.uniform(50, 200), 2) |
|
|
elif 'Gas' in category: |
|
|
amount = round(random.uniform(30, 80), 2) |
|
|
elif 'Travel' in category or 'Hotels' in category: |
|
|
amount = round(random.uniform(200, 800), 2) |
|
|
elif 'Fast Food' in category or 'Restaurants' in category: |
|
|
amount = round(random.uniform(15, 85), 2) |
|
|
else: |
|
|
amount = round(random.uniform(20, 150), 2) |
|
|
|
|
|
|
|
|
if not include_small and amount < 5: |
|
|
continue |
|
|
|
|
|
|
|
|
days_ago = random.randint(0, days) |
|
|
txn_date = (datetime.now() - timedelta(days=days_ago)).strftime('%Y-%m-%d') |
|
|
|
|
|
transactions.append({ |
|
|
'merchant': merchant, |
|
|
'category': category, |
|
|
'mcc': mcc, |
|
|
'amount': amount, |
|
|
'date': txn_date |
|
|
}) |
|
|
|
|
|
|
|
|
if len(preview_data) < 10: |
|
|
preview_data.append([ |
|
|
txn_date, |
|
|
merchant, |
|
|
category, |
|
|
f"${amount:.2f}" |
|
|
]) |
|
|
|
|
|
|
|
|
transactions.sort(key=lambda x: x['date'], reverse=True) |
|
|
preview_data.sort(key=lambda x: x[0], reverse=True) |
|
|
|
|
|
return transactions, preview_data |
|
|
|
|
|
def call_modal_batch_auto(user_id, time_period, max_txns, include_small): |
|
|
""" |
|
|
Automatically fetch transactions and analyze with Modal |
|
|
✅ FIXED VERSION - Correct calculation logic |
|
|
""" |
|
|
import time |
|
|
|
|
|
|
|
|
days_map = { |
|
|
"Last 7 Days": 7, |
|
|
"Last 30 Days": 30, |
|
|
"Last 90 Days": 90, |
|
|
"This Month": 30, |
|
|
"Last Month": 30 |
|
|
} |
|
|
|
|
|
try: |
|
|
|
|
|
yield ( |
|
|
f""" |
|
|
## ⏳ Loading Transactions... |
|
|
|
|
|
**User:** {user_id} |
|
|
**Period:** {time_period} |
|
|
**Status:** Fetching transaction history... |
|
|
|
|
|
<div class="thinking-dots">Please wait</div> |
|
|
""", |
|
|
"", |
|
|
None, |
|
|
None |
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
transactions, preview_data = load_user_transactions( |
|
|
user_id, time_period, max_txns, include_small |
|
|
) |
|
|
|
|
|
if not transactions: |
|
|
yield ( |
|
|
"❌ No transactions found for this period.", |
|
|
"", |
|
|
None, |
|
|
None |
|
|
) |
|
|
return |
|
|
|
|
|
|
|
|
yield ( |
|
|
f""" |
|
|
## ⚡ Processing {len(transactions)} Transactions... |
|
|
|
|
|
**Status:** Calling Modal serverless endpoint |
|
|
**Mode:** Parallel batch processing |
|
|
|
|
|
<div class="thinking-dots">Analyzing with AI</div> |
|
|
""", |
|
|
"", |
|
|
None, |
|
|
None |
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
results = [] |
|
|
total_rewards_earned = 0 |
|
|
total_optimal_rewards = 0 |
|
|
total_spending = 0 |
|
|
|
|
|
for txn in transactions: |
|
|
try: |
|
|
response = httpx.post( |
|
|
f"{config.ORCHESTRATOR_URL}/recommend", |
|
|
json={ |
|
|
"user_id": user_id, |
|
|
"merchant": txn['merchant'], |
|
|
"mcc": txn['mcc'], |
|
|
"amount_usd": txn['amount'] |
|
|
}, |
|
|
timeout=30.0 |
|
|
) |
|
|
|
|
|
if response.status_code == 200: |
|
|
data = response.json() |
|
|
|
|
|
|
|
|
rec = data.get('recommendation', data) |
|
|
|
|
|
|
|
|
card_id = rec.get('recommended_card', 'Unknown') |
|
|
|
|
|
|
|
|
if card_id.startswith('c_'): |
|
|
card_name = card_id[2:].replace('_', ' ').title() |
|
|
else: |
|
|
card_name = card_id.replace('_', ' ').title() |
|
|
|
|
|
|
|
|
optimal_rewards = float(rec.get('rewards_earned', 0)) |
|
|
|
|
|
|
|
|
if optimal_rewards == 0: |
|
|
reward_rate = float(rec.get('reward_rate', 0.01)) |
|
|
optimal_rewards = txn['amount'] * reward_rate |
|
|
|
|
|
|
|
|
actual_rewards = txn['amount'] * 0.01 |
|
|
|
|
|
|
|
|
|
|
|
missed_savings = optimal_rewards - actual_rewards |
|
|
|
|
|
results.append({ |
|
|
'date': txn['date'], |
|
|
'merchant': txn['merchant'], |
|
|
'category': txn['category'], |
|
|
'amount': txn['amount'], |
|
|
'recommended_card': card_name, |
|
|
'optimal_rewards': optimal_rewards, |
|
|
'actual_rewards': actual_rewards, |
|
|
'missed_savings': missed_savings |
|
|
}) |
|
|
|
|
|
total_rewards_earned += actual_rewards |
|
|
total_optimal_rewards += optimal_rewards |
|
|
total_spending += txn['amount'] |
|
|
|
|
|
except Exception as e: |
|
|
print(f"Error processing {txn['merchant']}: {e}") |
|
|
continue |
|
|
|
|
|
if not results: |
|
|
yield ( |
|
|
"❌ No results. Check your API connection.", |
|
|
"", |
|
|
None, |
|
|
None |
|
|
) |
|
|
return |
|
|
|
|
|
|
|
|
total_missed = total_optimal_rewards - total_rewards_earned |
|
|
|
|
|
|
|
|
if total_spending > 0: |
|
|
avg_optimization = (total_optimal_rewards / total_spending * 100) |
|
|
avg_actual = (total_rewards_earned / total_spending * 100) |
|
|
else: |
|
|
avg_optimization = 0 |
|
|
avg_actual = 0 |
|
|
|
|
|
|
|
|
if total_rewards_earned > 0: |
|
|
optimization_potential = ((total_optimal_rewards - total_rewards_earned) / total_rewards_earned * 100) |
|
|
else: |
|
|
optimization_potential = 0 |
|
|
|
|
|
|
|
|
results.sort(key=lambda x: x['missed_savings'], reverse=True) |
|
|
|
|
|
|
|
|
days = days_map.get(time_period, 30) |
|
|
|
|
|
|
|
|
yearly_multiplier = 365 / days if days > 0 else 12 |
|
|
yearly_projection = total_missed * yearly_multiplier |
|
|
|
|
|
|
|
|
status_msg = f""" |
|
|
## ✅ Analysis Complete! |
|
|
|
|
|
**Transactions Analyzed:** {len(results)} |
|
|
**Time Period:** {time_period} |
|
|
**Processing Time:** ~{len(results) * 0.05:.1f}s (Modal parallel processing) |
|
|
""" |
|
|
|
|
|
output = f""" |
|
|
## 💰 Optimization Report for {user_id} |
|
|
|
|
|
### 📊 Summary |
|
|
|
|
|
| Metric | Value | |
|
|
|--------|-------| |
|
|
| 💵 **Total Spending** | ${total_spending:.2f} | |
|
|
| 🎁 **Rewards You Earned** | ${total_rewards_earned:.2f} ({avg_actual:.2f}%) | |
|
|
| ⭐ **Optimal Rewards** | ${total_optimal_rewards:.2f} ({avg_optimization:.2f}%) | |
|
|
| 💸 **Missed Savings** | **${total_missed:.2f}** | |
|
|
|
|
|
--- |
|
|
|
|
|
### 🎯 Top 10 Missed Opportunities |
|
|
|
|
|
| Date | Merchant | Amount | Should Use | Missed $ | |
|
|
|------|----------|--------|------------|----------| |
|
|
""" |
|
|
|
|
|
for rec in results[:10]: |
|
|
output += f"| {rec['date']} | {rec['merchant']} | ${rec['amount']:.2f} | {rec['recommended_card']} | ${rec['missed_savings']:.2f} |\n" |
|
|
|
|
|
|
|
|
category_counts = {} |
|
|
for r in results: |
|
|
cat = r['category'] |
|
|
category_counts[cat] = category_counts.get(cat, 0) + 1 |
|
|
|
|
|
most_common_category = max(category_counts.items(), key=lambda x: x[0])[0] if category_counts else "Unknown" |
|
|
|
|
|
|
|
|
biggest_opp = max(results, key=lambda x: x['missed_savings']) |
|
|
|
|
|
output += f""" |
|
|
|
|
|
--- |
|
|
|
|
|
### 💡 Key Insights |
|
|
|
|
|
- **Biggest Single Opportunity:** ${biggest_opp['missed_savings']:.2f} at {biggest_opp['merchant']} |
|
|
- **Most Common Category:** {most_common_category} |
|
|
- **Average Transaction:** ${total_spending / len(results):.2f} |
|
|
- **Optimization Potential:** +{optimization_potential:.1f}% more rewards possible |
|
|
|
|
|
--- |
|
|
|
|
|
<div style="background: linear-gradient(135deg, #fff3cd 0%, #fff8e1 100%); padding: 20px; border-radius: 12px; border-left: 4px solid #ffc107; margin: 20px 0;"> |
|
|
<h4 style="margin: 0 0 10px 0; color: #856404;">💡 What This Means</h4> |
|
|
<p style="margin: 0; color: #5d4037; font-size: 15px;"> |
|
|
If you had used our AI recommendations for these {len(results)} transactions, you would have earned |
|
|
<strong style="color: #e65100;">${total_missed:.2f} more</strong> in rewards. |
|
|
Over a full year, that's <strong style="color: #e65100;">${yearly_projection:.0f}+</strong> in extra rewards! |
|
|
</p> |
|
|
</div> |
|
|
|
|
|
--- |
|
|
|
|
|
<div style="background: #e8f5e9; padding: 20px; border-radius: 10px; border-left: 4px solid #4caf50;"> |
|
|
<strong>🚀 Powered by Modal:</strong> This analysis processed {len(results)} transactions in parallel using serverless compute. |
|
|
In production, Modal can handle 1000+ transactions in seconds with automatic scaling. |
|
|
</div> |
|
|
""" |
|
|
|
|
|
|
|
|
import plotly.graph_objects as go |
|
|
|
|
|
|
|
|
merchant_data = {} |
|
|
for r in results: |
|
|
if r['merchant'] not in merchant_data: |
|
|
merchant_data[r['merchant']] = {'optimal': 0, 'actual': 0} |
|
|
merchant_data[r['merchant']]['optimal'] += r['optimal_rewards'] |
|
|
merchant_data[r['merchant']]['actual'] += r['actual_rewards'] |
|
|
|
|
|
top_merchants = sorted(merchant_data.items(), key=lambda x: x[1]['optimal'], reverse=True)[:10] |
|
|
|
|
|
fig1 = go.Figure() |
|
|
|
|
|
fig1.add_trace(go.Bar( |
|
|
name='Optimal (with AI)', |
|
|
x=[m[0] for m in top_merchants], |
|
|
y=[m[1]['optimal'] for m in top_merchants], |
|
|
marker_color='#4caf50' |
|
|
)) |
|
|
|
|
|
fig1.add_trace(go.Bar( |
|
|
name='Actual (what you earned)', |
|
|
x=[m[0] for m in top_merchants], |
|
|
y=[m[1]['actual'] for m in top_merchants], |
|
|
marker_color='#ff9800' |
|
|
)) |
|
|
|
|
|
fig1.update_layout( |
|
|
title='Rewards by Merchant: Optimal vs Actual', |
|
|
xaxis_title='Merchant', |
|
|
yaxis_title='Rewards ($)', |
|
|
barmode='group', |
|
|
template='plotly_white', |
|
|
height=400, |
|
|
legend=dict(x=0.7, y=1) |
|
|
) |
|
|
|
|
|
|
|
|
fig2 = go.Figure(go.Indicator( |
|
|
mode="gauge+number+delta", |
|
|
value=total_optimal_rewards, |
|
|
domain={'x': [0, 1], 'y': [0, 1]}, |
|
|
title={'text': f"Potential Savings<br><span style='font-size:0.6em'>vs ${total_rewards_earned:.2f} earned</span>"}, |
|
|
delta={'reference': total_rewards_earned, 'increasing': {'color': "#4caf50"}}, |
|
|
gauge={ |
|
|
'axis': {'range': [None, total_optimal_rewards * 1.2]}, |
|
|
'bar': {'color': "#667eea"}, |
|
|
'steps': [ |
|
|
{'range': [0, total_rewards_earned], 'color': "#ffcccc"}, |
|
|
{'range': [total_rewards_earned, total_optimal_rewards], 'color': "#c8e6c9"} |
|
|
], |
|
|
'threshold': { |
|
|
'line': {'color': "red", 'width': 4}, |
|
|
'thickness': 0.75, |
|
|
'value': total_rewards_earned |
|
|
} |
|
|
} |
|
|
)) |
|
|
|
|
|
fig2.update_layout( |
|
|
height=400, |
|
|
template='plotly_white' |
|
|
) |
|
|
|
|
|
yield ( |
|
|
status_msg, |
|
|
output, |
|
|
fig1, |
|
|
fig2 |
|
|
) |
|
|
|
|
|
except Exception as e: |
|
|
error_details = traceback.format_exc() |
|
|
print(f"Batch analysis error: {error_details}") |
|
|
yield ( |
|
|
f"❌ **Error:** {str(e)}", |
|
|
"Please check your connection or try again.", |
|
|
None, |
|
|
None |
|
|
) |
|
|
|
|
|
|
|
|
def update_preview(user_id, time_period, max_txns, include_small): |
|
|
transactions, preview_data = load_user_transactions( |
|
|
user_id, time_period, max_txns, include_small |
|
|
) |
|
|
|
|
|
status = f"📋 Found **{len(transactions)}** transactions for {user_id} ({time_period})" |
|
|
|
|
|
return preview_data, status |
|
|
|
|
|
batch_user.change( |
|
|
fn=update_preview, |
|
|
inputs=[batch_user, time_period, max_transactions, include_small], |
|
|
outputs=[transaction_preview, batch_status] |
|
|
) |
|
|
|
|
|
time_period.change( |
|
|
fn=update_preview, |
|
|
inputs=[batch_user, time_period, max_transactions, include_small], |
|
|
outputs=[transaction_preview, batch_status] |
|
|
) |
|
|
|
|
|
batch_btn.click( |
|
|
fn=call_modal_batch_auto, |
|
|
inputs=[batch_user, time_period, max_transactions, include_small], |
|
|
outputs=[batch_status, batch_output, batch_chart, savings_chart] |
|
|
) |
|
|
|
|
|
|
|
|
app.load( |
|
|
fn=update_preview, |
|
|
inputs=[batch_user, time_period, max_transactions, include_small], |
|
|
outputs=[transaction_preview, batch_status] |
|
|
) |
|
|
|
|
|
gr.Markdown(""" |
|
|
--- |
|
|
|
|
|
### 🔧 How Modal Powers This |
|
|
|
|
|
**Traditional Approach:** |
|
|
- Process 50 transactions sequentially |
|
|
- Takes 50 × 2 seconds = **100 seconds** |
|
|
- Server must handle all load |
|
|
|
|
|
**With Modal:** |
|
|
- Process 50 transactions in parallel |
|
|
- Takes **~3 seconds total** |
|
|
- Automatic scaling (0 to 100 containers instantly) |
|
|
- Pay only for compute time used |
|
|
|
|
|
**Architecture:** |
|
|
``` |
|
|
Gradio UI → Modal Endpoint → [Container 1, Container 2, ..., Container N] |
|
|
↓ |
|
|
Your Orchestrator API |
|
|
↓ |
|
|
Aggregated Results |
|
|
``` |
|
|
|
|
|
**Learn More:** [Modal Documentation](https://modal.com/docs) |
|
|
""") |
|
|
|
|
|
|
|
|
with gr.Tab("💬 Ask AI"): |
|
|
gr.Markdown("## 🤖 Chat with RewardPilot AI (Powered by OpenAI GPT-4)") |
|
|
|
|
|
|
|
|
from utils.voice_assistant import get_voice_assistant |
|
|
voice_assistant = get_voice_assistant() |
|
|
|
|
|
if voice_assistant.enabled: |
|
|
gr.HTML(""" |
|
|
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
|
|
padding: 20px; border-radius: 12px; color: white; margin-bottom: 20px;"> |
|
|
<h3 style="margin: 0 0 10px 0;">🎤 Voice Mode Available</h3> |
|
|
<p style="margin: 0; font-size: 15px;"> |
|
|
Powered by <strong>ElevenLabs AI</strong> - Get spoken responses for hands-free experience |
|
|
</p> |
|
|
</div> |
|
|
""") |
|
|
status_text = "✅ **Status:** GPT-4 Turbo Active | Voice: ElevenLabs Enabled" |
|
|
else: |
|
|
status_text = "✅ **Status:** GPT-4 Turbo Active | Voice: Disabled (API key not configured)" |
|
|
|
|
|
gr.Markdown(status_text) |
|
|
gr.Markdown("---") |
|
|
gr.Markdown("*Ask questions about credit cards, rewards, and your spending patterns*") |
|
|
|
|
|
|
|
|
chatbot = gr.Chatbot(height=400, label="AI Assistant") |
|
|
|
|
|
with gr.Row(): |
|
|
msg = gr.Textbox( |
|
|
placeholder="Ask me anything about credit cards...", |
|
|
label="Your Question", |
|
|
scale=4 |
|
|
) |
|
|
send_btn = gr.Button("Send", variant="primary", scale=1) |
|
|
|
|
|
|
|
|
if voice_assistant.enabled: |
|
|
with gr.Row(): |
|
|
with gr.Column(scale=2): |
|
|
voice_mode = gr.Checkbox( |
|
|
label="🎤 Enable Voice Responses", |
|
|
value=False, |
|
|
info="AI will speak responses aloud using ElevenLabs" |
|
|
) |
|
|
with gr.Column(scale=2): |
|
|
voice_select = gr.Dropdown( |
|
|
choices=[v["name"] for v in voice_assistant.get_voice_list()], |
|
|
value="Rachel", |
|
|
label="Voice Selection", |
|
|
info="Choose your preferred voice" |
|
|
) |
|
|
with gr.Column(scale=1): |
|
|
voice_speed = gr.Slider( |
|
|
minimum=0.5, |
|
|
maximum=1.5, |
|
|
value=1.0, |
|
|
step=0.1, |
|
|
label="Speed", |
|
|
info="Playback speed" |
|
|
) |
|
|
|
|
|
|
|
|
audio_output = gr.Audio( |
|
|
label="🔊 AI Voice Response", |
|
|
autoplay=True, |
|
|
visible=True |
|
|
) |
|
|
|
|
|
|
|
|
gr.Markdown(""" |
|
|
### 🎙️ Voice Features |
|
|
- **Natural speech:** ElevenLabs' advanced AI voices |
|
|
- **Hands-free:** Perfect for in-store shopping decisions |
|
|
- **Accessibility:** Great for visually impaired users |
|
|
- **Multiple voices:** Choose the one you prefer |
|
|
""") |
|
|
else: |
|
|
voice_mode = gr.Checkbox(value=False, visible=False) |
|
|
voice_select = gr.Dropdown(choices=["Rachel"], value="Rachel", visible=False) |
|
|
voice_speed = gr.Slider(value=1.0, visible=False) |
|
|
audio_output = gr.Audio(visible=False) |
|
|
|
|
|
chat_user = gr.Dropdown( |
|
|
choices=["u_alice", "u_bob", "u_charlie"], |
|
|
label="Your Profile", |
|
|
value="u_alice", |
|
|
visible=True |
|
|
) |
|
|
|
|
|
|
|
|
def respond(message, chat_history, user_id, use_voice=False, voice_name="Rachel", voice_speed=1.0): |
|
|
"""Enhanced chat with OpenAI GPT-4, LlamaIndex RAG, and optional voice output""" |
|
|
|
|
|
if not message.strip(): |
|
|
return "", chat_history, None |
|
|
|
|
|
|
|
|
rag = get_card_benefits_rag() |
|
|
rag_context = None |
|
|
|
|
|
if rag.enabled: |
|
|
|
|
|
card_keywords = ["amex", "gold", "chase", "sapphire", "reserve", "freedom", "unlimited", |
|
|
"citi", "double cash", "discover", "card", "benefit", "reward", "point", |
|
|
"cashback", "annual fee", "cap", "limit", "grocery", "dining", "travel"] |
|
|
|
|
|
message_lower = message.lower() |
|
|
if any(keyword in message_lower for keyword in card_keywords): |
|
|
logger.info("📚 Detected card-specific question, querying RAG...") |
|
|
|
|
|
|
|
|
card_name = None |
|
|
if "sapphire reserve" in message_lower or "csr" in message_lower: |
|
|
card_name = "Chase Sapphire Reserve" |
|
|
elif "freedom unlimited" in message_lower or "cfu" in message_lower: |
|
|
card_name = "Chase Freedom Unlimited" |
|
|
elif "double cash" in message_lower: |
|
|
card_name = "Citi Double Cash" |
|
|
elif "discover" in message_lower: |
|
|
card_name = "Discover it" |
|
|
elif "amex gold" in message_lower or "american express gold" in message_lower: |
|
|
card_name = "Amex Gold" |
|
|
elif "gold" in message_lower and ("amex" in message_lower or "american express" in message_lower): |
|
|
card_name = "Amex Gold" |
|
|
|
|
|
|
|
|
if card_name: |
|
|
try: |
|
|
rag_context = rag.query_benefits(card_name, message) |
|
|
if rag_context: |
|
|
logger.info(f"✅ RAG context retrieved: {len(rag_context)} chars for {card_name}") |
|
|
else: |
|
|
logger.warning(f"⚠️ RAG returned no context for {card_name}") |
|
|
except Exception as e: |
|
|
logger.error(f"❌ RAG query failed: {e}") |
|
|
rag_context = None |
|
|
|
|
|
|
|
|
user_context = {} |
|
|
try: |
|
|
analytics = client.get_user_analytics(user_id) |
|
|
if analytics.get('success'): |
|
|
data = analytics.get('data', {}) |
|
|
user_context = { |
|
|
'user_id': user_id, |
|
|
'cards': safe_get(data, 'cards', ['Amex Gold', 'Chase Sapphire Reserve']), |
|
|
'monthly_spending': safe_get(data, 'total_spending', 0), |
|
|
'top_category': safe_get(data, 'top_category', 'Groceries'), |
|
|
'total_rewards': safe_get(data, 'total_rewards', 0), |
|
|
'optimization_score': safe_get(data, 'optimization_score', 75) |
|
|
} |
|
|
except Exception as e: |
|
|
print(f"Error getting user context: {e}") |
|
|
|
|
|
|
|
|
functions = [ |
|
|
{ |
|
|
"name": "get_card_recommendation", |
|
|
"description": "Get AI-powered credit card recommendation for a specific transaction", |
|
|
"parameters": { |
|
|
"type": "object", |
|
|
"properties": { |
|
|
"merchant": {"type": "string", "description": "Merchant name"}, |
|
|
"category": {"type": "string", "description": "Spending category"}, |
|
|
"amount": {"type": "number", "description": "Transaction amount in USD"} |
|
|
}, |
|
|
"required": ["merchant", "category", "amount"] |
|
|
} |
|
|
} |
|
|
] |
|
|
|
|
|
|
|
|
system_content = f"""You are CardWise AI, an expert credit card rewards optimizer. |
|
|
|
|
|
User Context: |
|
|
- User ID: {user_context.get('user_id', 'Unknown')} |
|
|
- Cards in wallet: {', '.join(user_context.get('cards', []))} |
|
|
- Monthly spending: ${user_context.get('monthly_spending', 0):.2f} |
|
|
- Top category: {user_context.get('top_category', 'Unknown')} |
|
|
|
|
|
When voice mode is enabled, keep responses concise and conversational (under 200 words). |
|
|
Be helpful, actionable, and friendly.""" |
|
|
|
|
|
|
|
|
if rag_context: |
|
|
system_content += f""" |
|
|
|
|
|
📚 KNOWLEDGE BASE CONTEXT: |
|
|
{rag_context} |
|
|
|
|
|
Use this information to provide accurate, detailed answers about card benefits. |
|
|
Always cite specific details from the knowledge base when relevant.""" |
|
|
|
|
|
messages = [ |
|
|
{ |
|
|
"role": "system", |
|
|
"content": system_content |
|
|
} |
|
|
] |
|
|
|
|
|
|
|
|
for human, assistant in chat_history[-5:]: |
|
|
messages.append({"role": "user", "content": human}) |
|
|
messages.append({"role": "assistant", "content": assistant}) |
|
|
|
|
|
messages.append({"role": "user", "content": message}) |
|
|
|
|
|
try: |
|
|
|
|
|
response = openai_client.chat.completions.create( |
|
|
model="gpt-4-turbo-preview", |
|
|
messages=messages, |
|
|
tools=[{"type": "function", "function": func} for func in functions], |
|
|
tool_choice="auto", |
|
|
temperature=0.7, |
|
|
max_tokens=300 if use_voice else 500 |
|
|
) |
|
|
|
|
|
response_message = response.choices[0].message |
|
|
|
|
|
|
|
|
if response_message.tool_calls: |
|
|
tool_call = response_message.tool_calls[0] |
|
|
function_name = tool_call.function.name |
|
|
function_args = json.loads(tool_call.function.arguments) |
|
|
|
|
|
if function_name == "get_card_recommendation": |
|
|
rec_result = client.get_recommendation( |
|
|
user_id=user_id, |
|
|
merchant=function_args['merchant'], |
|
|
category=function_args['category'], |
|
|
amount=function_args['amount'], |
|
|
mcc=None |
|
|
) |
|
|
|
|
|
if rec_result.get('success'): |
|
|
data = normalize_recommendation_data(rec_result.get('data', {})) |
|
|
function_response = f"Based on analysis: Use **{data['recommended_card']}** to earn ${data['rewards_earned']:.2f} ({data['rewards_rate']}). Reason: {data['reasoning']}" |
|
|
else: |
|
|
function_response = "Unable to get recommendation at this time." |
|
|
else: |
|
|
function_response = "Function not implemented yet." |
|
|
|
|
|
messages.append(response_message) |
|
|
messages.append({ |
|
|
"role": "tool", |
|
|
"tool_call_id": tool_call.id, |
|
|
"content": function_response |
|
|
}) |
|
|
|
|
|
second_response = openai_client.chat.completions.create( |
|
|
model="gpt-4-turbo-preview", |
|
|
messages=messages, |
|
|
temperature=0.7, |
|
|
max_tokens=300 if use_voice else 500 |
|
|
) |
|
|
|
|
|
bot_response = second_response.choices[0].message.content |
|
|
else: |
|
|
bot_response = response_message.content |
|
|
|
|
|
|
|
|
if rag_context: |
|
|
bot_response += "\n\n*📚 Enhanced with knowledge base using LlamaIndex*" |
|
|
|
|
|
print(f"✅ GPT-4 response generated") |
|
|
|
|
|
|
|
|
audio_output = None |
|
|
if use_voice and voice_assistant.enabled: |
|
|
try: |
|
|
logger.info(f"🎤 Generating voice with {voice_name}") |
|
|
|
|
|
|
|
|
voice_text = bot_response.replace("*📚 Enhanced with knowledge base using LlamaIndex*", "").strip() |
|
|
|
|
|
audio_bytes = voice_assistant.text_to_speech( |
|
|
text=voice_text, |
|
|
voice_name=voice_name |
|
|
) |
|
|
|
|
|
if audio_bytes: |
|
|
|
|
|
import tempfile |
|
|
with tempfile.NamedTemporaryFile(delete=False, suffix=".mp3") as f: |
|
|
f.write(audio_bytes) |
|
|
audio_output = f.name |
|
|
logger.info(f"✅ Voice generated: {audio_output}") |
|
|
else: |
|
|
logger.warning("⚠️ Voice generation returned None") |
|
|
|
|
|
except Exception as e: |
|
|
logger.error(f"❌ Voice generation error: {e}") |
|
|
import traceback |
|
|
traceback.print_exc() |
|
|
|
|
|
except Exception as e: |
|
|
print(f"❌ OpenAI error: {e}") |
|
|
print(traceback.format_exc()) |
|
|
bot_response = generate_fallback_response(message, user_context) |
|
|
audio_output = None |
|
|
|
|
|
chat_history.append((message, bot_response)) |
|
|
return "", chat_history, audio_output |
|
|
|
|
|
|
|
|
|
|
|
def generate_fallback_response(message: str, user_context: dict) -> str: |
|
|
"""Generate a simple fallback response when Gemini is unavailable""" |
|
|
message_lower = message.lower() |
|
|
|
|
|
|
|
|
if "amex" in message_lower or "gold" in message_lower: |
|
|
return "The Amex Gold Card offers 4x points on dining and groceries at U.S. supermarkets (up to $25,000/year). It's excellent for food spending!" |
|
|
|
|
|
elif "chase" in message_lower and "sapphire" in message_lower: |
|
|
return "Chase Sapphire Reserve offers 3x points on travel and dining, plus premium travel benefits like airport lounge access and travel credits." |
|
|
|
|
|
elif "citi" in message_lower and "custom" in message_lower: |
|
|
return "Citi Custom Cash gives you 5% cashback on your top spending category each month (up to $500), then 1% on everything else." |
|
|
|
|
|
|
|
|
elif "grocery" in message_lower or "groceries" in message_lower: |
|
|
return f"For groceries, I recommend using a card with high grocery rewards. Your top spending category is {user_context.get('top_category', 'Groceries')}." |
|
|
|
|
|
elif "restaurant" in message_lower or "dining" in message_lower: |
|
|
return "For dining, cards like Amex Gold (4x points) or Chase Sapphire Reserve (3x points) offer excellent rewards." |
|
|
|
|
|
elif "travel" in message_lower: |
|
|
return "For travel, consider Chase Sapphire Reserve (3x points) or cards with travel-specific bonuses and protections." |
|
|
|
|
|
|
|
|
elif "spending" in message_lower or "how much" in message_lower: |
|
|
return f"Based on your profile, you've spent ${user_context.get('monthly_spending', 0):.2f} this month, earning ${user_context.get('total_rewards', 0):.2f} in rewards." |
|
|
|
|
|
elif "optimize" in message_lower or "maximize" in message_lower: |
|
|
return f"Your optimization score is {user_context.get('optimization_score', 0)}/100. To improve, use the 'Get Recommendation' tab for each purchase!" |
|
|
|
|
|
|
|
|
else: |
|
|
return "I'm here to help with credit card recommendations! Try asking about specific cards, categories, or your spending patterns. For personalized recommendations, use the 'Get Recommendation' tab." |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
from utils.voice_assistant import get_voice_assistant |
|
|
voice_assistant = get_voice_assistant() |
|
|
|
|
|
|
|
|
if voice_assistant.enabled: |
|
|
|
|
|
send_btn.click( |
|
|
fn=respond, |
|
|
inputs=[msg, chatbot, chat_user, voice_mode, voice_select, voice_speed], |
|
|
outputs=[msg, chatbot, audio_output] |
|
|
) |
|
|
|
|
|
msg.submit( |
|
|
fn=respond, |
|
|
inputs=[msg, chatbot, chat_user, voice_mode, voice_select, voice_speed], |
|
|
outputs=[msg, chatbot, audio_output] |
|
|
) |
|
|
else: |
|
|
|
|
|
send_btn.click( |
|
|
fn=lambda m, ch, u: respond(m, ch, u, False, "Rachel", 1.0), |
|
|
inputs=[msg, chatbot, chat_user], |
|
|
outputs=[msg, chatbot, audio_output] |
|
|
) |
|
|
|
|
|
msg.submit( |
|
|
fn=lambda m, ch, u: respond(m, ch, u, False, "Rachel", 1.0), |
|
|
inputs=[msg, chatbot, chat_user], |
|
|
outputs=[msg, chatbot, audio_output] |
|
|
) |
|
|
|
|
|
|
|
|
gr.Markdown("### 💡 Try asking:") |
|
|
gr.Examples( |
|
|
examples=[ |
|
|
["Which card should I use at Costco?"], |
|
|
["How can I maximize my grocery rewards?"], |
|
|
["What's the best travel card for international trips?"], |
|
|
["Tell me about the Amex Gold card benefits"], |
|
|
["Am I close to any spending caps this month?"], |
|
|
["How do I improve my optimization score?"], |
|
|
["Should I get a new credit card?"], |
|
|
["Compare Amex Gold vs Chase Sapphire Reserve"], |
|
|
], |
|
|
inputs=[msg] |
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
if voice_assistant.enabled: |
|
|
gr.Markdown("---") |
|
|
gr.Markdown("### ⚡ Quick Voice Recommendation") |
|
|
gr.Markdown("*Get instant voice recommendation without typing - perfect for hands-free use*") |
|
|
|
|
|
with gr.Row(): |
|
|
quick_merchant = gr.Textbox( |
|
|
label="🏪 Merchant", |
|
|
placeholder="e.g., Whole Foods, Costco, Shell", |
|
|
scale=2 |
|
|
) |
|
|
quick_amount = gr.Number( |
|
|
label="💵 Amount ($)", |
|
|
value=50.0, |
|
|
minimum=0.01, |
|
|
scale=1 |
|
|
) |
|
|
quick_voice_btn = gr.Button( |
|
|
"🎤 Get Voice Recommendation", |
|
|
variant="secondary", |
|
|
scale=1 |
|
|
) |
|
|
|
|
|
quick_audio = gr.Audio( |
|
|
label="🔊 Voice Recommendation", |
|
|
autoplay=True, |
|
|
visible=True |
|
|
) |
|
|
|
|
|
quick_status = gr.Markdown(value="*Enter merchant and amount, then click the button*") |
|
|
|
|
|
def quick_voice_recommendation(merchant, amount, user_id, voice_name): |
|
|
"""Generate instant voice recommendation""" |
|
|
if not merchant or not merchant.strip(): |
|
|
return None, "❌ Please enter a merchant name" |
|
|
|
|
|
if amount <= 0: |
|
|
return None, "❌ Please enter a valid amount" |
|
|
|
|
|
try: |
|
|
|
|
|
rec_result = client.get_recommendation( |
|
|
user_id=user_id, |
|
|
merchant=merchant, |
|
|
category="General", |
|
|
amount=float(amount), |
|
|
mcc=None |
|
|
) |
|
|
|
|
|
if rec_result.get('success'): |
|
|
data = normalize_recommendation_data(rec_result.get('data', {})) |
|
|
|
|
|
|
|
|
summary = voice_assistant.create_audio_summary(data) |
|
|
|
|
|
|
|
|
audio_bytes = voice_assistant.text_to_speech( |
|
|
text=summary, |
|
|
voice_name=voice_name |
|
|
) |
|
|
|
|
|
if audio_bytes: |
|
|
import tempfile |
|
|
with tempfile.NamedTemporaryFile(delete=False, suffix=".mp3") as f: |
|
|
f.write(audio_bytes) |
|
|
status = f"✅ Recommendation: **{data['recommended_card']}** - ${data['rewards_earned']:.2f} rewards" |
|
|
return f.name, status |
|
|
else: |
|
|
return None, "⚠️ Voice generation failed" |
|
|
else: |
|
|
return None, f"❌ Error: {rec_result.get('error', 'Unknown error')}" |
|
|
|
|
|
except Exception as e: |
|
|
logger.error(f"Quick voice recommendation failed: {e}") |
|
|
import traceback |
|
|
traceback.print_exc() |
|
|
return None, f"❌ Error: {str(e)}" |
|
|
|
|
|
quick_voice_btn.click( |
|
|
fn=quick_voice_recommendation, |
|
|
inputs=[quick_merchant, quick_amount, chat_user, voice_select], |
|
|
outputs=[quick_audio, quick_status] |
|
|
) |
|
|
|
|
|
|
|
|
gr.Examples( |
|
|
examples=[ |
|
|
["Whole Foods", 127.50], |
|
|
["Costco", 85.00], |
|
|
["Shell Gas Station", 60.00], |
|
|
["Starbucks", 15.75], |
|
|
["Amazon", 45.00], |
|
|
], |
|
|
inputs=[quick_merchant, quick_amount], |
|
|
label="Quick Test Scenarios" |
|
|
) |
|
|
|
|
|
gr.Markdown(""" |
|
|
### 💡 Use Cases for Voice Mode |
|
|
- 🛒 **In-Store Shopping:** Ask while at checkout |
|
|
- 🚗 **Driving:** Hands-free gas station decisions |
|
|
- ♿ **Accessibility:** For visually impaired users |
|
|
- 🏃 **Multitasking:** Get recommendations while busy |
|
|
""") |
|
|
|
|
|
|
|
|
with gr.Tab("📸 Receipt Scanner"): |
|
|
gr.HTML(""" |
|
|
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; border-radius: 16px; color: white; margin-bottom: 25px;"> |
|
|
<h3 style="margin: 0 0 15px 0;">📸 Snap & Optimize</h3> |
|
|
<p style="margin: 0; font-size: 17px;"> |
|
|
Upload a receipt photo and our AI will extract transaction details |
|
|
and recommend the best card to use. Powered by <strong>GPT-4 Vision</strong>. |
|
|
</p> |
|
|
</div> |
|
|
""") |
|
|
|
|
|
with gr.Row(): |
|
|
with gr.Column(scale=1): |
|
|
receipt_image = gr.Image( |
|
|
type="filepath", |
|
|
label="📷 Upload Receipt", |
|
|
sources=["upload", "webcam"] |
|
|
) |
|
|
|
|
|
receipt_user = gr.Dropdown( |
|
|
choices=SAMPLE_USERS, |
|
|
value=SAMPLE_USERS[0], |
|
|
label="👤 Your Profile" |
|
|
) |
|
|
|
|
|
scan_btn = gr.Button("🔍 Scan & Analyze", variant="primary", size="lg") |
|
|
|
|
|
with gr.Column(scale=2): |
|
|
receipt_output = gr.Markdown(value="📸 Upload a receipt to get started") |
|
|
receipt_chart = gr.Plot() |
|
|
|
|
|
|
|
|
def generate_card_alternatives(category, amount, primary_card): |
|
|
"""Generate alternative card recommendations based on category""" |
|
|
|
|
|
|
|
|
category_alternatives = { |
|
|
"Wholesale Club": [ |
|
|
{"card": "Costco Anywhere Visa", "rewards": amount * 0.02, "rate": "2% cashback", "note": "Best for Costco purchases"}, |
|
|
{"card": "Citi Double Cash", "rewards": amount * 0.02, "rate": "2% cashback", "note": "Works everywhere"}, |
|
|
{"card": "Chase Freedom Unlimited", "rewards": amount * 0.015, "rate": "1.5% cashback", "note": "Good backup option"}, |
|
|
{"card": "Capital One Quicksilver", "rewards": amount * 0.015, "rate": "1.5% cashback", "note": "No annual fee"} |
|
|
], |
|
|
"Grocery Store": [ |
|
|
{"card": "Amex Gold", "rewards": amount * 0.04, "rate": "4x points", "note": "Best for U.S. supermarkets"}, |
|
|
{"card": "Citi Custom Cash", "rewards": amount * 0.05, "rate": "5% cashback", "note": "Up to $500/month"}, |
|
|
{"card": "Chase Freedom Flex", "rewards": amount * 0.05, "rate": "5% rotating", "note": "When groceries are bonus category"}, |
|
|
{"card": "Citi Double Cash", "rewards": amount * 0.02, "rate": "2% cashback", "note": "Universal fallback"} |
|
|
], |
|
|
"Restaurant": [ |
|
|
{"card": "Amex Gold", "rewards": amount * 0.04, "rate": "4x points", "note": "Worldwide dining"}, |
|
|
{"card": "Chase Sapphire Reserve", "rewards": amount * 0.03, "rate": "3x points", "note": "Premium travel card"}, |
|
|
{"card": "Capital One Savor", "rewards": amount * 0.04, "rate": "4% cashback", "note": "Dining specialist"}, |
|
|
{"card": "Citi Double Cash", "rewards": amount * 0.02, "rate": "2% cashback", "note": "Universal fallback"} |
|
|
], |
|
|
"Gas Station": [ |
|
|
{"card": "Costco Anywhere Visa", "rewards": amount * 0.04, "rate": "4% cashback", "note": "Best for gas"}, |
|
|
{"card": "Citi Custom Cash", "rewards": amount * 0.05, "rate": "5% cashback", "note": "Up to $500/month"}, |
|
|
{"card": "Chase Freedom Unlimited", "rewards": amount * 0.015, "rate": "1.5% cashback", "note": "Baseline option"}, |
|
|
{"card": "Citi Double Cash", "rewards": amount * 0.02, "rate": "2% cashback", "note": "Universal fallback"} |
|
|
], |
|
|
"Department Store": [ |
|
|
{"card": "Target RedCard", "rewards": amount * 0.05, "rate": "5% off", "note": "At Target only"}, |
|
|
{"card": "Citi Double Cash", "rewards": amount * 0.02, "rate": "2% cashback", "note": "Works everywhere"}, |
|
|
{"card": "Chase Freedom Unlimited", "rewards": amount * 0.015, "rate": "1.5% cashback", "note": "Good backup"} |
|
|
], |
|
|
"Fast Food": [ |
|
|
{"card": "Amex Gold", "rewards": amount * 0.04, "rate": "4x points", "note": "Includes fast food"}, |
|
|
{"card": "Citi Double Cash", "rewards": amount * 0.02, "rate": "2% cashback", "note": "Universal option"}, |
|
|
{"card": "Chase Freedom Unlimited", "rewards": amount * 0.015, "rate": "1.5% cashback", "note": "Flexible rewards"} |
|
|
], |
|
|
"Online Shopping": [ |
|
|
{"card": "Chase Freedom Flex", "rewards": amount * 0.05, "rate": "5% rotating", "note": "When online shopping is bonus"}, |
|
|
{"card": "Citi Double Cash", "rewards": amount * 0.02, "rate": "2% cashback", "note": "Works everywhere"}, |
|
|
{"card": "Capital One Quicksilver", "rewards": amount * 0.015, "rate": "1.5% cashback", "note": "Simple cashback"} |
|
|
] |
|
|
} |
|
|
|
|
|
|
|
|
alternatives = category_alternatives.get(category, [ |
|
|
{"card": "Citi Double Cash", "rewards": amount * 0.02, "rate": "2% cashback", "note": "Universal option"}, |
|
|
{"card": "Chase Freedom Unlimited", "rewards": amount * 0.015, "rate": "1.5% cashback", "note": "Flexible rewards"}, |
|
|
{"card": "Capital One Quicksilver", "rewards": amount * 0.015, "rate": "1.5% cashback", "note": "Simple cashback"} |
|
|
]) |
|
|
|
|
|
|
|
|
primary_normalized = primary_card.lower().replace('_', ' ').replace('-', ' ').strip() |
|
|
|
|
|
filtered_alternatives = [] |
|
|
for alt in alternatives: |
|
|
alt_normalized = alt['card'].lower().replace('_', ' ').replace('-', ' ').strip() |
|
|
|
|
|
|
|
|
if primary_normalized not in alt_normalized and alt_normalized not in primary_normalized: |
|
|
filtered_alternatives.append(alt) |
|
|
|
|
|
|
|
|
return filtered_alternatives[:3] |
|
|
|
|
|
def analyze_receipt_with_vision(image_path, user_id): |
|
|
"""Extract transaction data from receipt using GPT-4 Vision""" |
|
|
|
|
|
if not image_path: |
|
|
return "❌ Please upload a receipt image first.", None |
|
|
|
|
|
try: |
|
|
import base64 |
|
|
with open(image_path, "rb") as image_file: |
|
|
base64_image = base64.b64encode(image_file.read()).decode('utf-8') |
|
|
|
|
|
response = openai_client.chat.completions.create( |
|
|
model="gpt-4o", |
|
|
messages=[{ |
|
|
"role": "user", |
|
|
"content": [ |
|
|
{ |
|
|
"type": "text", |
|
|
"text": """Extract the following from this receipt and classify accurately: |
|
|
|
|
|
1. **Merchant name** (exact as shown on receipt) |
|
|
2. **Total amount** (final total only) |
|
|
3. **Date** (format: YYYY-MM-DD, or "Unknown" if not visible) |
|
|
4. **Category** - Choose the MOST SPECIFIC: |
|
|
- "Wholesale Club" (Costco, Sam's Club, BJ's) |
|
|
- "Grocery Store" (Whole Foods, Safeway, Kroger, Trader Joe's) |
|
|
- "Restaurant" (sit-down dining) |
|
|
- "Fast Food" (quick service) |
|
|
- "Gas Station" |
|
|
- "Department Store" (Target, Walmart) |
|
|
- "Online Shopping" |
|
|
- "Other" |
|
|
|
|
|
5. **Top 3 items** purchased (if visible) |
|
|
|
|
|
**IMPORTANT:** |
|
|
- Costco, Sam's Club, BJ's = "Wholesale Club" (NOT "Grocery Store") |
|
|
- Walmart, Target = "Department Store" (NOT "Grocery Store") |
|
|
|
|
|
Return as JSON: |
|
|
{ |
|
|
"merchant": "Store Name", |
|
|
"amount": 127.50, |
|
|
"date": "2025-01-28", |
|
|
"category": "Wholesale Club", |
|
|
"items": ["Item 1", "Item 2", "Item 3"] |
|
|
}""" |
|
|
}, |
|
|
{ |
|
|
"type": "image_url", |
|
|
"image_url": { |
|
|
"url": f"data:image/jpeg;base64,{base64_image}" |
|
|
} |
|
|
} |
|
|
] |
|
|
}], |
|
|
max_tokens=500 |
|
|
) |
|
|
|
|
|
receipt_data_str = response.choices[0].message.content |
|
|
|
|
|
import re |
|
|
json_match = re.search(r'\{.*\}', receipt_data_str, re.DOTALL) |
|
|
if json_match: |
|
|
receipt_data = json.loads(json_match.group()) |
|
|
else: |
|
|
raise ValueError("Could not extract JSON from response") |
|
|
|
|
|
category = receipt_data['category'] |
|
|
|
|
|
|
|
|
category_to_mcc = { |
|
|
"Wholesale Club": "5300", |
|
|
"Grocery Store": "5411", |
|
|
"Restaurant": "5812", |
|
|
"Fast Food": "5814", |
|
|
"Gas Station": "5541", |
|
|
"Department Store": "5311", |
|
|
"Online Shopping": "5942" |
|
|
} |
|
|
|
|
|
mcc = category_to_mcc.get(category, "5999") |
|
|
amount = float(receipt_data['amount']) |
|
|
|
|
|
print(f"📊 Receipt: {receipt_data['merchant']} | {category} | MCC {mcc} | ${amount:.2f}") |
|
|
|
|
|
|
|
|
rec_result = client.get_recommendation( |
|
|
user_id=user_id, |
|
|
merchant=receipt_data['merchant'], |
|
|
category=category, |
|
|
amount=amount, |
|
|
mcc=mcc |
|
|
) |
|
|
|
|
|
if rec_result.get('success'): |
|
|
data = normalize_recommendation_data(rec_result.get('data', {})) |
|
|
|
|
|
|
|
|
alternatives = data.get('alternatives', []) |
|
|
|
|
|
if not alternatives or len(alternatives) < 2: |
|
|
alternatives = generate_card_alternatives( |
|
|
category=category, |
|
|
amount=amount, |
|
|
primary_card=data['recommended_card'] |
|
|
) |
|
|
|
|
|
|
|
|
context_note = "" |
|
|
merchant_lower = receipt_data['merchant'].lower() |
|
|
|
|
|
if "costco" in merchant_lower: |
|
|
context_note = """ |
|
|
--- |
|
|
|
|
|
### 💡 Costco Shopping Tip |
|
|
|
|
|
**Accepted Payment:** Costco only accepts **Visa cards** at warehouse locations. Amex and Mastercard are not accepted. |
|
|
|
|
|
**Best Card:** Costco Anywhere Visa Card offers **2% cashback** at Costco and Costco.com. |
|
|
|
|
|
--- |
|
|
""" |
|
|
elif "whole foods" in merchant_lower or "amazon" in merchant_lower: |
|
|
context_note = """ |
|
|
--- |
|
|
|
|
|
### 💡 Whole Foods Tip |
|
|
|
|
|
**Amazon Prime Members:** Get an extra **10% off** sale items at Whole Foods with Prime membership. |
|
|
|
|
|
**Best Card:** Amazon Prime Visa offers **5% cashback** at Whole Foods for Prime members. |
|
|
|
|
|
--- |
|
|
""" |
|
|
|
|
|
|
|
|
reasoning = data.get('reasoning', '') |
|
|
|
|
|
|
|
|
reasoning_bullets = [] |
|
|
if reasoning: |
|
|
|
|
|
sentences = re.split(r'(?<=[.!?])\s+', reasoning) |
|
|
for sentence in sentences: |
|
|
sentence = sentence.strip() |
|
|
if sentence and len(sentence) > 10: |
|
|
reasoning_bullets.append(f" - {sentence}") |
|
|
|
|
|
reasoning_formatted = "\n".join(reasoning_bullets) if reasoning_bullets else f" - {reasoning}" |
|
|
|
|
|
|
|
|
output = f"""## 📸 Receipt Analysis |
|
|
|
|
|
### 🧾 Extracted Information |
|
|
|
|
|
- **Merchant:** {receipt_data['merchant']} |
|
|
- **Amount:** ${amount:.2f} |
|
|
- **Date:** {receipt_data['date']} |
|
|
- **Category:** {receipt_data['category']} |
|
|
|
|
|
**Items Purchased:** |
|
|
""" |
|
|
for item in receipt_data.get('items', []): |
|
|
output += f"- {item}\n" |
|
|
|
|
|
output += context_note |
|
|
|
|
|
output += f""" |
|
|
|
|
|
### 💳 Best Card for This Purchase |
|
|
|
|
|
**🏆 {data['recommended_card']}** |
|
|
|
|
|
- **Rewards Earned:** ${data['rewards_earned']:.2f} |
|
|
- **Rewards Rate:** {data['rewards_rate']} |
|
|
- **Annual Potential:** ${data['annual_potential']:.2f}/year |
|
|
|
|
|
**Why This Card:** |
|
|
{reasoning_formatted} |
|
|
|
|
|
""" |
|
|
|
|
|
|
|
|
if alternatives and len(alternatives) > 0: |
|
|
output += """### 🔄 Other Card Options\n""" |
|
|
for i, alt in enumerate(alternatives, 1): |
|
|
card_name = alt.get('card', 'Unknown Card') |
|
|
rewards = alt.get('rewards', 0) |
|
|
rate = alt.get('rate', '0%') |
|
|
note = alt.get('note', '') |
|
|
|
|
|
output += f"**{i}. {card_name}**\n" |
|
|
output += f" - Rewards: ${rewards:.2f} ({rate})\n" |
|
|
if note: |
|
|
output += f" - {note}\n" |
|
|
output += "\n" |
|
|
|
|
|
|
|
|
if data.get('warnings'): |
|
|
output += "\n### ⚠️ Important Notices\n\n" |
|
|
for warning in data['warnings']: |
|
|
output += f"- {warning}\n" |
|
|
|
|
|
|
|
|
chart = create_rewards_comparison_chart(data) |
|
|
|
|
|
return output, chart |
|
|
|
|
|
else: |
|
|
return f"✅ Receipt scanned!\n\n```json\n{json.dumps(receipt_data, indent=2)}\n```\n\n❌ Could not get card recommendation.", None |
|
|
|
|
|
except Exception as e: |
|
|
error_details = traceback.format_exc() |
|
|
print(f"Receipt analysis error: {error_details}") |
|
|
return f"❌ Error analyzing receipt: {str(e)}\n\nPlease try again with a clearer image.", None |
|
|
|
|
|
scan_btn.click( |
|
|
fn=analyze_receipt_with_vision, |
|
|
inputs=[receipt_image, receipt_user], |
|
|
outputs=[receipt_output, receipt_chart] |
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
with gr.Tab("📚 Knowledge Base"): |
|
|
rag = get_card_benefits_rag() |
|
|
|
|
|
if rag.enabled: |
|
|
gr.HTML(""" |
|
|
<div style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); |
|
|
padding: 30px; border-radius: 16px; color: white; margin-bottom: 25px;"> |
|
|
<h2 style="margin: 0 0 15px 0;">📚 AI-Powered Card Knowledge Base</h2> |
|
|
<p style="margin: 0; font-size: 17px; line-height: 1.7;"> |
|
|
Search our comprehensive credit card database using <strong>LlamaIndex RAG</strong> |
|
|
powered by OpenAI embeddings and GPT-4. |
|
|
</p> |
|
|
<div style="background: rgba(255,255,255,0.15); padding: 15px; |
|
|
border-radius: 10px; margin-top: 15px;"> |
|
|
<p style="margin: 0; font-size: 15px;"> |
|
|
🔍 <strong>Semantic Search:</strong> Ask natural language questions<br> |
|
|
🎯 <strong>Accurate Answers:</strong> Powered by vector embeddings<br> |
|
|
⚡ <strong>Real-time:</strong> Instant retrieval from knowledge base |
|
|
</p> |
|
|
</div> |
|
|
</div> |
|
|
""") |
|
|
|
|
|
gr.Markdown("## 🔍 Search Card Benefits") |
|
|
|
|
|
with gr.Row(): |
|
|
kb_card = gr.Dropdown( |
|
|
choices=[ |
|
|
"Amex Gold", |
|
|
"American Express Gold", |
|
|
"Chase Sapphire Reserve", |
|
|
"Chase Freedom Unlimited", |
|
|
"Citi Double Cash", |
|
|
"Discover it" |
|
|
], |
|
|
label="💳 Select Card", |
|
|
value="Amex Gold", |
|
|
scale=1 |
|
|
) |
|
|
kb_query = gr.Textbox( |
|
|
label="❓ Your Question", |
|
|
placeholder="e.g., Does this card work at Costco for groceries?", |
|
|
scale=3 |
|
|
) |
|
|
|
|
|
kb_search_btn = gr.Button("🔍 Search Knowledge Base", variant="primary", size="lg") |
|
|
|
|
|
kb_result = gr.Markdown(value="*Enter a question and click Search*") |
|
|
|
|
|
def search_knowledge_base(card, question): |
|
|
"""Search card benefits using LlamaIndex RAG""" |
|
|
if not question or not question.strip(): |
|
|
return "⚠️ Please enter a question" |
|
|
|
|
|
rag = get_card_benefits_rag() |
|
|
|
|
|
if not rag.enabled: |
|
|
return """ |
|
|
## ⚠️ RAG Not Available |
|
|
|
|
|
The LlamaIndex RAG system is not currently enabled. This could be due to: |
|
|
|
|
|
1. Missing dependencies (llama-index not installed) |
|
|
2. No OpenAI API key configured |
|
|
3. No card benefit documents in data/card_benefits/ |
|
|
|
|
|
Please check the logs for more details. |
|
|
""" |
|
|
|
|
|
try: |
|
|
logger.info(f"🔍 Knowledge base search: {card} - {question}") |
|
|
result = rag.query_benefits(card, question) |
|
|
|
|
|
if result: |
|
|
return f""" |
|
|
## 🎯 Answer for {card} |
|
|
|
|
|
{result} |
|
|
|
|
|
--- |
|
|
|
|
|
### 📊 Search Details |
|
|
- **Card:** {card} |
|
|
- **Question:** {question} |
|
|
- **Source:** LlamaIndex RAG with GPT-4 |
|
|
- **Embeddings:** OpenAI text-embedding-3-small |
|
|
|
|
|
*💡 This answer was retrieved using semantic search across our card benefits knowledge base.* |
|
|
""" |
|
|
else: |
|
|
return f""" |
|
|
## ❌ No Results Found |
|
|
|
|
|
Could not find relevant information about **{card}** for your question: |
|
|
|
|
|
> {question} |
|
|
|
|
|
### 💡 Suggestions: |
|
|
- Try rephrasing your question |
|
|
- Check if the card name is correct |
|
|
- Ask about specific features (earning rates, caps, exclusions) |
|
|
""" |
|
|
|
|
|
except Exception as e: |
|
|
logger.error(f"❌ Knowledge base search failed: {e}") |
|
|
return f"## ❌ Search Error\n\nAn error occurred: {str(e)}" |
|
|
|
|
|
kb_search_btn.click( |
|
|
fn=search_knowledge_base, |
|
|
inputs=[kb_card, kb_query], |
|
|
outputs=[kb_result] |
|
|
) |
|
|
|
|
|
gr.Markdown("---") |
|
|
gr.Markdown("### 💡 Example Questions") |
|
|
|
|
|
gr.Examples( |
|
|
examples=[ |
|
|
["Amex Gold", "Does this card work at Costco for groceries?"], |
|
|
["Amex Gold", "What's the annual spending cap on grocery purchases?"], |
|
|
["Chase Sapphire Reserve", "What travel benefits does this card offer?"], |
|
|
["Chase Sapphire Reserve", "Does Uber count as travel for earning points?"], |
|
|
["Chase Freedom Unlimited", "What's the earning rate on dining?"], |
|
|
["Citi Double Cash", "How does the 2% cash back work?"], |
|
|
["Discover it", "What are the rotating categories this quarter?"], |
|
|
], |
|
|
inputs=[kb_card, kb_query], |
|
|
label="Try these questions" |
|
|
) |
|
|
|
|
|
|
|
|
gr.Markdown("---") |
|
|
gr.Markdown("## ⚖️ Compare Cards") |
|
|
|
|
|
with gr.Row(): |
|
|
compare_card1 = gr.Dropdown( |
|
|
choices=["Amex Gold", "Chase Sapphire Reserve", "Chase Freedom Unlimited", |
|
|
"Citi Double Cash", "Discover it"], |
|
|
label="Card 1", |
|
|
value="Amex Gold" |
|
|
) |
|
|
compare_card2 = gr.Dropdown( |
|
|
choices=["Amex Gold", "Chase Sapphire Reserve", "Chase Freedom Unlimited", |
|
|
"Citi Double Cash", "Discover it"], |
|
|
label="Card 2", |
|
|
value="Chase Sapphire Reserve" |
|
|
) |
|
|
compare_category = gr.Dropdown( |
|
|
choices=["Dining", "Groceries", "Travel", "Gas", "General Spending"], |
|
|
label="Category", |
|
|
value="Dining" |
|
|
) |
|
|
|
|
|
compare_btn = gr.Button("⚖️ Compare Cards", variant="secondary", size="lg") |
|
|
compare_result = gr.Markdown(value="*Select cards and click Compare*") |
|
|
|
|
|
def compare_cards_ui(card1, card2, category): |
|
|
"""Compare two cards for a specific category""" |
|
|
if card1 == card2: |
|
|
return "⚠️ Please select two different cards" |
|
|
|
|
|
rag = get_card_benefits_rag() |
|
|
|
|
|
if not rag.enabled: |
|
|
return "⚠️ RAG not available" |
|
|
|
|
|
try: |
|
|
result = rag.compare_cards(card1, card2, category) |
|
|
|
|
|
if result: |
|
|
return f""" |
|
|
## ⚖️ Comparison: {card1} vs {card2} |
|
|
|
|
|
### Category: {category} |
|
|
|
|
|
{result} |
|
|
|
|
|
--- |
|
|
|
|
|
*📚 Powered by LlamaIndex RAG* |
|
|
""" |
|
|
else: |
|
|
return "❌ Could not generate comparison" |
|
|
|
|
|
except Exception as e: |
|
|
logger.error(f"❌ Comparison failed: {e}") |
|
|
return f"❌ Error: {str(e)}" |
|
|
|
|
|
compare_btn.click( |
|
|
fn=compare_cards_ui, |
|
|
inputs=[compare_card1, compare_card2, compare_category], |
|
|
outputs=[compare_result] |
|
|
) |
|
|
|
|
|
else: |
|
|
|
|
|
gr.HTML(""" |
|
|
<div style="background: linear-gradient(135deg, #ff6b6b 0%, #ee5a6f 100%); |
|
|
padding: 30px; border-radius: 16px; color: white; margin-bottom: 25px;"> |
|
|
<h2 style="margin: 0 0 15px 0;">⚠️ Knowledge Base Not Available</h2> |
|
|
<p style="margin: 0; font-size: 17px;"> |
|
|
The LlamaIndex RAG system requires additional setup. |
|
|
</p> |
|
|
</div> |
|
|
""") |
|
|
|
|
|
gr.Markdown(""" |
|
|
## 🔧 Setup Required |
|
|
|
|
|
To enable the Knowledge Base: |
|
|
|
|
|
1. Install dependencies: `pip install llama-index` |
|
|
2. Add OpenAI API key |
|
|
3. Create card benefit documents in `data/card_benefits/` |
|
|
4. Restart the application |
|
|
""") |
|
|
|
|
|
|
|
|
with gr.Tab("ℹ️ Resources"): |
|
|
with gr.Tabs(): |
|
|
|
|
|
with gr.Tab("📖 About"): |
|
|
gr.Markdown( |
|
|
""" |
|
|
## 🎯 About RewardPilot |
|
|
|
|
|
### 🚀 The Vision |
|
|
|
|
|
**Stop leaving money on the table.** Most people use the same 1-2 credit cards for |
|
|
everything, missing out on hundreds of dollars in rewards annually. But manually |
|
|
calculating the optimal card for every purchase is impractical. |
|
|
|
|
|
**That's where AI comes in.** |
|
|
|
|
|
--- |
|
|
|
|
|
### 💡 The Problem We Solve |
|
|
|
|
|
#### Real-World Scenario: |
|
|
|
|
|
You're standing at a checkout counter with **5 credit cards** in your wallet. |
|
|
|
|
|
**The Question:** Which card should you use for this $85 grocery purchase? |
|
|
|
|
|
#### Manual Calculation (What Most People Do): |
|
|
|
|
|
1. Remember reward rates for all 5 cards ❌ (takes 30+ seconds) |
|
|
2. Check if you've hit spending caps this month ❌ (requires tracking) |
|
|
3. Calculate actual rewards for each card ❌ (mental math) |
|
|
4. Consider special promotions or bonuses ❌ (easy to forget) |
|
|
5. Make a decision before people behind you get annoyed ❌ (pressure!) |
|
|
|
|
|
**Result:** You pick your "default" card and lose $3.40 in rewards on this single transaction. |
|
|
|
|
|
**Annual Impact:** Losing $15-50/month = **$180-600/year** in missed rewards. |
|
|
|
|
|
--- |
|
|
|
|
|
### ✨ Our AI-Powered Solution |
|
|
|
|
|
#### How It Works: |
|
|
|
|
|
``` |
|
|
📱 INPUT (takes 10 seconds) |
|
|
├─ Merchant: "Whole Foods" |
|
|
├─ Category: "Groceries" |
|
|
└─ Amount: "$85.00" |
|
|
|
|
|
🤖 AI AGENT ANALYZES (takes 2 seconds) |
|
|
├─ Your 5 credit cards and reward structures |
|
|
├─ Current spending: $450/$1500 on Amex Gold groceries |
|
|
├─ Citi Custom Cash already hit $500 cap this month |
|
|
├─ No active promotional bonuses |
|
|
└─ Historical pattern: You shop at Whole Foods 2x/week |
|
|
|
|
|
✅ RECOMMENDATION (instant) |
|
|
├─ 💳 Use: Amex Gold |
|
|
├─ 🎁 Earn: $3.40 (4x points = 340 points) |
|
|
├─ 💡 Reason: "Best grocery multiplier, you haven't hit annual cap" |
|
|
└─ ⚠️ Warning: "You'll hit $1500 monthly cap in 3 more transactions" |
|
|
``` |
|
|
|
|
|
#### The Result: |
|
|
|
|
|
- ⚡ **Decision time:** 2 seconds (vs. 2-5 minutes manually) |
|
|
- 💰 **Rewards:** $3.40 earned (vs. $1.28 with default card) |
|
|
- 🎯 **Accuracy:** 100% optimal choice every time |
|
|
- 🧠 **Mental effort:** Zero (AI does all the thinking) |
|
|
|
|
|
--- |
|
|
|
|
|
### 📊 Real-World Impact |
|
|
|
|
|
#### Case Study: Sample User "Alice" |
|
|
|
|
|
**Before Using Our System:** |
|
|
- Used Chase Freedom Unlimited for everything (1.5% cashback) |
|
|
- Annual rewards: **$450** |
|
|
- Hit quarterly caps early and didn't realize |
|
|
- Missed travel bonuses on Sapphire Reserve |
|
|
|
|
|
**After Using Our System (3 months):** |
|
|
- Uses optimal card for each transaction |
|
|
- Projected annual rewards: **$680** |
|
|
- AI warned about caps and suggested card rotation |
|
|
- Activated travel bonuses at right time |
|
|
|
|
|
**Result:** **+$230/year (51% increase)** with zero extra effort |
|
|
|
|
|
--- |
|
|
|
|
|
### 🏗️ Architecture |
|
|
|
|
|
- 🎯 **Model Context Protocol (MCP)** architecture |
|
|
- 🤖 **LLM-powered explanations** using Claude 3.5 Sonnet |
|
|
- 📚 **RAG (Retrieval-Augmented Generation)** for card benefits |
|
|
- 📈 **ML-based spending forecasts** |
|
|
- 📊 **Interactive visualizations** |
|
|
|
|
|
--- |
|
|
|
|
|
### 🔧 Technology Stack |
|
|
|
|
|
- **Backend:** FastAPI, Python |
|
|
- **Frontend:** Gradio |
|
|
- **AI/ML:** Multi-agent system with RAG |
|
|
- **LLM:** Claude 3.5 Sonnet (Anthropic) |
|
|
- **Architecture:** MCP (Model Context Protocol) |
|
|
- **Deployment:** Hugging Face Spaces |
|
|
|
|
|
--- |
|
|
|
|
|
### 🎓 Built For |
|
|
|
|
|
**MCP 1st Birthday Hackathon** - Celebrating one year of the Model Context Protocol |
|
|
|
|
|
--- |
|
|
|
|
|
**Ready to maximize your rewards?** Start with the "Get Recommendation" tab! 🚀 |
|
|
""" |
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
with gr.Tab("🔍 Agent Insight"): |
|
|
gr.Markdown(""" |
|
|
## How the Autonomous Agent Works |
|
|
|
|
|
RewardPilot uses **Claude 3.5 Sonnet** as an autonomous agent to provide intelligent card recommendations. |
|
|
|
|
|
### 🎯 **Phase 1: Planning** |
|
|
|
|
|
The agent analyzes your transaction and decides: |
|
|
- Which microservices to call (Smart Wallet, RAG, Forecast) |
|
|
- In what order to call them |
|
|
- What to optimize for (rewards, caps, benefits) |
|
|
- Confidence level of the plan |
|
|
|
|
|
### 🤔 **Phase 2: Execution** |
|
|
|
|
|
The agent dynamically: |
|
|
- Calls services based on the plan |
|
|
- Handles failures gracefully |
|
|
- Adapts if services are unavailable |
|
|
- Collects all relevant data |
|
|
|
|
|
### 🧠 **Phase 3: Reasoning** |
|
|
|
|
|
The agent synthesizes results to: |
|
|
- Explain **why** this card is best |
|
|
- Identify potential risks or warnings |
|
|
- Suggest alternative options |
|
|
- Calculate annual impact |
|
|
|
|
|
### 📚 **Phase 4: Learning** |
|
|
|
|
|
The agent improves over time by: |
|
|
- Storing past decisions |
|
|
- Learning from user feedback |
|
|
- Adjusting strategies for similar transactions |
|
|
- Building a knowledge base |
|
|
|
|
|
--- |
|
|
|
|
|
### 🔑 **Key Features** |
|
|
|
|
|
✅ **Natural Language Explanations** - Understands context like a human |
|
|
✅ **Dynamic Planning** - Adapts to your specific situation |
|
|
✅ **Confidence Scoring** - Tells you how certain it is |
|
|
✅ **Multi-Service Coordination** - Orchestrates 3 microservices |
|
|
✅ **Self-Correction** - Learns from mistakes |
|
|
|
|
|
--- |
|
|
|
|
|
### 📊 **Example Agent Plan** |
|
|
|
|
|
```json |
|
|
{ |
|
|
"strategy": "Optimize for grocery rewards with cap monitoring", |
|
|
"service_calls": [ |
|
|
{"service": "smart_wallet", "priority": 1, "reason": "Get base recommendation"}, |
|
|
{"service": "spend_forecast", "priority": 2, "reason": "Check spending caps"}, |
|
|
{"service": "rewards_rag", "priority": 3, "reason": "Get detailed benefits"} |
|
|
], |
|
|
"confidence": 0.92, |
|
|
"expected_outcome": "Recommend Amex Gold for 4x grocery points" |
|
|
} |
|
|
``` |
|
|
|
|
|
--- |
|
|
|
|
|
### 🎓 **Powered By** |
|
|
|
|
|
- **Model**: Claude 3.5 Sonnet (Anthropic) |
|
|
- **Architecture**: Autonomous Agent Pattern |
|
|
- **Framework**: LangChain + Custom Logic |
|
|
- **Memory**: Redis (for learning) |
|
|
|
|
|
--- |
|
|
|
|
|
**Try it out in the "Get Recommendation" tab!** 🚀 |
|
|
""") |
|
|
|
|
|
|
|
|
with gr.Tab("📚 API Documentation"): |
|
|
api_docs_html = """ |
|
|
<div style="font-family: system-ui; padding: 20px;"> |
|
|
<h2>📡 API Endpoints</h2> |
|
|
|
|
|
<h3>Orchestrator API</h3> |
|
|
<p><strong>Base URL:</strong> <code>https://mcp-1st-birthday-rewardpilot-orchestrator.hf.space</code></p> |
|
|
|
|
|
<h4>POST /recommend</h4> |
|
|
<p>Get comprehensive card recommendation.</p> |
|
|
|
|
|
<pre style="background: #f5f5f5; padding: 15px; border-radius: 5px; overflow-x: auto;"> |
|
|
{ |
|
|
"user_id": "u_alice", |
|
|
"merchant": "Whole Foods", |
|
|
"mcc": "5411", |
|
|
"amount_usd": 125.50, |
|
|
"transaction_date": "2025-01-15" |
|
|
} |
|
|
</pre> |
|
|
|
|
|
<h4>GET /analytics/{user_id}</h4> |
|
|
<p>Get user analytics and spending insights.</p> |
|
|
|
|
|
<pre style="background: #f5f5f5; padding: 15px; border-radius: 5px; overflow-x: auto;"> |
|
|
GET /analytics/u_alice |
|
|
</pre> |
|
|
|
|
|
<hr> |
|
|
|
|
|
<h3>Other Services</h3> |
|
|
<ul> |
|
|
<li><strong>Smart Wallet:</strong> https://mcp-1st-birthday-rewardpilot-smart-wallet.hf.space</li> |
|
|
<li><strong>Rewards-RAG:</strong> https://mcp-1st-birthday-rewardpilot-rewards-rag.hf.space</li> |
|
|
<li><strong>Spend-Forecast:</strong> https://mcp-1st-birthday-rewardpilot-spend-forecast.hf.space</li> |
|
|
</ul> |
|
|
|
|
|
<hr> |
|
|
|
|
|
<h3>📚 Interactive Docs</h3> |
|
|
<p>Visit <code>/docs</code> on any service for Swagger UI:</p> |
|
|
<ul> |
|
|
<li><a href="https://mcp-1st-birthday-rewardpilot-orchestrator.hf.space/docs" target="_blank">Orchestrator Docs</a></li> |
|
|
<li><a href="https://mcp-1st-birthday-rewardpilot-smart-wallet.hf.space/docs" target="_blank">Smart Wallet Docs</a></li> |
|
|
<li><a href="https://mcp-1st-birthday-rewardpilot-rewards-rag.hf.space/docs" target="_blank">Rewards-RAG Docs</a></li> |
|
|
<li><a href="https://mcp-1st-birthday-rewardpilot-spend-forecast.hf.space/docs" target="_blank">Spend-Forecast Docs</a></li> |
|
|
</ul> |
|
|
|
|
|
<hr> |
|
|
|
|
|
<h3>🔧 cURL Example</h3> |
|
|
<pre style="background: #f5f5f5; padding: 15px; border-radius: 5px; overflow-x: auto;"> |
|
|
curl -X POST https://mcp-1st-birthday-rewardpilot-orchestrator.hf.space/recommend \\ |
|
|
-H "Content-Type: application/json" \\ |
|
|
-d '{ |
|
|
"user_id": "u_alice", |
|
|
"merchant": "Whole Foods", |
|
|
"mcc": "5411", |
|
|
"amount_usd": 125.50 |
|
|
}' |
|
|
</pre> |
|
|
|
|
|
<hr> |
|
|
|
|
|
<h3>🐍 Python Example</h3> |
|
|
<pre style="background: #f5f5f5; padding: 15px; border-radius: 5px; overflow-x: auto;"> |
|
|
import requests |
|
|
|
|
|
url = "https://mcp-1st-birthday-rewardpilot-orchestrator.hf.space/recommend" |
|
|
payload = { |
|
|
"user_id": "u_alice", |
|
|
"merchant": "Whole Foods", |
|
|
"mcc": "5411", |
|
|
"amount_usd": 125.50 |
|
|
} |
|
|
|
|
|
response = requests.post(url, json=payload) |
|
|
print(response.json()) |
|
|
</pre> |
|
|
|
|
|
<hr> |
|
|
|
|
|
<h3>📋 Response Format</h3> |
|
|
<pre style="background: #f5f5f5; padding: 15px; border-radius: 5px; overflow-x: auto;"> |
|
|
{ |
|
|
"recommended_card": "c_amex_gold", |
|
|
"rewards_earned": 5.02, |
|
|
"rewards_rate": "4x points", |
|
|
"confidence": 0.95, |
|
|
"reasoning": "Amex Gold offers 4x points on groceries...", |
|
|
"alternative_options": [ |
|
|
{ |
|
|
"card": "c_citi_custom_cash", |
|
|
"reward_amount": 6.28, |
|
|
"reason": "5% cashback on groceries..." |
|
|
} |
|
|
], |
|
|
"warnings": [ |
|
|
"You're approaching your $500 monthly cap" |
|
|
] |
|
|
} |
|
|
</pre> |
|
|
|
|
|
<hr> |
|
|
|
|
|
<h3>🔐 Authentication</h3> |
|
|
<p>Currently, the API is open for demo purposes. In production, you would need:</p> |
|
|
<ul> |
|
|
<li>API Key in headers: <code>X-API-Key: your_key_here</code></li> |
|
|
<li>OAuth 2.0 for user-specific data</li> |
|
|
</ul> |
|
|
|
|
|
<hr> |
|
|
|
|
|
<h3>⚡ Rate Limits</h3> |
|
|
<ul> |
|
|
<li><strong>Free Tier:</strong> 100 requests/hour</li> |
|
|
<li><strong>Pro Tier:</strong> 1000 requests/hour</li> |
|
|
<li><strong>Enterprise:</strong> Unlimited</li> |
|
|
</ul> |
|
|
|
|
|
<hr> |
|
|
|
|
|
<h3>❓ Support</h3> |
|
|
<p>For API support, please visit our <a href="https://github.com/your-repo" target="_blank">GitHub repository</a> or contact support.</p> |
|
|
</div> |
|
|
""" |
|
|
gr.HTML(api_docs_html) |
|
|
|
|
|
|
|
|
with gr.Tab("❓ FAQs"): |
|
|
gr.Markdown(""" |
|
|
## Frequently Asked Questions |
|
|
|
|
|
### General Questions |
|
|
|
|
|
**Q: What is RewardPilot?** |
|
|
A: RewardPilot is an AI-powered system that recommends the best credit card to use for each transaction to maximize your rewards. |
|
|
|
|
|
**Q: How does it work?** |
|
|
A: It analyzes your transaction details (merchant, amount, category) against your credit card portfolio and recommends the card that will earn you the most rewards. |
|
|
|
|
|
**Q: Is my data secure?** |
|
|
A: Yes! All data is encrypted and we follow industry-standard security practices. We never store sensitive card information like CVV or full card numbers. |
|
|
|
|
|
--- |
|
|
|
|
|
### Using the System |
|
|
|
|
|
**Q: How accurate are the recommendations?** |
|
|
A: Our AI agent has a 95%+ confidence rate for most recommendations. The system considers reward rates, spending caps, and category bonuses. |
|
|
|
|
|
**Q: What if I don't have the recommended card?** |
|
|
A: The system shows alternative options from your wallet. You can also view the "Alternative Options" section for other good choices. |
|
|
|
|
|
**Q: Can I add custom MCC codes?** |
|
|
A: Yes! Use the "Advanced Options" section in the Get Recommendation tab to enter custom MCC codes. |
|
|
|
|
|
--- |
|
|
|
|
|
### Analytics & Forecasts |
|
|
|
|
|
**Q: How is the optimization score calculated?** |
|
|
A: It's based on reward rates (30%), cap availability (25%), annual fee value (20%), category match (20%), and penalties (5%). |
|
|
|
|
|
**Q: How accurate are the spending forecasts?** |
|
|
A: Our ML models achieve 85-92% accuracy based on your historical spending patterns. |
|
|
|
|
|
**Q: Can I export my analytics data?** |
|
|
A: This feature is coming soon! You'll be able to export to CSV and PDF. |
|
|
|
|
|
--- |
|
|
|
|
|
### Technical Questions |
|
|
|
|
|
**Q: What APIs does RewardPilot use?** |
|
|
A: We use 4 main services: Orchestrator, Smart Wallet, Rewards-RAG, and Spend-Forecast. |
|
|
|
|
|
**Q: Can I integrate RewardPilot into my app?** |
|
|
A: Yes! Check the API Documentation tab for integration details. |
|
|
|
|
|
**Q: What LLM powers the AI agent?** |
|
|
A: We use Claude 3.5 Sonnet by Anthropic for intelligent reasoning and explanations. |
|
|
|
|
|
--- |
|
|
|
|
|
### Troubleshooting |
|
|
|
|
|
**Q: Why am I seeing "Demo Mode" warnings?** |
|
|
A: This means the system is using mock data. Ensure the orchestrator API is connected. |
|
|
|
|
|
**Q: The recommendation seems wrong. Why?** |
|
|
A: Check the "Agent Insight" tab to see the reasoning. If you still think it's wrong, please report it. |
|
|
|
|
|
**Q: How do I report a bug?** |
|
|
A: Please open an issue on our [GitHub repository](https://github.com/your-repo). |
|
|
|
|
|
--- |
|
|
|
|
|
**Still have questions?** Contact us at [email protected] |
|
|
""") |
|
|
|
|
|
|
|
|
if __name__ == "__main__": |
|
|
app.launch( |
|
|
server_name="0.0.0.0", |
|
|
server_port=7860, |
|
|
share=False, |
|
|
) |
|
|
|