Upload folder using huggingface_hub
Browse files- .dockerignore +37 -0
- .gitattributes +36 -35
- .gitignore +56 -0
- Dockerfile +24 -0
- README.md +12 -11
- app.py +64 -0
- faiss.index +3 -0
- metadata.json +0 -0
- requirements.txt +8 -0
- src/__init__.py +0 -0
- src/api/__init__.py +0 -0
- src/api/main.py +89 -0
- src/core/exceptions.py +10 -0
- src/embedding/__init__.py +0 -0
- src/embedding/embedding_client.py +23 -0
- src/embedding/embeddings.py +18 -0
- src/evaluation/faithfulness.py +21 -0
- src/evaluation/relevance.py +9 -0
- src/generation/llm_client.py +66 -0
- src/ingestion/__init__.py +0 -0
- src/ingestion/document_loader.py +34 -0
- src/ingestion/pdf_to_markdown.py +91 -0
- src/mlops/__init__.py +0 -0
- src/mlops/tracking.py +42 -0
- src/preprocessing/__init__.py +0 -0
- src/preprocessing/chunker_markdown.py +143 -0
- src/preprocessing/cleaner.py +41 -0
- src/services/rag_service.py +85 -0
- src/training/run_ingestion.py +74 -0
- src/vector_store/FAISS_store.py +34 -0
- src/vector_store/__init__.py +0 -0
.dockerignore
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Environment & secrets
|
| 2 |
+
.env
|
| 3 |
+
.env.*
|
| 4 |
+
|
| 5 |
+
# Python cache
|
| 6 |
+
__pycache__/
|
| 7 |
+
*.pyc
|
| 8 |
+
*.pyo
|
| 9 |
+
*.pyd
|
| 10 |
+
|
| 11 |
+
# Git
|
| 12 |
+
.git/
|
| 13 |
+
.gitignore
|
| 14 |
+
|
| 15 |
+
# Tests & local tooling
|
| 16 |
+
tests/
|
| 17 |
+
.pytest_cache/
|
| 18 |
+
|
| 19 |
+
# Local experiment tracking
|
| 20 |
+
mlflow.db
|
| 21 |
+
mlruns/
|
| 22 |
+
|
| 23 |
+
# Dev & notebooks
|
| 24 |
+
*.ipynb
|
| 25 |
+
|
| 26 |
+
# OS files
|
| 27 |
+
.DS_Store
|
| 28 |
+
|
| 29 |
+
# Build artifacts
|
| 30 |
+
build/
|
| 31 |
+
dist/
|
| 32 |
+
*.egg-info/
|
| 33 |
+
|
| 34 |
+
# Not needed on HuggingFace
|
| 35 |
+
script.py
|
| 36 |
+
src/training/
|
| 37 |
+
src/ingestion/
|
.gitattributes
CHANGED
|
@@ -1,35 +1,36 @@
|
|
| 1 |
-
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
-
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
-
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
-
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
-
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
-
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
-
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
-
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
-
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
-
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
-
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
-
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
-
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
-
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
-
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
-
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
-
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
-
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
-
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
-
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
-
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
-
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
-
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
-
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
-
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
-
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
-
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
-
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
-
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
-
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
-
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
-
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
-
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
-
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
-
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 1 |
+
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
+
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
+
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
+
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
+
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
+
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
+
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
+
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
+
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
+
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
+
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
+
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
+
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
+
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
+
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
+
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
+
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
+
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
+
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
+
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
+
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
+
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
+
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
+
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
+
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
*.index filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 1. البيئة والمتغيرات
|
| 2 |
+
venv/
|
| 3 |
+
env/
|
| 4 |
+
.venv/
|
| 5 |
+
.env
|
| 6 |
+
.env.local
|
| 7 |
+
|
| 8 |
+
# 2. بايثون
|
| 9 |
+
__pycache__/
|
| 10 |
+
*.py[cod]
|
| 11 |
+
*$py.class
|
| 12 |
+
*.so
|
| 13 |
+
.python-version
|
| 14 |
+
|
| 15 |
+
# 3. FAISS والـ Embedding
|
| 16 |
+
# *.faiss
|
| 17 |
+
# *.index
|
| 18 |
+
# faiss_index/
|
| 19 |
+
|
| 20 |
+
# 4. MLflow (كله لا ترفعه)
|
| 21 |
+
mlflow.db
|
| 22 |
+
mlruns/
|
| 23 |
+
.mlflow/
|
| 24 |
+
metadata.json # إذا كان تابع لـ MLflow
|
| 25 |
+
|
| 26 |
+
# 5. الموديلات
|
| 27 |
+
models/
|
| 28 |
+
*.pkl
|
| 29 |
+
*.joblib
|
| 30 |
+
*.h5
|
| 31 |
+
*.keras
|
| 32 |
+
*.pt
|
| 33 |
+
*.bin
|
| 34 |
+
|
| 35 |
+
# 6. الاختبارات
|
| 36 |
+
.pytest_cache/
|
| 37 |
+
.coverage
|
| 38 |
+
htmlcov/
|
| 39 |
+
.coverage.*
|
| 40 |
+
|
| 41 |
+
# 7. ملفات مؤقتة
|
| 42 |
+
temp/
|
| 43 |
+
tmp/
|
| 44 |
+
uploads/
|
| 45 |
+
*.log
|
| 46 |
+
logs/
|
| 47 |
+
|
| 48 |
+
# 8. أنظمة التشغيل
|
| 49 |
+
.DS_Store
|
| 50 |
+
Thumbs.db
|
| 51 |
+
|
| 52 |
+
# 9. الـ IDEs
|
| 53 |
+
.vscode/
|
| 54 |
+
.idea/
|
| 55 |
+
*.swp
|
| 56 |
+
*.swo
|
Dockerfile
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
RUN apt-get update && apt-get install -y \
|
| 6 |
+
build-essential \
|
| 7 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 8 |
+
|
| 9 |
+
COPY requirements.txt .
|
| 10 |
+
|
| 11 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 12 |
+
|
| 13 |
+
RUN python - <<EOF
|
| 14 |
+
from sentence_transformers import SentenceTransformer
|
| 15 |
+
SentenceTransformer("paraphrase-multilingual-MiniLM-L12-v2")
|
| 16 |
+
EOF
|
| 17 |
+
|
| 18 |
+
COPY . .
|
| 19 |
+
|
| 20 |
+
ENV PYTHONPATH=/app/src
|
| 21 |
+
|
| 22 |
+
EXPOSE 7860
|
| 23 |
+
|
| 24 |
+
CMD ["uvicorn", "src.api.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
|
@@ -1,11 +1,12 @@
|
|
| 1 |
-
---
|
| 2 |
-
title: Rag Lecture Saver
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom: green
|
| 5 |
-
colorTo:
|
| 6 |
-
sdk: docker
|
| 7 |
-
pinned: false
|
| 8 |
-
license: mit
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Rag Lecture Saver
|
| 3 |
+
emoji: 🏃
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: yellow
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
license: mit
|
| 9 |
+
app_port: 7860
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
app.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
ROOT_DIR = Path(__file__).resolve().parent.parent
|
| 5 |
+
if str(ROOT_DIR) not in sys.path:
|
| 6 |
+
sys.path.insert(0, str(ROOT_DIR))
|
| 7 |
+
|
| 8 |
+
import streamlit as st
|
| 9 |
+
import requests
|
| 10 |
+
|
| 11 |
+
API_URL = "http://0.0.0.0:8000/ask"
|
| 12 |
+
|
| 13 |
+
st.set_page_config(
|
| 14 |
+
page_title="Lecture RAG",
|
| 15 |
+
layout="wide",
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
st.title("📚 Lecture RAG Assistant")
|
| 19 |
+
|
| 20 |
+
if "messages" not in st.session_state:
|
| 21 |
+
st.session_state.messages = []
|
| 22 |
+
|
| 23 |
+
chat_container = st.container()
|
| 24 |
+
|
| 25 |
+
with chat_container:
|
| 26 |
+
for msg in st.session_state.messages:
|
| 27 |
+
with st.chat_message(msg["role"]):
|
| 28 |
+
st.markdown(msg["content"])
|
| 29 |
+
|
| 30 |
+
question = st.chat_input("اكتب سؤالك هنا / Ask your question")
|
| 31 |
+
|
| 32 |
+
if question:
|
| 33 |
+
st.session_state.messages.append({"role": "user", "content": question})
|
| 34 |
+
with chat_container:
|
| 35 |
+
with st.chat_message("user"):
|
| 36 |
+
st.markdown(question)
|
| 37 |
+
|
| 38 |
+
with st.chat_message("assistant"):
|
| 39 |
+
with st.spinner("Thinking..."):
|
| 40 |
+
try:
|
| 41 |
+
response = requests.post(
|
| 42 |
+
API_URL,
|
| 43 |
+
json={"question": question},
|
| 44 |
+
timeout=120
|
| 45 |
+
)
|
| 46 |
+
response.raise_for_status()
|
| 47 |
+
data = response.json()
|
| 48 |
+
answer = data.get("answer", "No answer returned.")
|
| 49 |
+
except Exception as e:
|
| 50 |
+
data = {}
|
| 51 |
+
answer = f"❌ API error: {e}"
|
| 52 |
+
|
| 53 |
+
st.markdown(answer)
|
| 54 |
+
|
| 55 |
+
if data.get("citations"):
|
| 56 |
+
with st.expander("📌 Sources"):
|
| 57 |
+
seen = set()
|
| 58 |
+
for c in data["citations"].values():
|
| 59 |
+
key = (c["source"], c["page"])
|
| 60 |
+
if key not in seen:
|
| 61 |
+
seen.add(key)
|
| 62 |
+
st.markdown(f"- 📄 `{c['source']}` | page `{c['page']}`")
|
| 63 |
+
|
| 64 |
+
st.session_state.messages.append({"role": "assistant", "content": answer})
|
faiss.index
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:dfff733e02cc164e9ca95eedb12cd27368f78de9014d6a359985dc60f5b9a477
|
| 3 |
+
size 222765
|
metadata.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
requirements.txt
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi
|
| 2 |
+
uvicorn
|
| 3 |
+
pydantic
|
| 4 |
+
python-multipart
|
| 5 |
+
sentence-transformers
|
| 6 |
+
faiss-cpu
|
| 7 |
+
groq
|
| 8 |
+
langchain-text-splitters
|
src/__init__.py
ADDED
|
File without changes
|
src/api/__init__.py
ADDED
|
File without changes
|
src/api/main.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI, HTTPException
|
| 2 |
+
from pydantic import BaseModel
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
import json
|
| 5 |
+
import faiss
|
| 6 |
+
import logging
|
| 7 |
+
from contextlib import asynccontextmanager
|
| 8 |
+
|
| 9 |
+
from src.embedding.embedding_client import EmbeddingClient
|
| 10 |
+
import os
|
| 11 |
+
from src.embedding.embeddings import EmbeddingModel
|
| 12 |
+
from src.vector_store.FAISS_store import VectorStore
|
| 13 |
+
from src.services.rag_service import RAGService
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger("rag-api")
|
| 16 |
+
logging.basicConfig(level=logging.INFO)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@asynccontextmanager
|
| 20 |
+
async def lifespan(app: FastAPI):
|
| 21 |
+
try:
|
| 22 |
+
logger.info("Starting RAG API...")
|
| 23 |
+
|
| 24 |
+
faiss_path = Path("faiss.index")
|
| 25 |
+
metadata_path = Path("metadata.json")
|
| 26 |
+
|
| 27 |
+
if not faiss_path.exists() or not metadata_path.exists():
|
| 28 |
+
logger.warning("FAISS or metadata not found. API will start in degraded mode.")
|
| 29 |
+
app.state.rag_service = None
|
| 30 |
+
yield
|
| 31 |
+
return
|
| 32 |
+
|
| 33 |
+
embedder = EmbeddingModel()
|
| 34 |
+
index = faiss.read_index(str(faiss_path))
|
| 35 |
+
store = VectorStore(dim=index.d)
|
| 36 |
+
store.index = index
|
| 37 |
+
|
| 38 |
+
with open(metadata_path, "r", encoding="utf-8") as f:
|
| 39 |
+
store.metadata = json.load(f)
|
| 40 |
+
|
| 41 |
+
app.state.store = store
|
| 42 |
+
app.state.rag_service = RAGService(store, embedder)
|
| 43 |
+
|
| 44 |
+
logger.info("RAG API ready.")
|
| 45 |
+
yield
|
| 46 |
+
|
| 47 |
+
except Exception as e:
|
| 48 |
+
logger.exception("Startup failed")
|
| 49 |
+
raise e
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
app = FastAPI(
|
| 53 |
+
title="Lecture-Saver 3000 API",
|
| 54 |
+
version="1.0.0",
|
| 55 |
+
lifespan=lifespan,
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class ChatRequest(BaseModel):
|
| 60 |
+
question: str
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
@app.get("/health")
|
| 64 |
+
def health():
|
| 65 |
+
return {
|
| 66 |
+
"status": "ok",
|
| 67 |
+
"rag_loaded": app.state.rag_service is not None
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
@app.post("/ask")
|
| 72 |
+
async def ask(request: ChatRequest):
|
| 73 |
+
if app.state.rag_service is None:
|
| 74 |
+
raise HTTPException(
|
| 75 |
+
status_code=503,
|
| 76 |
+
detail="RAG service not initialized"
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
try:
|
| 80 |
+
response = await app.state.rag_service.answer_question(request.question)
|
| 81 |
+
except Exception as e:
|
| 82 |
+
logger.exception("Unhandled RAG error")
|
| 83 |
+
raise HTTPException(status_code=500, detail="Internal RAG failure")
|
| 84 |
+
|
| 85 |
+
if response.get("status") == "error":
|
| 86 |
+
status_code = 503 if response.get("error_type") == "LLMGenerationError" else 500
|
| 87 |
+
raise HTTPException(status_code=status_code, detail=response.get("message"))
|
| 88 |
+
|
| 89 |
+
return response
|
src/core/exceptions.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
class RAGException(Exception):
|
| 3 |
+
|
| 4 |
+
pass
|
| 5 |
+
|
| 6 |
+
class RetrievalError(RAGException):
|
| 7 |
+
pass
|
| 8 |
+
|
| 9 |
+
class LLMGenerationError(RAGException):
|
| 10 |
+
pass
|
src/embedding/__init__.py
ADDED
|
File without changes
|
src/embedding/embedding_client.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import httpx
|
| 2 |
+
from typing import List
|
| 3 |
+
import logging
|
| 4 |
+
|
| 5 |
+
logger = logging.getLogger("embedding-client")
|
| 6 |
+
|
| 7 |
+
class EmbeddingClient:
|
| 8 |
+
def __init__(self, base_url: str):
|
| 9 |
+
self.base_url = base_url.rstrip("/")
|
| 10 |
+
|
| 11 |
+
async def encode(self, texts: List[str]) -> List[List[float]]:
|
| 12 |
+
try:
|
| 13 |
+
async with httpx.AsyncClient(timeout=30) as client:
|
| 14 |
+
response = await client.post(
|
| 15 |
+
f"{self.base_url}/embed",
|
| 16 |
+
json={"texts": texts}
|
| 17 |
+
)
|
| 18 |
+
response.raise_for_status()
|
| 19 |
+
return response.json()["embeddings"]
|
| 20 |
+
|
| 21 |
+
except Exception as e:
|
| 22 |
+
logger.exception("Embedding service call failed")
|
| 23 |
+
raise RuntimeError("EmbeddingServiceError") from e
|
src/embedding/embeddings.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sentence_transformers import SentenceTransformer
|
| 2 |
+
import numpy as np
|
| 3 |
+
from typing import List
|
| 4 |
+
|
| 5 |
+
class EmbeddingModel:
|
| 6 |
+
def __init__(self , model_name:str = "paraphrase-multilingual-MiniLM-L12-v2"):
|
| 7 |
+
self.model_name = model_name
|
| 8 |
+
self.model = SentenceTransformer(
|
| 9 |
+
model_name)
|
| 10 |
+
|
| 11 |
+
def encode(self , texts: List[str])-> np.ndarray:
|
| 12 |
+
|
| 13 |
+
return self.model.encode(
|
| 14 |
+
texts,
|
| 15 |
+
show_progress_bar=False,
|
| 16 |
+
convert_to_numpy=True,
|
| 17 |
+
normalize_embeddings=True
|
| 18 |
+
)
|
src/evaluation/faithfulness.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from src.generation.llm_client import LLMClient
|
| 2 |
+
|
| 3 |
+
llm = LLMClient()
|
| 4 |
+
|
| 5 |
+
FAITHFULNESS_PROMPT = """
|
| 6 |
+
Given the following CONTEXT and ANSWER:
|
| 7 |
+
|
| 8 |
+
Determine whether the ANSWER is fully supported by the CONTEXT.
|
| 9 |
+
Reply ONLY with one word:
|
| 10 |
+
- YES
|
| 11 |
+
- NO
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
def faithfulness_score(context: str, answer: str)-> int:
|
| 15 |
+
|
| 16 |
+
response = llm.generate(
|
| 17 |
+
question=FAITHFULNESS_PROMPT,
|
| 18 |
+
context_chunks=[f"CONTEXT:\n{context}\n\nANSWER:\n{answer}"]
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
return 1 if response.strip().upper().startswith("YES") else 0
|
src/evaluation/relevance.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
from sklearn.metrics.pairwise import cosine_similarity
|
| 3 |
+
|
| 4 |
+
def context_relevance(query_embedding , chunk_embedding)-> float:
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
sims = cosine_similarity(query_embedding , chunk_embedding)
|
| 8 |
+
|
| 9 |
+
return float(np.mean(sims))
|
src/generation/llm_client.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import logging
|
| 3 |
+
from typing import List
|
| 4 |
+
from groq import Groq
|
| 5 |
+
import asyncio
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger("llm-client")
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
SYSTEM_PROMPT = """
|
| 11 |
+
You are a university teaching assistant.
|
| 12 |
+
|
| 13 |
+
Rules:
|
| 14 |
+
- Answer the question using ONLY the provided context.
|
| 15 |
+
- You may rephrase or translate the information to match the language of the user's question.
|
| 16 |
+
- Do NOT add new information.
|
| 17 |
+
- Do NOT use external knowledge.
|
| 18 |
+
- When you state a fact, add the reference number in square brackets, e.g. [1], [2].
|
| 19 |
+
- If the answer is not explicitly present, say:
|
| 20 |
+
"Not found in the provided lecture material."
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class LLMClient:
|
| 25 |
+
def __init__(self):
|
| 26 |
+
api_key = os.getenv("GROQ_API_KEY")
|
| 27 |
+
if not api_key:
|
| 28 |
+
raise RuntimeError("GROQ_API_KEY not set")
|
| 29 |
+
|
| 30 |
+
self.model = os.getenv(
|
| 31 |
+
"GROQ_MODEL",
|
| 32 |
+
"llama-3.3-70b-versatile"
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
self.client = Groq(api_key=api_key)
|
| 36 |
+
|
| 37 |
+
async def generate(
|
| 38 |
+
self,
|
| 39 |
+
question: str,
|
| 40 |
+
context_chunks: List[str],
|
| 41 |
+
chat_history: List[str] | None = None
|
| 42 |
+
) -> str:
|
| 43 |
+
context = "\n\n".join(context_chunks)
|
| 44 |
+
|
| 45 |
+
messages = [
|
| 46 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 47 |
+
{
|
| 48 |
+
"role": "user",
|
| 49 |
+
"content": f"Context:\n{context}\n\nQuestion:\n{question}"
|
| 50 |
+
}
|
| 51 |
+
]
|
| 52 |
+
|
| 53 |
+
try:
|
| 54 |
+
response = await asyncio.to_thread(
|
| 55 |
+
self.client.chat.completions.create,
|
| 56 |
+
model=self.model,
|
| 57 |
+
messages=messages,
|
| 58 |
+
temperature=0.3,
|
| 59 |
+
max_tokens=512
|
| 60 |
+
|
| 61 |
+
)
|
| 62 |
+
return response.choices[0].message.content.strip()
|
| 63 |
+
|
| 64 |
+
except Exception as e:
|
| 65 |
+
logger.exception("Groq LLM call failed")
|
| 66 |
+
raise RuntimeError("LLMGenerationError") from e
|
src/ingestion/__init__.py
ADDED
|
File without changes
|
src/ingestion/document_loader.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
from typing import List , Dict
|
| 4 |
+
from src.ingestion.pdf_to_markdown import MarkdownLoader
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
def load_documents(path: Path):
|
| 8 |
+
|
| 9 |
+
documents: List[Dict] = []
|
| 10 |
+
loader = MarkdownLoader()
|
| 11 |
+
|
| 12 |
+
if path.is_file():
|
| 13 |
+
if path.suffix.lower() == ".pdf" :
|
| 14 |
+
|
| 15 |
+
documents.extend(loader.load_pdf(path))
|
| 16 |
+
|
| 17 |
+
else :
|
| 18 |
+
raise ValueError(f"Unsupported file type: {path.suffix}")
|
| 19 |
+
|
| 20 |
+
elif path.is_dir():
|
| 21 |
+
pdf_files = sorted(path.rglob("*.pdf"))
|
| 22 |
+
|
| 23 |
+
if not pdf_files:
|
| 24 |
+
raise ValueError("No PDF files found in the directory")
|
| 25 |
+
|
| 26 |
+
for pdf_path in pdf_files :
|
| 27 |
+
documents.extend(loader.load_pdf(pdf_path))
|
| 28 |
+
|
| 29 |
+
else:
|
| 30 |
+
raise ValueError(f"Invalid path: {path}")
|
| 31 |
+
|
| 32 |
+
return documents
|
| 33 |
+
|
| 34 |
+
|
src/ingestion/pdf_to_markdown.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
import logging
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
from typing import List, Dict
|
| 5 |
+
from docling.document_converter import DocumentConverter
|
| 6 |
+
from docling_core.types.doc import DocItemLabel, TableItem, TextItem
|
| 7 |
+
from src.preprocessing.cleaner import normalize_text
|
| 8 |
+
from docling.datamodel.pipeline_options import PdfPipelineOptions
|
| 9 |
+
from docling.document_converter import DocumentConverter, PdfFormatOption
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
logging.basicConfig(level=logging.INFO)
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
+
|
| 15 |
+
class MarkdownLoader:
|
| 16 |
+
def __init__(self):
|
| 17 |
+
options = PdfPipelineOptions()
|
| 18 |
+
options.do_ocr = False
|
| 19 |
+
options.do_table_structure = True
|
| 20 |
+
|
| 21 |
+
self.converter = DocumentConverter(
|
| 22 |
+
format_options={
|
| 23 |
+
"pdf": PdfFormatOption(pipeline_options=options)
|
| 24 |
+
}
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def get_element_markdown(self, element) -> str:
|
| 29 |
+
|
| 30 |
+
if isinstance(element, TableItem):
|
| 31 |
+
try:
|
| 32 |
+
return element.export_to_markdown()
|
| 33 |
+
except:
|
| 34 |
+
return element.text if hasattr(element, 'text') else ""
|
| 35 |
+
|
| 36 |
+
elif isinstance(element, TextItem):
|
| 37 |
+
text = element.text
|
| 38 |
+
label = element.label
|
| 39 |
+
if label == DocItemLabel.TITLE:
|
| 40 |
+
return f"# {text}"
|
| 41 |
+
elif label == DocItemLabel.SECTION_HEADER:
|
| 42 |
+
|
| 43 |
+
return f"## {text}"
|
| 44 |
+
elif label == DocItemLabel.LIST_ITEM:
|
| 45 |
+
return f"* {text}"
|
| 46 |
+
return text
|
| 47 |
+
|
| 48 |
+
return ""
|
| 49 |
+
|
| 50 |
+
def load_pdf(self, pdf_path: Path) -> List[Dict]:
|
| 51 |
+
documents = []
|
| 52 |
+
try:
|
| 53 |
+
logger.info(f"Processing.... {pdf_path.name}")
|
| 54 |
+
result = self.converter.convert(str(pdf_path))
|
| 55 |
+
doc = result.document
|
| 56 |
+
|
| 57 |
+
pages_content = {}
|
| 58 |
+
|
| 59 |
+
for element, level in doc.iterate_items():
|
| 60 |
+
if hasattr(element, 'prov') and element.prov:
|
| 61 |
+
page_no = element.prov[0].page_no
|
| 62 |
+
|
| 63 |
+
element_md = self.get_element_markdown(element)
|
| 64 |
+
|
| 65 |
+
if element_md:
|
| 66 |
+
if page_no not in pages_content:
|
| 67 |
+
pages_content[page_no] = []
|
| 68 |
+
pages_content[page_no].append(element_md)
|
| 69 |
+
|
| 70 |
+
for page_number in sorted(pages_content.keys()):
|
| 71 |
+
raw_text = "\n\n".join(pages_content[page_number])
|
| 72 |
+
|
| 73 |
+
final_text = normalize_text(raw_text)
|
| 74 |
+
|
| 75 |
+
if final_text.strip():
|
| 76 |
+
documents.append({
|
| 77 |
+
"text": final_text,
|
| 78 |
+
"metadata": {
|
| 79 |
+
"source": pdf_path.name,
|
| 80 |
+
"page": page_number,
|
| 81 |
+
"format": "markdown"
|
| 82 |
+
}
|
| 83 |
+
})
|
| 84 |
+
|
| 85 |
+
logger.info(f" {len(documents)} successfully")
|
| 86 |
+
return documents
|
| 87 |
+
|
| 88 |
+
except Exception as e:
|
| 89 |
+
logger.error(f"error in processing {pdf_path.name}: {e}")
|
| 90 |
+
import traceback
|
| 91 |
+
logger.error(traceback.format_exc())
|
src/mlops/__init__.py
ADDED
|
File without changes
|
src/mlops/tracking.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import mlflow
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
import faiss
|
| 4 |
+
from contextlib import contextmanager
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def start_experiment(experiment_name: str):
|
| 8 |
+
mlflow.set_experiment(experiment_name)
|
| 9 |
+
mlflow.start_run()
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def init_mlflow(experiment_name: str):
|
| 14 |
+
mlflow.set_experiment(experiment_name)
|
| 15 |
+
|
| 16 |
+
@contextmanager
|
| 17 |
+
def start_rag_run(run_name: str = "rag_query"):
|
| 18 |
+
with mlflow.start_run(run_name=run_name):
|
| 19 |
+
yield
|
| 20 |
+
|
| 21 |
+
def log_params_dict(params: dict):
|
| 22 |
+
for k, v in params.items():
|
| 23 |
+
mlflow.log_param(k, v)
|
| 24 |
+
|
| 25 |
+
def log_metrics_dict(metrics: dict):
|
| 26 |
+
for k, v in metrics.items():
|
| 27 |
+
mlflow.log_metric(k, v)
|
| 28 |
+
|
| 29 |
+
def log_text_artifact(text: str, artifact_path: str):
|
| 30 |
+
mlflow.log_text(text, artifact_path)
|
| 31 |
+
|
| 32 |
+
def log_faiss_index(index, path: Path):
|
| 33 |
+
faiss.write_index(index, str(path))
|
| 34 |
+
mlflow.log_artifact(str(path))
|
| 35 |
+
|
| 36 |
+
def log_artifact_metadatas(metadatas: Path ="metadata.json"):
|
| 37 |
+
mlflow.log_artifact(str(metadatas))
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def end_run():
|
| 41 |
+
mlflow.end_run()
|
| 42 |
+
|
src/preprocessing/__init__.py
ADDED
|
File without changes
|
src/preprocessing/chunker_markdown.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List, Dict
|
| 2 |
+
import re
|
| 3 |
+
|
| 4 |
+
from langchain_text_splitters import (
|
| 5 |
+
MarkdownHeaderTextSplitter,
|
| 6 |
+
RecursiveCharacterTextSplitter,
|
| 7 |
+
)
|
| 8 |
+
|
| 9 |
+
# --------------------------------------
|
| 10 |
+
# Settings
|
| 11 |
+
# --------------------------------------
|
| 12 |
+
HEADERS_TO_SPLIT_ON = [
|
| 13 |
+
("#", "h1"),
|
| 14 |
+
("##", "h2"),
|
| 15 |
+
("###", "h3"),
|
| 16 |
+
]
|
| 17 |
+
|
| 18 |
+
SEPARATORS = ["\n\n", "\n", " ", ""]
|
| 19 |
+
|
| 20 |
+
MIN_CHUNK_LENGTH = 40
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
# --------------------------------------
|
| 24 |
+
# Main API
|
| 25 |
+
# --------------------------------------
|
| 26 |
+
def chunk_document(
|
| 27 |
+
document: Dict,
|
| 28 |
+
chunk_size: int = 800,
|
| 29 |
+
overlap: int = 100,
|
| 30 |
+
) -> List[str]:
|
| 31 |
+
"""
|
| 32 |
+
Final structure-aware chunking for Markdown (Docling output)
|
| 33 |
+
|
| 34 |
+
Input:
|
| 35 |
+
document = {
|
| 36 |
+
"text": "... markdown ...",
|
| 37 |
+
"metadata": {
|
| 38 |
+
"source": "...",
|
| 39 |
+
"page": int,
|
| 40 |
+
"format": "markdown"
|
| 41 |
+
}
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
Output:
|
| 45 |
+
List[str]
|
| 46 |
+
"""
|
| 47 |
+
|
| 48 |
+
text = document
|
| 49 |
+
|
| 50 |
+
# 1) Header-based splitting
|
| 51 |
+
header_splitter = MarkdownHeaderTextSplitter(
|
| 52 |
+
headers_to_split_on=HEADERS_TO_SPLIT_ON,
|
| 53 |
+
strip_headers=False,
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
header_sections = header_splitter.split_text(text)
|
| 57 |
+
|
| 58 |
+
# 2) Recursive splitter (size-based)
|
| 59 |
+
recursive_splitter = RecursiveCharacterTextSplitter(
|
| 60 |
+
chunk_size=chunk_size,
|
| 61 |
+
chunk_overlap=overlap,
|
| 62 |
+
separators=SEPARATORS,
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
final_chunks: List[str] = []
|
| 66 |
+
|
| 67 |
+
for section in header_sections:
|
| 68 |
+
section_text = section.page_content.strip()
|
| 69 |
+
|
| 70 |
+
# -----------------------------
|
| 71 |
+
# Noise filtering
|
| 72 |
+
# -----------------------------
|
| 73 |
+
if _is_noise(section_text):
|
| 74 |
+
continue
|
| 75 |
+
|
| 76 |
+
# -----------------------------
|
| 77 |
+
# Table handling
|
| 78 |
+
# -----------------------------
|
| 79 |
+
if _looks_like_markdown_table(section_text):
|
| 80 |
+
final_chunks.append(section_text)
|
| 81 |
+
continue
|
| 82 |
+
|
| 83 |
+
# -----------------------------
|
| 84 |
+
# Merge header-only chunks
|
| 85 |
+
# -----------------------------
|
| 86 |
+
if _is_header_only(section_text):
|
| 87 |
+
continue
|
| 88 |
+
|
| 89 |
+
# -----------------------------
|
| 90 |
+
# Size-based splitting
|
| 91 |
+
# -----------------------------
|
| 92 |
+
sub_chunks = recursive_splitter.split_text(section_text)
|
| 93 |
+
|
| 94 |
+
for sub in sub_chunks:
|
| 95 |
+
sub = sub.strip()
|
| 96 |
+
if len(sub) < MIN_CHUNK_LENGTH:
|
| 97 |
+
continue
|
| 98 |
+
|
| 99 |
+
final_chunks.append(sub)
|
| 100 |
+
|
| 101 |
+
return final_chunks
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
# --------------------------------------
|
| 105 |
+
# Helpers
|
| 106 |
+
# --------------------------------------
|
| 107 |
+
def _looks_like_markdown_table(text: str) -> bool:
|
| 108 |
+
lines = text.splitlines()
|
| 109 |
+
if len(lines) < 2:
|
| 110 |
+
return False
|
| 111 |
+
|
| 112 |
+
has_pipes = any("|" in line for line in lines)
|
| 113 |
+
has_separator = any(
|
| 114 |
+
re.match(r"^\s*\|?[\s:-]+\|", line) for line in lines
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
return has_pipes and has_separator
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def _is_header_only(text: str) -> bool:
|
| 121 |
+
"""
|
| 122 |
+
Detect chunks that are only headers (e.g. '## العنوان')
|
| 123 |
+
"""
|
| 124 |
+
lines = text.splitlines()
|
| 125 |
+
if len(lines) != 1:
|
| 126 |
+
return False
|
| 127 |
+
|
| 128 |
+
return lines[0].lstrip().startswith("#")
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def _is_noise(text: str) -> bool:
|
| 132 |
+
"""
|
| 133 |
+
Remove garbage chunks: symbols, single letters, etc.
|
| 134 |
+
"""
|
| 135 |
+
stripped = text.strip()
|
| 136 |
+
|
| 137 |
+
if len(stripped) < 10:
|
| 138 |
+
return True
|
| 139 |
+
|
| 140 |
+
if re.fullmatch(r"[■S\s]+", stripped):
|
| 141 |
+
return True
|
| 142 |
+
|
| 143 |
+
return False
|
src/preprocessing/cleaner.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
|
| 3 |
+
# --------------------------------------
|
| 4 |
+
# Arabic-safe normalization (RAG oriented)
|
| 5 |
+
# --------------------------------------
|
| 6 |
+
|
| 7 |
+
ARABIC_DIACRITICS = re.compile(r"[ًٌٍَُِّْـ]")
|
| 8 |
+
|
| 9 |
+
def normalize_arabic_text(text: str) -> str:
|
| 10 |
+
"""
|
| 11 |
+
Safe normalization for Arabic PDFs:
|
| 12 |
+
- Remove diacritics
|
| 13 |
+
- Normalize punctuation
|
| 14 |
+
- Preserve word boundaries
|
| 15 |
+
- DO NOT hallucinate spaces
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
# 1. Remove diacritics
|
| 19 |
+
text = re.sub(ARABIC_DIACRITICS, "", text)
|
| 20 |
+
|
| 21 |
+
# 2. Normalize Arabic punctuation spacing
|
| 22 |
+
text = re.sub(r"\s*([،؛؟!])\s*", r"\1 ", text)
|
| 23 |
+
text = re.sub(r"\s*([.:])\s*", r"\1 ", text)
|
| 24 |
+
|
| 25 |
+
# 3. Fix obvious header glue (##عنوان)
|
| 26 |
+
text = re.sub(r"(#+)([^\s#])", r"\1 \2", text)
|
| 27 |
+
|
| 28 |
+
# 4. Separate bullets safely
|
| 29 |
+
text = re.sub(r"\*\s*", "* ", text)
|
| 30 |
+
|
| 31 |
+
# 5. Collapse excessive whitespace
|
| 32 |
+
text = re.sub(r"[ \t]{2,}", " ", text)
|
| 33 |
+
text = re.sub(r"\n{3,}", "\n\n", text)
|
| 34 |
+
|
| 35 |
+
return text.strip()
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def normalize_text(text: str) -> str:
|
| 39 |
+
text = normalize_arabic_text(text)
|
| 40 |
+
lines = [line.strip() for line in text.splitlines()]
|
| 41 |
+
return "\n".join(lines)
|
src/services/rag_service.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from src.embedding.embedding_client import EmbeddingClient
|
| 2 |
+
from src.generation.llm_client import LLMClient
|
| 3 |
+
import asyncio
|
| 4 |
+
import logging
|
| 5 |
+
|
| 6 |
+
logger = logging.getLogger("rag-service")
|
| 7 |
+
|
| 8 |
+
class RAGService:
|
| 9 |
+
def __init__(self, store, embedder :EmbeddingClient):
|
| 10 |
+
self.store = store
|
| 11 |
+
self.embedder = embedder
|
| 12 |
+
self.llm = LLMClient()
|
| 13 |
+
|
| 14 |
+
async def answer_question(self, question: str, k: int = 5):
|
| 15 |
+
|
| 16 |
+
if not question or len(question.strip()) == 0:
|
| 17 |
+
return {
|
| 18 |
+
"status": "error",
|
| 19 |
+
"message" : "Empty question"
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
# Basic safety limit
|
| 23 |
+
if len(question) > 2000:
|
| 24 |
+
return {
|
| 25 |
+
"status" : "error",
|
| 26 |
+
"message" : "Question too long"
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
try:
|
| 30 |
+
|
| 31 |
+
# --- Retrieval ---
|
| 32 |
+
query_vector = self.embedder.encode([question])
|
| 33 |
+
retrieved_docs = self.store.search(query_vector, k=k)
|
| 34 |
+
|
| 35 |
+
if not retrieved_docs:
|
| 36 |
+
|
| 37 |
+
return {
|
| 38 |
+
"status": "success",
|
| 39 |
+
"answer": "Not found in the provided lecture material.",
|
| 40 |
+
"citations": {}
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
# --- Generation ---
|
| 44 |
+
context_chunks = [
|
| 45 |
+
f"[{i+1}] {doc['text']}"
|
| 46 |
+
for i, doc in enumerate(retrieved_docs)
|
| 47 |
+
]
|
| 48 |
+
|
| 49 |
+
try:
|
| 50 |
+
answer = await asyncio.wait_for(
|
| 51 |
+
self.llm.generate(question, context_chunks),
|
| 52 |
+
timeout=20
|
| 53 |
+
)
|
| 54 |
+
except asyncio.TimeoutError:
|
| 55 |
+
logger.warning("LLM timout")
|
| 56 |
+
return {
|
| 57 |
+
"status" : "error",
|
| 58 |
+
"error_type": "LLMTimeout",
|
| 59 |
+
"message" : "LLM generation timed out"
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
# --- Citations ---
|
| 64 |
+
citations = {
|
| 65 |
+
str(i+1): doc["citation"]
|
| 66 |
+
for i, doc in enumerate(retrieved_docs)
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
# --- Logging ---
|
| 71 |
+
|
| 72 |
+
return {
|
| 73 |
+
"status": "success",
|
| 74 |
+
"answer": answer,
|
| 75 |
+
"citations": citations
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
except Exception as e:
|
| 79 |
+
|
| 80 |
+
logger.exception("RAG failure")
|
| 81 |
+
return {
|
| 82 |
+
"status": "error",
|
| 83 |
+
"error_type": "RAGRuntimeError",
|
| 84 |
+
"message": "Internal RAG error"
|
| 85 |
+
}
|
src/training/run_ingestion.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
import json
|
| 3 |
+
from typing import Union, List
|
| 4 |
+
|
| 5 |
+
from src.ingestion.document_loader import load_documents
|
| 6 |
+
from src.preprocessing.chunker_markdown import chunk_document
|
| 7 |
+
from src.embedding.embeddings import EmbeddingModel
|
| 8 |
+
from src.vector_store.FAISS_store import VectorStore
|
| 9 |
+
from src.mlops.tracking import *
|
| 10 |
+
import faiss
|
| 11 |
+
|
| 12 |
+
def run_ingestion(
|
| 13 |
+
data_path: Path,
|
| 14 |
+
chunk_size: int = 400,
|
| 15 |
+
overlap: int = 50,
|
| 16 |
+
model_name: str = "paraphrase-multilingual-MiniLM-L12-v2"
|
| 17 |
+
):
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
start_experiment("rag_ingestion")
|
| 21 |
+
|
| 22 |
+
documents = load_documents(data_path)
|
| 23 |
+
|
| 24 |
+
all_chunks = []
|
| 25 |
+
all_metadatas = []
|
| 26 |
+
|
| 27 |
+
chunk_id = 0
|
| 28 |
+
for doc in documents:
|
| 29 |
+
chunks = chunk_document(
|
| 30 |
+
doc["text"],
|
| 31 |
+
chunk_size=chunk_size,
|
| 32 |
+
overlap=overlap
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
for chunk in chunks:
|
| 36 |
+
all_chunks.append(chunk)
|
| 37 |
+
all_metadatas.append({
|
| 38 |
+
"source": doc["metadata"]["source"],
|
| 39 |
+
"page": doc["metadata"]["page"],
|
| 40 |
+
"text": chunk,
|
| 41 |
+
"chunk_id": chunk_id
|
| 42 |
+
})
|
| 43 |
+
chunk_id += 1
|
| 44 |
+
|
| 45 |
+
embedder = EmbeddingModel()
|
| 46 |
+
vectors = embedder.encode(all_chunks)
|
| 47 |
+
|
| 48 |
+
store = VectorStore(dim=vectors.shape[1])
|
| 49 |
+
store.add(vectors, all_metadatas)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
with open("metadata.json", "w", encoding="utf-8") as f:
|
| 53 |
+
json.dump(store.metadata, f, ensure_ascii=False, indent=2)
|
| 54 |
+
|
| 55 |
+
faiss.write_index(store.index, "faiss.index")
|
| 56 |
+
|
| 57 |
+
log_params_dict({
|
| 58 |
+
"chunk_size": chunk_size,
|
| 59 |
+
"overlap": overlap,
|
| 60 |
+
"embedding_model": model_name
|
| 61 |
+
})
|
| 62 |
+
|
| 63 |
+
log_metrics_dict({
|
| 64 |
+
"num_chunks": len(all_chunks),
|
| 65 |
+
"embedding_dim": vectors.shape[1]
|
| 66 |
+
})
|
| 67 |
+
|
| 68 |
+
log_artifact_metadatas("metadata.json")
|
| 69 |
+
end_run()
|
| 70 |
+
|
| 71 |
+
return {
|
| 72 |
+
"num_docs": len(documents),
|
| 73 |
+
"num_chunks": len(all_chunks)
|
| 74 |
+
}
|
src/vector_store/FAISS_store.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import faiss
|
| 2 |
+
import numpy as np
|
| 3 |
+
from typing import List , Dict
|
| 4 |
+
|
| 5 |
+
class VectorStore:
|
| 6 |
+
|
| 7 |
+
def __init__(self , dim : int):
|
| 8 |
+
self.index = faiss.IndexFlatIP(dim)
|
| 9 |
+
self.metadata : List[Dict] = []
|
| 10 |
+
|
| 11 |
+
def add(self , vectors: np.ndarray , metadatas:List[Dict]):
|
| 12 |
+
self.index.add(vectors)
|
| 13 |
+
self.metadata.extend(metadatas)
|
| 14 |
+
|
| 15 |
+
def search(self, query_vector : np.ndarray , k :int = 5):
|
| 16 |
+
scores, indices = self.index.search(query_vector , k)
|
| 17 |
+
|
| 18 |
+
results = []
|
| 19 |
+
|
| 20 |
+
for idx , score in zip(indices[0], scores[0]):
|
| 21 |
+
if idx == -1 :
|
| 22 |
+
continue
|
| 23 |
+
meta = self.metadata[idx]
|
| 24 |
+
|
| 25 |
+
results.append({
|
| 26 |
+
"score": float(score),
|
| 27 |
+
"text": meta["text"],
|
| 28 |
+
"citation": {
|
| 29 |
+
"source": meta["source"],
|
| 30 |
+
"page": meta["page"],
|
| 31 |
+
}
|
| 32 |
+
})
|
| 33 |
+
return results
|
| 34 |
+
|
src/vector_store/__init__.py
ADDED
|
File without changes
|