Ren1703 commited on
Commit
f67f20b
·
1 Parent(s): faa9f28

Advanced RAG Implemented

Browse files
.python-version ADDED
@@ -0,0 +1 @@
 
 
1
+ 3.12
.streamlit/config.toml ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ [theme]
2
+ base="dark"
3
+ primaryColor="#8B5CF6"
4
+ backgroundColor="#0F172A"
5
+ secondaryBackgroundColor="#1E293B"
6
+ textColor="#F8FAFC"
7
+ font="sans serif"
README.md ADDED
File without changes
app.py CHANGED
@@ -1,215 +1,509 @@
1
- import shutil
2
-
3
  import streamlit as st
4
- import os
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
- from src.ingest import load_documents
7
- from src.chunk import chunk_documents
8
- from src.embed import Embedder
9
- from src.vector_store import VectorStore
10
- from src.llm import generate_answer
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
 
12
- @st.cache_resource
13
- def get_embedder():
14
- return Embedder()
15
 
 
 
 
 
16
 
17
- UPLOAD_DIR = "data/uploads"
18
- INDEX_DIR = "data/index"
19
 
20
- os.makedirs(UPLOAD_DIR, exist_ok=True)
21
- os.makedirs(INDEX_DIR, exist_ok=True)
 
22
 
 
 
23
 
24
 
 
 
25
 
26
- st.set_page_config(page_title="RAG", layout="wide")
27
 
 
 
 
28
 
29
  st.sidebar.title("Navigation")
30
 
31
  page = st.sidebar.radio(
32
  "Go to",
33
- ["Manage Data", "Ask Questions"]
34
  )
35
 
36
- def rebuild_index():
37
- existing_files = os.listdir(UPLOAD_DIR)
38
 
39
- if not existing_files:
40
- # Remove index if no documents remain
41
- if os.path.exists(INDEX_DIR):
42
- shutil.rmtree(INDEX_DIR)
43
- return
44
 
45
- progress = st.progress(0)
46
- status = st.empty()
47
 
48
- try:
49
- status.text("Loading documents...")
50
- records = load_documents([
51
- os.path.join(UPLOAD_DIR, f)
52
- for f in existing_files
53
- ])
54
- progress.progress(20)
 
 
 
 
 
 
55
 
56
- status.text("Chunking documents...")
57
- chunks = chunk_documents(records)
58
- progress.progress(40)
59
 
60
- status.text("Generating embeddings...")
61
- embedder = Embedder()
62
- embeddings = embedder.embed_texts([c["text"] for c in chunks])
63
- progress.progress(70)
64
 
65
- status.text("Building vector index...")
66
- store = VectorStore(embeddings.shape[1])
67
- store.add(embeddings, chunks)
68
- store.save(INDEX_DIR)
69
- progress.progress(100)
70
 
71
- status.success("Index rebuilt successfully")
72
 
73
- except Exception as e:
74
- status.error("Indexing failed")
75
- raise e
76
 
 
 
 
77
 
78
 
79
- # -----------------------
80
- # EXISTING FILES
81
- # -----------------------
82
  def render_data_page():
83
- st.header("Manage Documents")
84
 
85
- left_col, right_col = st.columns([3, 1])
 
 
 
 
 
 
 
 
 
 
 
86
 
87
- # -------------------------------
88
- # LEFT: FILE UPLOAD
89
- # -------------------------------
90
- with left_col:
91
- st.subheader("Upload files")
92
 
93
- uploaded_files = st.file_uploader(
94
- "Upload PDFs / PPTX",
95
- type=["pdf", "pptx"],
96
- accept_multiple_files=True
97
- )
 
 
 
 
 
 
 
 
98
 
99
- if uploaded_files:
100
- for file in uploaded_files:
101
- path = os.path.join(UPLOAD_DIR, file.name)
102
- with open(path, "wb") as f:
103
- f.write(file.read())
104
-
105
- rebuild_index()
106
 
107
- st.success(f"{len(uploaded_files)} files uploaded successfully")
108
 
109
- # -------------------------------
110
- # RIGHT: EXISTING FILES
111
- # -------------------------------
112
- with right_col:
113
- st.subheader("Uploaded files")
114
 
115
- existing_files = os.listdir(UPLOAD_DIR)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
 
117
- if not existing_files:
118
- st.info("No files uploaded")
119
  else:
120
- for file in existing_files:
121
- col1, col2 = st.columns([4, 1])
122
- col1.write(file)
123
 
124
- if col2.button("❌", key=f"delete_{file}"):
125
- os.remove(os.path.join(UPLOAD_DIR, file))
126
- st.warning(f"Deleted {file}")
127
- rebuild_index()
128
- st.rerun()
129
 
 
 
 
130
 
131
-
132
- # -------------------------------
133
- # CHAT BOT
134
- # -------------------------------
135
 
136
  def render_chat_page():
137
- st.title("Text-Bounded RAG System ChatBot")
138
 
139
- if "messages" not in st.session_state:
140
- st.session_state.messages = []
141
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  for message in st.session_state.messages:
 
143
  with st.chat_message(message["role"]):
144
- st.markdown(message["content"])
145
-
146
- prompt = st.chat_input("Ask Question")
147
-
 
148
  if prompt:
149
-
 
 
 
 
 
 
 
 
 
 
 
150
  with st.chat_message("user"):
 
151
  st.markdown(prompt)
152
-
153
- st.session_state.messages.append({"role": "user", "content": prompt})
154
-
155
- response = ""
156
-
157
- upload_files = os.listdir(UPLOAD_DIR)
158
- assistant_placeholder = st.empty()
159
- assistant_placeholder.empty()
160
-
161
- if not upload_files:
162
- response = "No documents uploaded. Please upload files first."
163
-
164
- elif not os.path.exists(os.path.join(INDEX_DIR, "index.faiss")):
165
- response = "Please build the index first"
166
- else:
167
 
168
- with st.spinner("..."):
169
- store = VectorStore.load(INDEX_DIR)
170
- embedder = Embedder()
171
-
172
- q_emb = embedder.embed_texts([prompt])
173
- results = store.search(q_emb, top_k=4)
174
-
175
- MAX_SCORE = max(r["score"] for r in results)
176
- print("MAX_SCORE", MAX_SCORE)
177
- if MAX_SCORE < 0.4:
178
- response = "The uploaded material does not cover this topic."
179
- else:
180
- context_chunks = [
181
- f"[{r['doc_name']} | Page {r['page']}]\n{r['text']}"
182
- for r in results
183
- ]
184
- response = generate_answer(context_chunks, prompt)
185
-
186
- with assistant_placeholder.container():
187
  with st.chat_message("assistant"):
188
- st.markdown(response)
189
- if (
190
- "The uploaded material does not cover this topic." not in response
191
- and "Please build the index first" not in response
192
- and "No documents uploaded. Please upload files first." not in response
193
- ):
194
-
195
- st.markdown("Sources")
196
- for i in range(0,2):
197
- with st.expander(f"{results[i]['doc_name']} — Page {results[i]['page']}"):
198
- st.write(results[i]["text"])
199
-
200
-
201
-
202
- st.session_state.messages.append({"role": "assistant", "content": response})
203
- # -------------------------------
204
- # OUTPUT
205
- # -------------------------------
206
-
207
-
208
-
209
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
210
  if page == "Manage Data":
 
211
  render_data_page()
212
 
213
  elif page == "Ask Questions":
214
- render_chat_page()
215
 
 
 
1
+ import uuid
2
+ import requests
3
  import streamlit as st
4
+ import time
5
+
6
+ # -----------------------------------
7
+ # CONFIG
8
+ # -----------------------------------
9
+
10
+ API_BASE_URL = "http://127.0.0.1:8000/api"
11
+
12
+ st.set_page_config(
13
+ page_title="Syllabus RAG",
14
+ layout="wide",
15
+ )
16
+
17
 
18
+ # -----------------------------------
19
+ # CUSTOM CSS
20
+ # -----------------------------------
21
+
22
+ st.markdown(
23
+ """
24
+ <style>
25
+
26
+ html, body, [class*="css"] {
27
+ font-family: 'Inter', sans-serif;
28
+ }
29
+
30
+ .main {
31
+ background-color: #0F172A;
32
+ }
33
+
34
+ .block-container {
35
+ padding-top: 1.2rem;
36
+ padding-bottom: 2rem;
37
+ max-width: 1100px;
38
+ }
39
+
40
+ section[data-testid="stSidebar"] {
41
+ background-color: #0B1220;
42
+ border-right: 1px solid #334155;
43
+ }
44
+
45
+ h1, h2, h3 {
46
+ color: #F8FAFC;
47
+ font-weight: 700;
48
+ }
49
+
50
+ p, span, label {
51
+ color: #E2E8F0;
52
+ }
53
+
54
+ .stChatMessage {
55
+ border-radius: 16px;
56
+ padding: 0.8rem;
57
+ }
58
+
59
+ [data-testid="stChatMessageContent"] {
60
+ font-size: 1rem;
61
+ line-height: 1.7;
62
+ }
63
+
64
+ .stButton button {
65
+ border-radius: 12px;
66
+ border: none;
67
+ background: linear-gradient(
68
+ 135deg,
69
+ #7C3AED,
70
+ #8B5CF6
71
+ );
72
+ color: white;
73
+ font-weight: 600;
74
+ padding: 0.65rem 1rem;
75
+ width: auto;
76
+ }
77
+
78
+ .stButton button:hover {
79
+ opacity: 0.92;
80
+ }
81
+
82
+ .upload-box {
83
+ background: #1E293B;
84
+ padding: 1rem;
85
+ border-radius: 18px;
86
+ border: 1px solid #334155;
87
+ margin-bottom: 1rem;
88
+ }
89
+
90
+ .answer-box {
91
+ background: #111827;
92
+ padding: 1rem;
93
+ border-radius: 16px;
94
+ border: 1px solid #334155;
95
+ margin-top: 1rem;
96
+ }
97
+
98
+ .rewrite-box {
99
+ background: rgba(139, 92, 246, 0.12);
100
+ padding: 0.8rem;
101
+ border-radius: 12px;
102
+ border-left: 4px solid #8B5CF6;
103
+ margin-bottom: 1rem;
104
+ }
105
+
106
+ .source-box {
107
+ background: #0B1220;
108
+ padding: 0.55rem 0.8rem;
109
+ border-radius: 10px;
110
+ margin-bottom: 0.45rem;
111
+ border: 1px solid #243244;
112
+ font-size: 0.9rem;
113
+ line-height: 1.4;
114
+ }
115
+
116
+ .hero-box {
117
+ padding: 1.5rem;
118
+ border-radius: 20px;
119
+ background: linear-gradient(
120
+ 135deg,
121
+ #111827,
122
+ #1E293B
123
+ );
124
+ border: 1px solid #334155;
125
+ margin-bottom: 2rem;
126
+ }
127
+
128
+ .hero-title {
129
+ font-size: 2.2rem;
130
+ font-weight: 800;
131
+ color: white;
132
+ }
133
+
134
+ .hero-subtitle {
135
+ color: #CBD5E1;
136
+ margin-top: 0.5rem;
137
+ font-size: 1rem;
138
+ }
139
+
140
+ </style>
141
+ """,
142
+ unsafe_allow_html=True,
143
+ )
144
+
145
+
146
+ # -----------------------------------
147
+ # STREAMING EFFECT
148
+ # -----------------------------------
149
 
 
 
 
150
 
151
+ def stream_text(text):
152
+ for word in text.split():
153
+ yield word + " "
154
+ time.sleep(0.04)
155
 
 
 
156
 
157
+ # -----------------------------------
158
+ # SESSION STATE
159
+ # -----------------------------------
160
 
161
+ if "session_id" not in st.session_state:
162
+ st.session_state.session_id = str(uuid.uuid4())
163
 
164
 
165
+ if "messages" not in st.session_state:
166
+ st.session_state.messages = []
167
 
 
168
 
169
+ # -----------------------------------
170
+ # SIDEBAR
171
+ # -----------------------------------
172
 
173
  st.sidebar.title("Navigation")
174
 
175
  page = st.sidebar.radio(
176
  "Go to",
177
+ ["Manage Data", "Ask Questions"],
178
  )
179
 
180
+ st.sidebar.divider()
 
181
 
182
+ st.sidebar.caption(f"Session ID: {st.session_state.session_id[:8]}")
 
 
 
 
183
 
184
+ # RESET BUTTON
185
+ if st.sidebar.button("🗑 Reset Session"):
186
 
187
+ with st.spinner("Resetting session..."):
188
+
189
+ response = requests.delete(
190
+ f"{API_BASE_URL}/reset-session",
191
+ params={"session_id": st.session_state.session_id},
192
+ timeout=30,
193
+ )
194
+
195
+ if response.status_code == 200:
196
+
197
+ st.session_state.messages = []
198
+
199
+ st.session_state.session_id = str(uuid.uuid4())
200
 
201
+ st.sidebar.success("Session reset successfully!")
 
 
202
 
203
+ st.rerun()
 
 
 
204
 
205
+ else:
 
 
 
 
206
 
207
+ st.sidebar.error("Failed to reset session.")
208
 
 
 
 
209
 
210
+ # -----------------------------------
211
+ # DOCUMENT PAGE
212
+ # -----------------------------------
213
 
214
 
 
 
 
215
  def render_data_page():
 
216
 
217
+ st.markdown(
218
+ """<div class="hero-box">
219
+ <div class="hero-title">
220
+ Document Management
221
+ </div>
222
+ <div class="hero-subtitle">
223
+ Upload PDF and PPTX syllabus documents for retrieval-based question answering.
224
+ </div>
225
+ </div>
226
+ """,
227
+ unsafe_allow_html=True,
228
+ )
229
 
230
+ uploaded_files = st.file_uploader(
231
+ "Upload PDF/PPTX files",
232
+ type=["pdf", "pptx"],
233
+ accept_multiple_files=True,
234
+ )
235
 
236
+ if uploaded_files:
237
+
238
+ st.subheader("Selected Files")
239
+
240
+ for file in uploaded_files:
241
+
242
+ st.markdown(f"{file.name}")
243
+
244
+ if st.button("Process Documents"):
245
+
246
+ if not uploaded_files:
247
+
248
+ st.warning("Please upload at least one file.")
249
 
250
+ return
 
 
 
 
 
 
251
 
252
+ files = []
253
 
254
+ for file in uploaded_files:
 
 
 
 
255
 
256
+ files.append(
257
+ (
258
+ "files",
259
+ (
260
+ file.name,
261
+ file,
262
+ file.type,
263
+ ),
264
+ )
265
+ )
266
+
267
+ with st.spinner("Processing documents..."):
268
+ try:
269
+ response = requests.post(
270
+ f"{API_BASE_URL}/ingest",
271
+ params={"session_id": st.session_state.session_id},
272
+ files=files,
273
+ timeout=120,
274
+ )
275
+ except requests.RequestException as e:
276
+ st.error("Failed to process documents, please try again.")
277
+ return
278
+
279
+ if response.status_code == 200:
280
+
281
+ data = response.json()
282
+
283
+ st.success("Documents processed successfully!")
284
+
285
+ col1, col2, col3 = st.columns(3)
286
+
287
+ col1.metric(
288
+ "Documents",
289
+ data["documents_processed"],
290
+ )
291
+
292
+ col2.metric(
293
+ "Chunks",
294
+ data["chunks_created"],
295
+ )
296
+
297
+ col3.metric(
298
+ "Vectors",
299
+ data["vectors_stored"],
300
+ )
301
 
 
 
302
  else:
 
 
 
303
 
304
+ st.error("Failed to process documents, please try again.")
305
+
 
 
 
306
 
307
+ # -----------------------------------
308
+ # CHAT PAGE
309
+ # -----------------------------------
310
 
 
 
 
 
311
 
312
  def render_chat_page():
 
313
 
314
+ st.markdown(
315
+ """
316
+ <div class="hero-box">
317
+ <div class="hero-title">
318
+ Syllabus RAG Assistant
319
+ </div>
320
+ <div class="hero-subtitle">
321
+ Ask questions grounded strictly in your uploaded syllabus documents.
322
+ </div>
323
+ </div>
324
+ """,
325
+ unsafe_allow_html=True,
326
+ )
327
+
328
+ # -----------------------------------
329
+ # FETCH DOCUMENTS
330
+ # -----------------------------------
331
+
332
+ available_documents = []
333
+
334
+ try:
335
+
336
+ documents_response = requests.get(
337
+ f"{API_BASE_URL}/documents",
338
+ params={"session_id": st.session_state.session_id},
339
+ timeout=30,
340
+ )
341
+
342
+ if documents_response.status_code == 200:
343
+
344
+ available_documents = documents_response.json().get("documents", [])
345
+
346
+ except Exception:
347
+
348
+ available_documents = []
349
+
350
+ # -----------------------------------
351
+ # SIDEBAR FILTERING
352
+ # -----------------------------------
353
+
354
+ st.sidebar.subheader("🔎 Search Filters")
355
+
356
+ selected_documents = st.sidebar.multiselect(
357
+ "Search Specific Documents",
358
+ available_documents,
359
+ )
360
+
361
+ if not selected_documents:
362
+ st.sidebar.caption("Searching all uploaded documents")
363
+ else:
364
+ st.sidebar.caption(f"Searching {len(selected_documents)} selected documents")
365
+ # -----------------------------------
366
+ # EMPTY STATE
367
+ # -----------------------------------
368
+
369
+ if not available_documents:
370
+
371
+ st.info("Upload documents first before asking questions.")
372
+
373
+ return
374
+
375
+ # -----------------------------------
376
+ # CHAT HISTORY
377
+ # -----------------------------------
378
+
379
  for message in st.session_state.messages:
380
+
381
  with st.chat_message(message["role"]):
382
+
383
+ st.markdown(message["content"])
384
+
385
+ prompt = st.chat_input("Ask a question...")
386
+
387
  if prompt:
388
+
389
+ # -----------------------------------
390
+ # USER MESSAGE
391
+ # -----------------------------------
392
+
393
+ st.session_state.messages.append(
394
+ {
395
+ "role": "user",
396
+ "content": prompt,
397
+ }
398
+ )
399
+
400
  with st.chat_message("user"):
401
+
402
  st.markdown(prompt)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
403
 
404
+ # -----------------------------------
405
+ # QUERY API
406
+ # -----------------------------------
407
+
408
+ with st.spinner("Thinking..."):
409
+ try:
410
+ response = requests.post(
411
+ f"{API_BASE_URL}/query",
412
+ json={
413
+ "session_id": st.session_state.session_id,
414
+ "query": prompt,
415
+ "documents": selected_documents,
416
+ },
417
+ timeout=120,
418
+ )
419
+ except requests.RequestException as e:
420
+
 
 
421
  with st.chat_message("assistant"):
422
+
423
+ st.error("Query failed. Please try again.")
424
+
425
+ return
426
+
427
+ # -----------------------------------
428
+ # REQUEST FAILURE
429
+ # -----------------------------------
430
+
431
+ data = response.json()
432
+
433
+ # -----------------------------------
434
+ # RESPONSE
435
+ # -----------------------------------
436
+
437
+ if "message" in data:
438
+
439
+ assistant_response = data["message"]
440
+
441
+ else:
442
+
443
+ assistant_response = data["answer"]
444
+
445
+ # -----------------------------------
446
+ # ASSISTANT RESPONSE
447
+ # -----------------------------------
448
+
449
+ with st.chat_message("assistant"):
450
+
451
+ # rewritten query
452
+ if "rewritten_query" in data:
453
+
454
+ rewritten_query = data["rewritten_query"]
455
+
456
+ if rewritten_query.lower().strip() != prompt.lower().strip():
457
+
458
+ st.markdown(
459
+ f"""
460
+ <div class="rewrite-box">
461
+ <b>Rewritten Query</b><br>
462
+ {rewritten_query}
463
+ </div>
464
+ """,
465
+ unsafe_allow_html=True,
466
+ )
467
+
468
+ # streamed answer
469
+ st.write_stream(stream_text(assistant_response))
470
+
471
+ # citations
472
+ if "answer" in data and "citations" in data:
473
+
474
+ st.subheader("Sources")
475
+
476
+ for citation in data["citations"][:3]:
477
+
478
+ st.markdown(
479
+ f"""
480
+ <div class="source-box">
481
+ <b>{citation['document']}, Page {citation['page']}</b>
482
+ </div>
483
+ """,
484
+ unsafe_allow_html=True,
485
+ )
486
+
487
+ # -----------------------------------
488
+ # SAVE CHAT HISTORY
489
+ # -----------------------------------
490
+
491
+ st.session_state.messages.append(
492
+ {
493
+ "role": "assistant",
494
+ "content": assistant_response,
495
+ }
496
+ )
497
+
498
+
499
+ # -----------------------------------
500
+ # ROUTING
501
+ # -----------------------------------
502
+
503
  if page == "Manage Data":
504
+
505
  render_data_page()
506
 
507
  elif page == "Ask Questions":
 
508
 
509
+ render_chat_page()
main.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ def main():
2
+ print("Hello from rag!")
3
+
4
+
5
+ if __name__ == "__main__":
6
+ main()
pyproject.toml ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "rag"
3
+ version = "0.1.0"
4
+ description = "Add your description here"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ dependencies = [
8
+ "faiss-cpu>=1.13.2",
9
+ "fastapi>=0.136.1",
10
+ "huggingface-hub>=1.14.0",
11
+ "langchain>=1.3.0",
12
+ "langchain-huggingface>=1.2.2",
13
+ "nltk>=3.9.4",
14
+ "numpy>=2.4.4",
15
+ "pypdf>=6.11.0",
16
+ "python-dotenv>=1.2.2",
17
+ "python-multipart>=0.0.28",
18
+ "python-pptx>=1.0.2",
19
+ "requests>=2.34.1",
20
+ "sentence-transformers>=5.5.0",
21
+ "streamlit>=1.57.0",
22
+ "uvicorn>=0.46.0",
23
+ ]
requirements.txt CHANGED
Binary files a/requirements.txt and b/requirements.txt differ
 
src/api/document.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter
2
+
3
+ from src.core.store_manager import load_or_create_store
4
+
5
+ router = APIRouter()
6
+
7
+
8
+ @router.get("/documents")
9
+ async def get_documents(session_id: str):
10
+ """
11
+ Return all uploaded document names
12
+ for the current session.
13
+ """
14
+
15
+ vector_store = load_or_create_store(session_id)
16
+
17
+ documents = list({record.doc_name for record in vector_store.records})
18
+
19
+ documents.sort()
20
+
21
+ return {"documents": documents}
src/api/ingest.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Annotated
2
+ from fastapi import APIRouter, UploadFile, File, HTTPException
3
+
4
+ import tempfile
5
+ import os
6
+
7
+ from src.services.ingestion import load_documents
8
+ from src.services.chunking import chunk_documents
9
+ from src.services.embedding import Embedder
10
+
11
+ from typing import List
12
+
13
+ from src.core.store_manager import load_or_create_store, get_session_store_path
14
+
15
+ router = APIRouter()
16
+
17
+ embedder = Embedder()
18
+
19
+ ALLOWED_EXTENSIONS = [".pdf", ".pptx"]
20
+ MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
21
+
22
+
23
+ @router.post("/ingest")
24
+ async def ingest_documents(
25
+ session_id: str, files: Annotated[List[UploadFile], File(...)]
26
+ ):
27
+
28
+ temp_files = []
29
+
30
+ try:
31
+
32
+ for file in files:
33
+
34
+ ext = os.path.splitext(file.filename)[1].lower()
35
+
36
+ if ext not in ALLOWED_EXTENSIONS:
37
+
38
+ raise HTTPException(
39
+ status_code=400, detail=f"Unsupported file type: {file.filename}"
40
+ )
41
+
42
+ contents = await file.read()
43
+
44
+ if len(contents) > MAX_FILE_SIZE:
45
+
46
+ raise HTTPException(
47
+ status_code=400, detail=f"{file.filename} exceeds size limit"
48
+ )
49
+
50
+ with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as temp_file:
51
+
52
+ temp_file.write(contents)
53
+
54
+ temp_files.append(
55
+ {"temp_path": temp_file.name, "original_name": file.filename}
56
+ )
57
+
58
+ # extraction
59
+ records = load_documents(temp_files)
60
+
61
+ # chunking
62
+ chunks = chunk_documents(records)
63
+
64
+ # embeddings
65
+ texts = [chunk.text for chunk in chunks]
66
+
67
+ embeddings = embedder.embed_texts(texts)
68
+
69
+ # vector store
70
+ vector_store = load_or_create_store(session_id)
71
+ vector_store.add(embeddings=embeddings, records=chunks)
72
+ store_path = get_session_store_path(session_id)
73
+ vector_store.save(store_path)
74
+
75
+ return {
76
+ "status": "success",
77
+ "documents_processed": len(files),
78
+ "records_extracted": len(records),
79
+ "chunks_created": len(chunks),
80
+ "vectors_stored": len(chunks),
81
+ }
82
+
83
+ finally:
84
+
85
+ for file_info in temp_files:
86
+
87
+ if os.path.exists(file_info["temp_path"]):
88
+ os.remove(file_info["temp_path"])
src/api/query.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter
2
+
3
+ from src.schemas.models import QueryRequest
4
+
5
+ from src.services.retrieval import Retriever
6
+
7
+ from src.services.generator import generate_answer
8
+
9
+ from src.services.query_rewriter import rewrite_query
10
+
11
+ router = APIRouter()
12
+
13
+ retriever = Retriever()
14
+
15
+
16
+ @router.post("/query")
17
+ async def query_documents(request: QueryRequest):
18
+
19
+ original_query = request.query
20
+
21
+ session_id = request.session_id
22
+
23
+ # -----------------------------
24
+ # QUERY REWRITING
25
+ # -----------------------------
26
+
27
+ rewritten_query = rewrite_query(original_query)
28
+
29
+ # -----------------------------
30
+ # RETRIEVAL
31
+ # -----------------------------
32
+
33
+ results = retriever.retrieve(
34
+ session_id=session_id,
35
+ query=rewritten_query,
36
+ top_k=5,
37
+ documents=request.documents,
38
+ )
39
+
40
+ # -----------------------------
41
+ # OUT-OF-SYLLABUS REJECTION
42
+ # -----------------------------
43
+
44
+ if not results:
45
+
46
+ return {
47
+ "query": original_query,
48
+ "rewritten_query": rewritten_query,
49
+ "message": ("The uploaded material " "does not cover this topic."),
50
+ }
51
+
52
+ # -----------------------------
53
+ # GENERATION
54
+ # -----------------------------
55
+
56
+ answer = generate_answer(context_chunks=results, question=rewritten_query)
57
+
58
+ # -----------------------------
59
+ # CITATIONS
60
+ # -----------------------------
61
+
62
+ citations = []
63
+
64
+ seen = set()
65
+
66
+ for result in results:
67
+
68
+ chunk = result["chunk"]
69
+
70
+ key = (chunk.doc_name, chunk.page)
71
+
72
+ if key in seen:
73
+ continue
74
+
75
+ seen.add(key)
76
+
77
+ citations.append({"document": chunk.doc_name, "page": chunk.page})
78
+
79
+ # -----------------------------
80
+ # FINAL RESPONSE
81
+ # -----------------------------
82
+
83
+ return {
84
+ "query": original_query,
85
+ "rewritten_query": rewritten_query,
86
+ "answer": answer,
87
+ "citations": citations,
88
+ }
src/api/reset.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import shutil
2
+ import os
3
+
4
+ from fastapi import APIRouter
5
+
6
+ router = APIRouter()
7
+
8
+
9
+ @router.delete("/reset-session")
10
+ async def reset_session(session_id: str):
11
+
12
+ session_path = f"storage/sessions/{session_id}"
13
+
14
+ if os.path.exists(session_path):
15
+
16
+ shutil.rmtree(session_path)
17
+
18
+ return {"status": "success", "message": "Session reset successfully."}
src/chunk.py DELETED
@@ -1,34 +0,0 @@
1
- def chunk_documents(
2
- records: list[dict],
3
- chunk_size: int = 500,
4
- overlap: int = 100
5
- ) -> list[dict]:
6
- """
7
- Page-bounded sliding window chunking.
8
- Chunks NEVER cross page boundaries.
9
- """
10
-
11
- chunks = []
12
-
13
- for record in records:
14
- text = record["text"]
15
- doc_name = record["doc_name"]
16
- page = record["page"]
17
-
18
- start = 0
19
- text_len = len(text)
20
-
21
- while start < text_len:
22
- end = start + chunk_size
23
- chunk_text = text[start:end].strip()
24
-
25
- if chunk_text:
26
- chunks.append({
27
- "text": chunk_text,
28
- "doc_name": doc_name,
29
- "page": page
30
- })
31
-
32
- start += chunk_size - overlap
33
-
34
- return chunks
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/core/store_manager.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+
3
+ from src.services.vector_store import VectorStore
4
+ from src.utils.cleanup import cleanup_old_sessions
5
+
6
+ BASE_STORAGE_PATH = "storage/sessions"
7
+
8
+
9
+ def get_session_store_path(session_id: str) -> str:
10
+
11
+ return os.path.join(BASE_STORAGE_PATH, session_id)
12
+
13
+
14
+ def load_or_create_store(session_id: str) -> VectorStore:
15
+ cleanup_old_sessions()
16
+ store_path = get_session_store_path(session_id)
17
+
18
+ index_path = os.path.join(store_path, "index.faiss")
19
+
20
+ # existing store
21
+ if os.path.exists(index_path):
22
+
23
+ return VectorStore.load(store_path)
24
+
25
+ # new store
26
+ os.makedirs(store_path, exist_ok=True)
27
+
28
+ return VectorStore(embedding_dim=384)
src/main.py CHANGED
@@ -1,33 +1,20 @@
1
- from ingest import load_documents
2
- from chunk import chunk_documents
3
- from embed import Embedder
4
- from vector_store import VectorStore
5
- from llm import generate_answer
6
-
7
- # Build index (run once)
8
- records = load_documents(["data/AI & ML DIGITAL NOTES.pdf"])
9
- chunks = chunk_documents(records)
10
-
11
- embedder = Embedder()
12
- embeddings = embedder.embed_texts([c["text"] for c in chunks])
13
-
14
- store = VectorStore(embeddings.shape[1])
15
- store.add(embeddings, chunks)
16
- store.save("data/index")
17
-
18
- # Query time
19
- store = VectorStore.load("data/index")
20
-
21
- question = "What is deep learning?"
22
-
23
- q_emb = embedder.embed_texts([question])
24
- results = store.search(q_emb, top_k=3)
25
-
26
- context_chunks = [
27
- f"[{r['doc_name']} | Page {r['page']}]\n{r['text']}"
28
- for r in results
29
- ]
30
-
31
- answer = generate_answer(context_chunks, question)
32
-
33
- print(answer)
 
1
+ from fastapi import FastAPI
2
+ from src.api.ingest import router as ingest_router
3
+ from src.api.query import router as query_router
4
+ from src.api.reset import router as reset_router
5
+ from src.api.document import router as doc_router
6
+ from fastapi.middleware.cors import CORSMiddleware
7
+
8
+ app = FastAPI()
9
+
10
+ app.add_middleware(
11
+ CORSMiddleware,
12
+ allow_origins=["*"],
13
+ allow_credentials=True,
14
+ allow_methods=["*"],
15
+ allow_headers=["*"],
16
+ )
17
+ app.include_router(ingest_router, prefix="/api", tags=["ingestion"])
18
+ app.include_router(query_router, prefix="/api", tags=["query"])
19
+ app.include_router(reset_router, prefix="/api", tags=["reset"])
20
+ app.include_router(doc_router, prefix="/api", tags=["documents"])
 
 
 
 
 
 
 
 
 
 
 
 
 
src/schemas/models.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel
2
+ from typing import Optional
3
+
4
+
5
+ class DocumentRecord(BaseModel):
6
+ text: str
7
+ doc_name: str
8
+ page: int
9
+ source: str
10
+
11
+ document_id: Optional[str] = None
12
+ chunk_id: Optional[str] = None
13
+ section_title: Optional[str] = None
14
+ metadata: Optional[dict] = None
15
+
16
+
17
+ class ChunkRecord(BaseModel):
18
+ chunk_id: str
19
+ document_id: str
20
+ text: str
21
+ doc_name: str
22
+ page: int
23
+ chunk_index: int
24
+
25
+ metadata: Optional[dict] = None
26
+
27
+
28
+ class QueryRequest(BaseModel):
29
+ session_id: str
30
+ query: str
31
+ documents: list[str] | None = None
src/services/chunking.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import uuid
2
+ from typing import Optional
3
+
4
+ from nltk.tokenize import sent_tokenize
5
+ from pydantic import BaseModel
6
+
7
+ from src.schemas.models import DocumentRecord
8
+ from src.schemas.models import ChunkRecord
9
+
10
+
11
+ def chunk_documents(
12
+ records: list[DocumentRecord],
13
+ chunk_size: int = 500,
14
+ overlap: int = 100,
15
+ ) -> list[ChunkRecord]:
16
+ """
17
+ Sentence-aware page-bounded chunking.
18
+
19
+ - Chunks NEVER cross page boundaries
20
+ - Preserves semantic meaning better than character slicing
21
+ - Maintains overlap between chunks
22
+ """
23
+
24
+ chunks = []
25
+
26
+ for record in records:
27
+
28
+ sentences = sent_tokenize(record.text)
29
+
30
+ current_chunk = []
31
+ current_length = 0
32
+
33
+ chunk_index = 0
34
+
35
+ for sentence in sentences:
36
+
37
+ sentence_length = len(sentence)
38
+
39
+ # if adding this sentence exceeds chunk size,
40
+ # finalize current chunk
41
+ if current_length + sentence_length > chunk_size and current_chunk:
42
+
43
+ chunk_text = " ".join(current_chunk).strip()
44
+ word_count = len(chunk_text.split())
45
+ if word_count < 5:
46
+ continue
47
+ chunks.append(
48
+ ChunkRecord(
49
+ chunk_id=str(uuid.uuid4()),
50
+ document_id=record.document_id,
51
+ text=chunk_text,
52
+ doc_name=record.doc_name,
53
+ page=record.page,
54
+ chunk_index=chunk_index,
55
+ metadata=record.metadata,
56
+ )
57
+ )
58
+
59
+ chunk_index += 1
60
+
61
+ # overlap logic
62
+ overlap_sentences = []
63
+ overlap_length = 0
64
+
65
+ for s in reversed(current_chunk):
66
+
67
+ overlap_length += len(s)
68
+
69
+ if overlap_length > overlap:
70
+ break
71
+
72
+ overlap_sentences.insert(0, s)
73
+
74
+ current_chunk = overlap_sentences
75
+ current_length = sum(len(s) for s in current_chunk)
76
+
77
+ current_chunk.append(sentence)
78
+ current_length += sentence_length
79
+
80
+ # final chunk
81
+ if current_chunk:
82
+
83
+ chunk_text = " ".join(current_chunk).strip()
84
+ word_count = len(chunk_text.split())
85
+
86
+ if word_count < 5:
87
+ continue
88
+ chunks.append(
89
+ ChunkRecord(
90
+ chunk_id=str(uuid.uuid4()),
91
+ document_id=record.document_id,
92
+ text=chunk_text,
93
+ doc_name=record.doc_name,
94
+ page=record.page,
95
+ chunk_index=chunk_index,
96
+ metadata=record.metadata,
97
+ )
98
+ )
99
+
100
+ return chunks
src/{embed.py → services/embedding.py} RENAMED
@@ -1,17 +1,21 @@
1
  import numpy as np
 
2
  from sentence_transformers import SentenceTransformer
 
 
3
  class Embedder:
4
- def __init__(self, model_name: str = "all-MiniLM-L6-v2"):
 
 
5
  self.model = SentenceTransformer(model_name)
6
 
7
- def embed_texts(self, texts):
8
  """
9
- Convert a list of texts into embedding vectors.
10
  """
 
11
  embeddings = self.model.encode(
12
- texts,
13
- show_progress_bar=True,
14
- normalize_embeddings=True
15
  )
16
 
17
- return np.array(embeddings, dtype="float32")
 
1
  import numpy as np
2
+
3
  from sentence_transformers import SentenceTransformer
4
+
5
+
6
  class Embedder:
7
+
8
+ def __init__(self, model_name: str = "BAAI/bge-small-en-v1.5"):
9
+
10
  self.model = SentenceTransformer(model_name)
11
 
12
+ def embed_texts(self, texts: list[str]) -> np.ndarray:
13
  """
14
+ Convert texts into normalized embedding vectors.
15
  """
16
+
17
  embeddings = self.model.encode(
18
+ texts, normalize_embeddings=True, convert_to_numpy=True
 
 
19
  )
20
 
21
+ return embeddings.astype("float32")
src/{llm.py → services/generator.py} RENAMED
@@ -1,51 +1,79 @@
1
  import os
2
- from google import genai
3
  from dotenv import load_dotenv
4
 
5
- load_dotenv()
 
 
 
6
 
 
7
 
8
- from langchain_huggingface import ChatHuggingFace, HuggingFaceEndpoint
9
 
10
  llm = HuggingFaceEndpoint(
11
  repo_id="Qwen/Qwen3-4B-Instruct-2507",
12
- huggingfacehub_api_token=f"{os.getenv('HUGGINGFACE_API_KEY')}",
13
  task="text-generation",
14
  max_new_tokens=256,
15
- temperature=0.7,
 
16
  )
17
 
18
- chat_model = ChatHuggingFace(llm=llm)
19
 
 
20
 
21
 
22
  def generate_answer(context_chunks, question: str) -> str:
23
  """
24
- Generate a syllabus-grounded answer using retrieved chunks.
 
25
  """
26
 
27
- context = "\n\n".join(context_chunks)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
 
29
  prompt = f"""
30
- You are an academic assistant.
31
 
32
  STRICT RULES:
33
  - Use ONLY the provided context
34
  - Do NOT use outside knowledge
 
35
  - If the answer is fully present, answer clearly
36
- - If partially present, say "The uploaded material partially cover this topic." and also give the answer(s) based on the context
37
- - If not present, say "The uploaded material does not cover this topic."
38
- - Always cite page numbers in the answer
39
-
40
- Context:
41
- {context}
 
 
42
 
43
  Question:
44
  {question}
45
 
 
 
 
46
  Answer:
47
  """
48
 
49
  response = chat_model.invoke(prompt)
50
 
51
- return response.text.strip()
 
1
  import os
2
+
3
  from dotenv import load_dotenv
4
 
5
+ from langchain_huggingface import (
6
+ ChatHuggingFace,
7
+ HuggingFaceEndpoint,
8
+ )
9
 
10
+ load_dotenv()
11
 
 
12
 
13
  llm = HuggingFaceEndpoint(
14
  repo_id="Qwen/Qwen3-4B-Instruct-2507",
15
+ huggingfacehub_api_token=os.getenv("HUGGINGFACE_API_KEY"),
16
  task="text-generation",
17
  max_new_tokens=256,
18
+ temperature=0.1,
19
+ do_sample=False,
20
  )
21
 
 
22
 
23
+ chat_model = ChatHuggingFace(llm=llm)
24
 
25
 
26
  def generate_answer(context_chunks, question: str) -> str:
27
  """
28
+ Generate a syllabus-grounded answer
29
+ using retrieved chunks.
30
  """
31
 
32
+ # use only top reranked chunks
33
+ top_chunks = context_chunks[:3]
34
+
35
+ context_parts = []
36
+
37
+ for result in top_chunks:
38
+
39
+ chunk = result["chunk"]
40
+
41
+ context_parts.append(f"""
42
+ Document: {chunk.doc_name}
43
+ Page: {chunk.page}
44
+
45
+ Content:
46
+ {chunk.text}
47
+ """)
48
+
49
+ context = "\n\n".join(context_parts)
50
 
51
  prompt = f"""
52
+ You are a syllabus-bounded academic assistant.
53
 
54
  STRICT RULES:
55
  - Use ONLY the provided context
56
  - Do NOT use outside knowledge
57
+ - Check if the question is academic or casual, if its casual conversate and direct towards academics. IF its academic, answer using the context.
58
  - If the answer is fully present, answer clearly
59
+ - If partially present, say:
60
+ "The uploaded material partially covers this topic."
61
+ Then provide the available answer
62
+ - If the answer is not present, say:
63
+ "The uploaded material does not cover this topic."
64
+ - Keep answers concise and factual
65
+ - Cite sources in this format:
66
+ (Page <page>, <document>)
67
 
68
  Question:
69
  {question}
70
 
71
+ Context:
72
+ {context}
73
+
74
  Answer:
75
  """
76
 
77
  response = chat_model.invoke(prompt)
78
 
79
+ return response.content.strip()
src/{ingest.py → services/ingestion.py} RENAMED
@@ -1,69 +1,87 @@
1
- from importlib.resources import path
2
  import os
3
  from pypdf import PdfReader
4
- from pdf2image import convert_from_path
 
 
 
 
5
 
 
 
 
 
6
 
7
- def extract_pages_from_pdf(file_path: str) -> list[dict]:
8
- if(not os.path.exists(file_path)):
 
9
  raise FileNotFoundError("file not found")
10
-
11
  reader = PdfReader(file_path)
12
  records = []
13
- for pageno,page in enumerate(reader.pages,start = 1):
14
- text = (page.extract_text() or "").strip()
15
-
16
- records.append({
17
- "text": text,
18
- "doc_name": os.path.basename(file_path),
19
- "page": pageno,
20
- "source": "pdf"
21
- })
22
-
23
- return records
24
-
25
-
 
26
 
 
27
 
28
- from pptx import Presentation
29
 
30
- def extract_slides_from_ppt(file_path: str):
31
  prs = Presentation(file_path)
32
  records = []
33
-
34
  for slide_num, slide in enumerate(prs.slides, start=1):
35
  slide_text = []
36
 
37
  for shape in slide.shapes:
38
- if hasattr(shape, "text"):
39
  text = shape.text.strip()
40
  if text:
41
  slide_text.append(text)
42
 
43
- full_text = "\n".join(slide_text).strip()
44
 
45
  if full_text:
46
- records.append({
47
- "text": full_text,
48
- "doc_name": os.path.basename(file_path),
49
- "page": slide_num, # slide number
50
- "source": "ppt"
51
- })
 
 
 
 
 
 
 
52
 
53
  return records
54
 
55
 
56
- def load_documents(file_paths):
57
  all_records = []
58
 
59
- for path in file_paths:
 
 
60
  ext = path.lower()
61
 
62
  if ext.endswith(".pdf"):
63
- all_records.extend(extract_pages_from_pdf(path))
64
 
65
  elif ext.endswith(".pptx"):
66
- all_records.extend(extract_slides_from_ppt(path))
67
 
68
  elif ext.endswith(".ppt"):
69
  raise ValueError(
@@ -73,7 +91,4 @@ def load_documents(file_paths):
73
  else:
74
  raise ValueError(f"Unsupported file type: {path}")
75
 
76
- return [r for r in all_records if r["text"].strip()]
77
-
78
-
79
-
 
 
1
  import os
2
  from pypdf import PdfReader
3
+ from src.schemas.models import DocumentRecord
4
+ import re
5
+ import uuid
6
+ from pptx import Presentation
7
+
8
 
9
+ def clean_text(text: str) -> str:
10
+ text = re.sub(r"[ \t]+", " ", text)
11
+ text = re.sub(r"\n{2,}", "\n", text)
12
+ return text.strip()
13
 
14
+
15
+ def extract_pages_from_pdf(file_path: str, original_name: str) -> list[DocumentRecord]:
16
+ if not os.path.exists(file_path):
17
  raise FileNotFoundError("file not found")
18
+
19
  reader = PdfReader(file_path)
20
  records = []
21
+ document_id = str(uuid.uuid4())
22
+ for pageno, page in enumerate(reader.pages, start=1):
23
+ text = clean_text(page.extract_text() or "")
24
+
25
+ records.append(
26
+ DocumentRecord(
27
+ text=text,
28
+ doc_name=original_name,
29
+ page=pageno,
30
+ source="pdf",
31
+ document_id=document_id,
32
+ metadata={"file_type": "pdf", "file_name": original_name},
33
+ )
34
+ )
35
 
36
+ return records
37
 
 
38
 
39
+ def extract_slides_from_ppt(file_path: str, original_name: str) -> list[DocumentRecord]:
40
  prs = Presentation(file_path)
41
  records = []
42
+ document_id = str(uuid.uuid4())
43
  for slide_num, slide in enumerate(prs.slides, start=1):
44
  slide_text = []
45
 
46
  for shape in slide.shapes:
47
+ if hasattr(shape, "text") and shape.text:
48
  text = shape.text.strip()
49
  if text:
50
  slide_text.append(text)
51
 
52
+ full_text = clean_text("\n".join(slide_text))
53
 
54
  if full_text:
55
+ records.append(
56
+ DocumentRecord(
57
+ text=full_text,
58
+ doc_name=original_name,
59
+ page=slide_num,
60
+ source="ppt",
61
+ document_id=document_id,
62
+ metadata={
63
+ "file_type": "ppt",
64
+ "file_name": original_name,
65
+ },
66
+ )
67
+ )
68
 
69
  return records
70
 
71
 
72
+ def load_documents(files: list[dict]) -> list[DocumentRecord]:
73
  all_records = []
74
 
75
+ for file_info in files:
76
+ path = file_info["temp_path"]
77
+ original_name = file_info["original_name"]
78
  ext = path.lower()
79
 
80
  if ext.endswith(".pdf"):
81
+ all_records.extend(extract_pages_from_pdf(path, original_name))
82
 
83
  elif ext.endswith(".pptx"):
84
+ all_records.extend(extract_slides_from_ppt(path, original_name))
85
 
86
  elif ext.endswith(".ppt"):
87
  raise ValueError(
 
91
  else:
92
  raise ValueError(f"Unsupported file type: {path}")
93
 
94
+ return [r for r in all_records if r.text.strip()]
 
 
 
src/services/query_rewriter.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+
3
+ from dotenv import load_dotenv
4
+
5
+ from langchain_huggingface import (
6
+ ChatHuggingFace,
7
+ HuggingFaceEndpoint,
8
+ )
9
+
10
+ load_dotenv()
11
+
12
+
13
+ llm = HuggingFaceEndpoint(
14
+ repo_id="Qwen/Qwen3-4B-Instruct-2507",
15
+ huggingfacehub_api_token=os.getenv("HUGGINGFACE_API_KEY"),
16
+ task="text-generation",
17
+ max_new_tokens=64,
18
+ temperature=0.1,
19
+ do_sample=False,
20
+ )
21
+
22
+
23
+ chat_model = ChatHuggingFace(llm=llm)
24
+
25
+
26
+ def rewrite_query(query: str) -> str:
27
+ """
28
+ Rewrite student queries into
29
+ clearer retrieval-friendly queries.
30
+ """
31
+
32
+ prompt = f"""
33
+ You are a query rewriting assistant
34
+ for a syllabus-based RAG system.
35
+
36
+ Your job:
37
+ - If the query is casual conversation,
38
+ greeting, or unrelated to academics,
39
+ return the original query unchanged.
40
+ -Else, rewrite the query to be more specific and clear for retrieval.
41
+ - Expand abbreviations
42
+ - Improve clarity for retrieval
43
+ - Preserve original meaning
44
+ - Keep concise
45
+ - Return ONLY a single string which is the rewritten query
46
+
47
+ Examples:
48
+
49
+ Student Query:
50
+ what is util
51
+
52
+ Rewritten Query:
53
+ What is utilitarian theory in ethics?
54
+
55
+ Student Query:
56
+ oop pillars
57
+
58
+ Rewritten Query:
59
+ What are the four pillars of object-oriented programming?
60
+
61
+ Student Query:
62
+ db normalization explain
63
+
64
+ Rewritten Query:
65
+ Explain database normalization.
66
+
67
+ Student Query:
68
+ {query}
69
+
70
+ Rewritten Query:
71
+ """
72
+
73
+ response = chat_model.invoke(prompt)
74
+
75
+ rewritten_query = response.content.strip()
76
+
77
+ return rewritten_query
src/services/reranker.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sentence_transformers import CrossEncoder
2
+
3
+
4
+ class Reranker:
5
+
6
+ def __init__(self):
7
+
8
+ self.model = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")
9
+
10
+ def rerank(self, query: str, results: list):
11
+
12
+ pairs = [(query, result["chunk"].text) for result in results]
13
+
14
+ scores = self.model.predict(pairs)
15
+
16
+ reranked = []
17
+
18
+ for score, result in zip(scores, results):
19
+
20
+ reranked.append({"score": float(score), "chunk": result["chunk"]})
21
+
22
+ reranked.sort(key=lambda x: x["score"], reverse=True)
23
+
24
+ return reranked
src/services/retrieval.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from src.core.store_manager import load_or_create_store
2
+
3
+ from src.services.embedding import Embedder
4
+
5
+ from src.services.reranker import Reranker
6
+
7
+
8
+ class Retriever:
9
+
10
+ def __init__(self):
11
+
12
+ self.embedder = Embedder()
13
+
14
+ self.reranker = Reranker()
15
+
16
+ self.similarity_threshold = 0.60
17
+
18
+ def retrieve(self, session_id: str, query: str, top_k: int = 10, documents=None):
19
+
20
+ vector_store = load_or_create_store(session_id)
21
+
22
+ # --------------------------------
23
+ # QUERY EMBEDDING
24
+ # --------------------------------
25
+
26
+ query_embedding = self.embedder.embed_texts([query])[0]
27
+
28
+ # --------------------------------
29
+ # VECTOR RETRIEVAL
30
+ # --------------------------------
31
+
32
+ retrieved_results = vector_store.search(
33
+ query_embedding=query_embedding, top_k=top_k
34
+ )
35
+
36
+ # --------------------------------
37
+ # DOCUMENT FILTERING
38
+ # --------------------------------
39
+
40
+ if documents:
41
+
42
+ retrieved_results = [
43
+ r for r in retrieved_results if (r["chunk"].doc_name in documents)
44
+ ]
45
+
46
+ # --------------------------------
47
+ # NO RESULTS
48
+ # --------------------------------
49
+
50
+ if not retrieved_results:
51
+
52
+ return []
53
+
54
+ # --------------------------------
55
+ # THRESHOLD REJECTION
56
+ # --------------------------------
57
+
58
+ top_score = retrieved_results[0]["score"]
59
+
60
+ if top_score < self.similarity_threshold:
61
+
62
+ return []
63
+
64
+ # --------------------------------
65
+ # RERANKING
66
+ # --------------------------------
67
+
68
+ reranked_results = self.reranker.rerank(query=query, results=retrieved_results)
69
+
70
+ return reranked_results[:5]
src/{vector_store.py → services/vector_store.py} RENAMED
@@ -2,15 +2,16 @@ import faiss
2
  import pickle
3
  import os
4
  import numpy as np
 
5
 
6
 
7
  class VectorStore:
8
  def __init__(self, embedding_dim: int):
9
  # Inner Product index (cosine similarity with normalized vectors)
10
  self.index = faiss.IndexFlatIP(embedding_dim)
11
- self.records: list[dict] = []
12
 
13
- def add(self, embeddings: np.ndarray, records: list[dict]):
14
  """
15
  Add embeddings and their corresponding metadata records.
16
  Order MUST be preserved.
@@ -22,17 +23,20 @@ class VectorStore:
22
  """
23
  Search the index and return top_k matching chunks with metadata.
24
  """
 
 
 
 
 
 
25
  scores, indices = self.index.search(query_embedding, top_k)
26
 
27
  results = []
28
  for score, idx in zip(scores[0], indices[0]):
 
 
29
  record = self.records[idx]
30
- results.append({
31
- "score": float(score),
32
- "text": record["text"],
33
- "doc_name": record["doc_name"],
34
- "page": record["page"]
35
- })
36
 
37
  return results
38
 
 
2
  import pickle
3
  import os
4
  import numpy as np
5
+ from src.schemas.models import DocumentRecord, ChunkRecord
6
 
7
 
8
  class VectorStore:
9
  def __init__(self, embedding_dim: int):
10
  # Inner Product index (cosine similarity with normalized vectors)
11
  self.index = faiss.IndexFlatIP(embedding_dim)
12
+ self.records: list[ChunkRecord] = []
13
 
14
+ def add(self, embeddings: np.ndarray, records: list[ChunkRecord]):
15
  """
16
  Add embeddings and their corresponding metadata records.
17
  Order MUST be preserved.
 
23
  """
24
  Search the index and return top_k matching chunks with metadata.
25
  """
26
+ query_embedding = np.asarray(query_embedding, dtype="float32")
27
+
28
+ if query_embedding.ndim == 1:
29
+ query_embedding = np.expand_dims(query_embedding, axis=0)
30
+ if self.index.ntotal == 0:
31
+ return []
32
  scores, indices = self.index.search(query_embedding, top_k)
33
 
34
  results = []
35
  for score, idx in zip(scores[0], indices[0]):
36
+ if idx == -1:
37
+ continue
38
  record = self.records[idx]
39
+ results.append({"score": float(score), "chunk": record})
 
 
 
 
 
40
 
41
  return results
42
 
src/utils/cleanup.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import shutil
3
+ import time
4
+
5
+ BASE_PATH = "storage/sessions"
6
+
7
+ MAX_AGE_HOURS = 48
8
+
9
+
10
+ def cleanup_old_sessions():
11
+
12
+ now = time.time()
13
+
14
+ max_age_seconds = MAX_AGE_HOURS * 3600
15
+
16
+ if not os.path.exists(BASE_PATH):
17
+ return
18
+
19
+ for session_id in os.listdir(BASE_PATH):
20
+
21
+ session_path = os.path.join(BASE_PATH, session_id)
22
+
23
+ if not os.path.isdir(session_path):
24
+ continue
25
+
26
+ modified_time = os.path.getmtime(session_path)
27
+
28
+ age = now - modified_time
29
+
30
+ if age > max_age_seconds:
31
+
32
+ shutil.rmtree(session_path, ignore_errors=True)
uv.lock ADDED
The diff for this file is too large to render. See raw diff