Harshavard21/FinRAG
2
1"""2app/main.py — FinRAG | Production Financial Intelligence UI3================================================================4Single-page Streamlit app. No sub-pages.5 6Layout:7 Sidebar : logo · new chat · conversation history · quick actions8 Main : mode pills · chat thread · company context bar · chat input9"""10 11import sys, os, re, json12sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))13os.environ["PYTHONUTF8"] = "1"14 15from config.settings import settings16 17import streamlit as st18import plotly.graph_objects as go19 20# ── page config (MUST be first Streamlit call) ──────────────────────21st.set_page_config(22 page_title="FinRAG",23 page_icon="📈",24 layout="wide",25 initial_sidebar_state="expanded",26)27 28# ── imports ──────────────────────────────────────────────────────────29from app.rag_engine import (30 load_embedder, load_retriever, load_reranker,31 load_groq, load_comparator, load_metric_extractor,32)33from app.db.chat_store import (34 new_conversation, list_conversations, get_messages,35 add_message, update_conversation_title,36 delete_conversation, group_conversations_by_date,37)38 39# ════════════════════════════════════════════════════════════════════40# CSS41# ════════════════════════════════════════════════════════════════════42st.markdown("""43<style>44@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');45 46/* ── Reset & base ─────────────────────────────────────────────── */47*, *::before, *::after { box-sizing: border-box; }48html, body, .stApp {49 font-family: 'Inter', -apple-system, sans-serif;50 background: #0d0f17;51 color: #e2e8f0;52}53#MainMenu, footer, header { visibility: hidden; }54.block-container { padding: 0 !important; max-width: 100% !important; }55 56/* ── Sidebar ──────────────────────────────────────────────────── */57[data-testid="stSidebar"] {58 background: #0a0c13 !important;59 border-right: 1px solid rgba(255,255,255,0.06) !important;60 padding: 0 !important;61}62[data-testid="stSidebar"] > div:first-child { padding: 0 !important; }63 64/* ── Sidebar collapse button ─────────────────────────────────── */65[data-testid="collapsedControl"] {66 color: #64748b !important;67 background: #0a0c13 !important;68}69 70/* ── Remove default padding from main content ────────────────── */71[data-testid="stMainBlockContainer"] {72 padding: 0 !important;73}74 75/* ── Chat messages ───────────────────────────────────────────── */76[data-testid="stChatMessage"] {77 background: transparent !important;78 border: none !important;79 padding: 0.6rem 0 !important;80}81 82/* ── User bubble ─────────────────────────────────────────────── */83[data-testid="stChatMessage"][data-testid*="user"] {84 flex-direction: row-reverse !important;85}86 87/* ── Chat input ──────────────────────────────────────────────── */88[data-testid="stChatInput"] {89 background: #161929 !important;90 border: 1px solid rgba(255,255,255,0.1) !important;91 border-radius: 14px !important;92}93[data-testid="stChatInput"] textarea {94 color: #e2e8f0 !important;95 font-family: 'Inter', sans-serif !important;96 font-size: 0.95rem !important;97}98[data-testid="stChatInput"] button {99 background: linear-gradient(135deg, #3b82f6, #06b6d4) !important;100 border-radius: 8px !important;101}102 103/* ── Scrollbar ───────────────────────────────────────────────── */104::-webkit-scrollbar { width: 5px; }105::-webkit-scrollbar-track { background: transparent; }106::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 3px; }107 108/* ── Selectbox / radio ───────────────────────────────────────── */109.stSelectbox > div > div,110.stMultiSelect > div > div {111 background: #161929 !important;112 border: 1px solid rgba(255,255,255,0.08) !important;113 border-radius: 8px !important;114 color: #e2e8f0 !important;115}116.stRadio > div { gap: 0.4rem; }117.stRadio label { color: #94a3b8 !important; }118 119/* ── Buttons ─────────────────────────────────────────────────── */120.stButton > button {121 border-radius: 8px !important;122 font-weight: 500 !important;123 font-size: 0.85rem !important;124 transition: all 0.15s !important;125 border: none !important;126}127.stButton > button:hover { transform: translateY(-1px) !important; }128 129/* ── Expander ────────────────────────────────────────────────── */130.streamlit-expanderHeader {131 background: rgba(255,255,255,0.03) !important;132 border: 1px solid rgba(255,255,255,0.07) !important;133 border-radius: 8px !important;134 color: #64748b !important;135 font-size: 0.8rem !important;136}137.streamlit-expanderContent {138 background: rgba(255,255,255,0.02) !important;139 border: 1px solid rgba(255,255,255,0.05) !important;140 border-top: none !important;141}142 143/* ── Divider ─────────────────────────────────────────────────── */144hr { border-color: rgba(255,255,255,0.07) !important; }145 146/* ── Plotly charts ───────────────────────────────────────────── */147.js-plotly-plot { border-radius: 12px; overflow: hidden; }148 149/* ── Custom components ───────────────────────────────────────── */150.fin-brand {151 padding: 1.2rem 1rem 0.8rem 1rem;152 display: flex; align-items: center; gap: 0.6rem;153}154.fin-brand-icon { font-size: 1.4rem; }155.fin-brand-name {156 font-size: 1.1rem; font-weight: 700; letter-spacing: -0.02em;157 background: linear-gradient(135deg, #3b82f6, #06b6d4);158 -webkit-background-clip: text; -webkit-text-fill-color: transparent;159}160.fin-brand-sub {161 font-size: 0.65rem; color: #475569;162 text-transform: uppercase; letter-spacing: 0.08em;163 margin-top: -0.15rem;164}165 166.history-group-label {167 font-size: 0.68rem; font-weight: 600; color: #475569;168 text-transform: uppercase; letter-spacing: 0.08em;169 padding: 0.6rem 0.8rem 0.3rem 0.8rem;170}171.history-item {172 padding: 0.45rem 0.8rem; border-radius: 7px; cursor: pointer;173 font-size: 0.82rem; color: #94a3b8; white-space: nowrap;174 overflow: hidden; text-overflow: ellipsis;175 transition: background 0.1s, color 0.1s;176}177.history-item:hover { background: rgba(255,255,255,0.05); color: #e2e8f0; }178.history-item.active { background: rgba(59,130,246,0.12); color: #93c5fd; }179 180.mode-bar {181 display: flex; gap: 0.3rem; padding: 0.8rem 1.2rem 0;182}183.mode-pill {184 padding: 0.35rem 1rem; border-radius: 20px; font-size: 0.8rem;185 font-weight: 500; cursor: pointer; border: 1px solid rgba(255,255,255,0.08);186 color: #64748b; background: transparent; transition: all 0.15s;187}188.mode-pill.active {189 background: linear-gradient(135deg, rgba(59,130,246,0.2), rgba(6,182,212,0.1));190 border-color: rgba(59,130,246,0.4); color: #93c5fd;191}192 193.context-bar {194 display: flex; align-items: center; gap: 0.5rem;195 padding: 0.4rem 1.2rem; flex-wrap: wrap;196}197.ctx-tag {198 display: inline-flex; align-items: center; gap: 0.3rem;199 background: rgba(59,130,246,0.12); border: 1px solid rgba(59,130,246,0.25);200 border-radius: 20px; padding: 0.2rem 0.65rem; font-size: 0.75rem;201 color: #93c5fd; font-family: 'JetBrains Mono', monospace;202}203.ctx-hint { font-size: 0.72rem; color: #475569; }204 205.kpi-grid {206 display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.7rem;207 margin: 0.8rem 0;208}209.kpi-card {210 background: rgba(255,255,255,0.04);211 border: 1px solid rgba(255,255,255,0.08);212 border-radius: 10px; padding: 0.85rem 1rem; text-align: center;213}214.kpi-card.has-value { border-color: rgba(59,130,246,0.2); }215.kpi-val {216 font-size: 1.25rem; font-weight: 700; font-family: 'JetBrains Mono', monospace;217 color: #06b6d4; line-height: 1.2;218}219.kpi-val.na { color: #334155; font-size: 0.9rem; }220.kpi-lbl { font-size: 0.68rem; color: #64748b; margin-top: 0.25rem;221 text-transform: uppercase; letter-spacing: 0.05em; }222 223.source-chips { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-top: 0.6rem; }224.chip {225 background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08);226 border-radius: 5px; padding: 0.15rem 0.5rem; font-size: 0.68rem;227 color: #64748b; font-family: 'JetBrains Mono', monospace;228}229.chip.table-chip { border-color: rgba(16,185,129,0.3); color: #6ee7b7; }230 231.welcome-wrap {232 display: flex; flex-direction: column; align-items: center;233 justify-content: center; min-height: 60vh; gap: 1.2rem; text-align: center;234 padding: 2rem;235}236.welcome-logo { font-size: 3rem; }237.welcome-title {238 font-size: 2rem; font-weight: 800; letter-spacing: -0.04em;239 background: linear-gradient(135deg, #3b82f6 0%, #06b6d4 50%, #8b5cf6 100%);240 -webkit-background-clip: text; -webkit-text-fill-color: transparent;241}242.welcome-sub { font-size: 1rem; color: #64748b; max-width: 460px; line-height: 1.6; }243.suggestion-grid {244 display: grid; grid-template-columns: repeat(2, 1fr);245 gap: 0.6rem; max-width: 560px; width: 100%;246}247.suggestion-card {248 background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08);249 border-radius: 10px; padding: 0.8rem 1rem; font-size: 0.82rem;250 color: #94a3b8; cursor: pointer; text-align: left;251 transition: all 0.15s;252}253.suggestion-card:hover {254 background: rgba(59,130,246,0.08); border-color: rgba(59,130,246,0.3);255 color: #e2e8f0;256}257.suggestion-icon { font-size: 1rem; margin-bottom: 0.3rem; display: block; }258</style>259""", unsafe_allow_html=True)260 261 262# ════════════════════════════════════════════════════════════════════263# Session State Init264# ════════════════════════════════════════════════════════════════════265COMPANIES = list(settings.company_ticker_map.keys())266 267def _init_state():268 defaults = {269 "conversation_id": None,270 "messages": [], # list of dicts: role, content, metadata271 "mode": "chat", # "chat" | "compare" | "dashboard"272 "company": COMPANIES[0], # primary company273 "companies_compare": [], # for compare mode274 "fy": "FY2025",275 "pending_suggestion": None,276 }277 for k, v in defaults.items():278 if k not in st.session_state:279 st.session_state[k] = v280 281_init_state()282 283 284# ════════════════════════════════════════════════════════════════════285# Helpers286# ════════════════════════════════════════════════════════════════════287 288def start_new_conversation():289 cid = new_conversation(mode=st.session_state.mode)290 st.session_state.conversation_id = cid291 st.session_state.messages = []292 293 294def load_conversation(cid: str):295 st.session_state.conversation_id = cid296 st.session_state.messages = get_messages(cid)297 298 299def save_message(role: str, content: str, metadata: dict = None):300 cid = st.session_state.conversation_id301 if not cid:302 cid = new_conversation()303 st.session_state.conversation_id = cid304 add_message(cid, role, content, metadata)305 # Set title from first user message306 if role == "user" and len(st.session_state.messages) == 0:307 update_conversation_title(cid, content)308 309 310def parse_at_mentions(text: str) -> tuple[str, list[str]]:311 """Extract @COMPANY mentions; return (cleaned_text, [companies])."""312 found = []313 cleaned = text314 for c in COMPANIES:315 if f"@{c}" in text or f"@{c.lower()}" in text.lower():316 found.append(c)317 cleaned = re.sub(f"@{re.escape(c)}", c, cleaned, flags=re.IGNORECASE)318 return cleaned.strip(), found319 320 321def detect_intent(query: str, mentioned: list[str]) -> str:322 """Auto-detect mode from query text."""323 ql = query.lower()324 if any(k in ql for k in ["dashboard", "kpi", "extract metrics", "show metrics", "key metrics"]):325 return "dashboard"326 if len(mentioned) > 1 or any(k in ql for k in ["compare", " vs ", "versus", "better than", "difference between"]):327 return "compare"328 return "chat"329 330 331def fmt_crore(v):332 if v is None: return "N/A"333 if v >= 100000: return f"₹{v/100000:.1f}L Cr"334 return f"₹{v:,.0f} Cr"335 336def fmt_pct(v):337 return f"{v:.2f}%" if v is not None else "N/A"338 339def fmt_inr(v):340 return f"₹{v:.2f}" if v is not None else "N/A"341 342 343# ════════════════════════════════════════════════════════════════════344# Render helpers345# ════════════════════════════════════════════════════════════════════346 347def render_source_chips(sources: list[dict]):348 if not sources:349 return350 chips_html = '<div class="source-chips">'351 for s in sources:352 cls = "chip table-chip" if s.get("is_table") else "chip"353 chips_html += f'<span class="{cls}">{s["label"]}</span>'354 chips_html += "</div>"355 st.markdown(chips_html, unsafe_allow_html=True)356 357 358def render_kpi_cards(metrics: dict):359 """Render 2 rows of KPI cards from extracted metrics dict."""360 rows = [361 [362 ("Revenue", fmt_crore(metrics.get("revenue_crore")), metrics.get("revenue_crore") is not None),363 ("Net Profit", fmt_crore(metrics.get("net_profit_crore")), metrics.get("net_profit_crore") is not None),364 ("Net Margin", fmt_pct(metrics.get("net_profit_margin_pct")), metrics.get("net_profit_margin_pct") is not None),365 ("EBITDA Margin", fmt_pct(metrics.get("ebitda_margin_pct")), metrics.get("ebitda_margin_pct") is not None),366 ],367 [368 ("Total Assets", fmt_crore(metrics.get("total_assets_crore")), metrics.get("total_assets_crore") is not None),369 ("Equity", fmt_crore(metrics.get("equity_crore")), metrics.get("equity_crore") is not None),370 ("EPS", fmt_inr(metrics.get("eps_inr")), metrics.get("eps_inr") is not None),371 ("ROE", fmt_pct(metrics.get("roe_pct")), metrics.get("roe_pct") is not None),372 ],373 ]374 banking_row = None375 if any(metrics.get(k) is not None for k in ["npa_gross_pct", "npa_net_pct", "nim_pct"]):376 banking_row = [377 ("Gross NPA", fmt_pct(metrics.get("npa_gross_pct")), metrics.get("npa_gross_pct") is not None),378 ("Net NPA", fmt_pct(metrics.get("npa_net_pct")), metrics.get("npa_net_pct") is not None),379 ("NIM", fmt_pct(metrics.get("nim_pct")), metrics.get("nim_pct") is not None),380 ("EBITDA", fmt_crore(metrics.get("ebitda_crore")), metrics.get("ebitda_crore") is not None),381 ]382 383 for row in ([*rows] + ([banking_row] if banking_row else [])):384 cols = st.columns(4)385 for col, (label, value, has_val) in zip(cols, row):386 card_cls = "kpi-card has-value" if has_val else "kpi-card"387 val_cls = "kpi-val" if has_val else "kpi-val na"388 col.markdown(389 f'<div class="{card_cls}"><div class="{val_cls}">{value}</div>'390 f'<div class="kpi-lbl">{label}</div></div>',391 unsafe_allow_html=True,392 )393 394 395def render_kpi_chart(metrics: dict, company: str):396 chart_map = {397 "Revenue": metrics.get("revenue_crore"),398 "Net Profit": metrics.get("net_profit_crore"),399 "EBITDA": metrics.get("ebitda_crore"),400 "Total Assets": metrics.get("total_assets_crore"),401 "Equity": metrics.get("equity_crore"),402 }403 data = {k: v for k, v in chart_map.items() if v is not None}404 if not data:405 return406 407 colors = ["#3b82f6", "#06b6d4", "#8b5cf6", "#10b981", "#f59e0b"]408 fig = go.Figure(go.Bar(409 y=list(data.keys()), x=list(data.values()), orientation="h",410 marker=dict(color=colors[:len(data)], opacity=0.85),411 text=[f"₹{v:,.0f} Cr" for v in data.values()],412 textposition="outside", textfont=dict(color="#e2e8f0", size=11),413 ))414 fig.update_layout(415 paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(255,255,255,0.02)",416 font=dict(color="#94a3b8", family="Inter"),417 xaxis=dict(gridcolor="rgba(255,255,255,0.05)", tickfont=dict(color="#64748b")),418 yaxis=dict(tickfont=dict(color="#e2e8f0", size=12)),419 margin=dict(t=10, b=10, l=10, r=70), height=220,420 title=dict(text=f"{company} — Financial Overview", font=dict(color="#94a3b8", size=12)),421 )422 st.plotly_chart(fig, use_container_width=True, config={"displayModeBar": False})423 424 425def render_compare_chart(company_data: dict, metric_label: str):426 """Bar chart for one metric across multiple companies."""427 companies = list(company_data.keys())428 values = list(company_data.values())429 colors = ["#3b82f6", "#06b6d4", "#8b5cf6", "#10b981"]430 431 fig = go.Figure(go.Bar(432 x=companies, y=values,433 marker_color=colors[:len(companies)],434 text=[f"{v:,.1f}" if v else "N/A" for v in values],435 textposition="outside", textfont=dict(color="#e2e8f0", size=12),436 ))437 fig.update_layout(438 paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(255,255,255,0.02)",439 font=dict(color="#94a3b8", family="Inter"),440 title=dict(text=metric_label, font=dict(color="#94a3b8", size=12)),441 xaxis=dict(showgrid=False, tickfont=dict(color="#e2e8f0")),442 yaxis=dict(gridcolor="rgba(255,255,255,0.05)", tickfont=dict(color="#64748b")),443 margin=dict(t=40, b=10, l=10, r=10), height=260,444 )445 st.plotly_chart(fig, use_container_width=True, config={"displayModeBar": False})446 447 448def render_chat_history():449 """Render all messages in the current conversation."""450 for msg in st.session_state.messages:451 role = msg["role"]452 content = msg["content"]453 meta = msg.get("metadata") or {}454 455 with st.chat_message(role, avatar="🧑" if role == "user" else "📈"):456 st.markdown(content)457 458 # KPI cards (dashboard mode results)459 if meta.get("kpi_metrics"):460 render_kpi_cards(meta["kpi_metrics"])461 render_kpi_chart(meta["kpi_metrics"], meta.get("company", ""))462 463 # Comparison chart464 if meta.get("compare_chart"):465 cc = meta["compare_chart"]466 render_compare_chart(cc["data"], cc["label"])467 468 # Sources469 if meta.get("sources"):470 with st.expander(f"Sources ({len(meta['sources'])})", expanded=False):471 render_source_chips(meta["sources"])472 473 474# ════════════════════════════════════════════════════════════════════475# Pipeline runners476# ════════════════════════════════════════════════════════════════════477 478def run_chat(query: str, company: str, fy: str | None) -> tuple[str, dict]:479 from src.generation.prompts import build_qa_prompt480 481 retriever = load_retriever()482 reranker = load_reranker()483 llm = load_groq()484 485 with st.spinner("Retrieving relevant passages…"):486 candidates = retriever.retrieve(487 query=query, top_k=50,488 company_filter=company,489 fiscal_year_filter=fy,490 expand_query=True, promote_to_parent=True,491 )492 493 if not candidates:494 return "No relevant documents found for this query.", {}495 496 with st.spinner("Reranking results…"):497 reranked = reranker.rerank(query=query, candidates=candidates, top_n=5)498 499 system_prompt, user_msg = build_qa_prompt(500 query=query, results=reranked,501 company_context=f"{company} ({settings.company_ticker_map.get(company, '')})",502 )503 504 with st.chat_message("assistant", avatar="📈"):505 answer = st.write_stream(llm.generate_stream(system_prompt, user_msg))506 507 sources = [508 {"label": f"{c.company} | {c.fiscal_year} | Pg.{c.page_number}",509 "is_table": c.content_type == "table"}510 for c in reranked511 ]512 meta = {"sources": sources, "company": company}513 with st.chat_message("assistant", avatar="📈"):514 with st.expander(f"Sources ({len(sources)})", expanded=False):515 render_source_chips(sources)516 517 return answer, meta518 519 520def run_dashboard(company: str, fy: str | None) -> tuple[str, dict]:521 retriever = load_retriever()522 reranker = load_reranker()523 extractor = load_metric_extractor()524 525 with st.spinner(f"Retrieving financial data for {company}…"):526 all_cands = []527 for q in ["revenue net profit financial performance", "balance sheet assets equity"]:528 all_cands.extend(retriever.retrieve(529 query=q, top_k=20, company_filter=company,530 fiscal_year_filter=fy, expand_query=False,531 ))532 seen = set(); unique = []533 for c in all_cands:534 if c.chunk_id not in seen:535 seen.add(c.chunk_id); unique.append(c)536 537 reranked = reranker.rerank(538 query="financial metrics revenue profit assets equity EPS",539 candidates=unique, top_n=4,540 )541 542 with st.spinner("Extracting KPIs with AI…"):543 data = extractor.extract(company, reranked)544 545 metrics = data.get("metrics", {})546 fy_label = data.get("fiscal_year", fy or "FY2025")547 ticker = settings.company_ticker_map.get(company, company)548 549 answer = f"**{company}** ({ticker}) — KPI Summary for **{fy_label}**"550 551 with st.chat_message("assistant", avatar="📈"):552 st.markdown(answer)553 render_kpi_cards(metrics)554 render_kpi_chart(metrics, company)555 556 meta = {"kpi_metrics": metrics, "company": company, "fy": fy_label}557 return answer, meta558 559 560def run_compare(query: str, companies: list[str], fy: str | None) -> tuple[str, dict]:561 comparator = load_comparator()562 extractor = load_metric_extractor()563 564 with st.spinner(f"Comparing {' vs '.join(companies)}…"):565 company_results, answer_gen = comparator.compare(566 query=query, companies=companies, fiscal_year=fy, stream=True,567 )568 569 meta = {}570 with st.chat_message("assistant", avatar="📈"):571 answer = st.write_stream(answer_gen)572 573 # Try to extract one plottable metric574 try:575 with st.spinner("Generating comparison chart…"):576 mlist = extractor.extract_multi(company_results)577 comp_table = extractor.to_comparison_table(mlist)578 comp_table.pop("companies", None)579 580 for metric_label, vals in comp_table.items():581 numeric = {c: v for c, v in vals.items() if v is not None and isinstance(v, (int, float))}582 if len(numeric) >= 2:583 render_compare_chart(numeric, metric_label)584 meta["compare_chart"] = {"data": numeric, "label": metric_label}585 break586 except Exception:587 pass588 589 sources = []590 for company, results in company_results.items():591 for r in results:592 sources.append({"label": f"{company} Pg.{r.page_number}", "is_table": False})593 meta["sources"] = sources594 595 return answer, meta596 597 598# ════════════════════════════════════════════════════════════════════599# SIDEBAR600# ════════════════════════════════════════════════════════════════════601with st.sidebar:602 # Brand603 st.markdown("""604 <div class="fin-brand">605 <span class="fin-brand-icon">📈</span>606 <div>607 <div class="fin-brand-name">FinRAG</div>608 <div class="fin-brand-sub">Indian Markets Intelligence</div>609 </div>610 </div>611 """, unsafe_allow_html=True)612 613 # New chat button614 if st.button("+ New Chat", use_container_width=True,615 type="secondary", key="new_chat_btn"):616 start_new_conversation()617 st.rerun()618 619 st.markdown("<hr style='margin:0.6rem 0'>", unsafe_allow_html=True)620 621 # Quick actions622 st.markdown('<div class="history-group-label">Quick Actions</div>', unsafe_allow_html=True)623 col_a, col_b = st.columns(2)624 with col_a:625 if st.button("⚖️ Compare", use_container_width=True, key="sb_compare"):626 st.session_state.mode = "compare"627 if not st.session_state.conversation_id:628 start_new_conversation()629 st.rerun()630 with col_b:631 if st.button("📊 Dashboard", use_container_width=True, key="sb_dashboard"):632 st.session_state.mode = "dashboard"633 if not st.session_state.conversation_id:634 start_new_conversation()635 st.rerun()636 637 st.markdown("<hr style='margin:0.6rem 0'>", unsafe_allow_html=True)638 639 # Conversation history640 all_convs = list_conversations(40)641 if all_convs:642 grouped = group_conversations_by_date(all_convs)643 current_cid = st.session_state.conversation_id644 645 for group_label, convs in grouped.items():646 st.markdown(f'<div class="history-group-label">{group_label}</div>', unsafe_allow_html=True)647 for conv in convs:648 is_active = conv["id"] == current_cid649 active_cls = "history-item active" if is_active else "history-item"650 mode_icon = {"chat": "💬", "compare": "⚖️", "dashboard": "📊"}.get(conv["mode"], "💬")651 label = f"{mode_icon} {conv['title']}"652 653 # Use a button styled as a list item654 if st.button(label, key=f"hist_{conv['id']}", use_container_width=True,655 help=conv["title"]):656 load_conversation(conv["id"])657 st.rerun()658 else:659 st.markdown('<div style="padding:0.5rem 0.8rem;font-size:0.78rem;color:#334155;">No history yet</div>',660 unsafe_allow_html=True)661 662 # Sidebar footer663 st.markdown("<hr style='margin:0.6rem 0'>", unsafe_allow_html=True)664 st.markdown(665 '<div style="padding:0.3rem 0.8rem;font-size:0.68rem;color:#334155;line-height:1.6;">'666 f'17 companies · FY2024–25<br>'667 'Hybrid RAG · BGE-large · Groq LPU'668 '</div>', unsafe_allow_html=True,669 )670 671 672# ════════════════════════════════════════════════════════════════════673# MAIN AREA674# ════════════════════════════════════════════════════════════════════675main = st.container()676 677with main:678 # ── Mode pills (top bar) ────────────────────────────────────────679 st.markdown('<div style="height:0.8rem"></div>', unsafe_allow_html=True)680 mode_col, spacer = st.columns([3, 7])681 with mode_col:682 mode_labels = {"chat": "💬 Chat", "compare": "⚖️ Compare", "dashboard": "📊 Dashboard"}683 new_mode = st.radio(684 "Mode", list(mode_labels.values()),685 index=list(mode_labels.keys()).index(st.session_state.mode),686 horizontal=True, label_visibility="collapsed", key="mode_radio",687 )688 selected_mode_key = list(mode_labels.keys())[list(mode_labels.values()).index(new_mode)]689 if selected_mode_key != st.session_state.mode:690 st.session_state.mode = selected_mode_key691 st.rerun()692 693 st.markdown("<hr style='margin:0.3rem 0 0.6rem 0'>", unsafe_allow_html=True)694 695 # ── Context selectors ───────────────────────────────────────────696 if st.session_state.mode == "compare":697 ctx1, ctx2, ctx3 = st.columns([3, 1.5, 5])698 with ctx1:699 sel_companies = st.multiselect(700 "Companies", COMPANIES,701 default=st.session_state.companies_compare or ["TCS", "INFOSYS"],702 max_selections=4, label_visibility="collapsed", key="cmp_sel",703 )704 st.session_state.companies_compare = sel_companies705 with ctx2:706 fy_sel = st.selectbox("FY", ["FY2025", "FY2024", "All"], index=0,707 label_visibility="collapsed", key="cmp_fy")708 elif st.session_state.mode == "dashboard":709 ctx1, ctx2, ctx3 = st.columns([2.5, 1.5, 6])710 with ctx1:711 sel_company = st.selectbox("Company", COMPANIES, label_visibility="collapsed",712 key="dash_company_sel")713 st.session_state.company = sel_company714 with ctx2:715 fy_sel = st.selectbox("FY", ["FY2025", "FY2024"], index=0,716 label_visibility="collapsed", key="dash_fy")717 with ctx3:718 if st.button("Extract KPIs →", type="primary", key="dash_extract_btn"):719 fy_filter = None if fy_sel == "All" else fy_sel720 721 if not st.session_state.conversation_id:722 start_new_conversation()723 724 user_text = f"Show KPI dashboard for {st.session_state.company} {fy_sel}"725 st.session_state.messages.append({"role": "user", "content": user_text, "metadata": {}})726 save_message("user", user_text)727 728 answer, meta = run_dashboard(st.session_state.company, fy_filter)729 730 st.session_state.messages.append({"role": "assistant", "content": answer, "metadata": meta})731 save_message("assistant", answer, meta)732 st.rerun()733 else:734 # Chat mode context bar735 ctx1, ctx2, ctx3 = st.columns([2.5, 1.5, 6])736 with ctx1:737 sel_company = st.selectbox(738 "Company", COMPANIES,739 index=COMPANIES.index(st.session_state.company) if st.session_state.company in COMPANIES else 0,740 label_visibility="collapsed", key="chat_company_sel",741 )742 st.session_state.company = sel_company743 with ctx2:744 fy_sel = st.selectbox("FY", ["FY2025", "FY2024", "All"],745 index=0, label_visibility="collapsed", key="chat_fy_sel")746 747 st.markdown("<div style='height:0.4rem'></div>", unsafe_allow_html=True)748 749 # ── Chat thread ─────────────────────────────────────────────────750 if not st.session_state.messages:751 # Welcome screen752 st.markdown("""753 <div class="welcome-wrap">754 <div class="welcome-logo">📈</div>755 <div class="welcome-title">FinRAG</div>756 <div class="welcome-sub">757 Ask anything about 17 major Indian companies.<br>758 Grounded in official BSE annual reports.759 </div>760 </div>761 """, unsafe_allow_html=True)762 763 # Suggestion cards764 suggestions = [765 ("💰", "What was TCS's revenue and net profit for FY2025?"),766 ("🏦", "What is HDFC Bank's gross NPA ratio?"),767 ("⚖️", "Compare Infosys vs HCL Technologies on revenue growth"),768 ("📊", "Show KPI dashboard for Reliance Industries"),769 ]770 cols = st.columns(2)771 for i, (icon, text) in enumerate(suggestions):772 with cols[i % 2]:773 st.markdown(774 f'<div class="suggestion-card"><span class="suggestion-icon">{icon}</span>{text}</div>',775 unsafe_allow_html=True,776 )777 if st.button(text, key=f"sug_{i}", help=text,778 use_container_width=True):779 st.session_state.pending_suggestion = text780 st.rerun()781 else:782 render_chat_history()783 784 # ── Chat input ──────────────────────────────────────────────────785 mode = st.session_state.mode786 placeholders = {787 "chat": "Ask anything… use @CompanyName to specify (e.g. @TCS what was the revenue?)",788 "compare": "Compare selected companies… (e.g. Compare revenue and margins)",789 "dashboard": "Dashboard mode — use Extract KPIs button above, or type a question",790 }791 792 # Handle pending suggestion (clicked welcome card)793 prefill = st.session_state.pop("pending_suggestion", None) or ""794 795 user_input = st.chat_input(796 placeholder=placeholders.get(mode, "Ask anything…"),797 key="main_chat_input",798 )799 800 # Use suggestion if no direct input801 if not user_input and prefill:802 user_input = prefill803 804 if user_input:805 raw_query = user_input.strip()806 if not raw_query:807 st.stop()808 809 # Parse @ mentions810 query, mentioned_companies = parse_at_mentions(raw_query)811 812 # Override company from @ mention813 if mentioned_companies:814 st.session_state.company = mentioned_companies[0]815 if len(mentioned_companies) > 1:816 st.session_state.companies_compare = mentioned_companies817 818 # Auto-detect mode819 intent = detect_intent(query, mentioned_companies)820 if intent != "chat":821 st.session_state.mode = intent822 823 # Ensure conversation exists824 if not st.session_state.conversation_id:825 start_new_conversation()826 827 # Show user message828 with st.chat_message("user", avatar="🧑"):829 st.markdown(raw_query)830 831 # Save user message832 st.session_state.messages.append({"role": "user", "content": raw_query, "metadata": {}})833 save_message("user", raw_query)834 835 fy_filter = None if fy_sel == "All" else fy_sel836 837 # Route to correct pipeline838 try:839 if st.session_state.mode == "dashboard":840 answer, meta = run_dashboard(st.session_state.company, fy_filter)841 842 elif st.session_state.mode == "compare":843 compare_cos = st.session_state.companies_compare or [st.session_state.company]844 if len(compare_cos) < 2:845 with st.chat_message("assistant", avatar="📈"):846 st.warning("Please select at least 2 companies from the Compare selector above.")847 answer, meta = "Please select at least 2 companies.", {}848 else:849 answer, meta = run_compare(query, compare_cos, fy_filter)850 851 else: # chat852 answer, meta = run_chat(query, st.session_state.company, fy_filter)853 854 except Exception as e:855 with st.chat_message("assistant", avatar="📈"):856 st.error(f"Something went wrong: {e}")857 answer, meta = str(e), {}858 859 # Save assistant message860 st.session_state.messages.append({"role": "assistant", "content": answer, "metadata": meta})861 save_message("assistant", answer, meta)862 st.rerun()863 