CoolFace
Apppublic

Harshavard21/FinRAG

sourceHugging Faceupdated 19d agoView on Hugging Face
2likes
main.py863 linesDownload Raw Back to app
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