makdadTaleb commited on
Commit
4e7e4c0
·
verified ·
1 Parent(s): f1aa567

Upload folder using huggingface_hub

Browse files
.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 Api
3
- emoji: 👀
4
- colorFrom: green
5
- colorTo: green
6
- sdk: docker
7
- pinned: false
8
- license: mit
9
- ---
10
-
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
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