"""
app/main.py — FinRAG | Production Financial Intelligence UI
================================================================
Single-page Streamlit app. No sub-pages.
Layout:
Sidebar : logo · new chat · conversation history · quick actions
Main : mode pills · chat thread · company context bar · chat input
"""
import sys, os, re, json
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
os.environ["PYTHONUTF8"] = "1"
from config.settings import settings
import streamlit as st
import plotly.graph_objects as go
# ── page config (MUST be first Streamlit call) ──────────────────────
st.set_page_config(
page_title="FinRAG",
page_icon="📈",
layout="wide",
initial_sidebar_state="expanded",
)
# ── imports ──────────────────────────────────────────────────────────
from app.rag_engine import (
load_embedder, load_retriever, load_reranker,
load_groq, load_comparator, load_metric_extractor,
)
from app.db.chat_store import (
new_conversation, list_conversations, get_messages,
add_message, update_conversation_title,
delete_conversation, group_conversations_by_date,
)
# ════════════════════════════════════════════════════════════════════
# CSS
# ════════════════════════════════════════════════════════════════════
st.markdown("""
""", unsafe_allow_html=True)
# ════════════════════════════════════════════════════════════════════
# Session State Init
# ════════════════════════════════════════════════════════════════════
COMPANIES = list(settings.company_ticker_map.keys())
def _init_state():
defaults = {
"conversation_id": None,
"messages": [], # list of dicts: role, content, metadata
"mode": "chat", # "chat" | "compare" | "dashboard"
"company": COMPANIES[0], # primary company
"companies_compare": [], # for compare mode
"fy": "FY2025",
"pending_suggestion": None,
}
for k, v in defaults.items():
if k not in st.session_state:
st.session_state[k] = v
_init_state()
# ════════════════════════════════════════════════════════════════════
# Helpers
# ════════════════════════════════════════════════════════════════════
def start_new_conversation():
cid = new_conversation(mode=st.session_state.mode)
st.session_state.conversation_id = cid
st.session_state.messages = []
def load_conversation(cid: str):
st.session_state.conversation_id = cid
st.session_state.messages = get_messages(cid)
def save_message(role: str, content: str, metadata: dict = None):
cid = st.session_state.conversation_id
if not cid:
cid = new_conversation()
st.session_state.conversation_id = cid
add_message(cid, role, content, metadata)
# Set title from first user message
if role == "user" and len(st.session_state.messages) == 0:
update_conversation_title(cid, content)
def parse_at_mentions(text: str) -> tuple[str, list[str]]:
"""Extract @COMPANY mentions; return (cleaned_text, [companies])."""
found = []
cleaned = text
for c in COMPANIES:
if f"@{c}" in text or f"@{c.lower()}" in text.lower():
found.append(c)
cleaned = re.sub(f"@{re.escape(c)}", c, cleaned, flags=re.IGNORECASE)
return cleaned.strip(), found
def detect_intent(query: str, mentioned: list[str]) -> str:
"""Auto-detect mode from query text."""
ql = query.lower()
if any(k in ql for k in ["dashboard", "kpi", "extract metrics", "show metrics", "key metrics"]):
return "dashboard"
if len(mentioned) > 1 or any(k in ql for k in ["compare", " vs ", "versus", "better than", "difference between"]):
return "compare"
return "chat"
def fmt_crore(v):
if v is None: return "N/A"
if v >= 100000: return f"₹{v/100000:.1f}L Cr"
return f"₹{v:,.0f} Cr"
def fmt_pct(v):
return f"{v:.2f}%" if v is not None else "N/A"
def fmt_inr(v):
return f"₹{v:.2f}" if v is not None else "N/A"
# ════════════════════════════════════════════════════════════════════
# Render helpers
# ════════════════════════════════════════════════════════════════════
def render_source_chips(sources: list[dict]):
if not sources:
return
chips_html = '
'
for s in sources:
cls = "chip table-chip" if s.get("is_table") else "chip"
chips_html += f'{s["label"]}'
chips_html += "
"
st.markdown(chips_html, unsafe_allow_html=True)
def render_kpi_cards(metrics: dict):
"""Render 2 rows of KPI cards from extracted metrics dict."""
rows = [
[
("Revenue", fmt_crore(metrics.get("revenue_crore")), metrics.get("revenue_crore") is not None),
("Net Profit", fmt_crore(metrics.get("net_profit_crore")), metrics.get("net_profit_crore") is not None),
("Net Margin", fmt_pct(metrics.get("net_profit_margin_pct")), metrics.get("net_profit_margin_pct") is not None),
("EBITDA Margin", fmt_pct(metrics.get("ebitda_margin_pct")), metrics.get("ebitda_margin_pct") is not None),
],
[
("Total Assets", fmt_crore(metrics.get("total_assets_crore")), metrics.get("total_assets_crore") is not None),
("Equity", fmt_crore(metrics.get("equity_crore")), metrics.get("equity_crore") is not None),
("EPS", fmt_inr(metrics.get("eps_inr")), metrics.get("eps_inr") is not None),
("ROE", fmt_pct(metrics.get("roe_pct")), metrics.get("roe_pct") is not None),
],
]
banking_row = None
if any(metrics.get(k) is not None for k in ["npa_gross_pct", "npa_net_pct", "nim_pct"]):
banking_row = [
("Gross NPA", fmt_pct(metrics.get("npa_gross_pct")), metrics.get("npa_gross_pct") is not None),
("Net NPA", fmt_pct(metrics.get("npa_net_pct")), metrics.get("npa_net_pct") is not None),
("NIM", fmt_pct(metrics.get("nim_pct")), metrics.get("nim_pct") is not None),
("EBITDA", fmt_crore(metrics.get("ebitda_crore")), metrics.get("ebitda_crore") is not None),
]
for row in ([*rows] + ([banking_row] if banking_row else [])):
cols = st.columns(4)
for col, (label, value, has_val) in zip(cols, row):
card_cls = "kpi-card has-value" if has_val else "kpi-card"
val_cls = "kpi-val" if has_val else "kpi-val na"
col.markdown(
f'',
unsafe_allow_html=True,
)
def render_kpi_chart(metrics: dict, company: str):
chart_map = {
"Revenue": metrics.get("revenue_crore"),
"Net Profit": metrics.get("net_profit_crore"),
"EBITDA": metrics.get("ebitda_crore"),
"Total Assets": metrics.get("total_assets_crore"),
"Equity": metrics.get("equity_crore"),
}
data = {k: v for k, v in chart_map.items() if v is not None}
if not data:
return
colors = ["#3b82f6", "#06b6d4", "#8b5cf6", "#10b981", "#f59e0b"]
fig = go.Figure(go.Bar(
y=list(data.keys()), x=list(data.values()), orientation="h",
marker=dict(color=colors[:len(data)], opacity=0.85),
text=[f"₹{v:,.0f} Cr" for v in data.values()],
textposition="outside", textfont=dict(color="#e2e8f0", size=11),
))
fig.update_layout(
paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(255,255,255,0.02)",
font=dict(color="#94a3b8", family="Inter"),
xaxis=dict(gridcolor="rgba(255,255,255,0.05)", tickfont=dict(color="#64748b")),
yaxis=dict(tickfont=dict(color="#e2e8f0", size=12)),
margin=dict(t=10, b=10, l=10, r=70), height=220,
title=dict(text=f"{company} — Financial Overview", font=dict(color="#94a3b8", size=12)),
)
st.plotly_chart(fig, use_container_width=True, config={"displayModeBar": False})
def render_compare_chart(company_data: dict, metric_label: str):
"""Bar chart for one metric across multiple companies."""
companies = list(company_data.keys())
values = list(company_data.values())
colors = ["#3b82f6", "#06b6d4", "#8b5cf6", "#10b981"]
fig = go.Figure(go.Bar(
x=companies, y=values,
marker_color=colors[:len(companies)],
text=[f"{v:,.1f}" if v else "N/A" for v in values],
textposition="outside", textfont=dict(color="#e2e8f0", size=12),
))
fig.update_layout(
paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(255,255,255,0.02)",
font=dict(color="#94a3b8", family="Inter"),
title=dict(text=metric_label, font=dict(color="#94a3b8", size=12)),
xaxis=dict(showgrid=False, tickfont=dict(color="#e2e8f0")),
yaxis=dict(gridcolor="rgba(255,255,255,0.05)", tickfont=dict(color="#64748b")),
margin=dict(t=40, b=10, l=10, r=10), height=260,
)
st.plotly_chart(fig, use_container_width=True, config={"displayModeBar": False})
def render_chat_history():
"""Render all messages in the current conversation."""
for msg in st.session_state.messages:
role = msg["role"]
content = msg["content"]
meta = msg.get("metadata") or {}
with st.chat_message(role, avatar="🧑" if role == "user" else "📈"):
st.markdown(content)
# KPI cards (dashboard mode results)
if meta.get("kpi_metrics"):
render_kpi_cards(meta["kpi_metrics"])
render_kpi_chart(meta["kpi_metrics"], meta.get("company", ""))
# Comparison chart
if meta.get("compare_chart"):
cc = meta["compare_chart"]
render_compare_chart(cc["data"], cc["label"])
# Sources
if meta.get("sources"):
with st.expander(f"Sources ({len(meta['sources'])})", expanded=False):
render_source_chips(meta["sources"])
# ════════════════════════════════════════════════════════════════════
# Pipeline runners
# ════════════════════════════════════════════════════════════════════
def run_chat(query: str, company: str, fy: str | None) -> tuple[str, dict]:
from src.generation.prompts import build_qa_prompt
retriever = load_retriever()
reranker = load_reranker()
llm = load_groq()
with st.spinner("Retrieving relevant passages…"):
candidates = retriever.retrieve(
query=query, top_k=50,
company_filter=company,
fiscal_year_filter=fy,
expand_query=True, promote_to_parent=True,
)
if not candidates:
return "No relevant documents found for this query.", {}
with st.spinner("Reranking results…"):
reranked = reranker.rerank(query=query, candidates=candidates, top_n=5)
system_prompt, user_msg = build_qa_prompt(
query=query, results=reranked,
company_context=f"{company} ({settings.company_ticker_map.get(company, '')})",
)
with st.chat_message("assistant", avatar="📈"):
answer = st.write_stream(llm.generate_stream(system_prompt, user_msg))
sources = [
{"label": f"{c.company} | {c.fiscal_year} | Pg.{c.page_number}",
"is_table": c.content_type == "table"}
for c in reranked
]
meta = {"sources": sources, "company": company}
with st.chat_message("assistant", avatar="📈"):
with st.expander(f"Sources ({len(sources)})", expanded=False):
render_source_chips(sources)
return answer, meta
def run_dashboard(company: str, fy: str | None) -> tuple[str, dict]:
retriever = load_retriever()
reranker = load_reranker()
extractor = load_metric_extractor()
with st.spinner(f"Retrieving financial data for {company}…"):
all_cands = []
for q in ["revenue net profit financial performance", "balance sheet assets equity"]:
all_cands.extend(retriever.retrieve(
query=q, top_k=20, company_filter=company,
fiscal_year_filter=fy, expand_query=False,
))
seen = set(); unique = []
for c in all_cands:
if c.chunk_id not in seen:
seen.add(c.chunk_id); unique.append(c)
reranked = reranker.rerank(
query="financial metrics revenue profit assets equity EPS",
candidates=unique, top_n=4,
)
with st.spinner("Extracting KPIs with AI…"):
data = extractor.extract(company, reranked)
metrics = data.get("metrics", {})
fy_label = data.get("fiscal_year", fy or "FY2025")
ticker = settings.company_ticker_map.get(company, company)
answer = f"**{company}** ({ticker}) — KPI Summary for **{fy_label}**"
with st.chat_message("assistant", avatar="📈"):
st.markdown(answer)
render_kpi_cards(metrics)
render_kpi_chart(metrics, company)
meta = {"kpi_metrics": metrics, "company": company, "fy": fy_label}
return answer, meta
def run_compare(query: str, companies: list[str], fy: str | None) -> tuple[str, dict]:
comparator = load_comparator()
extractor = load_metric_extractor()
with st.spinner(f"Comparing {' vs '.join(companies)}…"):
company_results, answer_gen = comparator.compare(
query=query, companies=companies, fiscal_year=fy, stream=True,
)
meta = {}
with st.chat_message("assistant", avatar="📈"):
answer = st.write_stream(answer_gen)
# Try to extract one plottable metric
try:
with st.spinner("Generating comparison chart…"):
mlist = extractor.extract_multi(company_results)
comp_table = extractor.to_comparison_table(mlist)
comp_table.pop("companies", None)
for metric_label, vals in comp_table.items():
numeric = {c: v for c, v in vals.items() if v is not None and isinstance(v, (int, float))}
if len(numeric) >= 2:
render_compare_chart(numeric, metric_label)
meta["compare_chart"] = {"data": numeric, "label": metric_label}
break
except Exception:
pass
sources = []
for company, results in company_results.items():
for r in results:
sources.append({"label": f"{company} Pg.{r.page_number}", "is_table": False})
meta["sources"] = sources
return answer, meta
# ════════════════════════════════════════════════════════════════════
# SIDEBAR
# ════════════════════════════════════════════════════════════════════
with st.sidebar:
# Brand
st.markdown("""
📈
FinRAG
Indian Markets Intelligence
""", unsafe_allow_html=True)
# New chat button
if st.button("+ New Chat", use_container_width=True,
type="secondary", key="new_chat_btn"):
start_new_conversation()
st.rerun()
st.markdown("
", unsafe_allow_html=True)
# Quick actions
st.markdown('Quick Actions
', unsafe_allow_html=True)
col_a, col_b = st.columns(2)
with col_a:
if st.button("⚖️ Compare", use_container_width=True, key="sb_compare"):
st.session_state.mode = "compare"
if not st.session_state.conversation_id:
start_new_conversation()
st.rerun()
with col_b:
if st.button("📊 Dashboard", use_container_width=True, key="sb_dashboard"):
st.session_state.mode = "dashboard"
if not st.session_state.conversation_id:
start_new_conversation()
st.rerun()
st.markdown("
", unsafe_allow_html=True)
# Conversation history
all_convs = list_conversations(40)
if all_convs:
grouped = group_conversations_by_date(all_convs)
current_cid = st.session_state.conversation_id
for group_label, convs in grouped.items():
st.markdown(f'{group_label}
', unsafe_allow_html=True)
for conv in convs:
is_active = conv["id"] == current_cid
active_cls = "history-item active" if is_active else "history-item"
mode_icon = {"chat": "💬", "compare": "⚖️", "dashboard": "📊"}.get(conv["mode"], "💬")
label = f"{mode_icon} {conv['title']}"
# Use a button styled as a list item
if st.button(label, key=f"hist_{conv['id']}", use_container_width=True,
help=conv["title"]):
load_conversation(conv["id"])
st.rerun()
else:
st.markdown('No history yet
',
unsafe_allow_html=True)
# Sidebar footer
st.markdown("
", unsafe_allow_html=True)
st.markdown(
''
f'17 companies · FY2024–25
'
'Hybrid RAG · BGE-large · Groq LPU'
'
', unsafe_allow_html=True,
)
# ════════════════════════════════════════════════════════════════════
# MAIN AREA
# ════════════════════════════════════════════════════════════════════
main = st.container()
with main:
# ── Mode pills (top bar) ────────────────────────────────────────
st.markdown('', unsafe_allow_html=True)
mode_col, spacer = st.columns([3, 7])
with mode_col:
mode_labels = {"chat": "💬 Chat", "compare": "⚖️ Compare", "dashboard": "📊 Dashboard"}
new_mode = st.radio(
"Mode", list(mode_labels.values()),
index=list(mode_labels.keys()).index(st.session_state.mode),
horizontal=True, label_visibility="collapsed", key="mode_radio",
)
selected_mode_key = list(mode_labels.keys())[list(mode_labels.values()).index(new_mode)]
if selected_mode_key != st.session_state.mode:
st.session_state.mode = selected_mode_key
st.rerun()
st.markdown("
", unsafe_allow_html=True)
# ── Context selectors ───────────────────────────────────────────
if st.session_state.mode == "compare":
ctx1, ctx2, ctx3 = st.columns([3, 1.5, 5])
with ctx1:
sel_companies = st.multiselect(
"Companies", COMPANIES,
default=st.session_state.companies_compare or ["TCS", "INFOSYS"],
max_selections=4, label_visibility="collapsed", key="cmp_sel",
)
st.session_state.companies_compare = sel_companies
with ctx2:
fy_sel = st.selectbox("FY", ["FY2025", "FY2024", "All"], index=0,
label_visibility="collapsed", key="cmp_fy")
elif st.session_state.mode == "dashboard":
ctx1, ctx2, ctx3 = st.columns([2.5, 1.5, 6])
with ctx1:
sel_company = st.selectbox("Company", COMPANIES, label_visibility="collapsed",
key="dash_company_sel")
st.session_state.company = sel_company
with ctx2:
fy_sel = st.selectbox("FY", ["FY2025", "FY2024"], index=0,
label_visibility="collapsed", key="dash_fy")
with ctx3:
if st.button("Extract KPIs →", type="primary", key="dash_extract_btn"):
fy_filter = None if fy_sel == "All" else fy_sel
if not st.session_state.conversation_id:
start_new_conversation()
user_text = f"Show KPI dashboard for {st.session_state.company} {fy_sel}"
st.session_state.messages.append({"role": "user", "content": user_text, "metadata": {}})
save_message("user", user_text)
answer, meta = run_dashboard(st.session_state.company, fy_filter)
st.session_state.messages.append({"role": "assistant", "content": answer, "metadata": meta})
save_message("assistant", answer, meta)
st.rerun()
else:
# Chat mode context bar
ctx1, ctx2, ctx3 = st.columns([2.5, 1.5, 6])
with ctx1:
sel_company = st.selectbox(
"Company", COMPANIES,
index=COMPANIES.index(st.session_state.company) if st.session_state.company in COMPANIES else 0,
label_visibility="collapsed", key="chat_company_sel",
)
st.session_state.company = sel_company
with ctx2:
fy_sel = st.selectbox("FY", ["FY2025", "FY2024", "All"],
index=0, label_visibility="collapsed", key="chat_fy_sel")
st.markdown("", unsafe_allow_html=True)
# ── Chat thread ─────────────────────────────────────────────────
if not st.session_state.messages:
# Welcome screen
st.markdown("""
📈
FinRAG
Ask anything about 17 major Indian companies.
Grounded in official BSE annual reports.
""", unsafe_allow_html=True)
# Suggestion cards
suggestions = [
("💰", "What was TCS's revenue and net profit for FY2025?"),
("🏦", "What is HDFC Bank's gross NPA ratio?"),
("⚖️", "Compare Infosys vs HCL Technologies on revenue growth"),
("📊", "Show KPI dashboard for Reliance Industries"),
]
cols = st.columns(2)
for i, (icon, text) in enumerate(suggestions):
with cols[i % 2]:
st.markdown(
f'{icon}{text}
',
unsafe_allow_html=True,
)
if st.button(text, key=f"sug_{i}", help=text,
use_container_width=True):
st.session_state.pending_suggestion = text
st.rerun()
else:
render_chat_history()
# ── Chat input ──────────────────────────────────────────────────
mode = st.session_state.mode
placeholders = {
"chat": "Ask anything… use @CompanyName to specify (e.g. @TCS what was the revenue?)",
"compare": "Compare selected companies… (e.g. Compare revenue and margins)",
"dashboard": "Dashboard mode — use Extract KPIs button above, or type a question",
}
# Handle pending suggestion (clicked welcome card)
prefill = st.session_state.pop("pending_suggestion", None) or ""
user_input = st.chat_input(
placeholder=placeholders.get(mode, "Ask anything…"),
key="main_chat_input",
)
# Use suggestion if no direct input
if not user_input and prefill:
user_input = prefill
if user_input:
raw_query = user_input.strip()
if not raw_query:
st.stop()
# Parse @ mentions
query, mentioned_companies = parse_at_mentions(raw_query)
# Override company from @ mention
if mentioned_companies:
st.session_state.company = mentioned_companies[0]
if len(mentioned_companies) > 1:
st.session_state.companies_compare = mentioned_companies
# Auto-detect mode
intent = detect_intent(query, mentioned_companies)
if intent != "chat":
st.session_state.mode = intent
# Ensure conversation exists
if not st.session_state.conversation_id:
start_new_conversation()
# Show user message
with st.chat_message("user", avatar="🧑"):
st.markdown(raw_query)
# Save user message
st.session_state.messages.append({"role": "user", "content": raw_query, "metadata": {}})
save_message("user", raw_query)
fy_filter = None if fy_sel == "All" else fy_sel
# Route to correct pipeline
try:
if st.session_state.mode == "dashboard":
answer, meta = run_dashboard(st.session_state.company, fy_filter)
elif st.session_state.mode == "compare":
compare_cos = st.session_state.companies_compare or [st.session_state.company]
if len(compare_cos) < 2:
with st.chat_message("assistant", avatar="📈"):
st.warning("Please select at least 2 companies from the Compare selector above.")
answer, meta = "Please select at least 2 companies.", {}
else:
answer, meta = run_compare(query, compare_cos, fy_filter)
else: # chat
answer, meta = run_chat(query, st.session_state.company, fy_filter)
except Exception as e:
with st.chat_message("assistant", avatar="📈"):
st.error(f"Something went wrong: {e}")
answer, meta = str(e), {}
# Save assistant message
st.session_state.messages.append({"role": "assistant", "content": answer, "metadata": meta})
save_message("assistant", answer, meta)
st.rerun()