Sayandip commited on
Commit
dfb28c6
Β·
verified Β·
1 Parent(s): 24d3669

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +484 -0
app.py ADDED
@@ -0,0 +1,484 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+ import fitz # pymupdf
3
+ import math
4
+ import re
5
+ import json
6
+ import time
7
+ from google import genai
8
+ from google.genai import types
9
+
10
+ # ─── Chunking ─────────────────────────────────────────────────────────────────
11
+
12
+ CHUNK_SIZE = 800 # chars β€” bigger is fine, embedding-2 handles 8192 tokens
13
+ CHUNK_OVERLAP = 120
14
+
15
+
16
+ def split_into_chunks(text: str) -> list:
17
+ paragraphs = [p.strip() for p in re.split(r"\n{2,}", text) if p.strip()]
18
+ chunks = []
19
+ for para in paragraphs:
20
+ if len(para) <= CHUNK_SIZE:
21
+ chunks.append(para)
22
+ else:
23
+ i = 0
24
+ while i < len(para):
25
+ chunks.append(para[i : i + CHUNK_SIZE].strip())
26
+ if i + CHUNK_SIZE >= len(para):
27
+ break
28
+ i += CHUNK_SIZE - CHUNK_OVERLAP
29
+ return [c for c in chunks if c]
30
+
31
+
32
+ # ─── Dense Vector Store ────────────────────────────────────────────────────────
33
+
34
+ def cosine_similarity(a, b):
35
+ dot = sum(x * y for x, y in zip(a, b))
36
+ na = math.sqrt(sum(x * x for x in a))
37
+ nb = math.sqrt(sum(x * x for x in b))
38
+ return dot / (na * nb) if na and nb else 0.0
39
+
40
+
41
+ class EmbeddingStore:
42
+ """
43
+ In-memory dense vector store backed by gemini-embedding-2-preview.
44
+ Uses 1536-dim MRL truncation: excellent quality, half the storage of 3072.
45
+ """
46
+ EMBED_MODEL = "gemini-embedding-2-preview"
47
+ EMBED_DIM = 1536
48
+ BATCH_SIZE = 20
49
+
50
+ def __init__(self):
51
+ self.chunks = [] # list of {id, text, embedding}
52
+ self.client = None
53
+ self._api_key = ""
54
+
55
+ def set_client(self, api_key):
56
+ if api_key != self._api_key:
57
+ self.client = genai.Client(api_key=api_key)
58
+ self._api_key = api_key
59
+
60
+ def clear(self):
61
+ self.chunks = []
62
+
63
+ def _embed_batch(self, texts):
64
+ response = self.client.models.embed_content(
65
+ model=self.EMBED_MODEL,
66
+ contents=texts,
67
+ config=types.EmbedContentConfig(
68
+ task_type="RETRIEVAL_DOCUMENT",
69
+ output_dimensionality=self.EMBED_DIM,
70
+ ),
71
+ )
72
+ return [list(e.values) for e in response.embeddings]
73
+
74
+ def _embed_query(self, query):
75
+ response = self.client.models.embed_content(
76
+ model=self.EMBED_MODEL,
77
+ contents=query,
78
+ config=types.EmbedContentConfig(
79
+ task_type="RETRIEVAL_QUERY",
80
+ output_dimensionality=self.EMBED_DIM,
81
+ ),
82
+ )
83
+ return list(response.embeddings[0].values)
84
+
85
+ def add_chunks(self, texts, progress_cb=None):
86
+ start_idx = len(self.chunks)
87
+ total = len(texts)
88
+ added = 0
89
+
90
+ for batch_start in range(0, total, self.BATCH_SIZE):
91
+ batch = texts[batch_start : batch_start + self.BATCH_SIZE]
92
+ embeds = self._embed_batch(batch)
93
+
94
+ for text, emb in zip(batch, embeds):
95
+ cid = f"chunk_{start_idx + added}"
96
+ self.chunks.append({"id": cid, "text": text, "embedding": emb})
97
+ added += 1
98
+
99
+ if progress_cb:
100
+ progress_cb(added, total)
101
+
102
+ if batch_start + self.BATCH_SIZE < total:
103
+ time.sleep(0.3) # rate-limit buffer
104
+
105
+ return added
106
+
107
+ def search(self, query, k=5):
108
+ if not self.chunks:
109
+ return []
110
+ qvec = self._embed_query(query)
111
+ scored = [
112
+ {"id": c["id"], "text": c["text"],
113
+ "score": cosine_similarity(qvec, c["embedding"])}
114
+ for c in self.chunks
115
+ ]
116
+ scored.sort(key=lambda x: x["score"], reverse=True)
117
+ return scored[:k]
118
+
119
+ def get_by_id(self, cid):
120
+ return next((c for c in self.chunks if c["id"] == cid), None)
121
+
122
+ def overview(self):
123
+ return [{"id": c["id"], "preview": c["text"][:120] + "..."} for c in self.chunks]
124
+
125
+ @property
126
+ def size(self):
127
+ return len(self.chunks)
128
+
129
+
130
+ # ─── PDF Extraction ───────────────────────────────────────────────────────────
131
+
132
+ def extract_pdf_text(pdf_path):
133
+ doc = fitz.open(pdf_path)
134
+ pages = [page.get_text() for page in doc]
135
+ doc.close()
136
+ return "\n\n".join(pages)
137
+
138
+
139
+ # ─── Agent Tool Declarations ──────────────────────────────────────────────────
140
+
141
+ TOOL_DECLARATIONS = [
142
+ types.FunctionDeclaration(
143
+ name="search_chunks",
144
+ description=(
145
+ "Semantically search the PDF corpus using Gemini Embedding 2 dense vectors. "
146
+ "Returns top-k chunks ranked by cosine similarity. "
147
+ "Call multiple times with different queries to find different aspects."
148
+ ),
149
+ parameters=types.Schema(
150
+ type=types.Type.OBJECT,
151
+ properties={
152
+ "query": types.Schema(
153
+ type=types.Type.STRING,
154
+ description="Focused natural-language search query."
155
+ ),
156
+ "k": types.Schema(
157
+ type=types.Type.INTEGER,
158
+ description="Number of chunks to return (1-8, default 4)"
159
+ ),
160
+ },
161
+ required=["query"],
162
+ ),
163
+ ),
164
+ types.FunctionDeclaration(
165
+ name="get_chunk_by_id",
166
+ description="Retrieve the full text of a specific chunk by its ID. Use when a search preview isn't enough.",
167
+ parameters=types.Schema(
168
+ type=types.Type.OBJECT,
169
+ properties={
170
+ "chunk_id": types.Schema(type=types.Type.STRING, description="e.g. chunk_5"),
171
+ },
172
+ required=["chunk_id"],
173
+ ),
174
+ ),
175
+ types.FunctionDeclaration(
176
+ name="list_all_chunks",
177
+ description="Get an overview of all chunks (IDs + first 120 chars each). Use to understand corpus scope.",
178
+ parameters=types.Schema(type=types.Type.OBJECT, properties={}),
179
+ ),
180
+ ]
181
+
182
+ AGENT_TOOLS = [types.Tool(function_declarations=TOOL_DECLARATIONS)]
183
+
184
+ SYSTEM_PROMPT = """You are a precise RAG (Retrieval-Augmented Generation) agent.
185
+
186
+ You have access to a PDF corpus via semantic search tools powered by Gemini Embedding 2 (dense vectors).
187
+
188
+ To answer questions:
189
+ 1. PLAN what information you need
190
+ 2. RETRIEVE using search_chunks β€” run multiple searches with different phrasings for multi-part questions
191
+ 3. EXPAND with get_chunk_by_id when you need the full text of a promising chunk
192
+ 4. SYNTHESIZE a final answer grounded only in retrieved content
193
+
194
+ Rules:
195
+ - Always retrieve before answering β€” never rely on prior knowledge alone
196
+ - Cite chunk IDs inline e.g. [chunk_2] for every factual claim
197
+ - If retrieved chunks lack sufficient information, say so clearly
198
+ - Be thorough but concise"""
199
+
200
+
201
+ # ─── Agentic Loop ─────────────────────────────────────────────────────────────
202
+
203
+ def run_agent(api_key, query, store, history, log):
204
+ if not api_key.strip():
205
+ yield history, log + "\n❌ Enter your Gemini API key.", store
206
+ return
207
+ if not store or store.size == 0:
208
+ yield history, log + "\n❌ No PDF loaded. Upload and embed one first.", store
209
+ return
210
+ if not query.strip():
211
+ return
212
+
213
+ store.set_client(api_key.strip())
214
+ client = genai.Client(api_key=api_key.strip())
215
+
216
+ history = history + [{"role": "user", "content": query}]
217
+ log_out = log + f"\n\n{'─'*52}\nπŸ” {query}\n"
218
+ yield history, log_out, store
219
+
220
+ # Build Gemini message list
221
+ gemini_msgs = []
222
+ for msg in history:
223
+ role = "user" if msg["role"] == "user" else "model"
224
+ gemini_msgs.append(
225
+ types.Content(role=role, parts=[types.Part(text=msg["content"])])
226
+ )
227
+
228
+ MAX_STEPS = 12
229
+ step = 0
230
+
231
+ while step < MAX_STEPS:
232
+ step += 1
233
+ log_out += f"\nβš™οΈ step {step}\n"
234
+ yield history, log_out, store
235
+
236
+ response = client.models.generate_content(
237
+ model="gemini-2.0-flash",
238
+ contents=gemini_msgs,
239
+ config=types.GenerateContentConfig(
240
+ system_instruction=SYSTEM_PROMPT,
241
+ tools=AGENT_TOOLS,
242
+ temperature=0.2,
243
+ max_output_tokens=2048,
244
+ ),
245
+ )
246
+
247
+ candidate = response.candidates[0]
248
+ parts = candidate.content.parts
249
+ gemini_msgs.append(types.Content(role="model", parts=parts))
250
+
251
+ has_tool_call = False
252
+ tool_responses = []
253
+ text_parts = []
254
+
255
+ for part in parts:
256
+ if hasattr(part, "text") and part.text and part.text.strip():
257
+ log_out += f"\nπŸ’­ {part.text.strip()[:400]}\n"
258
+ yield history, log_out, store
259
+ text_parts.append(part.text)
260
+
261
+ if hasattr(part, "function_call") and part.function_call:
262
+ has_tool_call = True
263
+ fn = part.function_call
264
+ name = fn.name
265
+ args = dict(fn.args) if fn.args else {}
266
+
267
+ log_out += f"\nπŸ”§ {name}({json.dumps(args)})\n"
268
+ yield history, log_out, store
269
+
270
+ if name == "search_chunks":
271
+ results = store.search(args.get("query", ""), k=int(args.get("k", 4)))
272
+ result = {"results": [
273
+ {"id": r["id"], "score": round(r["score"], 4), "text": r["text"]}
274
+ for r in results
275
+ ]}
276
+ log_out += f" ↳ {len(results)} chunks: {', '.join(r['id'] for r in results)}\n"
277
+
278
+ elif name == "get_chunk_by_id":
279
+ chunk = store.get_by_id(args.get("chunk_id", ""))
280
+ result = ({"id": chunk["id"], "text": chunk["text"]}
281
+ if chunk else {"error": "Chunk not found"})
282
+ log_out += f" ↳ fetched {args.get('chunk_id')}\n"
283
+
284
+ elif name == "list_all_chunks":
285
+ result = {"chunks": store.overview()}
286
+ log_out += f" ↳ {store.size} chunks listed\n"
287
+
288
+ else:
289
+ result = {"error": f"Unknown tool: {name}"}
290
+
291
+ yield history, log_out, store
292
+
293
+ tool_responses.append(types.Part(
294
+ function_response=types.FunctionResponse(name=name, response=result)
295
+ ))
296
+
297
+ if has_tool_call and tool_responses:
298
+ gemini_msgs.append(types.Content(role="user", parts=tool_responses))
299
+ continue
300
+
301
+ # No tool calls β†’ final answer
302
+ final = "\n".join(text_parts).strip() or "(No response generated)"
303
+ history = history + [{"role": "assistant", "content": final}]
304
+ log_out += "\nβœ… Answer ready\n"
305
+ yield history, log_out, store
306
+ return
307
+
308
+ log_out += f"\n⚠️ Max steps ({MAX_STEPS}) reached.\n"
309
+ yield history, log_out, store
310
+
311
+
312
+ # ─── PDF Processing (generator for live status) ───────────────────────────────
313
+
314
+ def process_pdf(pdf_file, api_key, store):
315
+ if pdf_file is None:
316
+ yield store, "⚠️ No file uploaded.", "0 chunks", gr.update()
317
+ return
318
+ if not api_key.strip():
319
+ yield store, "❌ Enter your Gemini API key first.", "0 chunks", gr.update()
320
+ return
321
+
322
+ try:
323
+ new_store = EmbeddingStore()
324
+ new_store.set_client(api_key.strip())
325
+
326
+ yield new_store, "πŸ“„ Extracting text from PDF…", "…", gr.update(interactive=False)
327
+ text = extract_pdf_text(pdf_file.name)
328
+ if not text.strip():
329
+ yield new_store, "❌ No extractable text in this PDF.", "0 chunks", gr.update(interactive=True)
330
+ return
331
+
332
+ char_count = len(text)
333
+
334
+ yield new_store, f"βœ‚οΈ Splitting into chunks…", "…", gr.update(interactive=False)
335
+ chunks = split_into_chunks(text)
336
+ total = len(chunks)
337
+
338
+ yield new_store, f"πŸ”’ Embedding {total} chunks via gemini-embedding-2-preview…\n(this may take a moment)", f"0 / {total}", gr.update(interactive=False)
339
+
340
+ added = new_store.add_chunks(chunks)
341
+
342
+ msg = (
343
+ f"βœ… Ready! {char_count:,} chars β†’ {added} chunks\n"
344
+ f" Model : gemini-embedding-2-preview\n"
345
+ f" Dims : {new_store.EMBED_DIM} (MRL truncated from 3072)"
346
+ )
347
+ yield new_store, msg, f"{added} chunks", gr.update(interactive=True)
348
+
349
+ except Exception as e:
350
+ yield store, f"❌ {e}", "0 chunks", gr.update(interactive=True)
351
+
352
+
353
+ def clear_chat():
354
+ return [], ""
355
+
356
+
357
+ # ─── UI ───────────────────────────────────────────────────────────────────────
358
+
359
+ CSS = """
360
+ @import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,400;0,500;1,400&family=IBM+Plex+Sans:wght@300;400;500&display=swap');
361
+ body, .gradio-container { font-family: 'IBM Plex Sans', sans-serif !important; }
362
+ .status-out textarea, .chunk-badge textarea {
363
+ font-family: 'IBM Plex Mono', monospace !important;
364
+ font-size: 12px !important;
365
+ }
366
+ .log-out textarea {
367
+ font-family: 'IBM Plex Mono', monospace !important;
368
+ font-size: 11px !important;
369
+ color: #666 !important;
370
+ }
371
+ """
372
+
373
+ HEADER = """
374
+ <div style="padding:16px 4px 12px; border-bottom:1px solid #e5e7eb; margin-bottom:8px">
375
+ <h1 style="font-family:'IBM Plex Mono',monospace; font-size:18px; font-weight:500; color:#111; margin:0">
376
+ rag_agent
377
+ </h1>
378
+ <p style="font-size:11px; color:#999; margin:4px 0 0; font-family:'IBM Plex Mono',monospace">
379
+ gemini-embedding-2-preview &nbsp;Β·&nbsp; 1536-dim dense retrieval &nbsp;Β·&nbsp; gemini-2.0-flash agentic loop
380
+ </p>
381
+ </div>
382
+ """
383
+
384
+ with gr.Blocks(title="RAG Agent") as demo:
385
+
386
+ store_state = gr.State(EmbeddingStore())
387
+
388
+ gr.HTML(HEADER)
389
+
390
+ with gr.Row(equal_height=False):
391
+
392
+ # Left panel
393
+ with gr.Column(scale=1, min_width=290):
394
+
395
+ api_key = gr.Textbox(
396
+ label="Gemini API Key",
397
+ placeholder="AIza…",
398
+ type="password",
399
+ lines=1,
400
+ )
401
+ pdf_upload = gr.File(
402
+ label="Upload PDF",
403
+ file_types=[".pdf"],
404
+ type="filepath",
405
+ )
406
+ process_btn = gr.Button("βš™ Embed PDF", variant="primary")
407
+ chunk_badge = gr.Textbox(
408
+ label="Index",
409
+ value="0 chunks",
410
+ interactive=False,
411
+ lines=1,
412
+ elem_classes="chunk-badge",
413
+ )
414
+ pdf_status = gr.Textbox(
415
+ label="Status",
416
+ value="Upload a PDF and click Embed.",
417
+ interactive=False,
418
+ lines=4,
419
+ elem_classes="status-out",
420
+ )
421
+ with gr.Accordion("Agent trace log", open=False):
422
+ agent_log = gr.Textbox(
423
+ label="",
424
+ value="",
425
+ lines=22,
426
+ max_lines=400,
427
+ interactive=False,
428
+ elem_classes="log-out",
429
+ )
430
+
431
+ # Right panel β€” chat
432
+ with gr.Column(scale=2):
433
+ chatbot = gr.Chatbot(
434
+ label="",
435
+ height=520,
436
+ placeholder=(
437
+ "**How to use**\n\n"
438
+ "1. Paste your Gemini API key\n"
439
+ "2. Upload a PDF β†’ click **Embed PDF**\n"
440
+ " (chunks get embedded via `gemini-embedding-2-preview`)\n"
441
+ "3. Ask questions β€” the agent retrieves, reasons, and answers with citations"
442
+ ),
443
+ render_markdown=True,
444
+ show_label=False,
445
+ )
446
+ with gr.Row():
447
+ query_box = gr.Textbox(
448
+ placeholder="Ask a question about your document…",
449
+ label="",
450
+ lines=2,
451
+ scale=5,
452
+ show_label=False,
453
+ )
454
+ with gr.Column(scale=1, min_width=110):
455
+ send_btn = gr.Button("Ask β†’", variant="primary")
456
+ clear_btn = gr.Button("Clear", variant="secondary")
457
+
458
+ # Wiring
459
+ process_btn.click(
460
+ fn=process_pdf,
461
+ inputs=[pdf_upload, api_key, store_state],
462
+ outputs=[store_state, pdf_status, chunk_badge, send_btn],
463
+ )
464
+
465
+ def ask(api_key, query, store, history, log):
466
+ yield from run_agent(api_key, query, store, history, log)
467
+
468
+ send_btn.click(
469
+ fn=ask,
470
+ inputs=[api_key, query_box, store_state, chatbot, agent_log],
471
+ outputs=[chatbot, agent_log, store_state],
472
+ show_progress="hidden",
473
+ )
474
+ query_box.submit(
475
+ fn=ask,
476
+ inputs=[api_key, query_box, store_state, chatbot, agent_log],
477
+ outputs=[chatbot, agent_log, store_state],
478
+ show_progress="hidden",
479
+ )
480
+ clear_btn.click(fn=clear_chat, outputs=[chatbot, agent_log])
481
+
482
+
483
+ if __name__ == "__main__":
484
+ demo.launch(server_name="0.0.0.0", server_port=7860, share=False, css=CSS)