Area51base/cc-chatbot
0
1"""2Credit Card AI Chatbot — Streamlit UI3 4Run with:5 streamlit run ui/streamlit_app.py6 7Connects to the FastAPI backend at http://localhost:80008Falls back to direct import if the API is not running.9"""10 11import os12import sys13import uuid14import json15import time16from typing import Optional17 18import streamlit as st19import httpx20 21# ── Path setup for direct import fallback ─────────────────────────────────────22sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))23 24API_BASE = os.getenv("CC_API_URL", "http://localhost:8000")25DIRECT_MODE = False # switched to True if API is unreachable26 27# ── Page config ───────────────────────────────────────────────────────────────28st.set_page_config(29 page_title="CC AI Assistant",30 page_icon="💳",31 layout="centered",32 initial_sidebar_state="collapsed",33)34 35# ── Custom CSS ────────────────────────────────────────────────────────────────36st.markdown("""37<style>38/* Chat bubbles */39.user-bubble {40 background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);41 color: white;42 padding: 12px 16px;43 border-radius: 18px 18px 4px 18px;44 margin: 8px 0 8px 20%;45 max-width: 80%;46 float: right;47 clear: both;48 font-size: 0.95rem;49 box-shadow: 0 2px 8px rgba(0,0,0,0.15);50}51.assistant-bubble {52 background: linear-gradient(135deg, #f5f7fa 0%, #e8ecf0 100%);53 color: #1a1a2e;54 padding: 14px 18px;55 border-radius: 18px 18px 18px 4px;56 margin: 8px 20% 8px 0;57 max-width: 80%;58 float: left;59 clear: both;60 font-size: 0.95rem;61 box-shadow: 0 2px 8px rgba(0,0,0,0.1);62 border-left: 3px solid #667eea;63}64.chat-container {65 display: flex;66 flex-direction: column;67 gap: 4px;68 padding: 10px 0;69}70.meta-badge {71 display: inline-block;72 background: #e8f4fd;73 color: #0366d6;74 padding: 2px 8px;75 border-radius: 10px;76 font-size: 0.75rem;77 margin: 2px;78}79.suggestion-btn {80 background: #f0f7ff;81 border: 1px solid #c6ddf0;82 border-radius: 8px;83 padding: 6px 12px;84 margin: 4px;85 cursor: pointer;86 font-size: 0.85rem;87 color: #0366d6;88}89.header-gradient {90 background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);91 color: white;92 padding: 20px;93 border-radius: 12px;94 text-align: center;95 margin-bottom: 20px;96}97.clearfix { clear: both; }98/* Hide sidebar and its toggle button completely */99[data-testid="stSidebar"] { display: none !important; }100[data-testid="collapsedControl"] { display: none !important; }101pre {102 background: #f6f8fa;103 border: 1px solid #e1e4e8;104 border-radius: 6px;105 padding: 12px;106 white-space: pre-wrap;107 word-wrap: break-word;108 font-size: 0.9rem;109}110</style>111""", unsafe_allow_html=True)112 113 114# ── Session state init ────────────────────────────────────────────────────────115if "session_id" not in st.session_state:116 st.session_state.session_id = str(uuid.uuid4())117if "messages" not in st.session_state:118 st.session_state.messages = []119if "suggestions" not in st.session_state:120 st.session_state.suggestions = []121if "last_intent" not in st.session_state:122 st.session_state.last_intent = None123 124 125api_url = API_BASE126 127# ── Warm-up banner (shown on first load while ingestion runs in background) ────128def _api_ready() -> bool:129 try:130 r = httpx.get(f"{api_url}/health", timeout=2)131 return r.status_code == 200132 except Exception:133 return False134 135if not _api_ready():136 st.info(137 "⏳ **First-time setup in progress** — loading credit card data and "138 "starting AI engine (~2 min on first launch). "139 "You can explore the UI; the first query may take a moment longer.",140 icon="🚀",141 )142 143# ── Header ────────────────────────────────────────────────────────────────────144col_h, col_btn = st.columns([5, 1])145with col_h:146 st.markdown("""147<div class="header-gradient">148 <h2 style="margin:0">💳 Credit Card AI Assistant</h2>149 <p style="margin:4px 0 0 0; opacity:0.9; font-size:0.9rem">150 Find the best cards · Compare rewards · Plan redemptions151 </p>152</div>153""", unsafe_allow_html=True)154with col_btn:155 st.markdown("<div style='padding-top:18px'></div>", unsafe_allow_html=True)156 if st.button("🔄 New", help="Start a new conversation", use_container_width=True):157 st.session_state.session_id = str(uuid.uuid4())158 st.session_state.messages = []159 st.session_state.suggestions = []160 st.session_state.last_intent = None161 st.rerun()162 163# ── Example questions (shown only when chat is empty) ─────────────────────────164if not st.session_state.messages:165 st.markdown("**💡 Try asking:**")166 example_questions = [167 "Best card for Swiggy food orders?",168 "Which card for international travel?",169 "I have 20,000 HDFC Infinia points, want to go to Spain",170 "Compare HDFC Infinia vs Diners Club Black",171 "Best no-fee credit card with good rewards",172 "Which card gives unlimited lounge access?",173 "Best cashback card for Amazon shopping",174 "HDFC Regalia rewards and benefits",175 ]176 cols = st.columns(2)177 for i, eq in enumerate(example_questions):178 if cols[i % 2].button(eq, key=f"ex_{i}", use_container_width=True):179 st.session_state.pending_question = eq180 181 182# ── Chat history display ───────────────────────────────────────────────────────183def render_message(role: str, content: str, meta: Optional[dict] = None):184 if role == "user":185 st.markdown(186 f'<div class="user-bubble">{content}</div><div class="clearfix"></div>',187 unsafe_allow_html=True,188 )189 else:190 # Format assistant answer nicely191 formatted = _format_answer(content)192 badges = ""193 if meta:194 if meta.get("intent"):195 badges += f'<span class="meta-badge">🎯 {meta["intent"]}</span>'196 if meta.get("category"):197 badges += f'<span class="meta-badge">📂 {meta["category"]}</span>'198 if meta.get("cache_hit"):199 badges += f'<span class="meta-badge">⚡ Cached ({meta.get("similarity", 0):.2f})</span>'200 st.markdown(201 f'<div class="assistant-bubble">{badges}<br>{formatted}</div><div class="clearfix"></div>',202 unsafe_allow_html=True,203 )204 205 206def _format_answer(text: str) -> str:207 """Convert plain text answer to styled HTML."""208 import html209 import re210 # Escape HTML211 safe = html.escape(text)212 # Bold **text**213 safe = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", safe)214 # Markdown table rows: | col | col | → render as an HTML table215 if "|" in safe:216 lines = safe.split("\n")217 result_lines = []218 in_table = False219 table_rows = []220 for line in lines:221 stripped = line.strip()222 if stripped.startswith("|") and stripped.endswith("|"):223 if not in_table:224 in_table = True225 table_rows = []226 # Skip separator rows like |---|---|227 if re.match(r"^\|[\s\-|:]+\|$", stripped):228 continue229 cells = [c.strip() for c in stripped.strip("|").split("|")]230 table_rows.append(cells)231 else:232 if in_table:233 # Flush table234 if table_rows:235 html_table = '<table style="border-collapse:collapse;width:100%;margin:8px 0;font-size:0.88rem">'236 for i, row in enumerate(table_rows):237 tag = "th" if i == 0 else "td"238 style_row = ' style="background:#f0f4f8"' if i == 0 else (' style="background:#fafafa"' if i % 2 == 0 else "")239 html_table += f"<tr{style_row}>"240 for cell in row:241 html_table += f'<{tag} style="border:1px solid #ddd;padding:6px 10px">{cell}</{tag}>'242 html_table += "</tr>"243 html_table += "</table>"244 result_lines.append(html_table)245 in_table = False246 table_rows = []247 result_lines.append(line)248 if in_table and table_rows:249 html_table = '<table style="border-collapse:collapse;width:100%;margin:8px 0;font-size:0.88rem">'250 for i, row in enumerate(table_rows):251 tag = "th" if i == 0 else "td"252 style_row = ' style="background:#f0f4f8"' if i == 0 else (' style="background:#fafafa"' if i % 2 == 0 else "")253 html_table += f"<tr{style_row}>"254 for cell in row:255 html_table += f'<{tag} style="border:1px solid #ddd;padding:6px 10px">{cell}</{tag}>'256 html_table += "</tr>"257 html_table += "</table>"258 result_lines.append(html_table)259 safe = "\n".join(result_lines)260 # Numbered list items261 safe = re.sub(r"^(\d+)\.\s+", r"<br><strong>\1.</strong> ", safe, flags=re.MULTILINE)262 # Bullet / checkmark lines263 safe = re.sub(r"^✅\s+", r"<br>✅ ", safe, flags=re.MULTILINE)264 safe = re.sub(r"^[-•]\s+", r"<br>• ", safe, flags=re.MULTILINE)265 # Section headers with ──266 safe = re.sub(r"^(──.+)$", r"<br><strong>\1</strong>", safe, flags=re.MULTILINE)267 # Bold "Verdict:" line268 safe = re.sub(r"^(Verdict:)", r"<br><strong>Verdict:</strong>", safe, flags=re.MULTILINE)269 safe = re.sub(r"^(Pro tip:)", r"<br><strong>💡 Pro tip:</strong>", safe, flags=re.MULTILINE)270 # Section headers with emoji (🎁 ✈️ etc)271 safe = re.sub(r"^([🎁✈️🍽💳⭐🏦💰🛍⛽🏥🎬][^\n]+)$", r"<br><strong>\1</strong>", safe, flags=re.MULTILINE)272 # Newlines273 safe = safe.replace("\n", "<br>")274 return safe275 276 277# Render existing messages278with st.container():279 for msg in st.session_state.messages:280 render_message(281 role=msg["role"],282 content=msg["content"],283 meta=msg.get("meta"),284 )285 286# ── Follow-up suggestions ──────────────────────────────────────────────────────287if st.session_state.suggestions:288 st.markdown("**💬 You might also ask:**")289 cols = st.columns(len(st.session_state.suggestions[:3]))290 for i, suggestion in enumerate(st.session_state.suggestions[:3]):291 if cols[i].button(suggestion, key=f"sug_{i}_{suggestion[:20]}"):292 st.session_state.pending_question = suggestion293 294 295# ── Handle pending question (from sidebar or suggestions) ─────────────────────296if "pending_question" in st.session_state:297 pending = st.session_state.pop("pending_question")298 st.session_state.pending_send = pending299 300 301# ── Chat input ────────────────────────────────────────────────────────────────302user_input = st.chat_input(303 "Ask about credit cards — rewards, travel, dining, comparisons...",304 key="chat_input",305)306 307# Resolve input (typed or from pending)308question = user_input309if hasattr(st.session_state, "pending_send"):310 question = st.session_state.pop("pending_send")311 312 313def _send_question(q: str):314 """Send the question to API or direct service and update state."""315 # Show user message immediately316 st.session_state.messages.append({"role": "user", "content": q})317 st.session_state.suggestions = []318 319 with st.spinner("Thinking..."):320 try:321 response = httpx.post(322 f"{api_url}/api/chat",323 json={"question": q, "session_id": st.session_state.session_id},324 timeout=60,325 )326 response.raise_for_status()327 data = response.json()328 329 answer = data.get("answer", "Sorry, I couldn't generate an answer.")330 meta = {331 "intent": data.get("intent"),332 "category": data.get("category"),333 "cache_hit": data.get("cache_hit", False),334 "similarity": data.get("similarity", 0),335 }336 st.session_state.session_id = data.get("session_id", st.session_state.session_id)337 st.session_state.last_intent = data.get("intent")338 st.session_state.suggestions = data.get("suggestions", [])339 340 except httpx.ConnectError:341 # Fallback: direct import342 try:343 from cc_chatbot.services.orchestrator import ask344 data = ask(question=q, session_id=st.session_state.session_id)345 answer = data.get("answer", "")346 meta = {347 "intent": data.get("intent"),348 "category": data.get("category"),349 "cache_hit": data.get("cache_hit", False),350 "similarity": data.get("similarity", 0),351 }352 st.session_state.session_id = data.get("session_id", st.session_state.session_id)353 st.session_state.suggestions = data.get("suggestions", [])354 except Exception as e:355 answer = f"❌ Error: {e}\n\nMake sure the API server is running:\n`uvicorn cc_chatbot.main:app --reload`"356 meta = {}357 358 except Exception as e:359 answer = f"❌ Request error: {e}"360 meta = {}361 362 st.session_state.messages.append({363 "role": "assistant",364 "content": answer,365 "meta": meta,366 })367 368 369if question and question.strip():370 _send_question(question.strip())371 st.rerun()372 373 374# ── Footer ────────────────────────────────────────────────────────────────────375st.markdown("---")376st.markdown(377 "<p style='text-align:center; color:#999; font-size:0.8rem;'>"378 "💳 CC AI Assistant • Data from Livemint • Powered by Gemini"379 "</p>",380 unsafe_allow_html=True,381)382 