Spaces:
Sleeping
Sleeping
Upload 10 files
Browse files- .gitattributes +1 -0
- DEPLOY.md +77 -0
- README.md +30 -13
- app.py +300 -0
- infosys-ar-25.pdf +3 -0
- phase1_ingestion.py +200 -0
- phase2_retrieval.py +326 -0
- phase3_agent.py +572 -0
- phase4_tools.py +363 -0
- requirements.txt +35 -2
- table_store.json +454 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
infosys-ar-25.pdf filter=lfs diff=lfs merge=lfs -text
|
DEPLOY.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Deploying FinAgent to Hugging Face Spaces (from zero)
|
| 2 |
+
|
| 3 |
+
Everything in **this folder** is what gets uploaded. Nothing else from the
|
| 4 |
+
project is needed. Total size ~71 MB.
|
| 5 |
+
|
| 6 |
+
Files included:
|
| 7 |
+
- `app.py` — Streamlit entry point (HF runs this automatically)
|
| 8 |
+
- `phase1_ingestion.py`, `phase2_retrieval.py`, `phase3_agent.py`, `phase4_tools.py` — pipeline
|
| 9 |
+
- `requirements.txt` — Python deps (HF installs these)
|
| 10 |
+
- `infosys-ar-25.pdf` — source document (BM25 reads it at startup)
|
| 11 |
+
- `table_store.json` — pre-extracted financial tables
|
| 12 |
+
- `finance_db/` — **pre-built ChromaDB vector store** (so the app does NOT re-index on boot)
|
| 13 |
+
- `README.md` — contains the HF Space config header
|
| 14 |
+
- `.gitignore`, `.env.example`
|
| 15 |
+
|
| 16 |
+
---
|
| 17 |
+
|
| 18 |
+
## Step 0 — Create a Hugging Face account (you have none)
|
| 19 |
+
1. Go to https://huggingface.co/join
|
| 20 |
+
2. Sign up (email + password, or Google/GitHub). Verify your email.
|
| 21 |
+
|
| 22 |
+
## Step 1 — Get a free Groq API key
|
| 23 |
+
1. Go to https://console.groq.com/keys → sign in → **Create API Key** → copy it.
|
| 24 |
+
(Use a FRESH key — do not reuse one that was ever pasted into code/chat.)
|
| 25 |
+
|
| 26 |
+
## Step 2 — Create the Space
|
| 27 |
+
1. Go to https://huggingface.co/new-space
|
| 28 |
+
2. **Owner:** you · **Space name:** e.g. `finagent`
|
| 29 |
+
3. **License:** MIT (optional) · **SDK:** select **Streamlit**
|
| 30 |
+
4. **Hardware:** **CPU basic — FREE** (2 vCPU, 16 GB RAM — enough)
|
| 31 |
+
5. **Visibility:** Public (reviewers can open it) or Private (share access manually)
|
| 32 |
+
6. Click **Create Space**.
|
| 33 |
+
|
| 34 |
+
## Step 3 — Add your Groq key as a secret (NOT in code)
|
| 35 |
+
1. Open your Space → **Settings** → **Variables and secrets** → **New secret**
|
| 36 |
+
2. **Name:** `GROQ_API_KEY` · **Value:** your key → **Save**
|
| 37 |
+
|
| 38 |
+
## Step 4 — Upload the files
|
| 39 |
+
### Option A — Browser (simplest, no tools)
|
| 40 |
+
1. Space → **Files** tab → **Add file → Upload files**
|
| 41 |
+
2. Drag in **all files from this folder**, INCLUDING the `finance_db/` folder
|
| 42 |
+
and the PDF. (The browser uploader handles the 54 MB sqlite fine.)
|
| 43 |
+
3. Commit. The Space auto-builds.
|
| 44 |
+
|
| 45 |
+
### Option B — Git (recommended for the large `finance_db/`)
|
| 46 |
+
```bash
|
| 47 |
+
# install git-lfs once: https://git-lfs.com → then: git lfs install
|
| 48 |
+
git clone https://huggingface.co/spaces/<your-username>/finagent
|
| 49 |
+
cd finagent
|
| 50 |
+
# copy everything from this folder into the clone:
|
| 51 |
+
cp -r /Users/sumitsaurabh/Downloads/RAG/huggingface_deploy/. .
|
| 52 |
+
# track large binaries with LFS so the push succeeds
|
| 53 |
+
git lfs track "*.sqlite3" "*.bin" "*.pdf"
|
| 54 |
+
git add .gitattributes .
|
| 55 |
+
git commit -m "Deploy FinAgent"
|
| 56 |
+
git push
|
| 57 |
+
```
|
| 58 |
+
> If git asks for a password, use a **HF access token** (Settings → Access Tokens,
|
| 59 |
+
> role: write) as the password, not your account password.
|
| 60 |
+
|
| 61 |
+
## Step 5 — Watch it build
|
| 62 |
+
- Space page shows **Building** → **Running** (first build ~3–5 min: installs torch,
|
| 63 |
+
then first request downloads ~1 GB of models, ~1–2 min).
|
| 64 |
+
- Your public URL: `https://huggingface.co/spaces/<your-username>/finagent`
|
| 65 |
+
|
| 66 |
+
## Step 6 — Test
|
| 67 |
+
Open the URL, click an example question, hit **Ask FinAgent**. An answer with page
|
| 68 |
+
citations should appear. Share the URL with your reviewer.
|
| 69 |
+
|
| 70 |
+
---
|
| 71 |
+
|
| 72 |
+
## Notes & safety
|
| 73 |
+
- **Key safety:** it lives only in the Space secret, never in the repo. On the Groq
|
| 74 |
+
free tier (no card) the worst case is an exhausted daily quota — no money risk.
|
| 75 |
+
- **Protect your quota (optional):** keep the Space private, or add a password gate.
|
| 76 |
+
- **Sleeping:** free Spaces sleep after inactivity and wake on the next visit
|
| 77 |
+
(~30–60 s cold start). Fine for a demo.
|
README.md
CHANGED
|
@@ -1,19 +1,36 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
-
sdk:
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
- streamlit
|
| 10 |
pinned: false
|
| 11 |
-
|
| 12 |
---
|
| 13 |
|
| 14 |
-
#
|
| 15 |
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: FinAgent Infosys AR QA
|
| 3 |
+
emoji: 📊
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: streamlit
|
| 7 |
+
sdk_version: 1.58.0
|
| 8 |
+
app_file: app.py
|
|
|
|
| 9 |
pinned: false
|
| 10 |
+
license: mit
|
| 11 |
---
|
| 12 |
|
| 13 |
+
# 📊 FinAgent — Infosys Annual Report Q&A
|
| 14 |
|
| 15 |
+
An agentic **Retrieval-Augmented Generation** app over the Infosys Integrated
|
| 16 |
+
Annual Report 2024-25. Hybrid retrieval (BM25 + dense + HyDE) → cross-encoder
|
| 17 |
+
reranking → LangGraph multi-agent orchestration → FinBERT sentiment → inline
|
| 18 |
+
Self-RAG faithfulness check, served with Streamlit.
|
| 19 |
|
| 20 |
+
## Setup on Hugging Face Spaces
|
| 21 |
+
|
| 22 |
+
This Space needs **one secret** to answer questions:
|
| 23 |
+
|
| 24 |
+
- **`GROQ_API_KEY`** — add it under **Settings → Variables and secrets → New secret**.
|
| 25 |
+
Get a free key at https://console.groq.com/keys
|
| 26 |
+
|
| 27 |
+
The UI and models load without it; only the LLM answers require it.
|
| 28 |
+
|
| 29 |
+
## Stack
|
| 30 |
+
|
| 31 |
+
Groq `llama-3.3-70b-versatile` · LangGraph · LangChain · ChromaDB (pre-built,
|
| 32 |
+
shipped in `finance_db/`) · HuggingFace embeddings (`all-mpnet-base-v2`) ·
|
| 33 |
+
cross-encoder (`ms-marco-MiniLM-L-6-v2`) · FinBERT.
|
| 34 |
+
|
| 35 |
+
> First boot downloads ~1 GB of models and takes ~1–2 min. CPU basic (free,
|
| 36 |
+
> 16 GB RAM) is sufficient.
|
app.py
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FinAgent — Infosys AR 2024-25 | Streamlit Q&A Interface
|
| 3 |
+
|
| 4 |
+
Run:
|
| 5 |
+
streamlit run app.py
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import json
|
| 9 |
+
import re
|
| 10 |
+
import time
|
| 11 |
+
|
| 12 |
+
import streamlit as st
|
| 13 |
+
|
| 14 |
+
# ── Page config (must be first Streamlit call) ────────────────────────────────
|
| 15 |
+
st.set_page_config(
|
| 16 |
+
page_title="FinAgent · Infosys AR Q&A",
|
| 17 |
+
page_icon="📊",
|
| 18 |
+
layout="wide",
|
| 19 |
+
initial_sidebar_state="expanded",
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
# ── Custom CSS ────────────────────────────────────────────────────────────────
|
| 23 |
+
st.markdown(
|
| 24 |
+
"""
|
| 25 |
+
<style>
|
| 26 |
+
.main-title { font-size:2.2rem; font-weight:700; color:#1a3c5e; margin-bottom:0; }
|
| 27 |
+
.sub-title { font-size:1rem; color:#5a7fa6; margin-top:0; margin-bottom:1.5rem; }
|
| 28 |
+
.metric-card { background:#f0f6ff; border-radius:10px; padding:12px 18px;
|
| 29 |
+
border-left:4px solid #1a3c5e; margin-bottom:8px; }
|
| 30 |
+
.badge { display:inline-block; padding:2px 10px; border-radius:12px;
|
| 31 |
+
font-size:0.78rem; font-weight:600; margin-right:6px; }
|
| 32 |
+
.badge-blue { background:#dbeafe; color:#1e40af; }
|
| 33 |
+
.badge-green { background:#dcfce7; color:#166534; }
|
| 34 |
+
.badge-amber { background:#fef9c3; color:#854d0e; }
|
| 35 |
+
.badge-red { background:#fee2e2; color:#991b1b; }
|
| 36 |
+
.cite-chip { display:inline-block; background:#e0e7ff; color:#3730a3;
|
| 37 |
+
border-radius:6px; padding:1px 8px; font-size:0.8rem;
|
| 38 |
+
margin:2px; font-weight:500; }
|
| 39 |
+
.context-box { background:#fafafa; border:1px solid #e5e7eb; border-radius:8px;
|
| 40 |
+
padding:10px 14px; margin:6px 0; font-size:0.85rem; }
|
| 41 |
+
.answer-box { background:#f8faff; border-left:4px solid #3b82f6;
|
| 42 |
+
padding:16px 20px; border-radius:8px; }
|
| 43 |
+
</style>
|
| 44 |
+
""",
|
| 45 |
+
unsafe_allow_html=True,
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
# ── Helper: colour-coded intent badge ────────────────────────────────────────
|
| 50 |
+
INTENT_BADGE = {
|
| 51 |
+
"financial_metrics": ("📈", "badge-blue", "Financial Metrics"),
|
| 52 |
+
"compliance_check": ("⚖️", "badge-amber", "Compliance Check"),
|
| 53 |
+
"investment_alert": ("🔔", "badge-green", "Investment Alert"),
|
| 54 |
+
"analyst_review": ("📅", "badge-blue", "Analyst Review"),
|
| 55 |
+
"rag_query": ("🔍", "badge-blue", "General Query"),
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
def intent_badge(intent: str) -> str:
|
| 59 |
+
icon, cls, label = INTENT_BADGE.get(
|
| 60 |
+
intent, ("❓", "badge-blue", intent or "Unknown")
|
| 61 |
+
)
|
| 62 |
+
return f'<span class="badge {cls}">{icon} {label}</span>'
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def score_colour(score: float) -> str:
|
| 66 |
+
if score >= 0.8:
|
| 67 |
+
return "badge-green"
|
| 68 |
+
if score >= 0.5:
|
| 69 |
+
return "badge-amber"
|
| 70 |
+
return "badge-red"
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
# ── Cached resource: heavy models loaded once per session ─────────────────────
|
| 74 |
+
@st.cache_resource(show_spinner=False)
|
| 75 |
+
def load_pipeline():
|
| 76 |
+
"""
|
| 77 |
+
Load Phase 4 LangGraph pipeline.
|
| 78 |
+
Cached by Streamlit so models are only loaded once.
|
| 79 |
+
"""
|
| 80 |
+
from phase4_tools import build_phase4_components, build_phase4_graph
|
| 81 |
+
components = build_phase4_components()
|
| 82 |
+
app = build_phase4_graph(components)
|
| 83 |
+
return components, app
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def run_query(query: str, components: dict, app) -> dict:
|
| 87 |
+
from phase3_agent import make_initial_state
|
| 88 |
+
state = make_initial_state(query)
|
| 89 |
+
result = app.invoke(state)
|
| 90 |
+
return result
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def _parse_retry_seconds(error_msg: str) -> str:
|
| 94 |
+
"""Extract human-readable wait time from a Groq rate-limit error message."""
|
| 95 |
+
match = re.search(r"try again in ([\d]+m[\d.]+s|[\d.]+s|[\d]+m)", str(error_msg))
|
| 96 |
+
return match.group(1) if match else "a few minutes"
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
# ── Sidebar ───────────────────────────────────────────────────────────────────
|
| 100 |
+
with st.sidebar:
|
| 101 |
+
st.markdown("## 📊 FinAgent")
|
| 102 |
+
st.markdown("**Document:** Infosys Integrated Annual Report 2024-25")
|
| 103 |
+
st.divider()
|
| 104 |
+
|
| 105 |
+
st.markdown("### Pipeline")
|
| 106 |
+
st.markdown(
|
| 107 |
+
"- 🔍 **Hybrid retrieval** — BM25 + Dense (HyDE)\n"
|
| 108 |
+
"- 🏆 **Cross-encoder reranking** — top-4 chunks\n"
|
| 109 |
+
"- 🤖 **LangGraph agents** — intent-routed\n"
|
| 110 |
+
"- 💹 **FinBERT** — domain sentiment\n"
|
| 111 |
+
"- ♻️ **Self-RAG** — inline faithfulness check"
|
| 112 |
+
)
|
| 113 |
+
st.divider()
|
| 114 |
+
|
| 115 |
+
st.markdown("### Agent Intents")
|
| 116 |
+
for icon, label, desc in [
|
| 117 |
+
("📈", "Financial Metrics", "Revenue, margins, FCF, RoE"),
|
| 118 |
+
("⚖️", "Compliance Check", "SEBI, risk factors, ESG"),
|
| 119 |
+
("🔔", "Investment Alert", "Signals with FinBERT sentiment"),
|
| 120 |
+
("📅", "Analyst Review", "Schedule review meetings"),
|
| 121 |
+
("🔍", "General Query", "Free-form document Q&A"),
|
| 122 |
+
]:
|
| 123 |
+
st.markdown(f"**{icon} {label}** — {desc}")
|
| 124 |
+
|
| 125 |
+
st.divider()
|
| 126 |
+
st.caption("Powered by Groq · LangGraph · ChromaDB · HuggingFace")
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
# ── Main header ───────────────────────────────────────────────────────────────
|
| 130 |
+
st.markdown('<p class="main-title">📊 FinAgent</p>', unsafe_allow_html=True)
|
| 131 |
+
st.markdown(
|
| 132 |
+
'<p class="sub-title">Ask anything about the <b>Infosys Annual Report 2024-25</b></p>',
|
| 133 |
+
unsafe_allow_html=True,
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
# ── Example queries ───────────────────────────────────────────────────────────
|
| 137 |
+
st.markdown("##### Try an example")
|
| 138 |
+
example_cols = st.columns(3)
|
| 139 |
+
example_queries = [
|
| 140 |
+
"What was Infosys revenue and operating margin in FY25?",
|
| 141 |
+
"Are there any SEBI compliance issues mentioned in the annual report?",
|
| 142 |
+
"Generate an investment alert for Infosys based on FY25 performance.",
|
| 143 |
+
"What is Infosys AI strategy and Topaz platform?",
|
| 144 |
+
"What were the major risk factors disclosed by Infosys?",
|
| 145 |
+
"Schedule an analyst review for Infosys credit assessment.",
|
| 146 |
+
]
|
| 147 |
+
|
| 148 |
+
# The query lives in session_state so example clicks persist across reruns
|
| 149 |
+
# (otherwise clicking an example then "Ask" submits an empty box).
|
| 150 |
+
if "query_text" not in st.session_state:
|
| 151 |
+
st.session_state.query_text = ""
|
| 152 |
+
|
| 153 |
+
for i, col in enumerate(example_cols):
|
| 154 |
+
with col:
|
| 155 |
+
if st.button(f"💬 {example_queries[i*2][:45]}…", key=f"ex_{i*2}", use_container_width=True):
|
| 156 |
+
st.session_state.query_text = example_queries[i * 2]
|
| 157 |
+
if st.button(f"💬 {example_queries[i*2+1][:45]}…", key=f"ex_{i*2+1}", use_container_width=True):
|
| 158 |
+
st.session_state.query_text = example_queries[i * 2 + 1]
|
| 159 |
+
|
| 160 |
+
st.divider()
|
| 161 |
+
|
| 162 |
+
# ── Query input ───────────────────────────────────────────────────────────────
|
| 163 |
+
# Bound to session_state via key — the widget owns the value, so example
|
| 164 |
+
# clicks above pre-fill it and the text survives the "Ask" rerun.
|
| 165 |
+
query = st.text_area(
|
| 166 |
+
"Enter your question",
|
| 167 |
+
key="query_text",
|
| 168 |
+
height=80,
|
| 169 |
+
placeholder="e.g. What was Infosys revenue in FY25?",
|
| 170 |
+
label_visibility="collapsed",
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
col_btn, col_clear = st.columns([1, 6])
|
| 174 |
+
with col_btn:
|
| 175 |
+
submit = st.button("🔍 Ask FinAgent", type="primary", use_container_width=True)
|
| 176 |
+
with col_clear:
|
| 177 |
+
st.empty()
|
| 178 |
+
|
| 179 |
+
# ── Pipeline loader ───────────────────────────────────────────────────────────
|
| 180 |
+
if "pipeline_ready" not in st.session_state:
|
| 181 |
+
st.session_state.pipeline_ready = False
|
| 182 |
+
|
| 183 |
+
if not st.session_state.pipeline_ready:
|
| 184 |
+
with st.status("⚙️ Loading models (first run only — takes ~30s)…", expanded=True) as status:
|
| 185 |
+
st.write("Loading embedding model (all-mpnet-base-v2)…")
|
| 186 |
+
st.write("Connecting to ChromaDB vector store…")
|
| 187 |
+
st.write("Loading FinBERT sentiment model…")
|
| 188 |
+
components, app = load_pipeline()
|
| 189 |
+
st.session_state.pipeline_ready = True
|
| 190 |
+
status.update(label="✅ Pipeline ready", state="complete", expanded=False)
|
| 191 |
+
else:
|
| 192 |
+
components, app = load_pipeline()
|
| 193 |
+
|
| 194 |
+
# ── Run query ─────────────────────────────────────────────────────────────────
|
| 195 |
+
if submit and query.strip():
|
| 196 |
+
with st.spinner("🤔 Retrieving and reasoning…"):
|
| 197 |
+
t0 = time.time()
|
| 198 |
+
try:
|
| 199 |
+
result = run_query(query.strip(), components, app)
|
| 200 |
+
except Exception as e:
|
| 201 |
+
err_str = str(e)
|
| 202 |
+
if "rate_limit_exceeded" in err_str or "429" in err_str:
|
| 203 |
+
wait = _parse_retry_seconds(err_str)
|
| 204 |
+
st.error(
|
| 205 |
+
f"⏳ **Groq daily token limit reached.**\n\n"
|
| 206 |
+
f"Your free-tier quota (100,000 tokens/day) is exhausted. "
|
| 207 |
+
f"Please try again in **{wait}** — the limit resets at midnight UTC.\n\n"
|
| 208 |
+
f"**To avoid this:** upgrade to Groq Dev Tier at "
|
| 209 |
+
f"https://console.groq.com/settings/billing"
|
| 210 |
+
)
|
| 211 |
+
else:
|
| 212 |
+
st.error(f"❌ Unexpected error: {err_str}")
|
| 213 |
+
st.stop()
|
| 214 |
+
elapsed = round(time.time() - t0, 1)
|
| 215 |
+
|
| 216 |
+
# ── Extract fields ────────────────────────────────────────────────────────
|
| 217 |
+
answer = result.get("final_answer", "No answer generated.")
|
| 218 |
+
intent = result.get("intent", "")
|
| 219 |
+
faith_score = result.get("faithfulness_score", 0.0) or 0.0
|
| 220 |
+
citations = result.get("citations", [])
|
| 221 |
+
doc_dicts = result.get("retrieved_docs", [])
|
| 222 |
+
tool_name = result.get("tool_name", "")
|
| 223 |
+
tool_output = result.get("tool_output", {})
|
| 224 |
+
iteration = result.get("iteration_count", 0)
|
| 225 |
+
|
| 226 |
+
# ── Top metrics row ───────────────────────────────────────────────────────
|
| 227 |
+
m1, m2, m3, m4 = st.columns(4)
|
| 228 |
+
m1.metric("🎯 Intent", intent.replace("_", " ").title() if intent else "—")
|
| 229 |
+
m2.metric("⚡ Latency", f"{elapsed}s")
|
| 230 |
+
m3.metric("✅ Faithfulness", f"{faith_score:.0%}")
|
| 231 |
+
m4.metric("🔄 Self-RAG iters", f"{iteration + 1}")
|
| 232 |
+
|
| 233 |
+
st.divider()
|
| 234 |
+
|
| 235 |
+
# ── Answer ────────────────────────────────────────────────────────────────
|
| 236 |
+
st.markdown("### 💡 Answer")
|
| 237 |
+
st.markdown(
|
| 238 |
+
f'<div class="answer-box">{answer}</div>',
|
| 239 |
+
unsafe_allow_html=True,
|
| 240 |
+
)
|
| 241 |
+
|
| 242 |
+
# ── Citations ─────────────────────────────────────────────────────────────
|
| 243 |
+
if citations:
|
| 244 |
+
chips = " ".join(
|
| 245 |
+
f'<span class="cite-chip">📄 Page {p}</span>'
|
| 246 |
+
for p in sorted(set(citations))
|
| 247 |
+
if p is not None
|
| 248 |
+
)
|
| 249 |
+
st.markdown(f"**Citations:** {chips}", unsafe_allow_html=True)
|
| 250 |
+
|
| 251 |
+
st.divider()
|
| 252 |
+
|
| 253 |
+
# ── Retrieved context ─────────────────────────────────────────────────────
|
| 254 |
+
if doc_dicts:
|
| 255 |
+
with st.expander(f"📚 Retrieved context ({len(doc_dicts)} chunks)", expanded=False):
|
| 256 |
+
for i, doc in enumerate(doc_dicts, 1):
|
| 257 |
+
page = doc.get("metadata", {}).get("page", "?")
|
| 258 |
+
text = doc.get("page_content", "").strip()
|
| 259 |
+
st.markdown(
|
| 260 |
+
f'<div class="context-box">'
|
| 261 |
+
f'<b>Chunk {i} — Page {page}</b><br>{text[:600]}'
|
| 262 |
+
f'{"…" if len(text) > 600 else ""}'
|
| 263 |
+
f"</div>",
|
| 264 |
+
unsafe_allow_html=True,
|
| 265 |
+
)
|
| 266 |
+
|
| 267 |
+
# ── Tool output ───────────────────────────────────────────────────────────
|
| 268 |
+
if tool_output and tool_name:
|
| 269 |
+
with st.expander(f"🔧 Tool output — `{tool_name}`", expanded=False):
|
| 270 |
+
st.json(tool_output)
|
| 271 |
+
|
| 272 |
+
# ── FinBERT sentiment (if investment alert) ───────────────────────────────
|
| 273 |
+
finbert = None
|
| 274 |
+
if isinstance(tool_output, dict):
|
| 275 |
+
finbert = tool_output.get("finbert_sentiment")
|
| 276 |
+
if finbert:
|
| 277 |
+
with st.expander("💹 FinBERT Sentiment Breakdown", expanded=False):
|
| 278 |
+
label = finbert.get("label", "neutral").capitalize()
|
| 279 |
+
score = finbert.get("score", 0.0)
|
| 280 |
+
scores = finbert.get("all_scores", {})
|
| 281 |
+
|
| 282 |
+
fcol1, fcol2 = st.columns([1, 2])
|
| 283 |
+
with fcol1:
|
| 284 |
+
colour = {"Positive": "🟢", "Negative": "🔴", "Neutral": "🟡"}.get(label, "⚪")
|
| 285 |
+
st.metric(f"{colour} Dominant sentiment", f"{label} ({score:.0%})")
|
| 286 |
+
with fcol2:
|
| 287 |
+
if scores:
|
| 288 |
+
for sent, val in scores.items():
|
| 289 |
+
st.progress(val, text=f"{sent.capitalize()}: {val:.1%}")
|
| 290 |
+
|
| 291 |
+
elif submit and not query.strip():
|
| 292 |
+
st.warning("Please enter a question before clicking Ask.")
|
| 293 |
+
|
| 294 |
+
# ── Footer ────────────────────────────────────────────────────────────────────
|
| 295 |
+
st.divider()
|
| 296 |
+
st.caption(
|
| 297 |
+
"FinAgent · RAG + LangGraph + FinBERT · "
|
| 298 |
+
"Groq llama-3.3-70b-versatile · ChromaDB 3,607 chunks · "
|
| 299 |
+
"Infosys AR 2024-25"
|
| 300 |
+
)
|
infosys-ar-25.pdf
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:a2b9acdfbe79586ffdbe1780c4f14e5b2630743a4c81ee626f812f4c2fff1b44
|
| 3 |
+
size 7010611
|
phase1_ingestion.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Phase 1 — Document Ingestion Pipeline
|
| 3 |
+
Loads the Infosys AR PDF, extracts tables, tags entities, builds
|
| 4 |
+
Parent-Child ChromaDB retriever, and runs a smoke-test retrieval.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import json
|
| 8 |
+
import os
|
| 9 |
+
import time
|
| 10 |
+
|
| 11 |
+
import pandas as pd
|
| 12 |
+
import pdfplumber
|
| 13 |
+
import spacy
|
| 14 |
+
from dotenv import load_dotenv
|
| 15 |
+
from langchain_community.document_loaders import PyPDFLoader
|
| 16 |
+
from langchain_community.vectorstores import Chroma
|
| 17 |
+
from langchain_core.stores import InMemoryStore
|
| 18 |
+
from langchain_experimental.text_splitter import SemanticChunker
|
| 19 |
+
from langchain_groq import ChatGroq
|
| 20 |
+
from langchain_huggingface import HuggingFaceEmbeddings
|
| 21 |
+
from langchain_text_splitters import RecursiveCharacterTextSplitter, TextSplitter
|
| 22 |
+
from langchain_classic.retrievers import ParentDocumentRetriever
|
| 23 |
+
|
| 24 |
+
load_dotenv()
|
| 25 |
+
|
| 26 |
+
# ── Paths ────────────────────────────────────────────────────────────────────
|
| 27 |
+
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 28 |
+
PDF_PATH = os.path.join(BASE_DIR, "infosys-ar-25.pdf")
|
| 29 |
+
TABLE_STORE_PATH = os.path.join(BASE_DIR, "table_store.json")
|
| 30 |
+
CHROMA_DIR = os.path.join(BASE_DIR, "finance_db")
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
# ── Step 1: LLM connection check ─────────────────────────────────────────────
|
| 34 |
+
def build_llm(verify: bool = False):
|
| 35 |
+
llm = ChatGroq(
|
| 36 |
+
model="llama-3.3-70b-versatile",
|
| 37 |
+
temperature=0,
|
| 38 |
+
api_key=os.environ["GROQ_API_KEY"],
|
| 39 |
+
)
|
| 40 |
+
# Only ping Groq when explicitly asked (standalone phase scripts).
|
| 41 |
+
# The app skips this to avoid burning free-tier tokens on every launch.
|
| 42 |
+
if verify:
|
| 43 |
+
response = llm.invoke("Say exactly: Groq connected successfully")
|
| 44 |
+
print(response.content)
|
| 45 |
+
return llm
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
# ── Step 2: Embeddings ───────────────────────────────────────────────────────
|
| 49 |
+
def build_embeddings():
|
| 50 |
+
print("Loading embedding model... (first load downloads ~420MB, wait for it)")
|
| 51 |
+
embeddings = HuggingFaceEmbeddings(
|
| 52 |
+
model_name="sentence-transformers/all-mpnet-base-v2",
|
| 53 |
+
model_kwargs={"device": "cpu"},
|
| 54 |
+
encode_kwargs={"normalize_embeddings": True},
|
| 55 |
+
)
|
| 56 |
+
test_vector = embeddings.embed_query("What is Infosys revenue?")
|
| 57 |
+
print(f"Embeddings working. Vector length: {len(test_vector)}")
|
| 58 |
+
print(f"First 5 values: {test_vector[:5]}")
|
| 59 |
+
return embeddings
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
# ── Step 3: Load PDF ─────────────────────────────────────────────────────────
|
| 63 |
+
def load_pdf(pdf_path):
|
| 64 |
+
loader = PyPDFLoader(pdf_path)
|
| 65 |
+
pages = loader.load()
|
| 66 |
+
print(f"Total pages loaded: {len(pages)}")
|
| 67 |
+
print(f"\nPage 1 metadata: {pages[0].metadata}")
|
| 68 |
+
print(f"\nPage 1 preview:\n{pages[0].page_content[:500]}")
|
| 69 |
+
return pages
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
# ── Step 4: Table extraction ─────────────────────────────────────────────────
|
| 73 |
+
def extract_tables_from_pdf(pdf_path):
|
| 74 |
+
extracted_tables = []
|
| 75 |
+
with pdfplumber.open(pdf_path) as pdf:
|
| 76 |
+
for page_num, page in enumerate(pdf.pages, start=1):
|
| 77 |
+
for table_idx, table in enumerate(page.extract_tables()):
|
| 78 |
+
if not table or len(table) < 2:
|
| 79 |
+
continue
|
| 80 |
+
try:
|
| 81 |
+
df = pd.DataFrame(table[1:], columns=table[0]).fillna("")
|
| 82 |
+
table_text = f"Table on page {page_num}:\n" + df.to_string(
|
| 83 |
+
index=False
|
| 84 |
+
)
|
| 85 |
+
extracted_tables.append(
|
| 86 |
+
{
|
| 87 |
+
"page": page_num,
|
| 88 |
+
"table_index": table_idx,
|
| 89 |
+
"source_file": pdf_path.split("/")[-1],
|
| 90 |
+
"text_representation": table_text,
|
| 91 |
+
"headers": [str(c) for c in df.columns],
|
| 92 |
+
}
|
| 93 |
+
)
|
| 94 |
+
except Exception as e:
|
| 95 |
+
print(
|
| 96 |
+
f"[TABLE EXTRACTION] Skipped malformed table on page {page_num}: {e}"
|
| 97 |
+
)
|
| 98 |
+
print(f"[INGESTION] Extracted {len(extracted_tables)} tables")
|
| 99 |
+
return extracted_tables
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
# ── Step 5: spaCy NER entity tagging ─────────────────────────────────────────
|
| 103 |
+
def tag_financial_entities(documents):
|
| 104 |
+
nlp = spacy.load("en_core_web_sm")
|
| 105 |
+
for doc in documents:
|
| 106 |
+
ents = nlp(doc.page_content).ents
|
| 107 |
+
doc.metadata["entities"] = json.dumps({
|
| 108 |
+
"organizations": list(set(e.text for e in ents if e.label_ == "ORG")),
|
| 109 |
+
"monetary_values": list(set(e.text for e in ents if e.label_ == "MONEY")),
|
| 110 |
+
"percentages": list(set(e.text for e in ents if e.label_ == "PERCENT")),
|
| 111 |
+
"dates": list(set(e.text for e in ents if e.label_ == "DATE")),
|
| 112 |
+
})
|
| 113 |
+
return documents
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
# ── Step 6: Parent-Child Retriever ───────────────────────────────────────────
|
| 117 |
+
class SemanticParentSplitter(TextSplitter):
|
| 118 |
+
"""Wraps SemanticChunker to satisfy ParentDocumentRetriever's TextSplitter type check."""
|
| 119 |
+
|
| 120 |
+
def __init__(self, semantic_chunker, **kwargs):
|
| 121 |
+
super().__init__(**kwargs)
|
| 122 |
+
self._chunker = semantic_chunker
|
| 123 |
+
|
| 124 |
+
def split_text(self, text):
|
| 125 |
+
return self._chunker.split_text(text)
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def build_retriever(embeddings, chroma_dir):
|
| 129 |
+
parent_splitter = SemanticParentSplitter(
|
| 130 |
+
SemanticChunker(
|
| 131 |
+
embeddings,
|
| 132 |
+
breakpoint_threshold_type="percentile",
|
| 133 |
+
breakpoint_threshold_amount=95,
|
| 134 |
+
)
|
| 135 |
+
)
|
| 136 |
+
child_splitter = RecursiveCharacterTextSplitter(chunk_size=400, chunk_overlap=50)
|
| 137 |
+
|
| 138 |
+
vectorstore = Chroma(
|
| 139 |
+
collection_name="child_chunks",
|
| 140 |
+
embedding_function=embeddings,
|
| 141 |
+
persist_directory=chroma_dir,
|
| 142 |
+
)
|
| 143 |
+
store = InMemoryStore()
|
| 144 |
+
|
| 145 |
+
retriever = ParentDocumentRetriever(
|
| 146 |
+
vectorstore=vectorstore,
|
| 147 |
+
docstore=store,
|
| 148 |
+
child_splitter=child_splitter,
|
| 149 |
+
parent_splitter=parent_splitter,
|
| 150 |
+
search_kwargs={"k": 5},
|
| 151 |
+
)
|
| 152 |
+
print("Retriever built successfully")
|
| 153 |
+
return retriever, store
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
# ── Step 7: Smoke-test indexing & retrieval ──────────────────────────────────
|
| 157 |
+
def run_smoke_test(retriever, store, tagged_pages):
|
| 158 |
+
test_subset = tagged_pages[20:40]
|
| 159 |
+
print(f"\n[TEST] Indexing {len(test_subset)} pages...")
|
| 160 |
+
|
| 161 |
+
t0 = time.time()
|
| 162 |
+
retriever.add_documents(test_subset)
|
| 163 |
+
print(f"[TEST] Done in {time.time() - t0:.1f}s")
|
| 164 |
+
print(f"[TEST] Parent docs stored: {len(list(store.yield_keys()))}")
|
| 165 |
+
|
| 166 |
+
results = retriever.invoke("What are the key risk factors?")
|
| 167 |
+
print(f"\nRetrieved {len(results)} parent chunks")
|
| 168 |
+
for r in results[:2]:
|
| 169 |
+
print(f"--- Page {r.metadata.get('page')} | len={len(r.page_content)} ---")
|
| 170 |
+
print(r.page_content[:300], "\n")
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
# ── Main ─────────────────────────────────────────────────────────────────────
|
| 174 |
+
if __name__ == "__main__":
|
| 175 |
+
# 1. LLM
|
| 176 |
+
llm = build_llm(verify=True)
|
| 177 |
+
|
| 178 |
+
# 2. Embeddings
|
| 179 |
+
embeddings = build_embeddings()
|
| 180 |
+
|
| 181 |
+
# 3. Load PDF
|
| 182 |
+
assert os.path.exists(PDF_PATH), f"PDF not found at {PDF_PATH}"
|
| 183 |
+
pages = load_pdf(PDF_PATH)
|
| 184 |
+
|
| 185 |
+
# 4. Tables
|
| 186 |
+
tables = extract_tables_from_pdf(PDF_PATH)
|
| 187 |
+
with open(TABLE_STORE_PATH, "w") as f:
|
| 188 |
+
json.dump(tables, f, indent=2)
|
| 189 |
+
print(f"Saved {len(tables)} tables to {TABLE_STORE_PATH}")
|
| 190 |
+
|
| 191 |
+
# 5. NER tagging
|
| 192 |
+
tagged_pages = tag_financial_entities(pages)
|
| 193 |
+
print(f"Tagged {len(tagged_pages)} pages")
|
| 194 |
+
print(f"Sample (page 5) entities: {tagged_pages[4].metadata['entities']}")
|
| 195 |
+
|
| 196 |
+
# 6. Retriever
|
| 197 |
+
retriever, store = build_retriever(embeddings, CHROMA_DIR)
|
| 198 |
+
|
| 199 |
+
# 7. Smoke test
|
| 200 |
+
run_smoke_test(retriever, store, tagged_pages)
|
phase2_retrieval.py
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Phase 2 — Advanced Retrieval Pipeline
|
| 3 |
+
|
| 4 |
+
Steps:
|
| 5 |
+
1. Full ingestion — all 369 pages into Chroma (skipped if already done)
|
| 6 |
+
2. BM25 retriever — keyword search over full pages
|
| 7 |
+
3. Hybrid retrieval — BM25 (40%) + Dense (60%) via EnsembleRetriever
|
| 8 |
+
4. Query decomposition — break complex queries into sub-questions
|
| 9 |
+
5. HyDE — generate hypothetical answer, use it for dense retrieval
|
| 10 |
+
6. Cross-encoder reranking — accurate relevance scoring, keep top 4
|
| 11 |
+
7. Contextual compression — strip irrelevant sentences via LLM
|
| 12 |
+
|
| 13 |
+
Public API for Phase 3:
|
| 14 |
+
advanced_retrieval_pipeline(query, bm25_retriever, dense_retriever,
|
| 15 |
+
cross_encoder, llm) -> list[Document]
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
import json
|
| 19 |
+
import os
|
| 20 |
+
import time
|
| 21 |
+
|
| 22 |
+
from dotenv import load_dotenv
|
| 23 |
+
from langchain_classic.retrievers import ContextualCompressionRetriever, EnsembleRetriever
|
| 24 |
+
from langchain_classic.retrievers.document_compressors import (
|
| 25 |
+
EmbeddingsFilter,
|
| 26 |
+
LLMChainExtractor,
|
| 27 |
+
)
|
| 28 |
+
from langchain_community.retrievers import BM25Retriever
|
| 29 |
+
from langchain_community.vectorstores import Chroma
|
| 30 |
+
from langchain_core.documents import Document
|
| 31 |
+
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
| 32 |
+
from sentence_transformers import CrossEncoder
|
| 33 |
+
|
| 34 |
+
from phase1_ingestion import (
|
| 35 |
+
PDF_PATH,
|
| 36 |
+
CHROMA_DIR,
|
| 37 |
+
SemanticParentSplitter,
|
| 38 |
+
build_embeddings,
|
| 39 |
+
build_llm,
|
| 40 |
+
load_pdf,
|
| 41 |
+
tag_financial_entities,
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
load_dotenv()
|
| 45 |
+
|
| 46 |
+
PHASE2_COLLECTION = "child_chunks_full"
|
| 47 |
+
CROSS_ENCODER_MODEL = "cross-encoder/ms-marco-MiniLM-L-6-v2"
|
| 48 |
+
FULL_INDEX_THRESHOLD = 1000 # min chunks to consider vectorstore fully indexed
|
| 49 |
+
CHILD_CHUNK_SIZE = 400
|
| 50 |
+
CHILD_CHUNK_OVERLAP = 50
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
# ── Step 1: Full ingestion ────────────────────────────────────────────────────
|
| 54 |
+
def load_or_build_vectorstore(embeddings, tagged_pages):
|
| 55 |
+
"""
|
| 56 |
+
Load persisted Chroma vectorstore or build it fresh from all pages.
|
| 57 |
+
Uses a dedicated Phase 2 collection so Phase 1's smoke-test data is untouched.
|
| 58 |
+
"""
|
| 59 |
+
child_splitter = RecursiveCharacterTextSplitter(
|
| 60 |
+
chunk_size=CHILD_CHUNK_SIZE, chunk_overlap=CHILD_CHUNK_OVERLAP
|
| 61 |
+
)
|
| 62 |
+
vectorstore = Chroma(
|
| 63 |
+
collection_name=PHASE2_COLLECTION,
|
| 64 |
+
embedding_function=embeddings,
|
| 65 |
+
persist_directory=CHROMA_DIR,
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
count = vectorstore._collection.count()
|
| 69 |
+
if count >= FULL_INDEX_THRESHOLD:
|
| 70 |
+
print(f"[INGESTION] Vectorstore already has {count} chunks — skipping re-index")
|
| 71 |
+
return vectorstore
|
| 72 |
+
|
| 73 |
+
print(f"[INGESTION] Indexing all {len(tagged_pages)} pages into '{PHASE2_COLLECTION}'...")
|
| 74 |
+
all_chunks = child_splitter.split_documents(tagged_pages)
|
| 75 |
+
print(f"[INGESTION] {len(all_chunks)} child chunks to embed")
|
| 76 |
+
|
| 77 |
+
t0 = time.time()
|
| 78 |
+
batch_size = 100
|
| 79 |
+
for i in range(0, len(all_chunks), batch_size):
|
| 80 |
+
batch = all_chunks[i : i + batch_size]
|
| 81 |
+
vectorstore.add_documents(batch)
|
| 82 |
+
done = min(i + batch_size, len(all_chunks))
|
| 83 |
+
print(f" [{done}/{len(all_chunks)}]", end="\r")
|
| 84 |
+
|
| 85 |
+
print(f"\n[INGESTION] Done in {time.time() - t0:.1f}s — "
|
| 86 |
+
f"{vectorstore._collection.count()} chunks indexed")
|
| 87 |
+
return vectorstore
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
# ── Step 2: BM25 retriever ────────────────────────────────────────────────────
|
| 91 |
+
def build_bm25_retriever(tagged_pages, k: int = 8) -> BM25Retriever:
|
| 92 |
+
bm25 = BM25Retriever.from_documents(tagged_pages)
|
| 93 |
+
bm25.k = k
|
| 94 |
+
print(f"[BM25] Built over {len(tagged_pages)} pages (k={k})")
|
| 95 |
+
return bm25
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
# ── Step 3: Dense retriever ───────────────────────────────────────────────────
|
| 99 |
+
def build_dense_retriever(vectorstore, k: int = 8):
|
| 100 |
+
retriever = vectorstore.as_retriever(search_kwargs={"k": k})
|
| 101 |
+
print(f"[DENSE] Chroma retriever ready (k={k})")
|
| 102 |
+
return retriever
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
# ── Step 4: Hybrid EnsembleRetriever ─────────────────────────────────────────
|
| 106 |
+
def build_hybrid_retriever(bm25_retriever, dense_retriever) -> EnsembleRetriever:
|
| 107 |
+
hybrid = EnsembleRetriever(
|
| 108 |
+
retrievers=[bm25_retriever, dense_retriever],
|
| 109 |
+
weights=[0.4, 0.6],
|
| 110 |
+
)
|
| 111 |
+
print("[HYBRID] EnsembleRetriever: BM25 40% + Dense 60%")
|
| 112 |
+
return hybrid
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
# ── Step 5: Query decomposition ───────────────────────────────────────────────
|
| 116 |
+
_DECOMP_PROMPT = """You are a financial research assistant.
|
| 117 |
+
Break this question into 2-4 simpler sub-questions that together cover the full answer.
|
| 118 |
+
Return ONLY a valid JSON array of strings with no extra text.
|
| 119 |
+
|
| 120 |
+
Question: {query}
|
| 121 |
+
Sub-questions:"""
|
| 122 |
+
|
| 123 |
+
# Signals that a query genuinely covers multiple distinct sub-questions.
|
| 124 |
+
_MULTI_INTENT_MARKERS = (
|
| 125 |
+
"compare", "comparison", "versus", " vs ", "difference between",
|
| 126 |
+
"as well as", "and also", "both", "list all", "breakdown",
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
def is_complex_query(query: str) -> bool:
|
| 130 |
+
"""
|
| 131 |
+
Token-free heuristic deciding whether decomposition is worth an LLM call.
|
| 132 |
+
Decomposition HELPS multi-part questions but HURTS simple factual ones
|
| 133 |
+
(baseline showed 'operating margin?' decomposed into 'what is the formula…',
|
| 134 |
+
pulling irrelevant docs). Default: treat as simple unless clearly multi-intent.
|
| 135 |
+
"""
|
| 136 |
+
q = query.lower()
|
| 137 |
+
if query.count("?") > 1:
|
| 138 |
+
return True
|
| 139 |
+
if any(m in q for m in _MULTI_INTENT_MARKERS):
|
| 140 |
+
return True
|
| 141 |
+
# "X and Y" where both look like separate asks (long query with a conjunction)
|
| 142 |
+
if " and " in q and len(query.split()) > 18:
|
| 143 |
+
return True
|
| 144 |
+
return False
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def decompose_query(query: str, llm) -> list[str]:
|
| 148 |
+
# Skip the LLM call entirely for simple factual queries (saves tokens +
|
| 149 |
+
# latency and avoids candidate dilution).
|
| 150 |
+
if not is_complex_query(query):
|
| 151 |
+
print("[DECOMP] Simple query — skipping decomposition")
|
| 152 |
+
return [query]
|
| 153 |
+
try:
|
| 154 |
+
resp = llm.invoke(_DECOMP_PROMPT.format(query=query))
|
| 155 |
+
sub_queries = json.loads(resp.content)
|
| 156 |
+
if isinstance(sub_queries, list) and all(isinstance(q, str) for q in sub_queries):
|
| 157 |
+
print(f"[DECOMP] {len(sub_queries)} sub-queries: {sub_queries}")
|
| 158 |
+
return sub_queries
|
| 159 |
+
except Exception:
|
| 160 |
+
pass
|
| 161 |
+
print("[DECOMP] Fallback — using original query")
|
| 162 |
+
return [query]
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
# ── Step 6: HyDE ─────────────────────────────────────────────────────────────
|
| 166 |
+
_HYDE_PROMPT = """You are a financial analyst writing an annual report excerpt.
|
| 167 |
+
Write 2-3 sentences from a financial document that directly answers this question.
|
| 168 |
+
Write ONLY the document text, no preamble.
|
| 169 |
+
|
| 170 |
+
Question: {query}
|
| 171 |
+
Excerpt:"""
|
| 172 |
+
|
| 173 |
+
def generate_hyde(query: str, llm) -> str:
|
| 174 |
+
# Degrade gracefully: if the LLM is unavailable (e.g. rate limit), fall back
|
| 175 |
+
# to the raw query so dense retrieval still runs instead of crashing.
|
| 176 |
+
try:
|
| 177 |
+
return llm.invoke(_HYDE_PROMPT.format(query=query)).content
|
| 178 |
+
except Exception:
|
| 179 |
+
print("[HyDE] LLM unavailable — falling back to raw query")
|
| 180 |
+
return query
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
# ── Step 7: Cross-encoder reranking ──────────────────────────────────────────
|
| 184 |
+
def build_cross_encoder() -> CrossEncoder:
|
| 185 |
+
model = CrossEncoder(CROSS_ENCODER_MODEL)
|
| 186 |
+
print(f"[RERANKER] Loaded {CROSS_ENCODER_MODEL}")
|
| 187 |
+
return model
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def rerank(query: str, docs: list[Document], cross_encoder: CrossEncoder,
|
| 191 |
+
top_k: int = 4, min_keep: int = 1, rel_margin: float = 4.0) -> list[Document]:
|
| 192 |
+
"""
|
| 193 |
+
Rerank with the cross-encoder, then DROP docs much weaker than the best one
|
| 194 |
+
instead of always padding to top_k. Baseline kept 3/4 irrelevant docs that
|
| 195 |
+
diluted the LLM context; relative-margin filtering removes them while always
|
| 196 |
+
keeping at least `min_keep` so an answer can still be grounded.
|
| 197 |
+
"""
|
| 198 |
+
if not docs:
|
| 199 |
+
return docs
|
| 200 |
+
pairs = [(query, doc.page_content) for doc in docs]
|
| 201 |
+
scores = cross_encoder.predict(pairs)
|
| 202 |
+
ranked = sorted(zip(scores, docs), key=lambda x: x[0], reverse=True)
|
| 203 |
+
|
| 204 |
+
best = float(ranked[0][0])
|
| 205 |
+
kept = [doc for score, doc in ranked[:top_k] if float(score) >= best - rel_margin]
|
| 206 |
+
if len(kept) < min_keep:
|
| 207 |
+
kept = [doc for _, doc in ranked[:min_keep]]
|
| 208 |
+
print(f"[RERANKER] {len(docs)} candidates → {len(kept)} kept "
|
| 209 |
+
f"(best={best:.2f}, margin={rel_margin})")
|
| 210 |
+
return kept
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
# ── Step 8: Contextual compression ───────────────────────────────────────────
|
| 214 |
+
def compress_docs(query: str, docs: list[Document], llm,
|
| 215 |
+
embeddings=None) -> list[Document]:
|
| 216 |
+
"""
|
| 217 |
+
Token-free contextual compression.
|
| 218 |
+
|
| 219 |
+
The original implementation ran an LLMChainExtractor per doc — up to 4 extra
|
| 220 |
+
LLM calls per query, a major driver of the token exhaustion + latency seen in
|
| 221 |
+
the baseline, while leaving the doc set essentially unchanged. The cross-encoder
|
| 222 |
+
rerank already selects the relevant docs, so when embeddings are available we
|
| 223 |
+
do a cheap EmbeddingsFilter (no LLM); otherwise we pass the docs through.
|
| 224 |
+
"""
|
| 225 |
+
if not docs:
|
| 226 |
+
return docs
|
| 227 |
+
if embeddings is not None:
|
| 228 |
+
try:
|
| 229 |
+
efilter = EmbeddingsFilter(embeddings=embeddings, similarity_threshold=0.20)
|
| 230 |
+
result = list(efilter.compress_documents(docs, query))
|
| 231 |
+
kept = result if result else docs
|
| 232 |
+
print(f"[COMPRESS] EmbeddingsFilter {len(docs)} → {len(kept)} (no LLM)")
|
| 233 |
+
return kept
|
| 234 |
+
except Exception:
|
| 235 |
+
pass
|
| 236 |
+
print(f"[COMPRESS] pass-through {len(docs)} docs (no LLM)")
|
| 237 |
+
return docs
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
# ── Full pipeline (imported by Phase 3) ──────────────────────────────────────
|
| 241 |
+
def advanced_retrieval_pipeline(
|
| 242 |
+
query: str,
|
| 243 |
+
bm25_retriever: BM25Retriever,
|
| 244 |
+
dense_retriever,
|
| 245 |
+
cross_encoder: CrossEncoder,
|
| 246 |
+
llm,
|
| 247 |
+
embeddings=None,
|
| 248 |
+
top_k: int = 4,
|
| 249 |
+
) -> list[Document]:
|
| 250 |
+
"""
|
| 251 |
+
Complete 5-step retrieval pipeline.
|
| 252 |
+
|
| 253 |
+
BM25 searches by keyword on the original sub-query.
|
| 254 |
+
Dense retriever searches by meaning on the HyDE-generated hypothetical answer.
|
| 255 |
+
This separation is intentional: BM25 catches exact financial terms (e.g. 'EBITDA'),
|
| 256 |
+
dense catches semantically related content the keywords might miss.
|
| 257 |
+
"""
|
| 258 |
+
# 1. Decompose complex query into sub-questions
|
| 259 |
+
sub_queries = decompose_query(query, llm)
|
| 260 |
+
|
| 261 |
+
# 2. For each sub-query: HyDE → separate BM25 + dense retrieval → merge
|
| 262 |
+
seen: set[str] = set()
|
| 263 |
+
all_docs: list[Document] = []
|
| 264 |
+
|
| 265 |
+
for sub_q in sub_queries:
|
| 266 |
+
hyde_text = generate_hyde(sub_q, llm)
|
| 267 |
+
|
| 268 |
+
# BM25 uses the original sub-query (keyword precision)
|
| 269 |
+
bm25_docs = bm25_retriever.invoke(sub_q)
|
| 270 |
+
# Dense uses HyDE text (document-space semantic matching)
|
| 271 |
+
dense_docs = dense_retriever.invoke(hyde_text)
|
| 272 |
+
|
| 273 |
+
for doc in bm25_docs + dense_docs:
|
| 274 |
+
if doc.page_content not in seen:
|
| 275 |
+
seen.add(doc.page_content)
|
| 276 |
+
all_docs.append(doc)
|
| 277 |
+
|
| 278 |
+
print(f"[PIPELINE] {len(all_docs)} unique candidates from {len(sub_queries)} sub-queries")
|
| 279 |
+
|
| 280 |
+
# 3. Cross-encoder reranking
|
| 281 |
+
top_docs = rerank(query, all_docs, cross_encoder, top_k=top_k)
|
| 282 |
+
|
| 283 |
+
# 4. Contextual compression
|
| 284 |
+
final_docs = compress_docs(query, top_docs, llm, embeddings)
|
| 285 |
+
|
| 286 |
+
return final_docs
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
# ── Smoke test ────────────────────────────────────────────────────────────────
|
| 290 |
+
def run_pipeline_test(bm25_retriever, dense_retriever, cross_encoder, llm, embeddings):
|
| 291 |
+
test_queries = [
|
| 292 |
+
"What was Infosys revenue and EBITDA margin in FY25?",
|
| 293 |
+
"Compare the risk factors and compliance issues mentioned in the annual report",
|
| 294 |
+
]
|
| 295 |
+
|
| 296 |
+
for query in test_queries:
|
| 297 |
+
print(f"\n{'='*65}")
|
| 298 |
+
print(f"QUERY: {query}")
|
| 299 |
+
print("=" * 65)
|
| 300 |
+
docs = advanced_retrieval_pipeline(
|
| 301 |
+
query, bm25_retriever, dense_retriever, cross_encoder, llm, embeddings
|
| 302 |
+
)
|
| 303 |
+
for i, doc in enumerate(docs, 1):
|
| 304 |
+
page = doc.metadata.get("page", "?")
|
| 305 |
+
print(f"\n[Doc {i} | Page {page}]\n{doc.page_content[:400]}")
|
| 306 |
+
print()
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
# ── Main ─────────────────────────────────────────────────────────────────────
|
| 310 |
+
if __name__ == "__main__":
|
| 311 |
+
# Re-use Phase 1 setup
|
| 312 |
+
llm = build_llm(verify=True)
|
| 313 |
+
embeddings = build_embeddings()
|
| 314 |
+
pages = load_pdf(PDF_PATH)
|
| 315 |
+
tagged_pages = tag_financial_entities(pages)
|
| 316 |
+
|
| 317 |
+
# Full ingestion (skipped if already done)
|
| 318 |
+
vectorstore = load_or_build_vectorstore(embeddings, tagged_pages)
|
| 319 |
+
|
| 320 |
+
# Build retrievers
|
| 321 |
+
bm25_retriever = build_bm25_retriever(tagged_pages)
|
| 322 |
+
dense_retriever = build_dense_retriever(vectorstore)
|
| 323 |
+
cross_encoder = build_cross_encoder()
|
| 324 |
+
|
| 325 |
+
# Run test
|
| 326 |
+
run_pipeline_test(bm25_retriever, dense_retriever, cross_encoder, llm, embeddings)
|
phase3_agent.py
ADDED
|
@@ -0,0 +1,572 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Phase 3 — LangGraph Multi-Agent Orchestration
|
| 3 |
+
|
| 4 |
+
Graph flow:
|
| 5 |
+
orchestrator
|
| 6 |
+
│ (route_by_intent)
|
| 7 |
+
├──► rag_agent
|
| 8 |
+
├──► compliance_agent
|
| 9 |
+
├──► alert_agent
|
| 10 |
+
└──► finance_calculator
|
| 11 |
+
│
|
| 12 |
+
tool_executor
|
| 13 |
+
│
|
| 14 |
+
self_rag_evaluator
|
| 15 |
+
│ (route_self_rag)
|
| 16 |
+
├──► rag_agent (if faithfulness < 0.7 and retries < 2)
|
| 17 |
+
└──► END
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
import datetime
|
| 21 |
+
import json
|
| 22 |
+
import os
|
| 23 |
+
from typing import Optional, TypedDict
|
| 24 |
+
|
| 25 |
+
from dotenv import load_dotenv
|
| 26 |
+
from langgraph.graph import END, StateGraph
|
| 27 |
+
from langchain_core.documents import Document
|
| 28 |
+
|
| 29 |
+
from phase1_ingestion import (
|
| 30 |
+
CHROMA_DIR,
|
| 31 |
+
PDF_PATH,
|
| 32 |
+
TABLE_STORE_PATH,
|
| 33 |
+
build_embeddings,
|
| 34 |
+
build_llm,
|
| 35 |
+
load_pdf,
|
| 36 |
+
tag_financial_entities,
|
| 37 |
+
)
|
| 38 |
+
from phase2_retrieval import (
|
| 39 |
+
advanced_retrieval_pipeline,
|
| 40 |
+
build_bm25_retriever,
|
| 41 |
+
build_cross_encoder,
|
| 42 |
+
build_dense_retriever,
|
| 43 |
+
load_or_build_vectorstore,
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
load_dotenv()
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
# ── AgentState ────────────────────────────────────────────────────────────────
|
| 50 |
+
class AgentState(TypedDict):
|
| 51 |
+
query: str
|
| 52 |
+
intent: str
|
| 53 |
+
reasoning: str
|
| 54 |
+
retrieved_docs: list # list of {"page_content": str, "metadata": dict}
|
| 55 |
+
final_answer: str
|
| 56 |
+
citations: list # list of page numbers
|
| 57 |
+
tool_name: Optional[str]
|
| 58 |
+
tool_input: Optional[dict]
|
| 59 |
+
tool_output: Optional[dict]
|
| 60 |
+
faithfulness_score: float
|
| 61 |
+
should_rerun: bool
|
| 62 |
+
iteration_count: int # counts Self-RAG retry loops (max 2)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def make_initial_state(query: str) -> AgentState:
|
| 66 |
+
return {
|
| 67 |
+
"query": query,
|
| 68 |
+
"intent": "",
|
| 69 |
+
"reasoning": "",
|
| 70 |
+
"retrieved_docs": [],
|
| 71 |
+
"final_answer": "",
|
| 72 |
+
"citations": [],
|
| 73 |
+
"tool_name": None,
|
| 74 |
+
"tool_input": None,
|
| 75 |
+
"tool_output": None,
|
| 76 |
+
"faithfulness_score": 0.0,
|
| 77 |
+
"should_rerun": False,
|
| 78 |
+
"iteration_count": 0,
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
# ── Helpers ───────────────────────────────────────────────────────────────────
|
| 83 |
+
def docs_to_dicts(docs: list[Document]) -> list[dict]:
|
| 84 |
+
return [{"page_content": d.page_content, "metadata": d.metadata} for d in docs]
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def context_from_dicts(doc_dicts: list[dict]) -> str:
|
| 88 |
+
return "\n\n".join(
|
| 89 |
+
f"[Page {d['metadata'].get('page', '?')}]\n{d['page_content']}"
|
| 90 |
+
for d in doc_dicts
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def parse_json_from_response(text: str) -> dict:
|
| 95 |
+
"""Robustly extract the first JSON object from an LLM response."""
|
| 96 |
+
start = text.find("{")
|
| 97 |
+
end = text.rfind("}") + 1
|
| 98 |
+
if start == -1 or end == 0:
|
| 99 |
+
raise ValueError("No JSON object found in response")
|
| 100 |
+
return json.loads(text[start:end])
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
# ── Finance Tools (Phase 4 will enhance generate_investment_alert with FinBERT)
|
| 104 |
+
def extract_financial_metrics(tool_input: dict, llm) -> dict:
|
| 105 |
+
company = tool_input.get("company", "Infosys")
|
| 106 |
+
context = tool_input.get("context", "")
|
| 107 |
+
prompt = f"""Extract financial metrics from the context for {company}.
|
| 108 |
+
Return ONLY valid JSON:
|
| 109 |
+
{{
|
| 110 |
+
"company": "{company}",
|
| 111 |
+
"metrics": [
|
| 112 |
+
{{"name": "...", "value": "...", "unit": "...", "page": 0, "yoy_change": "..."}}
|
| 113 |
+
]
|
| 114 |
+
}}
|
| 115 |
+
|
| 116 |
+
Context:
|
| 117 |
+
{context[:3000]}"""
|
| 118 |
+
try:
|
| 119 |
+
return parse_json_from_response(llm.invoke(prompt).content)
|
| 120 |
+
except Exception as e:
|
| 121 |
+
return {"company": company, "metrics": [], "error": str(e)}
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def generate_risk_summary(tool_input: dict, llm) -> dict:
|
| 125 |
+
company = tool_input.get("company", "Infosys")
|
| 126 |
+
context = tool_input.get("context", "")
|
| 127 |
+
prompt = f"""Summarize risk factors from the context for {company}.
|
| 128 |
+
Return ONLY valid JSON:
|
| 129 |
+
{{
|
| 130 |
+
"company": "{company}",
|
| 131 |
+
"risks": [
|
| 132 |
+
{{"category": "market|credit|regulatory|operational", "description": "...", "severity": "low|medium|high", "page": 0, "mitigation": "..."}}
|
| 133 |
+
]
|
| 134 |
+
}}
|
| 135 |
+
|
| 136 |
+
Context:
|
| 137 |
+
{context[:3000]}"""
|
| 138 |
+
try:
|
| 139 |
+
return parse_json_from_response(llm.invoke(prompt).content)
|
| 140 |
+
except Exception as e:
|
| 141 |
+
return {"company": company, "risks": [], "error": str(e)}
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def flag_compliance_issue(tool_input: dict, llm) -> dict:
|
| 145 |
+
company = tool_input.get("company", "Infosys")
|
| 146 |
+
context = tool_input.get("context", "")
|
| 147 |
+
prompt = f"""Identify compliance and regulatory issues from the context for {company}.
|
| 148 |
+
Return ONLY valid JSON:
|
| 149 |
+
{{
|
| 150 |
+
"company": "{company}",
|
| 151 |
+
"compliance_flags": [
|
| 152 |
+
{{"regulation": "...", "issue": "...", "severity": "low|medium|high|critical", "page": 0, "recommended_action": "..."}}
|
| 153 |
+
]
|
| 154 |
+
}}
|
| 155 |
+
|
| 156 |
+
Context:
|
| 157 |
+
{context[:3000]}"""
|
| 158 |
+
try:
|
| 159 |
+
return parse_json_from_response(llm.invoke(prompt).content)
|
| 160 |
+
except Exception as e:
|
| 161 |
+
return {"company": company, "compliance_flags": [], "error": str(e)}
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def schedule_analyst_review(tool_input: dict, llm) -> dict:
|
| 165 |
+
company = tool_input.get("company", "Infosys")
|
| 166 |
+
priority = tool_input.get("priority", "normal")
|
| 167 |
+
context = tool_input.get("context", "")
|
| 168 |
+
prompt = f"""Based on the context about {company}, generate 3 agenda items for an analyst review meeting.
|
| 169 |
+
Return ONLY valid JSON: {{"agenda_items": ["item1", "item2", "item3"]}}
|
| 170 |
+
|
| 171 |
+
Context:
|
| 172 |
+
{context[:1500]}"""
|
| 173 |
+
try:
|
| 174 |
+
data = parse_json_from_response(llm.invoke(prompt).content)
|
| 175 |
+
agenda = data.get("agenda_items", [])
|
| 176 |
+
except Exception:
|
| 177 |
+
agenda = ["Review financial performance", "Assess risk exposure", "Compliance check"]
|
| 178 |
+
|
| 179 |
+
suggested_date = (datetime.date.today() + datetime.timedelta(days=3)).isoformat()
|
| 180 |
+
meeting_id = f"AR-{company[:3].upper()}-{datetime.date.today().strftime('%Y%m%d')}"
|
| 181 |
+
|
| 182 |
+
return {
|
| 183 |
+
"analyst_review_request": {
|
| 184 |
+
"company": company,
|
| 185 |
+
"review_type": "credit",
|
| 186 |
+
"priority": priority,
|
| 187 |
+
"suggested_date": suggested_date,
|
| 188 |
+
"duration_minutes": 60,
|
| 189 |
+
"agenda_items": agenda,
|
| 190 |
+
"status": "SCHEDULED",
|
| 191 |
+
"meeting_id": meeting_id,
|
| 192 |
+
}
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def generate_investment_alert(tool_input: dict, llm) -> dict:
|
| 197 |
+
# FinBERT sentiment will be injected here in Phase 4
|
| 198 |
+
company = tool_input.get("company", "Infosys")
|
| 199 |
+
context = tool_input.get("context", "")
|
| 200 |
+
prompt = f"""Based on the financial context for {company}, generate an investment alert.
|
| 201 |
+
Return ONLY valid JSON:
|
| 202 |
+
{{
|
| 203 |
+
"company": "{company}",
|
| 204 |
+
"signal": "buy|sell|hold|watch",
|
| 205 |
+
"trigger_reason": "...",
|
| 206 |
+
"confidence_score": 0.0,
|
| 207 |
+
"supporting_evidence": ["...", "..."],
|
| 208 |
+
"finbert_sentiment": {{"label": "positive|negative|neutral", "score": 0.0}}
|
| 209 |
+
}}
|
| 210 |
+
Note: finbert_sentiment is a placeholder — Phase 4 replaces with real FinBERT scores.
|
| 211 |
+
|
| 212 |
+
Context:
|
| 213 |
+
{context[:3000]}"""
|
| 214 |
+
try:
|
| 215 |
+
return parse_json_from_response(llm.invoke(prompt).content)
|
| 216 |
+
except Exception as e:
|
| 217 |
+
return {"company": company, "signal": "hold", "error": str(e)}
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
TOOL_REGISTRY = {
|
| 221 |
+
"extract_financial_metrics": extract_financial_metrics,
|
| 222 |
+
"generate_risk_summary": generate_risk_summary,
|
| 223 |
+
"flag_compliance_issue": flag_compliance_issue,
|
| 224 |
+
"schedule_analyst_review": schedule_analyst_review,
|
| 225 |
+
"generate_investment_alert": generate_investment_alert,
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
INTENT_TO_TOOL = {
|
| 229 |
+
"rag_query": "generate_risk_summary",
|
| 230 |
+
"compliance_check": "flag_compliance_issue",
|
| 231 |
+
"investment_alert": "generate_investment_alert",
|
| 232 |
+
"financial_metrics":"extract_financial_metrics",
|
| 233 |
+
"analyst_review": "schedule_analyst_review",
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
# ── Node builders (closures capture components) ───────────────────────────────
|
| 238 |
+
def build_agent_nodes(components: dict) -> dict:
|
| 239 |
+
llm = components["llm"]
|
| 240 |
+
bm25_retriever = components["bm25_retriever"]
|
| 241 |
+
dense_retriever= components["dense_retriever"]
|
| 242 |
+
cross_encoder = components["cross_encoder"]
|
| 243 |
+
tables = components["tables"]
|
| 244 |
+
|
| 245 |
+
# ── Orchestrator ──────────────────────────────────────────────────────────
|
| 246 |
+
def orchestrator_node(state: AgentState) -> dict:
|
| 247 |
+
prompt = """Classify the intent of this financial query into exactly one category.
|
| 248 |
+
|
| 249 |
+
Categories:
|
| 250 |
+
- rag_query : general information retrieval from the document
|
| 251 |
+
- compliance_check : identify regulatory/SEBI/RBI compliance issues
|
| 252 |
+
- investment_alert : generate buy/sell/hold investment signal
|
| 253 |
+
- financial_metrics : extract specific financial numbers, ratios, or metrics
|
| 254 |
+
- analyst_review : schedule or request an analyst review meeting
|
| 255 |
+
|
| 256 |
+
Return ONLY valid JSON: {"intent": "...", "reasoning": "..."}
|
| 257 |
+
|
| 258 |
+
Query: """ + state["query"]
|
| 259 |
+
|
| 260 |
+
try:
|
| 261 |
+
data = parse_json_from_response(llm.invoke(prompt).content)
|
| 262 |
+
intent = data.get("intent", "rag_query")
|
| 263 |
+
reasoning= data.get("reasoning", "")
|
| 264 |
+
except Exception:
|
| 265 |
+
intent, reasoning = "rag_query", "fallback"
|
| 266 |
+
|
| 267 |
+
print(f"\n[ORCHESTRATOR] intent={intent} | {reasoning[:80]}")
|
| 268 |
+
return {"intent": intent, "reasoning": reasoning}
|
| 269 |
+
|
| 270 |
+
# ── Shared retrieval helper ───────────────────────────────────────────────
|
| 271 |
+
def _retrieve(query: str) -> tuple[list[dict], list, str]:
|
| 272 |
+
"""Run Phase 2 pipeline, return (doc_dicts, citations, context_str)."""
|
| 273 |
+
docs = advanced_retrieval_pipeline(
|
| 274 |
+
query, bm25_retriever, dense_retriever, cross_encoder, llm
|
| 275 |
+
)
|
| 276 |
+
doc_dicts = docs_to_dicts(docs)
|
| 277 |
+
citations = list({d["metadata"].get("page") for d in doc_dicts})
|
| 278 |
+
context = context_from_dicts(doc_dicts)
|
| 279 |
+
return doc_dicts, citations, context
|
| 280 |
+
|
| 281 |
+
def _generate_answer(system_context: str, query: str, context: str) -> str:
|
| 282 |
+
prompt = f"""{system_context}
|
| 283 |
+
|
| 284 |
+
Retrieved context:
|
| 285 |
+
{context[:4000]}
|
| 286 |
+
|
| 287 |
+
Question: {query}
|
| 288 |
+
|
| 289 |
+
Answer with specific facts and page citations:"""
|
| 290 |
+
return llm.invoke(prompt).content
|
| 291 |
+
|
| 292 |
+
# ── RAG Agent ─────────────────────────────────────────────────────────────
|
| 293 |
+
def rag_agent_node(state: AgentState) -> dict:
|
| 294 |
+
print(f"[RAG AGENT] Retrieving for: {state['query'][:60]}")
|
| 295 |
+
doc_dicts, citations, context = _retrieve(state["query"])
|
| 296 |
+
|
| 297 |
+
system = ("You are a financial analyst. Answer only from the provided context. "
|
| 298 |
+
"Cite page numbers.")
|
| 299 |
+
answer = _generate_answer(system, state["query"], context)
|
| 300 |
+
|
| 301 |
+
tool_name = INTENT_TO_TOOL.get(state["intent"])
|
| 302 |
+
tool_input = {"company": "Infosys", "context": context} if tool_name else None
|
| 303 |
+
# Inject priority if analyst_review
|
| 304 |
+
if tool_name == "schedule_analyst_review":
|
| 305 |
+
tool_input["priority"] = (
|
| 306 |
+
"urgent" if "urgent" in state["query"].lower() else "normal"
|
| 307 |
+
)
|
| 308 |
+
|
| 309 |
+
print(f"[RAG AGENT] tool={tool_name} | {len(doc_dicts)} docs | "
|
| 310 |
+
f"citations={citations}")
|
| 311 |
+
return {
|
| 312 |
+
"retrieved_docs": doc_dicts,
|
| 313 |
+
"final_answer": answer,
|
| 314 |
+
"citations": citations,
|
| 315 |
+
"tool_name": tool_name,
|
| 316 |
+
"tool_input": tool_input,
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
# ── Compliance Agent ──────────────────────────────────────────────────────
|
| 320 |
+
def compliance_agent_node(state: AgentState) -> dict:
|
| 321 |
+
print(f"[COMPLIANCE AGENT] Checking: {state['query'][:60]}")
|
| 322 |
+
# Prepend compliance keywords to improve retrieval
|
| 323 |
+
retrieval_query = f"regulatory compliance SEBI RBI disclosure {state['query']}"
|
| 324 |
+
doc_dicts, citations, context = _retrieve(retrieval_query)
|
| 325 |
+
|
| 326 |
+
system = ("You are a compliance officer specialising in SEBI and RBI regulations. "
|
| 327 |
+
"Identify all compliance risks and regulatory obligations. "
|
| 328 |
+
"Cite the exact page and regulation.")
|
| 329 |
+
answer = _generate_answer(system, state["query"], context)
|
| 330 |
+
|
| 331 |
+
tool_input = {"company": "Infosys", "context": context}
|
| 332 |
+
print(f"[COMPLIANCE AGENT] {len(doc_dicts)} docs | citations={citations}")
|
| 333 |
+
return {
|
| 334 |
+
"retrieved_docs": doc_dicts,
|
| 335 |
+
"final_answer": answer,
|
| 336 |
+
"citations": citations,
|
| 337 |
+
"tool_name": "flag_compliance_issue",
|
| 338 |
+
"tool_input": tool_input,
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
# ── Alert Agent ───────────────────────────────────────────────────────────
|
| 342 |
+
def alert_agent_node(state: AgentState) -> dict:
|
| 343 |
+
print(f"[ALERT AGENT] Analysing: {state['query'][:60]}")
|
| 344 |
+
retrieval_query = (f"revenue profit growth performance outlook "
|
| 345 |
+
f"risk sentiment {state['query']}")
|
| 346 |
+
doc_dicts, citations, context = _retrieve(retrieval_query)
|
| 347 |
+
|
| 348 |
+
system = ("You are an investment analyst. Assess the company's financial health "
|
| 349 |
+
"and generate a clear investment signal with supporting evidence. "
|
| 350 |
+
"Note: FinBERT sentiment scoring will be added in Phase 4.")
|
| 351 |
+
answer = _generate_answer(system, state["query"], context)
|
| 352 |
+
|
| 353 |
+
tool_input = {"company": "Infosys", "context": context}
|
| 354 |
+
print(f"[ALERT AGENT] {len(doc_dicts)} docs | citations={citations}")
|
| 355 |
+
return {
|
| 356 |
+
"retrieved_docs": doc_dicts,
|
| 357 |
+
"final_answer": answer,
|
| 358 |
+
"citations": citations,
|
| 359 |
+
"tool_name": "generate_investment_alert",
|
| 360 |
+
"tool_input": tool_input,
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
# ── Finance Calculator Agent ──────────────────────────────────────────────
|
| 364 |
+
def finance_calculator_node(state: AgentState) -> dict:
|
| 365 |
+
print(f"[FINANCE CALC] Computing: {state['query'][:60]}")
|
| 366 |
+
doc_dicts, citations, context = _retrieve(state["query"])
|
| 367 |
+
|
| 368 |
+
# Augment context with matching table data
|
| 369 |
+
query_lower = state["query"].lower()
|
| 370 |
+
relevant_tables = [
|
| 371 |
+
t for t in tables
|
| 372 |
+
if any(w in t["text_representation"].lower()
|
| 373 |
+
for w in query_lower.split() if len(w) > 3)
|
| 374 |
+
][:3]
|
| 375 |
+
|
| 376 |
+
table_context = "\n\n".join(
|
| 377 |
+
t["text_representation"] for t in relevant_tables
|
| 378 |
+
)
|
| 379 |
+
full_context = (
|
| 380 |
+
f"FINANCIAL TABLES:\n{table_context}\n\nDOCUMENT EXCERPTS:\n{context}"
|
| 381 |
+
if table_context else context
|
| 382 |
+
)
|
| 383 |
+
|
| 384 |
+
system = ("You are a financial analyst specialising in quantitative analysis. "
|
| 385 |
+
"Extract precise numbers, ratios, and year-on-year changes. "
|
| 386 |
+
"Be exact — use figures directly from the tables and document.")
|
| 387 |
+
answer = _generate_answer(system, state["query"], full_context)
|
| 388 |
+
|
| 389 |
+
tool_input = {"company": "Infosys", "context": full_context}
|
| 390 |
+
print(f"[FINANCE CALC] {len(doc_dicts)} docs + {len(relevant_tables)} tables | "
|
| 391 |
+
f"citations={citations}")
|
| 392 |
+
return {
|
| 393 |
+
"retrieved_docs": doc_dicts,
|
| 394 |
+
"final_answer": answer,
|
| 395 |
+
"citations": citations,
|
| 396 |
+
"tool_name": "extract_financial_metrics",
|
| 397 |
+
"tool_input": tool_input,
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
# ── Tool Executor ─────────────────────────────────────────────────────────
|
| 401 |
+
def tool_executor_node(state: AgentState) -> dict:
|
| 402 |
+
tool_name = state.get("tool_name")
|
| 403 |
+
if not tool_name or tool_name not in TOOL_REGISTRY:
|
| 404 |
+
print("[TOOL EXECUTOR] No tool — passing answer through")
|
| 405 |
+
return {"tool_output": {"answer": state["final_answer"]}}
|
| 406 |
+
|
| 407 |
+
print(f"[TOOL EXECUTOR] Running: {tool_name}")
|
| 408 |
+
tool_fn = TOOL_REGISTRY[tool_name]
|
| 409 |
+
output = tool_fn(state["tool_input"], llm)
|
| 410 |
+
print(f"[TOOL EXECUTOR] Done — keys: {list(output.keys())}")
|
| 411 |
+
return {"tool_output": output}
|
| 412 |
+
|
| 413 |
+
# ── Self-RAG Evaluator ────────────────────────────────────────────────────
|
| 414 |
+
def self_rag_evaluator_node(state: AgentState) -> dict:
|
| 415 |
+
context = context_from_dicts(state["retrieved_docs"])
|
| 416 |
+
prompt = f"""Rate how faithfully this answer is grounded in the provided context.
|
| 417 |
+
A score of 1.0 means every claim is directly supported by the context.
|
| 418 |
+
A score of 0.0 means the answer contains hallucinated facts not in the context.
|
| 419 |
+
|
| 420 |
+
Return ONLY valid JSON: {{"score": <0.0-1.0>, "reasoning": "..."}}
|
| 421 |
+
|
| 422 |
+
Context (truncated):
|
| 423 |
+
{context[:2000]}
|
| 424 |
+
|
| 425 |
+
Answer:
|
| 426 |
+
{state['final_answer'][:1000]}"""
|
| 427 |
+
|
| 428 |
+
try:
|
| 429 |
+
data = parse_json_from_response(llm.invoke(prompt).content)
|
| 430 |
+
score = float(data.get("score", 0.8))
|
| 431 |
+
score = max(0.0, min(1.0, score)) # clamp to [0, 1]
|
| 432 |
+
except Exception:
|
| 433 |
+
score = 0.8 # conservative default on parse failure
|
| 434 |
+
|
| 435 |
+
current_iter = state["iteration_count"]
|
| 436 |
+
should_rerun = score < 0.7 and current_iter < 2
|
| 437 |
+
new_count = current_iter + 1 if should_rerun else current_iter
|
| 438 |
+
|
| 439 |
+
print(f"[SELF-RAG] faithfulness={score:.2f} | "
|
| 440 |
+
f"rerun={should_rerun} | iter={new_count}")
|
| 441 |
+
return {
|
| 442 |
+
"faithfulness_score": score,
|
| 443 |
+
"should_rerun": should_rerun,
|
| 444 |
+
"iteration_count": new_count,
|
| 445 |
+
}
|
| 446 |
+
|
| 447 |
+
return {
|
| 448 |
+
"orchestrator": orchestrator_node,
|
| 449 |
+
"rag_agent": rag_agent_node,
|
| 450 |
+
"compliance_agent": compliance_agent_node,
|
| 451 |
+
"alert_agent": alert_agent_node,
|
| 452 |
+
"finance_calculator":finance_calculator_node,
|
| 453 |
+
"tool_executor": tool_executor_node,
|
| 454 |
+
"self_rag_evaluator":self_rag_evaluator_node,
|
| 455 |
+
}
|
| 456 |
+
|
| 457 |
+
|
| 458 |
+
# ── Routing functions ─────────────────────────────────────────────────────────
|
| 459 |
+
def route_by_intent(state: AgentState) -> str:
|
| 460 |
+
mapping = {
|
| 461 |
+
"rag_query": "rag_agent",
|
| 462 |
+
"compliance_check": "compliance_agent",
|
| 463 |
+
"investment_alert": "alert_agent",
|
| 464 |
+
"financial_metrics": "finance_calculator",
|
| 465 |
+
"analyst_review": "rag_agent",
|
| 466 |
+
}
|
| 467 |
+
node = mapping.get(state["intent"], "rag_agent")
|
| 468 |
+
print(f"[ROUTER] intent={state['intent']} → {node}")
|
| 469 |
+
return node
|
| 470 |
+
|
| 471 |
+
|
| 472 |
+
def route_self_rag(state: AgentState) -> str:
|
| 473 |
+
return "rag_agent" if state["should_rerun"] else "end"
|
| 474 |
+
|
| 475 |
+
|
| 476 |
+
# ── Graph builder ─────────────────────────────────────────────────────────────
|
| 477 |
+
def build_graph(components: dict):
|
| 478 |
+
nodes = build_agent_nodes(components)
|
| 479 |
+
|
| 480 |
+
workflow = StateGraph(AgentState)
|
| 481 |
+
|
| 482 |
+
for name, fn in nodes.items():
|
| 483 |
+
workflow.add_node(name, fn)
|
| 484 |
+
|
| 485 |
+
workflow.set_entry_point("orchestrator")
|
| 486 |
+
|
| 487 |
+
workflow.add_conditional_edges(
|
| 488 |
+
"orchestrator",
|
| 489 |
+
route_by_intent,
|
| 490 |
+
{
|
| 491 |
+
"rag_agent": "rag_agent",
|
| 492 |
+
"compliance_agent": "compliance_agent",
|
| 493 |
+
"alert_agent": "alert_agent",
|
| 494 |
+
"finance_calculator": "finance_calculator",
|
| 495 |
+
},
|
| 496 |
+
)
|
| 497 |
+
|
| 498 |
+
for specialist in ["rag_agent", "compliance_agent",
|
| 499 |
+
"alert_agent", "finance_calculator"]:
|
| 500 |
+
workflow.add_edge(specialist, "tool_executor")
|
| 501 |
+
|
| 502 |
+
workflow.add_edge("tool_executor", "self_rag_evaluator")
|
| 503 |
+
|
| 504 |
+
workflow.add_conditional_edges(
|
| 505 |
+
"self_rag_evaluator",
|
| 506 |
+
route_self_rag,
|
| 507 |
+
{"rag_agent": "rag_agent", "end": END},
|
| 508 |
+
)
|
| 509 |
+
|
| 510 |
+
return workflow.compile()
|
| 511 |
+
|
| 512 |
+
|
| 513 |
+
# ── Component initialiser ─────────────────────────────────────────────────────
|
| 514 |
+
def build_components() -> dict:
|
| 515 |
+
print("=== Initialising Phase 3 components ===")
|
| 516 |
+
llm = build_llm()
|
| 517 |
+
embeddings = build_embeddings()
|
| 518 |
+
pages = load_pdf(PDF_PATH)
|
| 519 |
+
# spaCy NER (entities metadata) is not used at query time — BM25 needs only
|
| 520 |
+
# page_content and the dense store is already persisted. Skipping NER here
|
| 521 |
+
# cuts a large chunk off cold-start time. (Phase 1 ingestion still tags.)
|
| 522 |
+
vs = load_or_build_vectorstore(embeddings, pages)
|
| 523 |
+
bm25 = build_bm25_retriever(pages)
|
| 524 |
+
dense = build_dense_retriever(vs)
|
| 525 |
+
cross_enc = build_cross_encoder()
|
| 526 |
+
|
| 527 |
+
with open(TABLE_STORE_PATH) as f:
|
| 528 |
+
tables = json.load(f)
|
| 529 |
+
print(f"[INIT] Loaded {len(tables)} tables from table store")
|
| 530 |
+
|
| 531 |
+
return {
|
| 532 |
+
"llm": llm,
|
| 533 |
+
"embeddings": embeddings,
|
| 534 |
+
"bm25_retriever": bm25,
|
| 535 |
+
"dense_retriever": dense,
|
| 536 |
+
"cross_encoder": cross_enc,
|
| 537 |
+
"tables": tables,
|
| 538 |
+
}
|
| 539 |
+
|
| 540 |
+
|
| 541 |
+
# ── Pretty-print result ───────────────────────────────────────────────────────
|
| 542 |
+
def print_result(result: AgentState) -> None:
|
| 543 |
+
print("\n" + "=" * 65)
|
| 544 |
+
print(f"QUERY : {result['query']}")
|
| 545 |
+
print(f"INTENT : {result['intent']}")
|
| 546 |
+
print(f"FAITHFULNESS : {result['faithfulness_score']:.2f}")
|
| 547 |
+
print(f"CITATIONS : pages {result['citations']}")
|
| 548 |
+
print(f"\nANSWER:\n{result['final_answer'][:600]}")
|
| 549 |
+
if result.get("tool_output"):
|
| 550 |
+
out = result["tool_output"]
|
| 551 |
+
print(f"\nTOOL OUTPUT ({result['tool_name']}):")
|
| 552 |
+
print(json.dumps(out, indent=2)[:800])
|
| 553 |
+
print("=" * 65)
|
| 554 |
+
|
| 555 |
+
|
| 556 |
+
# ── Main ──────────────────────────────────────────────────────────────────────
|
| 557 |
+
if __name__ == "__main__":
|
| 558 |
+
components = build_components()
|
| 559 |
+
app = build_graph(components)
|
| 560 |
+
print("\n=== LangGraph compiled successfully ===")
|
| 561 |
+
|
| 562 |
+
test_queries = [
|
| 563 |
+
"What was Infosys revenue and operating margin in FY25?",
|
| 564 |
+
"Are there any SEBI compliance or regulatory issues mentioned in the annual report?",
|
| 565 |
+
"Generate an investment alert for Infosys based on their FY25 performance.",
|
| 566 |
+
"Schedule an urgent credit review for Infosys.",
|
| 567 |
+
]
|
| 568 |
+
|
| 569 |
+
for query in test_queries:
|
| 570 |
+
print(f"\n{'#' * 65}")
|
| 571 |
+
result = app.invoke(make_initial_state(query))
|
| 572 |
+
print_result(result)
|
phase4_tools.py
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Phase 4 — Enhanced Finance Tools with Real FinBERT Sentiment
|
| 3 |
+
|
| 4 |
+
Key addition over Phase 3:
|
| 5 |
+
generate_investment_alert now uses ProsusAI/finbert to score the retrieved
|
| 6 |
+
financial text before deciding the investment signal. The placeholder
|
| 7 |
+
sentiment from Phase 3 is replaced with actual model output.
|
| 8 |
+
|
| 9 |
+
Usage:
|
| 10 |
+
from phase4_tools import build_finbert, run_finbert, ENHANCED_TOOL_REGISTRY
|
| 11 |
+
# patch into Phase 3 graph at runtime (see main() below)
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import json
|
| 15 |
+
import datetime
|
| 16 |
+
import os
|
| 17 |
+
|
| 18 |
+
import torch
|
| 19 |
+
from dotenv import load_dotenv
|
| 20 |
+
from transformers import AutoModelForSequenceClassification, AutoTokenizer
|
| 21 |
+
|
| 22 |
+
from phase1_ingestion import (
|
| 23 |
+
PDF_PATH, CHROMA_DIR, TABLE_STORE_PATH,
|
| 24 |
+
build_embeddings, build_llm, load_pdf, tag_financial_entities,
|
| 25 |
+
)
|
| 26 |
+
from phase2_retrieval import (
|
| 27 |
+
advanced_retrieval_pipeline,
|
| 28 |
+
build_bm25_retriever,
|
| 29 |
+
build_cross_encoder,
|
| 30 |
+
build_dense_retriever,
|
| 31 |
+
load_or_build_vectorstore,
|
| 32 |
+
)
|
| 33 |
+
from phase3_agent import (
|
| 34 |
+
build_components,
|
| 35 |
+
build_graph,
|
| 36 |
+
context_from_dicts,
|
| 37 |
+
docs_to_dicts,
|
| 38 |
+
make_initial_state,
|
| 39 |
+
parse_json_from_response,
|
| 40 |
+
print_result,
|
| 41 |
+
# Phase 3 tools we keep unchanged
|
| 42 |
+
extract_financial_metrics,
|
| 43 |
+
generate_risk_summary,
|
| 44 |
+
flag_compliance_issue,
|
| 45 |
+
schedule_analyst_review,
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
load_dotenv()
|
| 49 |
+
|
| 50 |
+
FINBERT_MODEL = "ProsusAI/finbert"
|
| 51 |
+
FINBERT_LABELS = ["positive", "negative", "neutral"]
|
| 52 |
+
FINBERT_MAX_TOKENS = 512
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
# ── FinBERT loader ────────────────────────────────────────────────────────────
|
| 56 |
+
def build_finbert() -> tuple:
|
| 57 |
+
"""Load FinBERT tokenizer + model. Returns (tokenizer, model)."""
|
| 58 |
+
print(f"[FINBERT] Loading {FINBERT_MODEL}...")
|
| 59 |
+
tokenizer = AutoTokenizer.from_pretrained(FINBERT_MODEL)
|
| 60 |
+
model = AutoModelForSequenceClassification.from_pretrained(FINBERT_MODEL)
|
| 61 |
+
model.eval()
|
| 62 |
+
print("[FINBERT] Model ready")
|
| 63 |
+
return tokenizer, model
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
# ── FinBERT inference ─────────────────────────────────────────────────────────
|
| 67 |
+
def run_finbert(text: str, tokenizer, model) -> dict:
|
| 68 |
+
"""
|
| 69 |
+
Run FinBERT on a single text string (truncated to 512 tokens).
|
| 70 |
+
Returns {"label": "positive|negative|neutral", "score": float,
|
| 71 |
+
"all_scores": {"positive": float, "negative": float, "neutral": float}}
|
| 72 |
+
"""
|
| 73 |
+
inputs = tokenizer(
|
| 74 |
+
text,
|
| 75 |
+
return_tensors="pt",
|
| 76 |
+
truncation=True,
|
| 77 |
+
max_length=FINBERT_MAX_TOKENS,
|
| 78 |
+
padding=True,
|
| 79 |
+
)
|
| 80 |
+
with torch.no_grad():
|
| 81 |
+
logits = model(**inputs).logits
|
| 82 |
+
probs = torch.softmax(logits, dim=1)[0].tolist()
|
| 83 |
+
all_scores = {FINBERT_LABELS[i]: round(probs[i], 4) for i in range(3)}
|
| 84 |
+
dominant = max(all_scores, key=all_scores.get)
|
| 85 |
+
return {"label": dominant, "score": all_scores[dominant], "all_scores": all_scores}
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def run_finbert_on_docs(doc_dicts: list[dict], tokenizer, model) -> dict:
|
| 89 |
+
"""
|
| 90 |
+
Run FinBERT across all retrieved doc chunks and aggregate scores.
|
| 91 |
+
Strategy: average scores across all chunks, then pick the dominant label.
|
| 92 |
+
This handles the common case where a single page has mixed sentiment.
|
| 93 |
+
"""
|
| 94 |
+
if not doc_dicts:
|
| 95 |
+
return {"label": "neutral", "score": 0.5,
|
| 96 |
+
"all_scores": {"positive": 0.33, "negative": 0.33, "neutral": 0.34}}
|
| 97 |
+
|
| 98 |
+
agg = {"positive": 0.0, "negative": 0.0, "neutral": 0.0}
|
| 99 |
+
n = 0
|
| 100 |
+
for doc in doc_dicts:
|
| 101 |
+
text = doc["page_content"].strip()
|
| 102 |
+
if not text:
|
| 103 |
+
continue
|
| 104 |
+
result = run_finbert(text, tokenizer, model)
|
| 105 |
+
for label, score in result["all_scores"].items():
|
| 106 |
+
agg[label] += score
|
| 107 |
+
n += 1
|
| 108 |
+
|
| 109 |
+
if n == 0:
|
| 110 |
+
return {"label": "neutral", "score": 0.5,
|
| 111 |
+
"all_scores": {"positive": 0.33, "negative": 0.33, "neutral": 0.34}}
|
| 112 |
+
|
| 113 |
+
avg = {k: round(v / n, 4) for k, v in agg.items()}
|
| 114 |
+
dominant = max(avg, key=avg.get)
|
| 115 |
+
return {"label": dominant, "score": avg[dominant], "all_scores": avg}
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
# ── Enhanced generate_investment_alert (real FinBERT) ─────────────────────────
|
| 119 |
+
def make_enhanced_investment_alert(finbert_tokenizer, finbert_model):
|
| 120 |
+
"""
|
| 121 |
+
Returns an enhanced generate_investment_alert function that runs real
|
| 122 |
+
FinBERT sentiment before calling the LLM to decide the signal.
|
| 123 |
+
Injected into ENHANCED_TOOL_REGISTRY and into the Phase 3 graph.
|
| 124 |
+
"""
|
| 125 |
+
def generate_investment_alert(tool_input: dict, llm) -> dict:
|
| 126 |
+
company = tool_input.get("company", "Infosys")
|
| 127 |
+
context = tool_input.get("context", "")
|
| 128 |
+
doc_dicts= tool_input.get("doc_dicts", []) # injected by alert_agent
|
| 129 |
+
|
| 130 |
+
# ── Step 1: Run real FinBERT ──────────────────────────────────────────
|
| 131 |
+
# Prefer doc_dicts for per-chunk scoring; fall back to raw context string
|
| 132 |
+
if doc_dicts:
|
| 133 |
+
finbert_result = run_finbert_on_docs(doc_dicts, finbert_tokenizer, finbert_model)
|
| 134 |
+
else:
|
| 135 |
+
finbert_result = run_finbert(context[:FINBERT_MAX_TOKENS],
|
| 136 |
+
finbert_tokenizer, finbert_model)
|
| 137 |
+
|
| 138 |
+
print(f"[FINBERT] sentiment={finbert_result['label']} "
|
| 139 |
+
f"({finbert_result['score']:.2f}) | "
|
| 140 |
+
f"all={finbert_result['all_scores']}")
|
| 141 |
+
|
| 142 |
+
# ── Step 2: Let LLM decide signal using FinBERT as a hard input ──────
|
| 143 |
+
sentiment_hint = (
|
| 144 |
+
f"FinBERT (domain-specific sentiment model) scored this text as: "
|
| 145 |
+
f"{finbert_result['label'].upper()} "
|
| 146 |
+
f"(confidence {finbert_result['score']:.2f}). "
|
| 147 |
+
f"All scores — positive: {finbert_result['all_scores']['positive']}, "
|
| 148 |
+
f"negative: {finbert_result['all_scores']['negative']}, "
|
| 149 |
+
f"neutral: {finbert_result['all_scores']['neutral']}."
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
prompt = f"""You are a senior investment analyst.
|
| 153 |
+
{sentiment_hint}
|
| 154 |
+
|
| 155 |
+
Using BOTH the FinBERT sentiment above AND the financial context below,
|
| 156 |
+
generate a structured investment alert for {company}.
|
| 157 |
+
|
| 158 |
+
Return ONLY valid JSON:
|
| 159 |
+
{{
|
| 160 |
+
"company": "{company}",
|
| 161 |
+
"signal": "buy|sell|hold|watch",
|
| 162 |
+
"trigger_reason": "...",
|
| 163 |
+
"confidence_score": <0.0-1.0>,
|
| 164 |
+
"supporting_evidence": ["exact quote 1", "exact quote 2"],
|
| 165 |
+
"finbert_sentiment": {{
|
| 166 |
+
"label": "{finbert_result['label']}",
|
| 167 |
+
"score": {finbert_result['score']},
|
| 168 |
+
"all_scores": {{
|
| 169 |
+
"positive": {finbert_result['all_scores']['positive']},
|
| 170 |
+
"negative": {finbert_result['all_scores']['negative']},
|
| 171 |
+
"neutral": {finbert_result['all_scores']['neutral']}
|
| 172 |
+
}}
|
| 173 |
+
}}
|
| 174 |
+
}}
|
| 175 |
+
|
| 176 |
+
Financial context:
|
| 177 |
+
{context[:3000]}"""
|
| 178 |
+
|
| 179 |
+
try:
|
| 180 |
+
return parse_json_from_response(llm.invoke(prompt).content)
|
| 181 |
+
except Exception as e:
|
| 182 |
+
# Fallback: return what we have with FinBERT scores populated
|
| 183 |
+
return {
|
| 184 |
+
"company": company,
|
| 185 |
+
"signal": "hold",
|
| 186 |
+
"trigger_reason": "LLM parse error — FinBERT scores still valid",
|
| 187 |
+
"confidence_score": finbert_result["score"],
|
| 188 |
+
"supporting_evidence": [],
|
| 189 |
+
"finbert_sentiment": finbert_result,
|
| 190 |
+
"error": str(e),
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
return generate_investment_alert
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
# ── Build patched components dict ─────────────────────────────────────────────
|
| 197 |
+
def build_phase4_components() -> dict:
|
| 198 |
+
"""
|
| 199 |
+
Builds all Phase 3 components then loads FinBERT and patches
|
| 200 |
+
generate_investment_alert with the real-FinBERT version.
|
| 201 |
+
"""
|
| 202 |
+
components = build_components() # Phase 3 setup
|
| 203 |
+
|
| 204 |
+
finbert_tokenizer, finbert_model = build_finbert()
|
| 205 |
+
enhanced_fn = make_enhanced_investment_alert(finbert_tokenizer, finbert_model)
|
| 206 |
+
|
| 207 |
+
# Patch the tool registry in the components dict so the graph uses it
|
| 208 |
+
components["finbert_tokenizer"] = finbert_tokenizer
|
| 209 |
+
components["finbert_model"] = finbert_model
|
| 210 |
+
components["enhanced_alert_fn"] = enhanced_fn
|
| 211 |
+
|
| 212 |
+
return components
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
# ── Patch alert agent to pass doc_dicts to the tool ──────────────────────────
|
| 216 |
+
def build_phase4_graph(components: dict):
|
| 217 |
+
"""
|
| 218 |
+
Builds the Phase 3 graph but overrides the alert_agent_node closure
|
| 219 |
+
so it passes doc_dicts to generate_investment_alert for per-chunk FinBERT.
|
| 220 |
+
"""
|
| 221 |
+
from phase3_agent import (
|
| 222 |
+
build_agent_nodes, route_by_intent, route_self_rag,
|
| 223 |
+
INTENT_TO_TOOL, AgentState,
|
| 224 |
+
)
|
| 225 |
+
from langgraph.graph import StateGraph, END
|
| 226 |
+
|
| 227 |
+
# Build standard Phase 3 nodes
|
| 228 |
+
nodes = build_agent_nodes(components)
|
| 229 |
+
|
| 230 |
+
llm = components["llm"]
|
| 231 |
+
bm25_retriever = components["bm25_retriever"]
|
| 232 |
+
dense_retriever = components["dense_retriever"]
|
| 233 |
+
cross_encoder = components["cross_encoder"]
|
| 234 |
+
enhanced_alert = components["enhanced_alert_fn"]
|
| 235 |
+
|
| 236 |
+
# ── Override alert_agent_node ─────────────────────────────────────────────
|
| 237 |
+
def alert_agent_node_v4(state: AgentState) -> dict:
|
| 238 |
+
print(f"[ALERT AGENT v4] Analysing: {state['query'][:60]}")
|
| 239 |
+
retrieval_query = (
|
| 240 |
+
f"revenue profit growth performance outlook risk sentiment {state['query']}"
|
| 241 |
+
)
|
| 242 |
+
docs = advanced_retrieval_pipeline(
|
| 243 |
+
retrieval_query, bm25_retriever, dense_retriever, cross_encoder, llm
|
| 244 |
+
)
|
| 245 |
+
doc_dicts = docs_to_dicts(docs)
|
| 246 |
+
citations = list({d["metadata"].get("page") for d in doc_dicts})
|
| 247 |
+
context = context_from_dicts(doc_dicts)
|
| 248 |
+
|
| 249 |
+
answer_prompt = (
|
| 250 |
+
"You are an investment analyst. Assess the company's financial health "
|
| 251 |
+
"and generate a clear investment signal with supporting evidence. "
|
| 252 |
+
"Real FinBERT sentiment has already been computed and will be included.\n\n"
|
| 253 |
+
f"Retrieved context:\n{context[:4000]}\n\n"
|
| 254 |
+
f"Question: {state['query']}\n\nAnswer with specific facts and page citations:"
|
| 255 |
+
)
|
| 256 |
+
answer = llm.invoke(answer_prompt).content
|
| 257 |
+
|
| 258 |
+
# Pass doc_dicts so enhanced_alert can run FinBERT per chunk
|
| 259 |
+
tool_input = {
|
| 260 |
+
"company": "Infosys",
|
| 261 |
+
"context": context,
|
| 262 |
+
"doc_dicts": doc_dicts,
|
| 263 |
+
}
|
| 264 |
+
print(f"[ALERT AGENT v4] {len(doc_dicts)} docs | citations={citations}")
|
| 265 |
+
return {
|
| 266 |
+
"retrieved_docs": doc_dicts,
|
| 267 |
+
"final_answer": answer,
|
| 268 |
+
"citations": citations,
|
| 269 |
+
"tool_name": "generate_investment_alert",
|
| 270 |
+
"tool_input": tool_input,
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
nodes["alert_agent"] = alert_agent_node_v4
|
| 274 |
+
|
| 275 |
+
# ── Override tool_executor to use enhanced tool for investment_alert ──────
|
| 276 |
+
def tool_executor_node_v4(state: AgentState) -> dict:
|
| 277 |
+
tool_name = state.get("tool_name")
|
| 278 |
+
from phase3_agent import TOOL_REGISTRY
|
| 279 |
+
|
| 280 |
+
# Use enhanced alert function, standard registry for everything else
|
| 281 |
+
registry = {**TOOL_REGISTRY, "generate_investment_alert": enhanced_alert}
|
| 282 |
+
|
| 283 |
+
if not tool_name or tool_name not in registry:
|
| 284 |
+
print("[TOOL EXECUTOR v4] No tool — passing answer through")
|
| 285 |
+
return {"tool_output": {"answer": state["final_answer"]}}
|
| 286 |
+
|
| 287 |
+
print(f"[TOOL EXECUTOR v4] Running: {tool_name}")
|
| 288 |
+
output = registry[tool_name](state["tool_input"], llm)
|
| 289 |
+
print(f"[TOOL EXECUTOR v4] Done — keys: {list(output.keys())}")
|
| 290 |
+
return {"tool_output": output}
|
| 291 |
+
|
| 292 |
+
nodes["tool_executor"] = tool_executor_node_v4
|
| 293 |
+
|
| 294 |
+
# ── Compile graph ─────────────────────────────────────────────────────────
|
| 295 |
+
workflow = StateGraph(AgentState)
|
| 296 |
+
for name, fn in nodes.items():
|
| 297 |
+
workflow.add_node(name, fn)
|
| 298 |
+
workflow.set_entry_point("orchestrator")
|
| 299 |
+
workflow.add_conditional_edges(
|
| 300 |
+
"orchestrator", route_by_intent,
|
| 301 |
+
{
|
| 302 |
+
"rag_agent": "rag_agent",
|
| 303 |
+
"compliance_agent": "compliance_agent",
|
| 304 |
+
"alert_agent": "alert_agent",
|
| 305 |
+
"finance_calculator": "finance_calculator",
|
| 306 |
+
},
|
| 307 |
+
)
|
| 308 |
+
for specialist in ["rag_agent", "compliance_agent",
|
| 309 |
+
"alert_agent", "finance_calculator"]:
|
| 310 |
+
workflow.add_edge(specialist, "tool_executor")
|
| 311 |
+
workflow.add_edge("tool_executor", "self_rag_evaluator")
|
| 312 |
+
workflow.add_conditional_edges(
|
| 313 |
+
"self_rag_evaluator", route_self_rag,
|
| 314 |
+
{"rag_agent": "rag_agent", "end": END},
|
| 315 |
+
)
|
| 316 |
+
return workflow.compile()
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
# ── Standalone FinBERT demo ───────────────────────────────────────────────────
|
| 320 |
+
def run_finbert_demo(finbert_tokenizer, finbert_model) -> None:
|
| 321 |
+
"""Show FinBERT scoring on representative Infosys sentences."""
|
| 322 |
+
print("\n" + "=" * 65)
|
| 323 |
+
print("FINBERT STANDALONE DEMO")
|
| 324 |
+
print("=" * 65)
|
| 325 |
+
sentences = [
|
| 326 |
+
"Revenues grew 6.1% year-on-year to ₹1,62,990 crore in FY25.",
|
| 327 |
+
"Revenue fell short of analyst estimates amid macro uncertainty.",
|
| 328 |
+
"Operating margin improved to 21.1%, up from 20.7% in FY24.",
|
| 329 |
+
"The company faces risks from geopolitical tensions and talent attrition.",
|
| 330 |
+
"Infosys won large AI-led transformation deals across key verticals.",
|
| 331 |
+
"Net headcount declined as the company focused on utilisation.",
|
| 332 |
+
]
|
| 333 |
+
for sentence in sentences:
|
| 334 |
+
result = run_finbert(sentence, finbert_tokenizer, finbert_model)
|
| 335 |
+
bar = "+" * int(result["all_scores"]["positive"] * 20)
|
| 336 |
+
print(f" [{result['label']:8s} {result['score']:.2f}] {sentence[:55]}")
|
| 337 |
+
print(f" pos={result['all_scores']['positive']:.3f} "
|
| 338 |
+
f"neg={result['all_scores']['negative']:.3f} "
|
| 339 |
+
f"neu={result['all_scores']['neutral']:.3f}")
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
# ── Main ──────────────────────────────────────────────────────────────────────
|
| 343 |
+
if __name__ == "__main__":
|
| 344 |
+
# Build all components (Phase 3 + FinBERT)
|
| 345 |
+
components = build_phase4_components()
|
| 346 |
+
|
| 347 |
+
# Show FinBERT standalone demo
|
| 348 |
+
run_finbert_demo(components["finbert_tokenizer"], components["finbert_model"])
|
| 349 |
+
|
| 350 |
+
# Build Phase 4 graph (Phase 3 graph + patched alert agent + enhanced tool)
|
| 351 |
+
app = build_phase4_graph(components)
|
| 352 |
+
print("\n=== Phase 4 LangGraph compiled successfully ===\n")
|
| 353 |
+
|
| 354 |
+
# Test: investment alert query (the one that exercises FinBERT)
|
| 355 |
+
test_queries = [
|
| 356 |
+
"Generate an investment alert for Infosys based on their FY25 performance.",
|
| 357 |
+
"Should I be concerned about Infosys given the risk factors in their latest filing?",
|
| 358 |
+
]
|
| 359 |
+
|
| 360 |
+
for query in test_queries:
|
| 361 |
+
print(f"\n{'#' * 65}")
|
| 362 |
+
result = app.invoke(make_initial_state(query))
|
| 363 |
+
print_result(result)
|
requirements.txt
CHANGED
|
@@ -1,3 +1,36 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
pandas
|
| 3 |
-
|
|
|
|
|
|
| 1 |
+
# FinAgent — Python 3.12 runtime
|
| 2 |
+
# UI
|
| 3 |
+
streamlit
|
| 4 |
+
|
| 5 |
+
# Orchestration
|
| 6 |
+
langgraph
|
| 7 |
+
|
| 8 |
+
# LLM / LangChain stack
|
| 9 |
+
langchain
|
| 10 |
+
langchain-community
|
| 11 |
+
langchain-groq
|
| 12 |
+
groq
|
| 13 |
+
langchain-huggingface
|
| 14 |
+
langchain-experimental
|
| 15 |
+
langchain-classic
|
| 16 |
+
langchain-text-splitters
|
| 17 |
+
|
| 18 |
+
# Vector store + document loading
|
| 19 |
+
chromadb
|
| 20 |
+
pypdf
|
| 21 |
+
pdfplumber
|
| 22 |
+
|
| 23 |
+
# Embeddings, reranking, FinBERT
|
| 24 |
+
sentence-transformers
|
| 25 |
+
transformers
|
| 26 |
+
torch
|
| 27 |
+
|
| 28 |
+
# Retrieval / NLP
|
| 29 |
+
rank-bm25
|
| 30 |
+
spacy
|
| 31 |
+
|
| 32 |
+
# Utils
|
| 33 |
+
numpy
|
| 34 |
pandas
|
| 35 |
+
python-dotenv
|
| 36 |
+
# spaCy model installed separately: python -m spacy download en_core_web_sm
|
table_store.json
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"page": 13,
|
| 4 |
+
"table_index": 0,
|
| 5 |
+
"source_file": "infosys-ar-25.pdf",
|
| 6 |
+
"text_representation": "Table on page 13:\n \nHelene Auriol Potier\\nIndependent Director",
|
| 7 |
+
"headers": [
|
| 8 |
+
""
|
| 9 |
+
]
|
| 10 |
+
},
|
| 11 |
+
{
|
| 12 |
+
"page": 13,
|
| 13 |
+
"table_index": 1,
|
| 14 |
+
"source_file": "infosys-ar-25.pdf",
|
| 15 |
+
"text_representation": "Table on page 13:\n \nNitin Paranjpe\\nIndependent Director",
|
| 16 |
+
"headers": [
|
| 17 |
+
""
|
| 18 |
+
]
|
| 19 |
+
},
|
| 20 |
+
{
|
| 21 |
+
"page": 15,
|
| 22 |
+
"table_index": 0,
|
| 23 |
+
"source_file": "infosys-ar-25.pdf",
|
| 24 |
+
"text_representation": "Table on page 15:\nRevenues\\n1,62,990\\n\u20b9 cr\\n6.1% growth Y-o-Y\\n4.2% CC growth Y-o-Y\n ",
|
| 25 |
+
"headers": [
|
| 26 |
+
"Revenues\n1,62,990\n\u20b9 cr\n6.1% growth Y-o-Y\n4.2% CC growth Y-o-Y"
|
| 27 |
+
]
|
| 28 |
+
},
|
| 29 |
+
{
|
| 30 |
+
"page": 15,
|
| 31 |
+
"table_index": 1,
|
| 32 |
+
"source_file": "infosys-ar-25.pdf",
|
| 33 |
+
"text_representation": "Table on page 15:\n FY 2025\n1,62,990\n 26,713\n 64.50\n6,52,332",
|
| 34 |
+
"headers": [
|
| 35 |
+
"FY 2025"
|
| 36 |
+
]
|
| 37 |
+
},
|
| 38 |
+
{
|
| 39 |
+
"page": 15,
|
| 40 |
+
"table_index": 2,
|
| 41 |
+
"source_file": "infosys-ar-25.pdf",
|
| 42 |
+
"text_representation": "Table on page 15:\nFY 2025\n 19,277\n 3,158\n 0.76\n 75,797",
|
| 43 |
+
"headers": [
|
| 44 |
+
"FY 2025"
|
| 45 |
+
]
|
| 46 |
+
},
|
| 47 |
+
{
|
| 48 |
+
"page": 28,
|
| 49 |
+
"table_index": 0,
|
| 50 |
+
"source_file": "infosys-ar-25.pdf",
|
| 51 |
+
"text_representation": "Table on page 28:\n Performance highlights\nInfosys Prize winners since 2008\\n98\\nLives empowered via Tech\\nfor Good programs\\n125\\nmn+\\nLearners enabled in digital skills\\n13.3\\nmn\\nGlobal CSR spends\\n628\\n` cr\\nBeneficiaries of CSR projects\\nin India\\n10\\nmn\\n54",
|
| 52 |
+
"headers": [
|
| 53 |
+
"Performance highlights"
|
| 54 |
+
]
|
| 55 |
+
},
|
| 56 |
+
{
|
| 57 |
+
"page": 68,
|
| 58 |
+
"table_index": 0,
|
| 59 |
+
"source_file": "infosys-ar-25.pdf",
|
| 60 |
+
"text_representation": "Table on page 68:\n\n",
|
| 61 |
+
"headers": [
|
| 62 |
+
""
|
| 63 |
+
]
|
| 64 |
+
},
|
| 65 |
+
{
|
| 66 |
+
"page": 68,
|
| 67 |
+
"table_index": 1,
|
| 68 |
+
"source_file": "infosys-ar-25.pdf",
|
| 69 |
+
"text_representation": "Table on page 68:\n\n",
|
| 70 |
+
"headers": [
|
| 71 |
+
""
|
| 72 |
+
]
|
| 73 |
+
},
|
| 74 |
+
{
|
| 75 |
+
"page": 79,
|
| 76 |
+
"table_index": 0,
|
| 77 |
+
"source_file": "infosys-ar-25.pdf",
|
| 78 |
+
"text_representation": "Table on page 79:\n \nIncubateNext",
|
| 79 |
+
"headers": [
|
| 80 |
+
""
|
| 81 |
+
]
|
| 82 |
+
},
|
| 83 |
+
{
|
| 84 |
+
"page": 79,
|
| 85 |
+
"table_index": 1,
|
| 86 |
+
"source_file": "infosys-ar-25.pdf",
|
| 87 |
+
"text_representation": "Table on page 79:\n \nConnectNext",
|
| 88 |
+
"headers": [
|
| 89 |
+
""
|
| 90 |
+
]
|
| 91 |
+
},
|
| 92 |
+
{
|
| 93 |
+
"page": 88,
|
| 94 |
+
"table_index": 0,
|
| 95 |
+
"source_file": "infosys-ar-25.pdf",
|
| 96 |
+
"text_representation": "Table on page 88:\n \n ",
|
| 97 |
+
"headers": [
|
| 98 |
+
"",
|
| 99 |
+
"",
|
| 100 |
+
"",
|
| 101 |
+
"",
|
| 102 |
+
""
|
| 103 |
+
]
|
| 104 |
+
},
|
| 105 |
+
{
|
| 106 |
+
"page": 88,
|
| 107 |
+
"table_index": 1,
|
| 108 |
+
"source_file": "infosys-ar-25.pdf",
|
| 109 |
+
"text_representation": "Table on page 88:\n Board attendance None None None None None None None None None\nName of the director Attended\\nAGM on June\\n26, 2024 No. of Board meeting Held\\nduring\\ntenure Attended %\n 1 2 3 4 5 \n April 17-18,\\n2024 July 17-18,\\n2024 July 19,\\n2024 October\\n16-17,\\n2024 January\\n15-16,\\n2025 \n L 5 4 \n 5 5 \n 5 5 \n 5 5 \n 5 5 \n 5 5 \n 5 5 \n 5 5 \n 5 5 \n 100 89 100 100 100 100 ",
|
| 110 |
+
"headers": [
|
| 111 |
+
"Board attendance",
|
| 112 |
+
"None",
|
| 113 |
+
"None",
|
| 114 |
+
"None",
|
| 115 |
+
"None",
|
| 116 |
+
"None",
|
| 117 |
+
"None",
|
| 118 |
+
"None",
|
| 119 |
+
"None",
|
| 120 |
+
"None"
|
| 121 |
+
]
|
| 122 |
+
},
|
| 123 |
+
{
|
| 124 |
+
"page": 91,
|
| 125 |
+
"table_index": 0,
|
| 126 |
+
"source_file": "infosys-ar-25.pdf",
|
| 127 |
+
"text_representation": "Table on page 91:\nAudit Committee meeting None None None None None None None None None None\n Name of the member Committee meeting details Held\\nduring\\ntenure Attended %\n 1 2 3 4 5 6 7 \n April 17-18,\\n2024 July 17-18,\\n2024 September\\n19-20, 2024 October\\n16-17, 2024 January\\n15-16, 2025 March\\n10-11, 2025 March\\n27, 2025 \n 7 7 \n 7 7 \n 7 7 \n \u2013 \u2013 \u2013 \u2013 \u2013 \u2013 \u2013 \u2013 \u2013 \n 100 100 100 100 100 100 100 ",
|
| 128 |
+
"headers": [
|
| 129 |
+
"Audit Committee meeting",
|
| 130 |
+
"None",
|
| 131 |
+
"None",
|
| 132 |
+
"None",
|
| 133 |
+
"None",
|
| 134 |
+
"None",
|
| 135 |
+
"None",
|
| 136 |
+
"None",
|
| 137 |
+
"None",
|
| 138 |
+
"None",
|
| 139 |
+
"None"
|
| 140 |
+
]
|
| 141 |
+
},
|
| 142 |
+
{
|
| 143 |
+
"page": 94,
|
| 144 |
+
"table_index": 0,
|
| 145 |
+
"source_file": "infosys-ar-25.pdf",
|
| 146 |
+
"text_representation": "Table on page 94:\nNomination and Remuneration Committee meeting None None None None None None None\n Name of the member Committee meeting details Held during\\ntenure Attended %\n 1 2 3 4 \n April 17-18, 2024 July 17, 2024 October 16, 2024 January 15, 2025 \n 4 4 \n 4 4 \n 4 4 \n 4 4 \n 100 100 100 100 ",
|
| 147 |
+
"headers": [
|
| 148 |
+
"Nomination and Remuneration Committee meeting",
|
| 149 |
+
"None",
|
| 150 |
+
"None",
|
| 151 |
+
"None",
|
| 152 |
+
"None",
|
| 153 |
+
"None",
|
| 154 |
+
"None",
|
| 155 |
+
"None"
|
| 156 |
+
]
|
| 157 |
+
},
|
| 158 |
+
{
|
| 159 |
+
"page": 96,
|
| 160 |
+
"table_index": 0,
|
| 161 |
+
"source_file": "infosys-ar-25.pdf",
|
| 162 |
+
"text_representation": "Table on page 96:\nCSR Committee meeting None None None None None None None\n Name of the member Committee meeting details Held\\nduring\\ntenure Attended %\n 1 2 3 4 \n April 10, 2024 July 9, 2024 October 11, 2024 January 14, 2025 \n 4 4 \n 4 4 \n 4 4 \n 100 100 100 100 ",
|
| 163 |
+
"headers": [
|
| 164 |
+
"CSR Committee meeting",
|
| 165 |
+
"None",
|
| 166 |
+
"None",
|
| 167 |
+
"None",
|
| 168 |
+
"None",
|
| 169 |
+
"None",
|
| 170 |
+
"None",
|
| 171 |
+
"None"
|
| 172 |
+
]
|
| 173 |
+
},
|
| 174 |
+
{
|
| 175 |
+
"page": 98,
|
| 176 |
+
"table_index": 0,
|
| 177 |
+
"source_file": "infosys-ar-25.pdf",
|
| 178 |
+
"text_representation": "Table on page 98:\nESG Committee meeting None None None None None None None\n Name of the member Committee meeting details Held\\nduring\\ntenure Attended %\n 1 2 3 4 \n April 10, 2024 July 11, 2024 October 11, 2024 January 14, 2025 \n 4 4 \n 4 4 \n 4 4 \n 100 100 100 100 ",
|
| 179 |
+
"headers": [
|
| 180 |
+
"ESG Committee meeting",
|
| 181 |
+
"None",
|
| 182 |
+
"None",
|
| 183 |
+
"None",
|
| 184 |
+
"None",
|
| 185 |
+
"None",
|
| 186 |
+
"None",
|
| 187 |
+
"None"
|
| 188 |
+
]
|
| 189 |
+
},
|
| 190 |
+
{
|
| 191 |
+
"page": 100,
|
| 192 |
+
"table_index": 0,
|
| 193 |
+
"source_file": "infosys-ar-25.pdf",
|
| 194 |
+
"text_representation": "Table on page 100:\nRisk Management Committee meeting None None None None None None None\n Name of the member Committee meeting details Held\\nduring\\ntenure Attended %\n 1 2 3 4 \n April 9, 2024 July 11, 2024 October 10, 2024 January 14, 2025 \n 4 4 \n 4 4 \n 4 4 \n 4 4 \n 4 4 \n 4 4 \n 4 4 \n 100 100 100 100 ",
|
| 195 |
+
"headers": [
|
| 196 |
+
"Risk Management Committee meeting",
|
| 197 |
+
"None",
|
| 198 |
+
"None",
|
| 199 |
+
"None",
|
| 200 |
+
"None",
|
| 201 |
+
"None",
|
| 202 |
+
"None",
|
| 203 |
+
"None"
|
| 204 |
+
]
|
| 205 |
+
},
|
| 206 |
+
{
|
| 207 |
+
"page": 101,
|
| 208 |
+
"table_index": 0,
|
| 209 |
+
"source_file": "infosys-ar-25.pdf",
|
| 210 |
+
"text_representation": "Table on page 101:\nCybersecurity Risk Sub-committee meeting None None None None None None None\n Name of the member Committee meeting details Held\\nduring\\ntenure Attended %\n 1 2 3 4 \n April 8, 2024 July 10, 2024 October 10, 2024 January 14, 2025 \n 4 4 \n 4 4 \n 4 4 \n 4 4 \n 100 100 100 100 ",
|
| 211 |
+
"headers": [
|
| 212 |
+
"Cybersecurity Risk Sub-committee meeting",
|
| 213 |
+
"None",
|
| 214 |
+
"None",
|
| 215 |
+
"None",
|
| 216 |
+
"None",
|
| 217 |
+
"None",
|
| 218 |
+
"None",
|
| 219 |
+
"None"
|
| 220 |
+
]
|
| 221 |
+
},
|
| 222 |
+
{
|
| 223 |
+
"page": 104,
|
| 224 |
+
"table_index": 0,
|
| 225 |
+
"source_file": "infosys-ar-25.pdf",
|
| 226 |
+
"text_representation": "Table on page 104:\nStakeholders Relationship Committee meeting None None None None None None None\n Name of the member Committee meeting details Held\\nduring\\ntenure Attended %\n 1 2 3 4 \n April 8, 2024 July 10, 2024 October 11, 2024 January 14, 2025 \n 4 4 \n 4 4 \n 4 4 \n 4 4 \n 100 100 100 100 ",
|
| 227 |
+
"headers": [
|
| 228 |
+
"Stakeholders Relationship Committee meeting",
|
| 229 |
+
"None",
|
| 230 |
+
"None",
|
| 231 |
+
"None",
|
| 232 |
+
"None",
|
| 233 |
+
"None",
|
| 234 |
+
"None",
|
| 235 |
+
"None"
|
| 236 |
+
]
|
| 237 |
+
},
|
| 238 |
+
{
|
| 239 |
+
"page": 107,
|
| 240 |
+
"table_index": 0,
|
| 241 |
+
"source_file": "infosys-ar-25.pdf",
|
| 242 |
+
"text_representation": "Table on page 107:\n Name of the director Fixed salary None None Bonus /\\nincentives /\\nvariable pay Perquisites\\non account of\\nstock options\\nexercised(1)* Commission Total\n Base\\nsalary\\n(A) Retiral\\nbenefits\\n(B) Total fixed\\nsalary\\n(A+B) \nNon-executive and non-independent director ",
|
| 243 |
+
"headers": [
|
| 244 |
+
"Name of the director",
|
| 245 |
+
"Fixed salary",
|
| 246 |
+
"None",
|
| 247 |
+
"None",
|
| 248 |
+
"Bonus /\nincentives /\nvariable pay",
|
| 249 |
+
"Perquisites\non account of\nstock options\nexercised(1)*",
|
| 250 |
+
"Commission",
|
| 251 |
+
"Total"
|
| 252 |
+
]
|
| 253 |
+
},
|
| 254 |
+
{
|
| 255 |
+
"page": 108,
|
| 256 |
+
"table_index": 2,
|
| 257 |
+
"source_file": "infosys-ar-25.pdf",
|
| 258 |
+
"text_representation": "Table on page 108:\n Name Designation\n Anand Swaminathan Segment Head \u2013 Communication,\\nMedia and Technology\n Arun Kumar H.R. Head \u2013 Business Strategy,\\nPlanning and Operations\n Ashiss Kumar Dash Segment Head \u2013 Energy, Utilities,\\nResources and Services\nDennis Kantilal Gada Segment Head \u2013 Banking and\\nFinancial Services\n Dinesh R. Chief Delivery Officer\n Inderpreet Sawhney Chief Legal Officer and\\nChief Compliance Officer\n Jasmeet Singh Segment Head \u2013 Manufacturing\n Jayesh Sanghrajka Chief Financial Officer\n Manikantha A.G.S. Company Secretary",
|
| 259 |
+
"headers": [
|
| 260 |
+
"Name",
|
| 261 |
+
"Designation"
|
| 262 |
+
]
|
| 263 |
+
},
|
| 264 |
+
{
|
| 265 |
+
"page": 108,
|
| 266 |
+
"table_index": 3,
|
| 267 |
+
"source_file": "infosys-ar-25.pdf",
|
| 268 |
+
"text_representation": "Table on page 108:\n Name Designation\n Kannan Amaresh Industry Head \u2013 Insurance\n Karmesh Gul Vaswani Segment Head \u2013\\nCPG, Logistics & Retail\n Satish H.C. Chief Delivery Officer\n Shaji Mathew Chief Human Resources Officer\n Sumit Virmani Chief Marketing Officer\n Subhro Mallik Industry Head \u2013 Life Sciences\nVenkateshwaran Ananthakrishnan Industry Head \u2013 Healthcare\n Changes during fiscal 2025 \n Senior management personnel Effective date\n Resignations \n Hemant Lamba September 30, 2024",
|
| 269 |
+
"headers": [
|
| 270 |
+
"Name",
|
| 271 |
+
"Designation"
|
| 272 |
+
]
|
| 273 |
+
},
|
| 274 |
+
{
|
| 275 |
+
"page": 110,
|
| 276 |
+
"table_index": 0,
|
| 277 |
+
"source_file": "infosys-ar-25.pdf",
|
| 278 |
+
"text_representation": "Table on page 110:\nPrior to\\n1986 1986 1989 1991 1992 1994 1997 1999 2000 2005 2007 2015 2016\n Bonus Bonus Bonus Bonus Bonus Bonus Bonus Stock\\nSplit Bonus Bonus Bonus Bonus\n 1:1 1:1 1:1 1:1 1:1 1:1 1:1 2:1 3:1 1:1 1:1 1:1",
|
| 279 |
+
"headers": [
|
| 280 |
+
"Prior to\n1986",
|
| 281 |
+
"1986",
|
| 282 |
+
"1989",
|
| 283 |
+
"1991",
|
| 284 |
+
"1992",
|
| 285 |
+
"1994",
|
| 286 |
+
"1997",
|
| 287 |
+
"1999",
|
| 288 |
+
"2000",
|
| 289 |
+
"2005",
|
| 290 |
+
"2007",
|
| 291 |
+
"2015",
|
| 292 |
+
"2016"
|
| 293 |
+
]
|
| 294 |
+
},
|
| 295 |
+
{
|
| 296 |
+
"page": 113,
|
| 297 |
+
"table_index": 0,
|
| 298 |
+
"source_file": "infosys-ar-25.pdf",
|
| 299 |
+
"text_representation": "Table on page 113:\nCodes India None Global\n NSE BSE NYSE",
|
| 300 |
+
"headers": [
|
| 301 |
+
"Codes",
|
| 302 |
+
"India",
|
| 303 |
+
"None",
|
| 304 |
+
"Global"
|
| 305 |
+
]
|
| 306 |
+
},
|
| 307 |
+
{
|
| 308 |
+
"page": 114,
|
| 309 |
+
"table_index": 0,
|
| 310 |
+
"source_file": "infosys-ar-25.pdf",
|
| 311 |
+
"text_representation": "Table on page 114:\n No. of holders % to holders\n1,95,179 7.67\\n8,52,897 33.5\\n8,20,163 32.21\\n2,85,965 11.23\\n1,81,115 7.11\\n1,23,806 4.86\\n44,476 1.75\\n31,594 1.24\\n4,453 0.17\\n6,586 0.26\\n25,46,234 100",
|
| 312 |
+
"headers": [
|
| 313 |
+
"No. of holders % to holders"
|
| 314 |
+
]
|
| 315 |
+
},
|
| 316 |
+
{
|
| 317 |
+
"page": 122,
|
| 318 |
+
"table_index": 0,
|
| 319 |
+
"source_file": "infosys-ar-25.pdf",
|
| 320 |
+
"text_representation": "Table on page 122:\n Board of Directors\nRisk Management\\nCommittee (RMC)\\nof the Board",
|
| 321 |
+
"headers": [
|
| 322 |
+
"Board of Directors"
|
| 323 |
+
]
|
| 324 |
+
},
|
| 325 |
+
{
|
| 326 |
+
"page": 122,
|
| 327 |
+
"table_index": 1,
|
| 328 |
+
"source_file": "infosys-ar-25.pdf",
|
| 329 |
+
"text_representation": "Table on page 122:\n Cybersecurity\\nSub-Committee\n Risk councils\n Office of\\nrisk management\n Sub-risk councils\n Unit risk councils\nProject and\\naccount risk teams",
|
| 330 |
+
"headers": [
|
| 331 |
+
"Cybersecurity\nSub-Committee"
|
| 332 |
+
]
|
| 333 |
+
},
|
| 334 |
+
{
|
| 335 |
+
"page": 198,
|
| 336 |
+
"table_index": 0,
|
| 337 |
+
"source_file": "infosys-ar-25.pdf",
|
| 338 |
+
"text_representation": "Table on page 198:\n Other comprehensive income\nEquity Effective Other items\\ninstruments portion of other\\nthrough other of Cash comprehensive\\ncomprehensive flow income / (loss)\\nincome hedges\n 260 (5) (519)\n \n \u2013 \u2013 \u2013\n \u2013 \u2013 128\n 19 \u2013 \u2013\n \u2013 11 \u2013\n \u2013 \u2013 129\n 19 11 257\n \u2013 \u2013 \u2013",
|
| 339 |
+
"headers": [
|
| 340 |
+
"Other comprehensive income"
|
| 341 |
+
]
|
| 342 |
+
},
|
| 343 |
+
{
|
| 344 |
+
"page": 199,
|
| 345 |
+
"table_index": 0,
|
| 346 |
+
"source_file": "infosys-ar-25.pdf",
|
| 347 |
+
"text_representation": "Table on page 199:\n Other comprehensive income\nEquity Effective Other items\\ninstruments portion of other\\nthrough other of Cash comprehensive\\ncomprehensive flow income / (loss)\\nincome hedges\n \u2013 \u2013 \u2013\n \u2013 \u2013 \u2013\n \u2013 \u2013 \u2013\n \u2013 \u2013 \u2013\n \u2013 \u2013 \u2013\n \u2013 \u2013 \u2013\n \u2013 \u2013 \u2013\n 279 6 (262)",
|
| 348 |
+
"headers": [
|
| 349 |
+
"Other comprehensive income"
|
| 350 |
+
]
|
| 351 |
+
},
|
| 352 |
+
{
|
| 353 |
+
"page": 200,
|
| 354 |
+
"table_index": 0,
|
| 355 |
+
"source_file": "infosys-ar-25.pdf",
|
| 356 |
+
"text_representation": "Table on page 200:\n Other comprehensive income\nEquity Effective Other items\\ninstruments portion of of other\\nthrough other Cash flow comprehensive\\ncomprehensive hedges income / (loss)\\nincome\n 279 6 (262)\n \n \u2013 \u2013 \u2013\n \u2013 \u2013 (81)\n 19 \u2013 \u2013\n \u2013 (24) \u2013\n \u2013 \u2013 191",
|
| 357 |
+
"headers": [
|
| 358 |
+
"Other comprehensive income"
|
| 359 |
+
]
|
| 360 |
+
},
|
| 361 |
+
{
|
| 362 |
+
"page": 201,
|
| 363 |
+
"table_index": 0,
|
| 364 |
+
"source_file": "infosys-ar-25.pdf",
|
| 365 |
+
"text_representation": "Table on page 201:\n Other comprehensive income\nEquity Effective Other items\\ninstruments portion of of other\\nthrough other Cash flow comprehensive\\ncomprehensive hedges income / (loss)\\nincome\n 19 (24) 110\n \u2013 \u2013 \u2013\n \u2013 \u2013 \u2013\n \u2013 \u2013 \u2013\n \u2013 \u2013 \u2013\n \u2013 \u2013 \u2013\n \u2013 \u2013 \u2013",
|
| 366 |
+
"headers": [
|
| 367 |
+
"Other comprehensive income"
|
| 368 |
+
]
|
| 369 |
+
},
|
| 370 |
+
{
|
| 371 |
+
"page": 202,
|
| 372 |
+
"table_index": 0,
|
| 373 |
+
"source_file": "infosys-ar-25.pdf",
|
| 374 |
+
"text_representation": "Table on page 202:\n Other comprehensive income\nEquity Effective Other items\\ninstruments portion of of other\\nthrough other Cash flow comprehensive\\ncomprehensive hedges income / (loss)\\nincome\n \u2013 \u2013 \u2013\n \u2013 \u2013 \u2013\n \u2013 \u2013 \u2013\n 298 (18) (152)",
|
| 375 |
+
"headers": [
|
| 376 |
+
"Other comprehensive income"
|
| 377 |
+
]
|
| 378 |
+
},
|
| 379 |
+
{
|
| 380 |
+
"page": 271,
|
| 381 |
+
"table_index": 0,
|
| 382 |
+
"source_file": "infosys-ar-25.pdf",
|
| 383 |
+
"text_representation": "Table on page 271:\n Name of the Company CIN Relationship\nIdunn Information Technology Private Limited (formerly known\\nas Danske IT and Support Services India Private Limited) U74900KA2012PTC063260 Subsidiary\n InSemi Technology Services Private Limited U72200KA2013PTC069109 Subsidiary\n Elbrus Labs Private Limited U72200DL2018PTC339939 Subsidiary\n in-tech Group India Private Limited U72900KL2022FTC076055 Subsidiary",
|
| 384 |
+
"headers": [
|
| 385 |
+
"Name of the Company",
|
| 386 |
+
"CIN",
|
| 387 |
+
"Relationship"
|
| 388 |
+
]
|
| 389 |
+
},
|
| 390 |
+
{
|
| 391 |
+
"page": 278,
|
| 392 |
+
"table_index": 0,
|
| 393 |
+
"source_file": "infosys-ar-25.pdf",
|
| 394 |
+
"text_representation": "Table on page 278:\n Other comprehensive income\nEquity Exchange Effective Other items\\ninstruments differences portion of other\\nthrough other on of Cash comprehensive\\ncomprehensive translating Flow income / (loss)\\nincome the Hedges\\nfinancial\\nstatements\\nof a\\nforeign\\noperation\n 247 2,325 (5) (540)\n \n \u2013 \u2013 \u2013 \u2013\n \u2013 \u2013 \u2013 120\n 19 \u2013 \u2013 \u2013\n \u2013 \u2013 11 \u2013\n \u2013 227 \u2013 \u2013\n \u2013 \u2013 \u2013 144\n 19 227 11 264\n \u2013 \u2013 \u2013 \u2013\n \u2013 \u2013 \u2013 \u2013",
|
| 395 |
+
"headers": [
|
| 396 |
+
"Other comprehensive income"
|
| 397 |
+
]
|
| 398 |
+
},
|
| 399 |
+
{
|
| 400 |
+
"page": 279,
|
| 401 |
+
"table_index": 0,
|
| 402 |
+
"source_file": "infosys-ar-25.pdf",
|
| 403 |
+
"text_representation": "Table on page 279:\n Other comprehensive income\nEquity Exchange Effective Other items\\ninstruments differences portion of other\\nthrough other on of Cash comprehensive\\ncomprehensive translating Flow income / (loss)\\nincome the Hedges\\nfinancial\\nstatements\\nof a\\nforeign\\noperation\n \u2013 \u2013 \u2013 \u2013\n \u2013 \u2013 \u2013 \u2013\n \u2013 \u2013 \u2013 \u2013\n \u2013 \u2013 \u2013 \u2013\n \u2013 \u2013 \u2013 \u2013\n \u2013 \u2013 \u2013 \u2013\n \u2013 \u2013 \u2013 \u2013\n \u2013 \u2013 \u2013 \u2013\n \u2013 \u2013 \u2013 \u2013\n 266 2,552 6 (276)",
|
| 404 |
+
"headers": [
|
| 405 |
+
"Other comprehensive income"
|
| 406 |
+
]
|
| 407 |
+
},
|
| 408 |
+
{
|
| 409 |
+
"page": 280,
|
| 410 |
+
"table_index": 0,
|
| 411 |
+
"source_file": "infosys-ar-25.pdf",
|
| 412 |
+
"text_representation": "Table on page 280:\n Other comprehensive income\nEquity Exchange Effective Other items\\ninstruments differences portion of other\\nthrough other on of cash comprehensive\\ncomprehensive translating flow income /\\nincomethe financial hedges (loss)\\nstatements\\nof a foreign\\noperation\n 266 2,552 6 (276)\n \n \u2013 \u2013 \u2013 \u2013\n \u2013 \u2013 \u2013 (92)\n 19 \u2013 \u2013 \u2013\n \u2013 \u2013 (24) \u2013\n \u2013 352 \u2013 \u2013\n \u2013 \u2013 \u2013 199\n 19 352 (24) 107\n \u2013 \u2013 \u2013 \u2013\n \u2013 \u2013 \u2013 \u2013",
|
| 413 |
+
"headers": [
|
| 414 |
+
"Other comprehensive income"
|
| 415 |
+
]
|
| 416 |
+
},
|
| 417 |
+
{
|
| 418 |
+
"page": 281,
|
| 419 |
+
"table_index": 0,
|
| 420 |
+
"source_file": "infosys-ar-25.pdf",
|
| 421 |
+
"text_representation": "Table on page 281:\n Other comprehensive income\nEquity Exchange Effective Other items\\ninstruments differences portion of other\\nthrough other on of cash comprehensive\\ncomprehensive translating flow income /\\nincomethe financial hedges (loss)\\nstatements\\nof a foreign\\noperation\n \u2013 \u2013 \u2013 \u2013\n \u2013 \u2013 \u2013 \u2013\n \u2013 \u2013 \u2013 \u2013\n \u2013 \u2013 \u2013 \u2013\n \u2013 \u2013 \u2013 \u2013\n \u2013 \u2013 \u2013 \u2013\n \u2013 \u2013 \u2013 \u2013\n \u2013 \u2013 \u2013 \u2013\n \u2013 \u2013 \u2013 \u2013\n 285 2,904 (18) (169)",
|
| 422 |
+
"headers": [
|
| 423 |
+
"Other comprehensive income"
|
| 424 |
+
]
|
| 425 |
+
},
|
| 426 |
+
{
|
| 427 |
+
"page": 350,
|
| 428 |
+
"table_index": 0,
|
| 429 |
+
"source_file": "infosys-ar-25.pdf",
|
| 430 |
+
"text_representation": "Table on page 350:\nMembers having valid Permanent Account Number (PAN) 10%* or as notified by the Government of India (GOI)\n Members not having PAN / valid PAN 20% or as notified by the GOI",
|
| 431 |
+
"headers": [
|
| 432 |
+
"Members having valid Permanent Account Number (PAN)",
|
| 433 |
+
"10%* or as notified by the Government of India (GOI)"
|
| 434 |
+
]
|
| 435 |
+
},
|
| 436 |
+
{
|
| 437 |
+
"page": 352,
|
| 438 |
+
"table_index": 1,
|
| 439 |
+
"source_file": "infosys-ar-25.pdf",
|
| 440 |
+
"text_representation": "Table on page 352:\nForm for availing investor services to register PAN, email address, bank details and other KYC details or\\nchanges / update thereof for securities held in physical mode\n Update of signature of securities holder\n For nomination as provided in Rule 19(1) of the Companies (Share Capital and\\nDebentures) Rules, 2014\n Declaration to opt out\n Cancellation of nomination by the holder(s) (along with ISR-3) / Change of nominee\n Form for requesting issue of duplicate certificate and other service requests for shares / debentures /\\nbonds, etc., held in physical mode\n Please contact your DP and register your email address and bank account details in your demat account,\\nas per the process advised by your DP.",
|
| 441 |
+
"headers": [
|
| 442 |
+
"Form for availing investor services to register PAN, email address, bank details and other KYC details or\nchanges / update thereof for securities held in physical mode"
|
| 443 |
+
]
|
| 444 |
+
},
|
| 445 |
+
{
|
| 446 |
+
"page": 363,
|
| 447 |
+
"table_index": 0,
|
| 448 |
+
"source_file": "infosys-ar-25.pdf",
|
| 449 |
+
"text_representation": "Table on page 363:\n Access the VC portal by clicking this link: https://agm.\\nonwingspan.com/InfosysAGM or you could also join the AGM\\nby visiting the investor page on our Company\u2019s website,\\nwww.infosys.com\nLog in to join the VC session by using your DP ID and Client ID /\\nFolio Number together with your PAN\\na) Members with NSDL account: 8-character DP ID followed by\\n8-digit Client ID\\n(For example, if your DP ID is IN300*** and Client ID is 12******,\\nthen your user ID is IN300***12******).\\nb) Members with CDSL account: 16-digit Beneficiary ID (For\\nexample, if your Beneficiary ID is 12**************, then your\\nuser ID is 12**************).\\nc) Members with physical folio: ITL + Folio Number registered\\nwith the Company\\n(For example, if your Folio Number is 0*****, then your user ID\\nis ITL0*****)",
|
| 450 |
+
"headers": [
|
| 451 |
+
"Access the VC portal by clicking this link: https://agm.\nonwingspan.com/InfosysAGM or you could also join the AGM\nby visiting the investor page on our Company\u2019s website,\nwww.infosys.com"
|
| 452 |
+
]
|
| 453 |
+
}
|
| 454 |
+
]
|