Miladsaeedi70 commited on
Commit
725c26b
·
1 Parent(s): 77d5515

Deploy ResearchGPT application

Browse files
.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
+ *.faiss filter=lfs diff=lfs merge=lfs -text
app.py ADDED
@@ -0,0 +1,1050 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import faiss
11
+ import gradio as gr
12
+ import numpy as np
13
+ from google import genai
14
+ from rank_bm25 import BM25Okapi
15
+ from sentence_transformers import CrossEncoder, SentenceTransformer
16
+
17
+
18
+ # =============================================================================
19
+ # Configuration
20
+ # =============================================================================
21
+
22
+ BASE_DIR = Path(__file__).resolve().parent
23
+ ARTIFACT_DIR = BASE_DIR / "artifacts"
24
+
25
+ INDEX_PATH = ARTIFACT_DIR / "research_index.faiss"
26
+ CHUNKS_PATH = ARTIFACT_DIR / "chunks.json"
27
+ RETRIEVAL_TEXTS_PATH = ARTIFACT_DIR / "retrieval_texts.json"
28
+ CONFIG_PATH = ARTIFACT_DIR / "index_config.json"
29
+
30
+ GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-2.5-flash")
31
+
32
+ TOP_K = 8
33
+ FAISS_K = 50
34
+ BM25_K = 20
35
+ MAX_HISTORY_MESSAGES = 6
36
+
37
+ QUERY_EXPANSIONS = {
38
+ "gan": "generative adversarial network",
39
+ "gans": "generative adversarial networks",
40
+ "cnn": "convolutional neural network",
41
+ "llm": "large language model",
42
+ "llms": "large language models",
43
+ "lora": "low-rank adaptation",
44
+ "grpo": "group relative policy optimization",
45
+ "rnn": "recurrent neural network",
46
+ "lstm": "long short-term memory",
47
+ }
48
+
49
+
50
+ # =============================================================================
51
+ # Data structure
52
+ # =============================================================================
53
+
54
+ @dataclass
55
+ class Chunk:
56
+ text: str
57
+ source: str
58
+ file_type: str
59
+ chunk_id: str
60
+
61
+ page: int | None = None
62
+ document_type: str | None = None
63
+ page_header: str | None = None
64
+
65
+ repository: str | None = None
66
+ relative_path: str | None = None
67
+ section: str | None = None
68
+
69
+
70
+ # =============================================================================
71
+ # Load saved artifacts
72
+ # =============================================================================
73
+
74
+ def require_file(path: Path) -> None:
75
+ if not path.exists():
76
+ raise FileNotFoundError(
77
+ f"Required artifact is missing: {path}\n"
78
+ "Run the notebook artifact-saving section first."
79
+ )
80
+
81
+
82
+ for required_path in (
83
+ INDEX_PATH,
84
+ CHUNKS_PATH,
85
+ RETRIEVAL_TEXTS_PATH,
86
+ CONFIG_PATH,
87
+ ):
88
+ require_file(required_path)
89
+
90
+
91
+ with CONFIG_PATH.open("r", encoding="utf-8") as file:
92
+ index_config = json.load(file)
93
+
94
+ EMBEDDING_MODEL_NAME = index_config["embedding_model"]
95
+ RERANKER_MODEL_NAME = index_config["reranker_model"]
96
+ QUERY_INSTRUCTION = index_config["query_instruction"]
97
+
98
+ index = faiss.read_index(str(INDEX_PATH))
99
+
100
+ with CHUNKS_PATH.open("r", encoding="utf-8") as file:
101
+ chunks = [
102
+ Chunk(**item)
103
+ for item in json.load(file)
104
+ ]
105
+
106
+ with RETRIEVAL_TEXTS_PATH.open("r", encoding="utf-8") as file:
107
+ retrieval_texts: list[str] = json.load(file)
108
+
109
+
110
+ if index.ntotal != len(chunks):
111
+ raise ValueError(
112
+ f"FAISS contains {index.ntotal} vectors, "
113
+ f"but chunks.json contains {len(chunks)} chunks."
114
+ )
115
+
116
+ if len(chunks) != len(retrieval_texts):
117
+ raise ValueError(
118
+ "chunks.json and retrieval_texts.json contain "
119
+ "different numbers of records."
120
+ )
121
+
122
+ if index.d != index_config["embedding_dimension"]:
123
+ raise ValueError(
124
+ "The FAISS embedding dimension does not match index_config.json."
125
+ )
126
+
127
+
128
+ # Load retrieval models once when the app starts.
129
+ embedding_model = SentenceTransformer(
130
+ EMBEDDING_MODEL_NAME
131
+ )
132
+
133
+ reranker = CrossEncoder(
134
+ RERANKER_MODEL_NAME
135
+ )
136
+
137
+
138
+ def tokenize_for_bm25(text: str) -> list[str]:
139
+ """
140
+ Tokenize text while preserving technical terms and filenames.
141
+ """
142
+
143
+ return re.findall(
144
+ r"\b[a-zA-Z0-9][a-zA-Z0-9_.+-]*\b",
145
+ text.lower(),
146
+ )
147
+
148
+
149
+ bm25 = BM25Okapi(
150
+ [
151
+ tokenize_for_bm25(text)
152
+ for text in retrieval_texts
153
+ ]
154
+ )
155
+
156
+
157
+ # =============================================================================
158
+ # Query processing
159
+ # =============================================================================
160
+
161
+ def expand_query(query: str) -> str:
162
+ """
163
+ Append full forms of common technical abbreviations.
164
+ """
165
+
166
+ words = re.findall(
167
+ r"\b[\w-]+\b",
168
+ query.lower(),
169
+ )
170
+
171
+ expansions = [
172
+ QUERY_EXPANSIONS[word]
173
+ for word in words
174
+ if word in QUERY_EXPANSIONS
175
+ ]
176
+
177
+ if not expansions:
178
+ return query
179
+
180
+ unique_expansions = list(
181
+ dict.fromkeys(expansions)
182
+ )
183
+
184
+ return query + " " + " ".join(unique_expansions)
185
+
186
+
187
+ def encode_query(query: str) -> np.ndarray:
188
+ """
189
+ Encode a query for semantic retrieval using the same
190
+ instruction used when building the index.
191
+ """
192
+
193
+ instructed_query = (
194
+ QUERY_INSTRUCTION
195
+ + query.strip()
196
+ )
197
+
198
+ query_embedding = embedding_model.encode(
199
+ [instructed_query],
200
+ normalize_embeddings=True,
201
+ )
202
+
203
+ return np.asarray(
204
+ query_embedding,
205
+ dtype="float32",
206
+ )
207
+
208
+
209
+ # =============================================================================
210
+ # FAISS and BM25 retrieval
211
+ # =============================================================================
212
+
213
+ def retrieve_faiss_candidates(
214
+ query: str,
215
+ top_k: int = FAISS_K,
216
+ ) -> list[dict[str, Any]]:
217
+ """
218
+ Retrieve semantic candidates from FAISS.
219
+ """
220
+
221
+ if top_k <= 0:
222
+ raise ValueError("top_k must be positive")
223
+
224
+ search_k = min(
225
+ top_k,
226
+ index.ntotal,
227
+ )
228
+
229
+ scores, indices = index.search(
230
+ encode_query(query),
231
+ search_k,
232
+ )
233
+
234
+ results = []
235
+
236
+ for rank, (score, chunk_index) in enumerate(
237
+ zip(scores[0], indices[0]),
238
+ start=1,
239
+ ):
240
+ if chunk_index < 0:
241
+ continue
242
+
243
+ results.append(
244
+ {
245
+ "chunk_index": int(chunk_index),
246
+ "faiss_score": float(score),
247
+ "faiss_rank": rank,
248
+ }
249
+ )
250
+
251
+ return results
252
+
253
+
254
+ def retrieve_bm25_candidates(
255
+ query: str,
256
+ top_k: int = BM25_K,
257
+ ) -> list[dict[str, Any]]:
258
+ """
259
+ Retrieve keyword candidates from BM25.
260
+ """
261
+
262
+ if top_k <= 0:
263
+ raise ValueError("top_k must be positive")
264
+
265
+ query_tokens = tokenize_for_bm25(query)
266
+
267
+ if not query_tokens:
268
+ return []
269
+
270
+ scores = bm25.get_scores(query_tokens)
271
+
272
+ top_indices = np.argsort(
273
+ scores
274
+ )[::-1][:top_k]
275
+
276
+ results = []
277
+
278
+ for rank, chunk_index in enumerate(
279
+ top_indices,
280
+ start=1,
281
+ ):
282
+ score = float(scores[chunk_index])
283
+
284
+ if score <= 0:
285
+ continue
286
+
287
+ results.append(
288
+ {
289
+ "chunk_index": int(chunk_index),
290
+ "bm25_score": score,
291
+ "bm25_rank": rank,
292
+ }
293
+ )
294
+
295
+ return results
296
+
297
+
298
+ def merge_candidates(
299
+ faiss_results: list[dict[str, Any]],
300
+ bm25_results: list[dict[str, Any]],
301
+ ) -> list[dict[str, Any]]:
302
+ """
303
+ Merge FAISS and BM25 candidates by chunk index.
304
+ """
305
+
306
+ merged: dict[int, dict[str, Any]] = {}
307
+
308
+ for result in faiss_results:
309
+ chunk_index = result["chunk_index"]
310
+
311
+ merged[chunk_index] = {
312
+ "chunk_index": chunk_index,
313
+ "faiss_score": result["faiss_score"],
314
+ "faiss_rank": result["faiss_rank"],
315
+ "bm25_score": None,
316
+ "bm25_rank": None,
317
+ "retrieved_by": {"faiss"},
318
+ }
319
+
320
+ for result in bm25_results:
321
+ chunk_index = result["chunk_index"]
322
+
323
+ if chunk_index not in merged:
324
+ merged[chunk_index] = {
325
+ "chunk_index": chunk_index,
326
+ "faiss_score": None,
327
+ "faiss_rank": None,
328
+ "bm25_score": result["bm25_score"],
329
+ "bm25_rank": result["bm25_rank"],
330
+ "retrieved_by": {"bm25"},
331
+ }
332
+ else:
333
+ merged[chunk_index]["bm25_score"] = (
334
+ result["bm25_score"]
335
+ )
336
+ merged[chunk_index]["bm25_rank"] = (
337
+ result["bm25_rank"]
338
+ )
339
+ merged[chunk_index]["retrieved_by"].add(
340
+ "bm25"
341
+ )
342
+
343
+ merged_results = list(
344
+ merged.values()
345
+ )
346
+
347
+ for result in merged_results:
348
+ result["retrieved_by"] = sorted(
349
+ result["retrieved_by"]
350
+ )
351
+
352
+ return merged_results
353
+
354
+
355
+ def retrieve_hybrid_candidates(
356
+ query: str,
357
+ faiss_k: int = FAISS_K,
358
+ bm25_k: int = BM25_K,
359
+ ) -> list[dict[str, Any]]:
360
+ """
361
+ Retrieve and combine FAISS and BM25 candidates.
362
+ """
363
+
364
+ faiss_results = retrieve_faiss_candidates(
365
+ query=query,
366
+ top_k=faiss_k,
367
+ )
368
+
369
+ bm25_results = retrieve_bm25_candidates(
370
+ query=query,
371
+ top_k=bm25_k,
372
+ )
373
+
374
+ return merge_candidates(
375
+ faiss_results=faiss_results,
376
+ bm25_results=bm25_results,
377
+ )
378
+
379
+
380
+ def enrich_candidates(
381
+ candidates: list[dict[str, Any]],
382
+ ) -> list[dict[str, Any]]:
383
+ """
384
+ Attach chunk text and metadata to retrieval candidates.
385
+ """
386
+
387
+ enriched_results = []
388
+
389
+ for candidate in candidates:
390
+ chunk_index = candidate["chunk_index"]
391
+ chunk = chunks[chunk_index]
392
+
393
+ enriched_results.append(
394
+ {
395
+ **candidate,
396
+ "text": chunk.text,
397
+ "retrieval_text": retrieval_texts[
398
+ chunk_index
399
+ ],
400
+ "source": chunk.source,
401
+ "file_type": chunk.file_type,
402
+ "page": chunk.page,
403
+ "document_type": chunk.document_type,
404
+ "page_header": chunk.page_header,
405
+ "repository": chunk.repository,
406
+ "relative_path": chunk.relative_path,
407
+ "section": chunk.section,
408
+ "chunk_id": chunk.chunk_id,
409
+ }
410
+ )
411
+
412
+ return enriched_results
413
+
414
+
415
+ def rerank_candidates(
416
+ query: str,
417
+ candidates: list[dict[str, Any]],
418
+ top_k: int = TOP_K,
419
+ ) -> list[dict[str, Any]]:
420
+ """
421
+ Rerank hybrid candidates using the cross-encoder.
422
+ """
423
+
424
+ if top_k <= 0:
425
+ raise ValueError("top_k must be positive")
426
+
427
+ if not candidates:
428
+ return []
429
+
430
+ query_chunk_pairs = [
431
+ [
432
+ query,
433
+ candidate["retrieval_text"],
434
+ ]
435
+ for candidate in candidates
436
+ ]
437
+
438
+ reranker_scores = reranker.predict(
439
+ query_chunk_pairs,
440
+ show_progress_bar=False,
441
+ )
442
+
443
+ reranked_results = []
444
+
445
+ for candidate, score in zip(
446
+ candidates,
447
+ reranker_scores,
448
+ ):
449
+ result = candidate.copy()
450
+ result["reranker_score"] = float(score)
451
+ reranked_results.append(result)
452
+
453
+ reranked_results.sort(
454
+ key=lambda result: result[
455
+ "reranker_score"
456
+ ],
457
+ reverse=True,
458
+ )
459
+
460
+ return reranked_results[:top_k]
461
+
462
+
463
+ def retrieve_with_reranking(
464
+ query: str,
465
+ top_k: int = TOP_K,
466
+ faiss_k: int = FAISS_K,
467
+ bm25_k: int = BM25_K,
468
+ ) -> list[dict[str, Any]]:
469
+ """
470
+ Run query expansion, hybrid retrieval, and reranking.
471
+ """
472
+
473
+ search_query = expand_query(query)
474
+
475
+ hybrid_candidates = retrieve_hybrid_candidates(
476
+ query=search_query,
477
+ faiss_k=faiss_k,
478
+ bm25_k=bm25_k,
479
+ )
480
+
481
+ enriched_candidates = enrich_candidates(
482
+ hybrid_candidates
483
+ )
484
+
485
+ return rerank_candidates(
486
+ query=query,
487
+ candidates=enriched_candidates,
488
+ top_k=top_k,
489
+ )
490
+
491
+
492
+ # =============================================================================
493
+ # Source formatting and context construction
494
+ # =============================================================================
495
+
496
+ def format_source_location(
497
+ result: dict[str, Any],
498
+ ) -> str:
499
+ """
500
+ Format source metadata for the LLM context.
501
+ """
502
+
503
+ if result.get("repository"):
504
+ location = (
505
+ f"GitHub repository: "
506
+ f"{result['repository']}"
507
+ )
508
+
509
+ if result.get("relative_path"):
510
+ location += (
511
+ f", file: "
512
+ f"{result['relative_path']}"
513
+ )
514
+
515
+ if result.get("section"):
516
+ location += (
517
+ f", section: "
518
+ f"{result['section']}"
519
+ )
520
+
521
+ return location
522
+
523
+ location = result.get(
524
+ "source",
525
+ "Unknown source",
526
+ )
527
+
528
+ if result.get("page") is not None:
529
+ location += (
530
+ f", page {result['page']}"
531
+ )
532
+
533
+ if result.get("document_type"):
534
+ location += (
535
+ f", {result['document_type']}"
536
+ )
537
+
538
+ return location
539
+
540
+
541
+ def build_context(
542
+ results: list[dict[str, Any]],
543
+ ) -> str:
544
+ """
545
+ Build numbered source blocks for answer generation.
546
+ """
547
+
548
+ context_parts = []
549
+
550
+ for source_number, result in enumerate(
551
+ results,
552
+ start=1,
553
+ ):
554
+ location = format_source_location(
555
+ result
556
+ )
557
+
558
+ context_parts.append(
559
+ f"[Source {source_number}: "
560
+ f"{location}]\n"
561
+ f"{result['text']}"
562
+ )
563
+
564
+ return "\n\n".join(context_parts)
565
+
566
+
567
+ def format_source(
568
+ source: dict[str, Any],
569
+ ) -> str:
570
+ """
571
+ Create a readable public source label.
572
+ """
573
+
574
+ if source.get("repository"):
575
+ location = (
576
+ f"GitHub: "
577
+ f"{source['repository']}"
578
+ )
579
+
580
+ if source.get("relative_path"):
581
+ location += (
582
+ f" / "
583
+ f"{source['relative_path']}"
584
+ )
585
+
586
+ if source.get("section"):
587
+ location += (
588
+ f" — "
589
+ f"{source['section']}"
590
+ )
591
+
592
+ return location
593
+
594
+ location = source.get(
595
+ "source",
596
+ "Unknown source",
597
+ )
598
+
599
+ if source.get("page") is not None:
600
+ location += (
601
+ f", page {source['page']}"
602
+ )
603
+
604
+ return location
605
+
606
+
607
+ def format_sources(
608
+ sources: list[dict[str, Any]],
609
+ ) -> str:
610
+ """
611
+ Format unique sources as Markdown.
612
+ """
613
+
614
+ if not sources:
615
+ return ""
616
+
617
+ lines = ["### Sources"]
618
+ seen_locations = set()
619
+
620
+ for source in sources:
621
+ location = format_source(source)
622
+
623
+ if location in seen_locations:
624
+ continue
625
+
626
+ seen_locations.add(location)
627
+ lines.append(f"- {location}")
628
+
629
+ return "\n".join(lines)
630
+
631
+
632
+ # =============================================================================
633
+ # Conversation history
634
+ # =============================================================================
635
+
636
+ def remove_source_section(
637
+ text: str,
638
+ ) -> str:
639
+ """
640
+ Remove the displayed source list from a previous response.
641
+ """
642
+
643
+ if not text:
644
+ return ""
645
+
646
+ marker = "\n\n---\n\n### Sources"
647
+
648
+ return text.split(
649
+ marker,
650
+ 1,
651
+ )[0].strip()
652
+
653
+
654
+ def format_chat_history(
655
+ history,
656
+ max_messages: int = MAX_HISTORY_MESSAGES,
657
+ ) -> str:
658
+ """
659
+ Convert recent Gradio history into readable text.
660
+
661
+ Supports newer message dictionaries and older tuple-style history.
662
+ """
663
+
664
+ if not history:
665
+ return ""
666
+
667
+ lines = []
668
+
669
+ for item in history[-max_messages:]:
670
+
671
+ if isinstance(item, dict):
672
+ role = str(
673
+ item.get("role", "")
674
+ ).strip().lower()
675
+
676
+ content = item.get(
677
+ "content",
678
+ "",
679
+ )
680
+
681
+ if not isinstance(content, str):
682
+ continue
683
+
684
+ content = content.strip()
685
+
686
+ if role == "assistant":
687
+ content = remove_source_section(
688
+ content
689
+ )
690
+
691
+ if (
692
+ content
693
+ and role in {"user", "assistant"}
694
+ ):
695
+ lines.append(
696
+ f"{role.capitalize()}: "
697
+ f"{content}"
698
+ )
699
+
700
+ elif (
701
+ isinstance(item, (list, tuple))
702
+ and len(item) == 2
703
+ ):
704
+ user_message, assistant_message = item
705
+
706
+ if (
707
+ isinstance(user_message, str)
708
+ and user_message.strip()
709
+ ):
710
+ lines.append(
711
+ f"User: "
712
+ f"{user_message.strip()}"
713
+ )
714
+
715
+ if isinstance(
716
+ assistant_message,
717
+ str,
718
+ ):
719
+ assistant_message = (
720
+ remove_source_section(
721
+ assistant_message
722
+ )
723
+ )
724
+
725
+ if assistant_message:
726
+ lines.append(
727
+ f"Assistant: "
728
+ f"{assistant_message}"
729
+ )
730
+
731
+ return "\n".join(lines)
732
+
733
+
734
+ # =============================================================================
735
+ # Gemini generation
736
+ # =============================================================================
737
+
738
+ if not os.getenv("GEMINI_API_KEY"):
739
+ raise RuntimeError(
740
+ "GEMINI_API_KEY is not configured. Add it as a private Secret "
741
+ "in the Hugging Face Space settings."
742
+ )
743
+
744
+ gemini_client = genai.Client()
745
+
746
+
747
+ def generate_answer(
748
+ prompt: str,
749
+ model_name: str = GEMINI_MODEL,
750
+ ) -> str:
751
+ """
752
+ Generate a response using the Gemini API.
753
+ """
754
+
755
+ interaction = gemini_client.interactions.create(
756
+ model=model_name,
757
+ input=prompt,
758
+ )
759
+
760
+ answer = interaction.output_text
761
+
762
+ if not answer:
763
+ raise RuntimeError(
764
+ "Gemini returned an empty response."
765
+ )
766
+
767
+ return answer.strip()
768
+
769
+
770
+ def rewrite_question_with_history(
771
+ question: str,
772
+ history,
773
+ model_name: str = GEMINI_MODEL,
774
+ ) -> str:
775
+ """
776
+ Rewrite a follow-up question as a standalone retrieval query.
777
+ """
778
+
779
+ history_text = format_chat_history(history)
780
+
781
+ if not history_text:
782
+ return question
783
+
784
+ prompt = f"""
785
+ Rewrite the latest question as a standalone search query.
786
+
787
+ Use the conversation only to resolve references such as:
788
+ he, his, it, that project, that paper, or the thesis.
789
+
790
+ Do not answer the question.
791
+ Do not add unsupported facts.
792
+ Return only the rewritten query.
793
+
794
+ Conversation:
795
+ {history_text}
796
+
797
+ Latest question:
798
+ {question}
799
+
800
+ Standalone query:
801
+ """.strip()
802
+
803
+ try:
804
+ rewritten = generate_answer(
805
+ prompt=prompt,
806
+ model_name=model_name,
807
+ ).strip()
808
+
809
+ return rewritten or question
810
+
811
+ except Exception:
812
+ return question
813
+
814
+
815
+ def build_prompt(
816
+ question: str,
817
+ results: list[dict[str, Any]],
818
+ history=None,
819
+ ) -> str:
820
+ """
821
+ Build the grounded answer-generation prompt.
822
+ """
823
+
824
+ context = build_context(results)
825
+ history_text = format_chat_history(history)
826
+
827
+ if not history_text:
828
+ history_text = "No previous conversation."
829
+
830
+ return f"""
831
+ You are ResearchGPT, a research and portfolio assistant for Milad Saeedi.
832
+
833
+ Use only the retrieved context as factual evidence.
834
+
835
+ Citation requirements:
836
+ 1. Every paragraph containing a factual claim must include at least one citation.
837
+ 2. Use citations exactly in this format: [Source 1], [Source 2], etc.
838
+ 3. Place citations immediately after the sentence or claim they support.
839
+ 4. Use only source numbers that appear in the retrieved context.
840
+ 5. Do not create a separate source list; the application adds it automatically.
841
+ 6. Do not omit citations in summaries, lists, or conclusions.
842
+
843
+ Additional instructions:
844
+ 1. Answer the latest question directly.
845
+ 2. Use conversation history only to resolve follow-up references.
846
+ 3. Do not treat conversation history as factual evidence.
847
+ 4. Do not invent publications, methods, results, skills, projects, or experience.
848
+ 5. If the retrieved context is insufficient, say so clearly.
849
+ 6. Avoid unsupported praise or subjective claims.
850
+ 7. Contact information may be provided only when explicitly requested.
851
+ 8. Synthesize the evidence instead of copying long passages.
852
+
853
+ Recent conversation:
854
+ {history_text}
855
+
856
+ Retrieved context:
857
+ {context}
858
+
859
+ Latest question:
860
+ {question}
861
+
862
+ Write a grounded answer with inline citations:
863
+ """.strip()
864
+
865
+
866
+ def has_inline_citations(answer: str) -> bool:
867
+ """
868
+ Check whether an answer contains at least one [Source N] citation.
869
+ """
870
+
871
+ return bool(
872
+ re.search(r"\[Source\s+\d+\]", answer)
873
+ )
874
+
875
+
876
+ def answer_question(
877
+ question: str,
878
+ history=None,
879
+ top_k: int = TOP_K,
880
+ faiss_k: int = FAISS_K,
881
+ bm25_k: int = BM25_K,
882
+ model_name: str = GEMINI_MODEL,
883
+ ) -> tuple[str, list[dict[str, Any]]]:
884
+ """
885
+ Retrieve evidence and generate a grounded answer.
886
+ """
887
+
888
+ question = question.strip()
889
+
890
+ if not question:
891
+ return "Please enter a question.", []
892
+
893
+ retrieval_query = rewrite_question_with_history(
894
+ question=question,
895
+ history=history,
896
+ model_name=model_name,
897
+ )
898
+
899
+ results = retrieve_with_reranking(
900
+ query=retrieval_query,
901
+ top_k=top_k,
902
+ faiss_k=faiss_k,
903
+ bm25_k=bm25_k,
904
+ )
905
+
906
+ if not results:
907
+ return (
908
+ "I could not find relevant information in the knowledge base.",
909
+ [],
910
+ )
911
+
912
+ answer = generate_answer(
913
+ prompt=build_prompt(
914
+ question=question,
915
+ results=results,
916
+ history=history,
917
+ ),
918
+ model_name=model_name,
919
+ )
920
+
921
+ # Retry once if Gemini omitted the required inline citations.
922
+ if not has_inline_citations(answer):
923
+ retry_prompt = f"""
924
+ Revise the answer below by adding accurate inline citations.
925
+
926
+ Requirements:
927
+ - Every factual paragraph must contain at least one citation.
928
+ - Use only [Source 1] through [Source {len(results)}].
929
+ - Use the retrieved context to determine which citation supports each claim.
930
+ - Do not invent facts.
931
+ - Do not add a separate source list.
932
+ - Return only the revised answer.
933
+
934
+ Retrieved context:
935
+ {build_context(results)}
936
+
937
+ Original answer:
938
+ {answer}
939
+ """.strip()
940
+
941
+ answer = generate_answer(
942
+ prompt=retry_prompt,
943
+ model_name=model_name,
944
+ )
945
+
946
+ return answer, results
947
+
948
+
949
+ # =============================================================================
950
+ # Gradio application
951
+ # =============================================================================
952
+
953
+ def research_chat(
954
+ message: str,
955
+ history,
956
+ ) -> str:
957
+ """
958
+ Answer one Gradio message using the RAG pipeline.
959
+ """
960
+
961
+ message = message.strip()
962
+
963
+ if not message:
964
+ return "Please enter a question."
965
+
966
+ try:
967
+ answer, sources = answer_question(
968
+ question=message,
969
+ history=history,
970
+ top_k=TOP_K,
971
+ faiss_k=FAISS_K,
972
+ bm25_k=BM25_K,
973
+ model_name=GEMINI_MODEL,
974
+ )
975
+
976
+ response = answer
977
+
978
+ sources_markdown = format_sources(
979
+ sources
980
+ )
981
+
982
+ if sources_markdown:
983
+ response += (
984
+ f"\n\n---\n\n"
985
+ f"{sources_markdown}"
986
+ )
987
+
988
+ return response
989
+
990
+ except Exception as error:
991
+ return (
992
+ "I could not process this question because "
993
+ "an error occurred.\n\n"
994
+ f"`{type(error).__name__}: {error}`"
995
+ )
996
+
997
+
998
+ with gr.Blocks(
999
+ title="ResearchGPT — Milad Saeedi",
1000
+ ) as demo:
1001
+
1002
+ gr.Markdown(
1003
+ """
1004
+ # ResearchGPT — Milad Saeedi
1005
+
1006
+ Explore Milad Saeedi's research, PhD thesis, publications,
1007
+ machine-learning experience, and selected GitHub projects.
1008
+
1009
+ Answers are grounded in retrieved portfolio documents and
1010
+ include supporting sources.
1011
+ """
1012
+ )
1013
+
1014
+ chatbot = gr.Chatbot(
1015
+ placeholder=(
1016
+ "Ask about Milad's research, publications, "
1017
+ "technical skills, or GitHub projects."
1018
+ ),
1019
+ height=550,
1020
+ )
1021
+
1022
+ gr.ChatInterface(
1023
+ fn=research_chat,
1024
+ chatbot=chatbot,
1025
+ examples=[
1026
+ "Summarize Milad Saeedi's research.",
1027
+ "What are the main contributions of his PhD thesis?",
1028
+ "Summarize his GitHub GAN projects.",
1029
+ "Which projects involve computer vision?",
1030
+ "What experience does he have with LoRA and GRPO?",
1031
+ "What did his research show about spatial cross-validation?",
1032
+ "Tell me about his geospatial modeling experience.",
1033
+ "How can I contact Milad?",
1034
+ ],
1035
+ save_history=True,
1036
+ flagging_mode="never",
1037
+ )
1038
+
1039
+ gr.Markdown(
1040
+ """
1041
+ ---
1042
+ **ResearchGPT** uses hybrid retrieval, BGE embeddings,
1043
+ FAISS, BM25, cross-encoder reranking, Gemini,
1044
+ and grounded answer generation.
1045
+ """
1046
+ )
1047
+
1048
+
1049
+ if __name__ == "__main__":
1050
+ demo.queue().launch(server_name="0.0.0.0", server_port=7860)
artifacts/chunks.json ADDED
The diff for this file is too large to render. See raw diff
 
artifacts/index_config.json ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "embedding_model": "BAAI/bge-base-en-v1.5",
3
+ "reranker_model": "cross-encoder/ms-marco-MiniLM-L6-v2",
4
+ "embedding_dimension": 768,
5
+ "number_of_chunks": 1497,
6
+ "chunk_size": 1000,
7
+ "chunk_overlap": 200,
8
+ "query_instruction": "Represent this sentence for searching relevant passages: "
9
+ }
artifacts/research_index.faiss ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:8d0cc5b9f80ff3317bd9017fad94dd9bde8a5605c796bcc5214941e668b8aa4f
3
+ size 4598829
artifacts/retrieval_texts.json ADDED
The diff for this file is too large to render. See raw diff
 
requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ gradio>=5.0
2
+ faiss-cpu
3
+ google-genai
4
+ numpy
5
+ rank-bm25
6
+ sentence-transformers
7
+ torch