sumitnewold commited on
Commit
76bd1fc
·
verified ·
1 Parent(s): 2783e90

Upload 10 files

Browse files
Files changed (11) hide show
  1. .gitattributes +1 -0
  2. DEPLOY.md +77 -0
  3. README.md +30 -13
  4. app.py +300 -0
  5. infosys-ar-25.pdf +3 -0
  6. phase1_ingestion.py +200 -0
  7. phase2_retrieval.py +326 -0
  8. phase3_agent.py +572 -0
  9. phase4_tools.py +363 -0
  10. requirements.txt +35 -2
  11. 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: RAG
3
- emoji: 🚀
4
- colorFrom: red
5
- colorTo: red
6
- sdk: docker
7
- app_port: 8501
8
- tags:
9
- - streamlit
10
  pinned: false
11
- short_description: Finance base RAG
12
  ---
13
 
14
- # Welcome to Streamlit!
15
 
16
- Edit `/src/streamlit_app.py` to customize this app to your heart's desire. :heart:
 
 
 
17
 
18
- If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
19
- forums](https://discuss.streamlit.io).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- altair
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  pandas
3
- streamlit
 
 
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
+ ]