muddasser commited on
Commit
7a77d37
Β·
verified Β·
1 Parent(s): 3c97a91

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +328 -350
app.py CHANGED
@@ -1,290 +1,263 @@
1
- # app.py - Hybrid Memory-Augmented Graph RAG with Groq
2
- import hashlib
 
3
  import os
4
- import pickle
 
5
  from collections import defaultdict
6
- from datetime import datetime
7
 
8
- import networkx as nx
 
9
  import numpy as np
10
- import spacy
11
  import streamlit as st
12
- from langchain_community.vectorstores import FAISS
13
- from langchain_huggingface import HuggingFaceEmbeddings
14
  from langchain_groq import ChatGroq
15
- from langchain_text_splitters import RecursiveCharacterTextSplitter
16
- from pypdf import PdfReader
17
  from rank_bm25 import BM25Okapi
18
- from sentence_transformers import CrossEncoder, SentenceTransformer
19
- from sklearn.metrics.pairwise import cosine_similarity
20
-
21
- # For DOCX support
22
- try:
23
- from docx import Document
24
- except ImportError:
25
- pass # Will show warning if missing
26
 
27
  # ========================= CONSTANTS =========================
28
- EMBED_MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2"
29
- RERANKER_MODEL = "cross-encoder/ms-marco-MiniLM-L-6-v2"
30
  LLM_MODEL_NAME = "llama-3.3-70b-versatile"
31
- DEFAULT_TOP_K = 5
 
 
32
  RRF_K = 60
33
 
34
- # ========================= HELPERS =========================
35
- def get_groq_api_key():
36
- try:
37
- return st.secrets.get("GROQ_API_KEY") or os.getenv("GROQ_API_KEY")
38
- except:
39
- return os.getenv("GROQ_API_KEY")
40
-
41
- def rrf_score(rank: int, k: int = RRF_K) -> float:
42
- return 1.0 / (k + rank)
43
-
44
- # ========================= MEMORY LAYER =========================
45
- # ========================= MEMORY LAYER =========================
46
- class HybridMemory:
47
- def __init__(self):
48
- self.embeddings = HuggingFaceEmbeddings(
49
- model_name=EMBED_MODEL_NAME,
50
- cache_folder="/tmp/model_cache",
51
- model_kwargs={"device": "cpu"},
52
- )
53
- self.vector_memory = None
54
- self.graph_memory = nx.Graph()
55
- self.memory_dir = "user_memory"
56
- os.makedirs(self.memory_dir, exist_ok=True)
57
- self.load_memory()
58
-
59
- def load_memory(self):
60
- vector_path = os.path.join(self.memory_dir, "vector_memory")
61
- graph_path = os.path.join(self.memory_dir, "memory_graph.pkl")
62
- if os.path.exists(vector_path):
63
- self.vector_memory = FAISS.load_local(vector_path, self.embeddings, allow_dangerous_deserialization=True)
64
- if os.path.exists(graph_path):
65
- with open(graph_path, "rb") as f:
66
- self.graph_memory = pickle.load(f)
67
-
68
- def save_memory(self):
69
- if self.vector_memory:
70
- self.vector_memory.save_local(os.path.join(self.memory_dir, "vector_memory"))
71
- with open(os.path.join(self.memory_dir, "memory_graph.pkl"), "wb") as f:
72
- pickle.dump(self.graph_memory, f)
73
-
74
- def add_memory(self, text: str, memory_type: str = "fact", metadata: dict = None):
75
- if metadata is None:
76
- metadata = {}
77
- metadata.update({
78
- "timestamp": datetime.now().isoformat(),
79
- "type": memory_type,
80
- })
81
-
82
- # Add to Vector Memory
83
- if self.vector_memory is None:
84
- self.vector_memory = FAISS.from_texts([text], self.embeddings, metadatas=[metadata])
85
- else:
86
- self.vector_memory.add_texts([text], metadatas=[metadata])
87
-
88
- # Add to Graph Memory
89
- self._add_to_graph(text)
90
- self.save_memory()
91
-
92
- def _add_to_graph(self, text: str):
93
- """Safely add entities to graph"""
94
- try:
95
- # Get nlp from session_state or load it directly
96
- nlp = st.session_state.get("nlp")
97
- if not nlp:
98
- nlp = load_spacy() # fallback
99
- st.session_state.nlp = nlp
100
-
101
- doc = nlp(text[:30000])
102
- entities = [ent.text.strip().lower() for ent in doc.ents if len(ent.text.strip()) > 1]
103
-
104
- if not entities:
105
- return # No entities found
106
-
107
- for ent in entities:
108
- if not self.graph_memory.has_node(ent):
109
- self.graph_memory.add_node(ent, count=1)
110
- else:
111
- self.graph_memory.nodes[ent]["count"] = self.graph_memory.nodes[ent].get("count", 0) + 1
112
-
113
- for i in range(len(entities)):
114
- for j in range(i + 1, len(entities)):
115
- a, b = entities[i], entities[j]
116
- if self.graph_memory.has_edge(a, b):
117
- self.graph_memory[a][b]["weight"] += 1
118
- else:
119
- self.graph_memory.add_edge(a, b, weight=1)
120
- except Exception as e:
121
- # st.warning(f"Graph memory update skipped: {e}") # uncomment for debug
122
- pass
123
-
124
- def retrieve_memory(self, query: str, top_k: int = 6):
125
- if not self.vector_memory:
126
- return []
127
- docs = self.vector_memory.similarity_search(query, k=top_k)
128
- return [{"text": doc.page_content, "metadata": doc.metadata} for doc in docs]
129
-
130
- def get_stats(self):
131
- return {
132
- "vector_memories": len(self.vector_memory.index_to_docstore_id) if self.vector_memory else 0,
133
- "graph_nodes": self.graph_memory.number_of_nodes(),
134
- "graph_edges": self.graph_memory.number_of_edges()
135
- }
136
-
137
- # ========================= ORIGINAL PIPELINE FUNCTIONS =========================
138
- @st.cache_resource
139
- def load_embed_model():
140
- return SentenceTransformer(EMBED_MODEL_NAME, cache_folder="/tmp/model_cache")
141
-
142
- @st.cache_resource
143
- def load_reranker():
144
- return CrossEncoder(RERANKER_MODEL)
145
-
146
- @st.cache_resource
147
- def load_lc_embeddings():
148
- return HuggingFaceEmbeddings(
149
- model_name=EMBED_MODEL_NAME,
150
- cache_folder="/tmp/model_cache",
151
- model_kwargs={"device": "cpu"},
152
- )
153
-
154
- @st.cache_resource
155
- def load_spacy():
156
  try:
157
- return spacy.load("en_core_web_sm")
158
- except OSError:
159
- from spacy.cli import download
160
- download("en_core_web_sm")
161
- return spacy.load("en_core_web_sm")
162
-
163
- def load_documents(uploaded_files):
164
- """Support PDF, TXT, and DOCX"""
165
- docs = []
166
- for f in uploaded_files:
167
- text = ""
168
- source = f.name
169
-
170
- try:
171
- if f.name.lower().endswith('.pdf'):
172
- reader = PdfReader(f)
173
- text = "\n".join([page.extract_text() or "" for page in reader.pages])
174
- elif f.name.lower().endswith('.txt'):
175
- text = f.read().decode("utf-8")
176
- elif f.name.lower().endswith(('.docx', '.doc')):
177
- doc = Document(f)
178
- text = "\n".join([paragraph.text for paragraph in doc.paragraphs])
179
- else:
180
- st.warning(f"Unsupported file type: {f.name}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
  continue
182
- except Exception as e:
183
- st.warning(f"Error reading {f.name}: {str(e)}")
184
- continue
185
-
186
- if text.strip():
187
- docs.append({"text": text, "source": source})
188
- return docs
189
-
190
- def hybrid_chunking(text, embed_model, base=300, final=1000, thresh=0.6):
191
- splitter = RecursiveCharacterTextSplitter(chunk_size=base, chunk_overlap=50)
192
- base_chunks = splitter.split_text(text)
193
- if not base_chunks: return []
194
- embeddings = embed_model.encode(base_chunks, show_progress_bar=False)
195
- chunks, current = [], base_chunks[0]
196
- anchor = embeddings[0].reshape(1, -1)
197
- for i in range(1, len(base_chunks)):
198
- next_emb = embeddings[i].reshape(1, -1)
199
- sim = cosine_similarity(anchor, next_emb)[0][0]
200
- if sim > thresh and len(current) + len(base_chunks[i]) < final:
201
- current += " " + base_chunks[i]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
202
  else:
203
- chunks.append(current)
204
- current = base_chunks[i]
205
- anchor = next_emb
206
- chunks.append(current)
 
207
  return chunks
208
 
209
- def chunk_documents(docs, embed_model, method, **kwargs):
210
- result = []
211
- for doc in docs:
212
- if method == "Hybrid (Recursive + Semantic)":
213
- texts = hybrid_chunking(doc["text"], embed_model, **kwargs)
214
- else:
215
- splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)
216
- texts = splitter.split_text(doc["text"])
217
- for t in texts:
218
- result.append({"text": t, "source": doc["source"]})
219
- return result
220
-
221
- def build_knowledge_graph(chunks, nlp):
222
- G = nx.Graph()
223
- entity_to_chunks = defaultdict(list)
224
- for idx, chunk in enumerate(chunks):
225
- doc = nlp(chunk["text"][:50000])
226
- entities = [ent.text.strip().lower() for ent in doc.ents if len(ent.text.strip()) > 1]
227
- for e in set(entities):
228
- if not G.has_node(e):
229
- G.add_node(e)
230
- entity_to_chunks[e].append(idx)
231
- for i in range(len(entities)):
232
- for j in range(i+1, len(entities)):
233
- a, b = entities[i], entities[j]
234
- if G.has_edge(a, b):
235
- G[a][b]["weight"] += 1
236
- else:
237
- G.add_edge(a, b, weight=1)
238
- return G, dict(entity_to_chunks)
239
-
240
- def build_indexes(chunks, lc_embeddings):
241
- texts = [c["text"] for c in chunks]
242
- bm25 = BM25Okapi([t.lower().split() for t in texts])
243
- faiss_index = FAISS.from_texts(texts, lc_embeddings)
244
- chunk_lookup = {c["text"]: c for c in chunks}
245
- return bm25, faiss_index, chunk_lookup
246
-
247
- def hybrid_memory_retrieve(query, chunks, bm25, faiss_index, chunk_lookup, graph, entity_to_chunks, reranker, nlp, memory, top_k=5, hops=2):
248
- texts = [c["text"] for c in chunks]
249
- bm25_scores = bm25.get_scores(query.lower().split())
250
- bm25_idx = np.argsort(bm25_scores)[::-1][:top_k*3]
251
- faiss_docs = faiss_index.similarity_search(query, k=top_k*3)
252
-
253
- candidates = []
254
- seen = set()
255
- for i in bm25_idx[:top_k*2]:
256
- c = chunks[i]
257
- if c["text"] not in seen:
258
- candidates.append(c)
259
- seen.add(c["text"])
260
- for doc in faiss_docs:
261
- c = chunk_lookup.get(doc.page_content)
262
- if c and c["text"] not in seen:
263
- candidates.append(c)
264
- seen.add(c["text"])
265
-
266
- mem_items = memory.retrieve_memory(query, top_k=top_k*2)
267
-
268
- all_texts = [c["text"] for c in candidates] + [m["text"] for m in mem_items]
269
- pairs = [(query, t) for t in all_texts]
270
- scores = reranker.predict(pairs)
271
-
272
- scored = sorted(zip(scores, all_texts), reverse=True)
273
- final_texts = [t for _, t in scored[:top_k]]
274
-
275
- final_chunks = []
276
- for t in final_texts:
277
- for c in candidates:
278
- if c["text"] == t:
279
- final_chunks.append(c)
280
- break
281
- else:
282
- final_chunks.append({"text": t, "source": "🧠 Memory"})
283
- return final_chunks[:top_k], {"memory_used": len(mem_items)}
284
-
285
  def generate_answer(llm, query, retrieved):
286
- context = "\n\n---\n\n".join([f"[Source: {c['source']}]\n{c['text']}" for c in retrieved])
287
- prompt = f"""Answer using ONLY the provided context. Cite sources.
 
 
 
 
 
 
288
  Context:
289
  {context}
290
 
@@ -293,94 +266,99 @@ Answer:"""
293
  return llm.invoke(prompt).content
294
 
295
  # ========================= STREAMLIT APP =========================
296
- st.set_page_config(page_title="Hybrid Memory Graph RAG", page_icon="🧠", layout="wide")
297
- os.environ["STREAMLIT_SERVER_FILE_WATCHER_TYPE"] = "none"
298
- st.title("🧠 Hybrid Memory-Augmented Graph RAG")
299
 
300
- # Session State
301
- if "memory" not in st.session_state:
302
- st.session_state.memory = HybridMemory()
303
- if "chunks" not in st.session_state:
304
- st.session_state.chunks = None
305
-
306
- # Sidebar
307
  with st.sidebar:
308
- st.header("Configuration")
309
- chunk_method = st.selectbox("Chunking Method", ["Hybrid (Recursive + Semantic)", "Recursive Only"])
310
- top_k = st.slider("Top-K Results", 3, 15, DEFAULT_TOP_K)
311
- graph_hops = st.slider("Graph Hops", 1, 3, 2)
312
- if st.button("Clear Memory"):
313
- st.session_state.memory = HybridMemory()
314
- st.success("Memory cleared!")
315
-
316
- # Load models
317
- embed_model = load_embed_model()
318
- reranker = load_reranker()
319
- nlp = load_spacy()
320
- lc_embeddings = load_lc_embeddings()
321
-
322
- groq_key = get_groq_api_key()
323
  if not groq_key:
324
- st.error("Groq API key not found!")
325
  st.stop()
 
326
 
327
- llm = ChatGroq(temperature=0.2, model_name=LLM_MODEL_NAME, groq_api_key=groq_key)
328
-
329
- # File Upload - Now supports PDF, TXT, DOCX
330
  uploaded_files = st.file_uploader(
331
- "Upload Documents (PDF, TXT, DOCX)",
332
- type=["pdf", "txt", "docx"],
333
- accept_multiple_files=True
334
  )
335
 
336
- if uploaded_files and st.session_state.chunks is None:
337
- with st.spinner("Processing documents..."):
338
- docs = load_documents(uploaded_files)
339
- if not docs:
340
- st.error("No readable text found in uploaded files.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
341
  st.stop()
342
- chunks = chunk_documents(docs, embed_model, chunk_method)
343
- bm25, faiss_index, chunk_lookup = build_indexes(chunks, lc_embeddings)
344
- graph, entity_to_chunks = build_knowledge_graph(chunks, nlp)
345
-
346
- st.session_state.chunks = chunks
347
- st.session_state.bm25 = bm25
348
- st.session_state.faiss_index = faiss_index
349
- st.session_state.chunk_lookup = chunk_lookup
350
- st.session_state.graph = graph
351
- st.session_state.entity_to_chunks = entity_to_chunks
352
- st.success(f"Loaded {len(chunks)} chunks from {len(docs)} document(s)")
353
-
354
- # Query
355
- query = st.text_input("Ask a question about your documents:")
356
-
357
- if query and st.session_state.chunks:
358
- with st.spinner("Retrieving from Documents + Memory..."):
359
- retrieved, debug = hybrid_memory_retrieve(
360
- query, st.session_state.chunks, st.session_state.bm25,
361
- st.session_state.faiss_index, st.session_state.chunk_lookup,
362
- st.session_state.graph, st.session_state.entity_to_chunks,
363
- reranker, nlp, st.session_state.memory, top_k=top_k, hops=graph_hops
364
- )
365
-
366
- with st.spinner("Generating answer..."):
367
- answer = generate_answer(llm, query, retrieved)
368
-
369
- st.subheader("Answer")
370
- st.write(answer)
371
-
372
- if retrieved:
373
- summary = f"Q: {query}\nA: {answer[:300]}..."
374
- st.session_state.memory.add_memory(summary, memory_type="conversation")
375
-
376
- with st.expander("πŸ“Š Memory Stats"):
377
- st.json(st.session_state.memory.get_stats())
378
-
379
- with st.expander("πŸ” Retrieved Sources"):
380
- for i, chunk in enumerate(retrieved, 1):
381
- st.markdown(f"**{i}.** {chunk['source']}")
382
- st.write(chunk["text"][:500] + "..." if len(chunk["text"]) > 500 else chunk["text"])
383
  else:
384
- st.info("Upload documents (PDF, TXT, DOCX) and ask a question to begin.")
385
-
386
- st.caption("Hybrid Graph RAG + Persistent Memory β€’ Powered by Groq + NetworkX + FAISS")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app.py - Hybrid RAG (FAISS semantic + BM25, fused with RRF) for multi-format documents.
2
+ # Handles: PDFs (text + tables), DOCX (with tables), PPTX, XLSX/CSV, TXT/MD. Arabic + English.
3
+ import io
4
  import os
5
+ import re
6
+ import unicodedata
7
  from collections import defaultdict
 
8
 
9
+ import faiss
10
+ import fitz # PyMuPDF
11
  import numpy as np
12
+ import pandas as pd
13
  import streamlit as st
14
+ from docx import Document
 
15
  from langchain_groq import ChatGroq
16
+ from pptx import Presentation
 
17
  from rank_bm25 import BM25Okapi
18
+ from sentence_transformers import SentenceTransformer
 
 
 
 
 
 
 
19
 
20
  # ========================= CONSTANTS =========================
21
+ EMBED_MODEL_NAME = "paraphrase-multilingual-MiniLM-L12-v2" # Arabic + English, 384-dim
 
22
  LLM_MODEL_NAME = "llama-3.3-70b-versatile"
23
+ CHUNK_WORDS = 80 # stays under the embedding model's 128-token limit
24
+ CHUNK_OVERLAP = 15
25
+ TABLE_CHUNK_WORDS = 60 # table/spreadsheet rows are token-heavy, so smaller groups
26
  RRF_K = 60
27
 
28
+ # ========================= TEXT NORMALIZATION =========================
29
+ DIACRITICS_RE = re.compile(r"[\u0610-\u061A\u064B-\u065F\u0670\u06D6-\u06ED]")
30
+ DIGIT_MAP = str.maketrans("Ω Ω‘Ω’Ω£Ω€Ω₯Ω¦Ω§Ω¨Ω©Ϋ°Ϋ±Ϋ²Ϋ³Ϋ΄Ϋ΅ΫΆΫ·ΫΈΫΉ", "01234567890123456789")
31
+
32
+ def light_clean(text):
33
+ text = unicodedata.normalize("NFKC", text)
34
+ text = DIACRITICS_RE.sub("", text)
35
+ return text.replace("\u0640", "")
36
+
37
+ def normalize(text):
38
+ text = light_clean(text).translate(DIGIT_MAP)
39
+ text = re.sub("[Ψ₯Ψ£Ψ’Ω±]", "Ψ§", text)
40
+ text = text.replace("Ω‰", "ي").replace("Ψ©", "Ω‡")
41
+ return text.lower()
42
+
43
+ def tokenize(text):
44
+ tokens = re.findall(r"[\w\+#]+", normalize(text))
45
+ return [t[2:] if len(t) > 4 and t.startswith("Ψ§Ω„") else t for t in tokens]
46
+
47
+ # ========================= EXTRACTION =========================
48
+ def make_block(text, source, loc, kind):
49
+ return {"text": text, "source": source, "loc": loc, "kind": kind}
50
+
51
+ def pdf_tables(page):
52
+ out = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  try:
54
+ for t in page.find_tables().tables:
55
+ lines = []
56
+ for r in t.extract():
57
+ cells = [(c or "").replace("\n", " ").strip() for c in r]
58
+ if any(cells):
59
+ lines.append(" | ".join(cells))
60
+ if len(lines) >= 2:
61
+ out.append("\n".join(lines))
62
+ except Exception:
63
+ pass
64
+ return out
65
+
66
+ def extract_pdf(name, data):
67
+ blocks = []
68
+ stats = {"pages": 0, "tables": 0, "notes": []}
69
+ doc = fitz.open(stream=data, filetype="pdf")
70
+ empty_pages = []
71
+ for i, page in enumerate(doc, 1):
72
+ stats["pages"] += 1
73
+ loc = f"p.{i}"
74
+ text = unicodedata.normalize("NFKC", page.get_text("text") or "").strip()
75
+ for tbl in pdf_tables(page):
76
+ blocks.append(make_block(tbl, name, loc, "table"))
77
+ stats["tables"] += 1
78
+ if len(text) < 50:
79
+ empty_pages.append(i)
80
+ if text:
81
+ blocks.append(make_block(text, name, loc, "text"))
82
+ if empty_pages:
83
+ shown = ", ".join(map(str, empty_pages[:15])) + ("..." if len(empty_pages) > 15 else "")
84
+ stats["notes"].append(f"Little or no text on page(s) {shown}. Scanned pages cannot be read by this app.")
85
+ return blocks, stats
86
+
87
+ def extract_docx(name, data):
88
+ doc = Document(io.BytesIO(data))
89
+ blocks, tables = [], 0
90
+ text = "\n".join(p.text for p in doc.paragraphs if p.text.strip())
91
+ if text.strip():
92
+ blocks.append(make_block(text, name, None, "text"))
93
+ for table in doc.tables:
94
+ lines = []
95
+ for row in table.rows:
96
+ cells = [c.text.replace("\n", " ").strip() for c in row.cells]
97
+ if any(cells):
98
+ lines.append(" | ".join(cells))
99
+ if len(lines) >= 2:
100
+ blocks.append(make_block("\n".join(lines), name, None, "table"))
101
+ tables += 1
102
+ return blocks, {"pages": 1, "tables": tables,
103
+ "notes": ["Images embedded inside the DOCX are not read."]}
104
+
105
+ def extract_pptx(name, data):
106
+ prs = Presentation(io.BytesIO(data))
107
+ blocks, tables = [], 0
108
+ for i, slide in enumerate(prs.slides, 1):
109
+ loc = f"slide {i}"
110
+ texts = []
111
+ for shape in slide.shapes:
112
+ if shape.has_text_frame and shape.text_frame.text.strip():
113
+ texts.append(shape.text_frame.text.strip())
114
+ if getattr(shape, "has_table", False) and shape.has_table:
115
+ lines = [" | ".join(c.text.strip() for c in row.cells) for row in shape.table.rows]
116
+ if len(lines) >= 2:
117
+ blocks.append(make_block("\n".join(lines), name, loc, "table"))
118
+ tables += 1
119
+ if slide.has_notes_slide and slide.notes_slide.notes_text_frame.text.strip():
120
+ texts.append("Notes: " + slide.notes_slide.notes_text_frame.text.strip())
121
+ if texts:
122
+ blocks.append(make_block("\n".join(texts), name, loc, "text"))
123
+ return blocks, {"pages": len(prs.slides), "tables": tables,
124
+ "notes": ["Pictures and charts on slides are not read."]}
125
+
126
+ def df_to_rows(df):
127
+ df = df.dropna(how="all").dropna(axis=1, how="all").fillna("")
128
+ headers = [str(c) for c in df.columns]
129
+ lines = []
130
+ for _, row in df.iterrows():
131
+ cells = []
132
+ for h, v in zip(headers, row):
133
+ v = str(v).strip()
134
+ if v:
135
+ cells.append(v if h.startswith("Unnamed") else f"{h}: {v}")
136
+ if cells:
137
+ lines.append(" | ".join(cells))
138
+ return "\n".join(lines)
139
+
140
+ def extract_spreadsheet(name, data):
141
+ blocks = []
142
+ if name.lower().endswith(".csv"):
143
+ df = None
144
+ for enc in ("utf-8-sig", "cp1256", "latin-1"):
145
+ try:
146
+ df = pd.read_csv(io.BytesIO(data), encoding=enc)
147
+ break
148
+ except Exception:
149
  continue
150
+ sheets = {"": df} if df is not None else {}
151
+ else:
152
+ sheets = pd.read_excel(io.BytesIO(data), sheet_name=None)
153
+ for sheet, df in sheets.items():
154
+ text = df_to_rows(df)
155
+ if text:
156
+ blocks.append(make_block(text, name, f"sheet {sheet}" if sheet else None, "rows"))
157
+ return blocks, {"pages": len(sheets), "tables": len(blocks), "notes": []}
158
+
159
+ def extract_text_file(name, data):
160
+ text = data.decode("utf-8", errors="ignore")
161
+ blocks = [make_block(text, name, None, "text")] if text.strip() else []
162
+ return blocks, {"pages": 1, "tables": 0, "notes": []}
163
+
164
+ @st.cache_data(show_spinner=False)
165
+ def extract_file(name, data):
166
+ lower = name.lower()
167
+ if lower.endswith(".pdf"):
168
+ return extract_pdf(name, data)
169
+ if lower.endswith(".docx"):
170
+ return extract_docx(name, data)
171
+ if lower.endswith(".pptx"):
172
+ return extract_pptx(name, data)
173
+ if lower.endswith((".xlsx", ".csv")):
174
+ return extract_spreadsheet(name, data)
175
+ if lower.endswith((".txt", ".md")):
176
+ return extract_text_file(name, data)
177
+ return [], {"pages": 0, "tables": 0, "notes": ["Unsupported file type."]}
178
+
179
+ # ========================= CHUNKING =========================
180
+ def make_chunk(block, text):
181
+ return {"text": text, "source": block["source"], "loc": block["loc"], "kind": block["kind"]}
182
+
183
+ def chunk_blocks(blocks):
184
+ chunks = []
185
+ step = max(1, CHUNK_WORDS - CHUNK_OVERLAP)
186
+ for b in blocks:
187
+ if b["kind"] in ("table", "rows"):
188
+ lines = [l for l in b["text"].split("\n") if l.strip()]
189
+ header = lines[0] if b["kind"] == "table" else None
190
+ body = lines[1:] if b["kind"] == "table" else lines
191
+ group, wc = [], 0
192
+ for line in body:
193
+ lw = len(line.split())
194
+ if group and wc + lw > TABLE_CHUNK_WORDS:
195
+ chunks.append(make_chunk(b, "\n".join(([header] if header else []) + group)))
196
+ group, wc = [], 0
197
+ group.append(line)
198
+ wc += lw
199
+ if group:
200
+ chunks.append(make_chunk(b, "\n".join(([header] if header else []) + group)))
201
  else:
202
+ words = b["text"].split()
203
+ for i in range(0, len(words), step):
204
+ piece = " ".join(words[i:i + CHUNK_WORDS])
205
+ if len(piece.strip()) > 20:
206
+ chunks.append(make_chunk(b, piece))
207
  return chunks
208
 
209
+ def cite_label(c):
210
+ return f"{c['source']}, {c['loc']}" if c["loc"] else c["source"]
211
+
212
+ # ========================= INDEX + HYBRID SEARCH =========================
213
+ @st.cache_resource(show_spinner="Loading embedding model...")
214
+ def load_embedder():
215
+ return SentenceTransformer(EMBED_MODEL_NAME, cache_folder="/tmp/model_cache", device="cpu")
216
+
217
+ def build_index(chunks, embedder):
218
+ texts = [light_clean(c["text"]) for c in chunks]
219
+ emb = embedder.encode(texts, batch_size=32, convert_to_numpy=True,
220
+ normalize_embeddings=True, show_progress_bar=False).astype("float32")
221
+ faiss_index = faiss.IndexFlatIP(emb.shape[1]) # inner product on normalized vectors = cosine
222
+ faiss_index.add(emb)
223
+ bm25 = BM25Okapi([tokenize(c["text"]) for c in chunks])
224
+ return {"chunks": chunks, "faiss": faiss_index, "bm25": bm25}
225
+
226
+ def hybrid_search(query, index, embedder, top_k, w_sem):
227
+ """FAISS semantic + BM25, fused with Reciprocal Rank Fusion."""
228
+ q_vec = embedder.encode([light_clean(query)], convert_to_numpy=True,
229
+ normalize_embeddings=True, show_progress_bar=False).astype("float32")
230
+ n = len(index["chunks"])
231
+ pool = min(n, top_k * 4)
232
+
233
+ scores, ids = index["faiss"].search(q_vec, pool)
234
+ sem_scores = {int(i): float(s) for i, s in zip(ids[0], scores[0]) if i >= 0}
235
+ best_sem = float(scores[0][0])
236
+
237
+ bm = index["bm25"].get_scores(tokenize(query))
238
+ bm_rank = np.argsort(-bm)[:pool]
239
+
240
+ fused = defaultdict(float)
241
+ for r, i in enumerate(ids[0]):
242
+ if i >= 0:
243
+ fused[int(i)] += w_sem / (RRF_K + r + 1)
244
+ for r, i in enumerate(bm_rank):
245
+ if bm[i] > 0:
246
+ fused[int(i)] += (1 - w_sem) / (RRF_K + r + 1)
247
+
248
+ top = sorted(fused, key=fused.get, reverse=True)[:top_k]
249
+ return top, best_sem, sem_scores, bm
250
+
251
+ # ========================= GENERATION =========================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
252
  def generate_answer(llm, query, retrieved):
253
+ context = "\n\n---\n\n".join(f"[{cite_label(c)}]\n{c['text']}" for c in retrieved)
254
+ prompt = f"""You are a document assistant. Answer ONLY from the context below.
255
+ - Answer in the same language as the question.
256
+ - After each fact, cite its source exactly as shown in the context, e.g. [file, p.N].
257
+ - Table rows use " | " between cells; the first line of a table chunk is its header.
258
+ - If the context does not contain the answer, say it was not found in the documents and
259
+ suggest contacting the document owner. Do not guess.
260
+
261
  Context:
262
  {context}
263
 
 
266
  return llm.invoke(prompt).content
267
 
268
  # ========================= STREAMLIT APP =========================
269
+ st.set_page_config(page_title="Hybrid RAG Assistant", page_icon="πŸ“š", layout="wide")
270
+ st.title("πŸ“š Hybrid RAG Assistant")
271
+ st.caption("FAISS semantic search + BM25, fused with Reciprocal Rank Fusion")
272
 
 
 
 
 
 
 
 
273
  with st.sidebar:
274
+ st.header("Settings")
275
+ top_k = st.slider("Chunks sent to the LLM (Top-K)", 3, 10, 5)
276
+ w_sem = st.slider("Semantic weight (BM25 gets the rest)", 0.0, 1.0, 0.6, 0.1)
277
+ min_rel = st.slider("Minimum relevance to answer", 0.0, 0.8, 0.25, 0.05,
278
+ help="If the best semantic match is below this, the app says 'not found' instead of guessing.")
279
+
280
+ embedder = load_embedder()
281
+
282
+ groq_key = None
283
+ try:
284
+ groq_key = st.secrets.get("GROQ_API_KEY")
285
+ except Exception:
286
+ pass
287
+ groq_key = groq_key or os.getenv("GROQ_API_KEY")
 
288
  if not groq_key:
289
+ st.error("GROQ_API_KEY not found. Add it to Streamlit secrets or environment variables.")
290
  st.stop()
291
+ llm = ChatGroq(temperature=0.1, model_name=LLM_MODEL_NAME, groq_api_key=groq_key)
292
 
 
 
 
293
  uploaded_files = st.file_uploader(
294
+ "Upload documents",
295
+ type=["pdf", "docx", "pptx", "xlsx", "csv", "txt", "md"],
296
+ accept_multiple_files=True,
297
  )
298
 
299
+ # Rebuild the index whenever the set of uploaded files changes
300
+ if uploaded_files:
301
+ signature = tuple(sorted((f.name, f.size) for f in uploaded_files))
302
+ if st.session_state.get("signature") != signature:
303
+ all_blocks, report = [], {}
304
+ with st.spinner("Reading documents..."):
305
+ for f in uploaded_files:
306
+ try:
307
+ blocks, stats = extract_file(f.name, f.getvalue())
308
+ except Exception as e:
309
+ blocks, stats = [], {"pages": 0, "tables": 0, "notes": [f"Could not read file: {e}"]}
310
+ all_blocks.extend(blocks)
311
+ report[f.name] = stats
312
+ chunks = chunk_blocks(all_blocks)
313
+ if not chunks:
314
+ st.error("No readable text found in the uploaded files.")
315
+ st.session_state.pop("index", None)
316
+ st.session_state.pop("signature", None)
317
+ with st.expander("Extraction report", expanded=True):
318
+ for fname, s in report.items():
319
+ for note in s["notes"]:
320
+ st.write(f"**{fname}**: {note}")
321
  st.stop()
322
+ with st.spinner("Building FAISS + BM25 indexes..."):
323
+ st.session_state.index = build_index(chunks, embedder)
324
+ st.session_state.report = report
325
+ st.session_state.signature = signature
326
+ st.success(f"Indexed {len(st.session_state.index['chunks'])} chunks from {len(uploaded_files)} file(s)")
327
+
328
+ with st.expander("πŸ“‹ Extraction report"):
329
+ for fname, s in st.session_state.report.items():
330
+ st.write(f"**{fname}**: {s['pages']} page(s)/sheet(s), {s['tables']} table(s)")
331
+ for note in s["notes"]:
332
+ st.caption(f"⚠️ {note}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
333
  else:
334
+ st.session_state.pop("index", None)
335
+ st.session_state.pop("signature", None)
336
+
337
+ # Query form: the LLM is only called when the user presses the button
338
+ with st.form("ask"):
339
+ query = st.text_input("Ask a question about your documents (Arabic or English):")
340
+ submitted = st.form_submit_button("Ask")
341
+
342
+ if submitted and query.strip():
343
+ index = st.session_state.get("index")
344
+ if not index:
345
+ st.warning("Upload at least one document first.")
346
+ else:
347
+ top, best_sem, sem_scores, bm = hybrid_search(query, index, embedder, top_k, w_sem)
348
+ retrieved = [index["chunks"][i] for i in top]
349
+
350
+ st.subheader("Answer")
351
+ if not retrieved or best_sem < min_rel:
352
+ st.info("Not found in the uploaded documents.")
353
+ else:
354
+ with st.spinner("Generating answer..."):
355
+ st.write(generate_answer(llm, query, retrieved))
356
+
357
+ with st.expander("πŸ” Retrieved chunks"):
358
+ for rank, i in enumerate(top, 1):
359
+ c = index["chunks"][i]
360
+ sem_txt = f"{sem_scores[i]:.2f}" if i in sem_scores else "n/a"
361
+ st.markdown(f"**{rank}. {cite_label(c)}** ({c['kind']}) Β· semantic {sem_txt} Β· BM25 {bm[i]:.2f}")
362
+ st.text(c["text"])
363
+ else:
364
+ st.info("Upload documents, type a question, and press Ask.")