Advanced RAG Implemented
Browse files- .python-version +1 -0
- .streamlit/config.toml +7 -0
- README.md +0 -0
- app.py +452 -158
- main.py +6 -0
- pyproject.toml +23 -0
- requirements.txt +0 -0
- src/api/document.py +21 -0
- src/api/ingest.py +88 -0
- src/api/query.py +88 -0
- src/api/reset.py +18 -0
- src/chunk.py +0 -34
- src/core/store_manager.py +28 -0
- src/main.py +20 -33
- src/schemas/models.py +31 -0
- src/services/chunking.py +100 -0
- src/{embed.py → services/embedding.py} +11 -7
- src/{llm.py → services/generator.py} +44 -16
- src/{ingest.py → services/ingestion.py} +52 -37
- src/services/query_rewriter.py +77 -0
- src/services/reranker.py +24 -0
- src/services/retrieval.py +70 -0
- src/{vector_store.py → services/vector_store.py} +12 -8
- src/utils/cleanup.py +32 -0
- uv.lock +0 -0
.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
|
| 2 |
-
|
| 3 |
import streamlit as st
|
| 4 |
-
import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 21 |
-
|
|
|
|
| 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 |
-
|
| 37 |
-
existing_files = os.listdir(UPLOAD_DIR)
|
| 38 |
|
| 39 |
-
|
| 40 |
-
# Remove index if no documents remain
|
| 41 |
-
if os.path.exists(INDEX_DIR):
|
| 42 |
-
shutil.rmtree(INDEX_DIR)
|
| 43 |
-
return
|
| 44 |
|
| 45 |
-
|
| 46 |
-
|
| 47 |
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
-
|
| 57 |
-
chunks = chunk_documents(records)
|
| 58 |
-
progress.progress(40)
|
| 59 |
|
| 60 |
-
|
| 61 |
-
embedder = Embedder()
|
| 62 |
-
embeddings = embedder.embed_texts([c["text"] for c in chunks])
|
| 63 |
-
progress.progress(70)
|
| 64 |
|
| 65 |
-
|
| 66 |
-
store = VectorStore(embeddings.shape[1])
|
| 67 |
-
store.add(embeddings, chunks)
|
| 68 |
-
store.save(INDEX_DIR)
|
| 69 |
-
progress.progress(100)
|
| 70 |
|
| 71 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
|
| 99 |
-
|
| 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 |
-
|
| 108 |
|
| 109 |
-
|
| 110 |
-
# RIGHT: EXISTING FILES
|
| 111 |
-
# -------------------------------
|
| 112 |
-
with right_col:
|
| 113 |
-
st.subheader("Uploaded files")
|
| 114 |
|
| 115 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 125 |
-
|
| 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 |
-
|
| 140 |
-
|
| 141 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
for message in st.session_state.messages:
|
|
|
|
| 143 |
with st.chat_message(message["role"]):
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 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 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
with assistant_placeholder.container():
|
| 187 |
with st.chat_message("assistant"):
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 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
|
| 2 |
-
from
|
| 3 |
-
from
|
| 4 |
-
from
|
| 5 |
-
from
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 5 |
self.model = SentenceTransformer(model_name)
|
| 6 |
|
| 7 |
-
def embed_texts(self, texts):
|
| 8 |
"""
|
| 9 |
-
Convert
|
| 10 |
"""
|
|
|
|
| 11 |
embeddings = self.model.encode(
|
| 12 |
-
texts,
|
| 13 |
-
show_progress_bar=True,
|
| 14 |
-
normalize_embeddings=True
|
| 15 |
)
|
| 16 |
|
| 17 |
-
return
|
|
|
|
| 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 |
-
|
| 3 |
from dotenv import load_dotenv
|
| 4 |
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 13 |
task="text-generation",
|
| 14 |
max_new_tokens=256,
|
| 15 |
-
temperature=0.
|
|
|
|
| 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
|
|
|
|
| 25 |
"""
|
| 26 |
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
prompt = f"""
|
| 30 |
-
You are
|
| 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
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
|
|
|
|
|
|
| 42 |
|
| 43 |
Question:
|
| 44 |
{question}
|
| 45 |
|
|
|
|
|
|
|
|
|
|
| 46 |
Answer:
|
| 47 |
"""
|
| 48 |
|
| 49 |
response = chat_model.invoke(prompt)
|
| 50 |
|
| 51 |
-
return response.
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
-
|
| 8 |
-
|
|
|
|
| 9 |
raise FileNotFoundError("file not found")
|
| 10 |
-
|
| 11 |
reader = PdfReader(file_path)
|
| 12 |
records = []
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 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)
|
| 44 |
|
| 45 |
if full_text:
|
| 46 |
-
records.append(
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
|
| 53 |
return records
|
| 54 |
|
| 55 |
|
| 56 |
-
def load_documents(
|
| 57 |
all_records = []
|
| 58 |
|
| 59 |
-
for
|
|
|
|
|
|
|
| 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
|
| 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[
|
| 12 |
|
| 13 |
-
def add(self, embeddings: np.ndarray, records: list[
|
| 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
|
|
|