Instructions to use aelgendy/QModel with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Notebooks
- Google Colab
- Kaggle
- Local Apps Settings
- llama.cpp
How to use aelgendy/QModel with llama.cpp:
Install (macOS, Linux)
curl -LsSf https://llama.app/install.sh | sh # Start a local OpenAI-compatible server with a web UI: llama serve -hf aelgendy/QModel:Q8_0 # Run inference directly in the terminal: llama cli -hf aelgendy/QModel:Q8_0
Install from WinGet (Windows)
winget install llama.cpp # Start a local OpenAI-compatible server with a web UI: llama serve -hf aelgendy/QModel:Q8_0 # Run inference directly in the terminal: llama cli -hf aelgendy/QModel:Q8_0
Build from source code
git clone https://github.com/ggml-org/llama.cpp.git cd llama.cpp cmake -B build cmake --build build -j --target llama-app # Start a local OpenAI-compatible server with a web UI: ./build/bin/llama serve -hf aelgendy/QModel:Q8_0 # Run inference directly in the terminal: ./build/bin/llama cli -hf aelgendy/QModel:Q8_0
Use Docker
docker model run hf.co/aelgendy/QModel:Q8_0
- Llama new
- LM Studio
- Jan
- Ollama
How to use aelgendy/QModel with Ollama:
ollama run hf.co/aelgendy/QModel:Q8_0
- Unsloth Desktop
- Pi
How to use aelgendy/QModel with Pi:
Start the llama.cpp server
# Install llama.cpp: brew install llama.cpp # Start a local OpenAI-compatible server: llama serve -hf aelgendy/QModel:Q8_0
Configure the model in Pi
# Install Pi: npm install -g @earendil-works/pi-coding-agent # Add to ~/.pi/agent/models.json: { "providers": { "llama-cpp": { "baseUrl": "http://localhost:8080/v1", "api": "openai-completions", "apiKey": "none", "models": [ { "id": "aelgendy/QModel:Q8_0" } ] } } }Run Pi
# Start Pi in your project directory: pi
- Docker Model Runner
How to use aelgendy/QModel with Docker Model Runner:
docker model run hf.co/aelgendy/QModel:Q8_0
- Lemonade
How to use aelgendy/QModel with Lemonade:
Pull the model
# Download Lemonade from https://lemonade-server.ai/ lemonade pull aelgendy/QModel:Q8_0
Run and chat with the model
lemonade run user.QModel-Q8_0
List all available models
lemonade list
- Hermes Agent
How to use aelgendy/QModel with Hermes Agent:
Start the llama.cpp server
# Install llama.cpp: brew install llama.cpp # Start a local OpenAI-compatible server: llama serve -hf aelgendy/QModel:Q8_0
Configure Hermes
# Install Hermes: curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash hermes setup # Point Hermes at the local server: hermes config set model.provider custom hermes config set model.base_url http://127.0.0.1:8080/v1 hermes config set model.default aelgendy/QModel:Q8_0
Run Hermes
hermes
- Atomic Chat
- OpenClaw
How to use aelgendy/QModel with OpenClaw:
Start the llama.cpp server
# Install llama.cpp: brew install llama.cpp # Start a local OpenAI-compatible server: llama serve -hf aelgendy/QModel:Q8_0
Configure OpenClaw
# Install OpenClaw: npm install -g openclaw@latest # Register the local server and set it as the default model: openclaw onboard --non-interactive --mode local \ --auth-choice custom-api-key \ --custom-base-url http://127.0.0.1:8080/v1 \ --custom-model-id "aelgendy/QModel:Q8_0" \ --custom-provider-id llama-cpp \ --custom-compatibility openai \ --custom-text-input \ --accept-risk \ --skip-health
Run OpenClaw
openclaw agent --local --agent main --message "Hello from Hugging Face"
QModel v8.5: accuracy release (code, 3,598-fatwa index, quran.com links)
Browse files- .env.example +26 -18
- .gitignore +6 -0
- Dockerfile +7 -5
- README.md +83 -108
- app/__init__.py +1 -1
- app/analysis.py +5 -5
- app/arabic_nlp.py +21 -4
- app/config.py +7 -12
- app/prompts.py +21 -6
- app/routers/fatwa.py +1 -0
- app/routers/hadith.py +20 -18
- app/routers/ops.py +7 -5
- app/routers/quran.py +11 -4
- app/search.py +377 -45
- app/state.py +479 -111
- app/synonyms.py +42 -0
- build_index.py +90 -40
- data_pipeline/scrape_fatwas.py +48 -32
- docker-compose.yml +1 -1
- eval/__init__.py +0 -0
- eval/golden.jsonl +147 -0
- eval/label_review.csv +0 -0
- eval/run_answers.py +306 -0
- eval/run_eval.py +450 -0
- main.py +3 -3
- scripts/update_fatwa_index.py +104 -0
- tests/test_enhancements.py +459 -23
- tests/test_eval_parsing.py +73 -0
.env.example
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
# QModel
|
| 2 |
# ==================================
|
| 3 |
# Copy this to .env and update values for your environment
|
| 4 |
|
|
@@ -9,33 +9,31 @@ LLM_BACKEND=gguf
|
|
| 9 |
# ─────────────────────────────────────────────────────────────────────
|
| 10 |
# GGUF BACKEND (if LLM_BACKEND=gguf) — self-contained, no external daemon
|
| 11 |
# ─────────────────────────────────────────────────────────────────────
|
| 12 |
-
GGUF_MODEL_PATH=./models/
|
| 13 |
-
GGUF_N_CTX=
|
| 14 |
GGUF_N_GPU_LAYERS=-1 # -1 = offload all layers to GPU (Metal on Mac)
|
| 15 |
|
| 16 |
# ─────────────────────────────────────────────────────────────────────
|
| 17 |
# OLLAMA BACKEND (if LLM_BACKEND=ollama)
|
| 18 |
# ─────────────────────────────────────────────────────────────────────
|
| 19 |
# OLLAMA_HOST=http://localhost:11434
|
| 20 |
-
# OLLAMA_MODEL=
|
| 21 |
# Available models: llama3.1, mistral, neural-chat, openhermes
|
| 22 |
|
| 23 |
# ─────────────────────────────────────────────────────────────────────
|
| 24 |
# HUGGINGFACE BACKEND (if LLM_BACKEND=hf)
|
| 25 |
# ─────────────────────────────────────────────────────────────────────
|
| 26 |
-
# HF_MODEL_NAME=Qwen/
|
| 27 |
# HF_DEVICE=auto # Options: auto, cuda, cpu
|
| 28 |
# HF_MAX_NEW_TOKENS=2048
|
| 29 |
# Popular models:
|
| 30 |
-
# - Qwen/
|
| 31 |
-
# - mistralai/Mistral-7B-Instruct-v0.2
|
| 32 |
-
# - meta-llama/Llama-2-13b-chat-hf
|
| 33 |
|
| 34 |
# ─────────────────────────────────────────────────────────────────────
|
| 35 |
# LM STUDIO BACKEND (if LLM_BACKEND=lmstudio)
|
| 36 |
# ─────────────────────────────────────────────────────────────────────
|
| 37 |
# LMSTUDIO_URL=http://localhost:1234
|
| 38 |
-
# LMSTUDIO_MODEL=qwen2.5-
|
| 39 |
|
| 40 |
# ─────────────────────────────────────────────────────────────────────
|
| 41 |
# EMBEDDING MODEL (shared by all backends)
|
|
@@ -63,11 +61,21 @@ MAX_TOKENS=2048 # Max output length
|
|
| 63 |
# ─────────────────────────────────────────────────────────────────────
|
| 64 |
# SAFETY & QUALITY
|
| 65 |
# ─────────────────────────────────────────────────────────────────────
|
| 66 |
-
# Confidence
|
| 67 |
-
#
|
| 68 |
-
#
|
| 69 |
-
#
|
| 70 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
|
| 72 |
# Hadith boost: Score bonus when intent=hadith
|
| 73 |
# Prevents Quran verses from outranking relevant Hadiths
|
|
@@ -99,19 +107,19 @@ ALLOWED_ORIGINS=* # CORS origins (restrict in production: origin1.com,orig
|
|
| 99 |
#
|
| 100 |
# Self-contained (GGUF, e.g. HF Spaces):
|
| 101 |
# LLM_BACKEND=gguf
|
| 102 |
-
# GGUF_MODEL_PATH=./models/
|
| 103 |
#
|
| 104 |
# Development (Ollama):
|
| 105 |
# LLM_BACKEND=ollama
|
| 106 |
# OLLAMA_HOST=http://localhost:11434
|
| 107 |
-
# OLLAMA_MODEL=
|
| 108 |
#
|
| 109 |
# Production (HuggingFace GPU):
|
| 110 |
# LLM_BACKEND=hf
|
| 111 |
-
# HF_MODEL_NAME=Qwen/
|
| 112 |
# HF_DEVICE=cuda
|
| 113 |
#
|
| 114 |
# Production (HuggingFace CPU):
|
| 115 |
# LLM_BACKEND=hf
|
| 116 |
-
# HF_MODEL_NAME=Qwen/
|
| 117 |
# HF_DEVICE=cpu
|
|
|
|
| 1 |
+
# QModel 8.5 Configuration Template
|
| 2 |
# ==================================
|
| 3 |
# Copy this to .env and update values for your environment
|
| 4 |
|
|
|
|
| 9 |
# ─────────────────────────────────────────────────────────────────────
|
| 10 |
# GGUF BACKEND (if LLM_BACKEND=gguf) — self-contained, no external daemon
|
| 11 |
# ─────────────────────────────────────────────────────────────────────
|
| 12 |
+
GGUF_MODEL_PATH=./models/Qwen2.5-14B-Instruct-Q8_0.gguf
|
| 13 |
+
GGUF_N_CTX=8192 # Context window size
|
| 14 |
GGUF_N_GPU_LAYERS=-1 # -1 = offload all layers to GPU (Metal on Mac)
|
| 15 |
|
| 16 |
# ─────────────────────────────────────────────────────────────────────
|
| 17 |
# OLLAMA BACKEND (if LLM_BACKEND=ollama)
|
| 18 |
# ─────────────────────────────────────────────────────────────────────
|
| 19 |
# OLLAMA_HOST=http://localhost:11434
|
| 20 |
+
# OLLAMA_MODEL=qwen2.5:14b
|
| 21 |
# Available models: llama3.1, mistral, neural-chat, openhermes
|
| 22 |
|
| 23 |
# ─────────────────────────────────────────────────────────────────────
|
| 24 |
# HUGGINGFACE BACKEND (if LLM_BACKEND=hf)
|
| 25 |
# ─────────────────────────────────────────────────────────────────────
|
| 26 |
+
# HF_MODEL_NAME=Qwen/Qwen2.5-14B-Instruct
|
| 27 |
# HF_DEVICE=auto # Options: auto, cuda, cpu
|
| 28 |
# HF_MAX_NEW_TOKENS=2048
|
| 29 |
# Popular models:
|
| 30 |
+
# - Qwen/Qwen2.5-14B-Instruct (recommended)
|
|
|
|
|
|
|
| 31 |
|
| 32 |
# ─────────────────────────────────────────────────────────────────────
|
| 33 |
# LM STUDIO BACKEND (if LLM_BACKEND=lmstudio)
|
| 34 |
# ─────────────────────────────────────────────────────────────────────
|
| 35 |
# LMSTUDIO_URL=http://localhost:1234
|
| 36 |
+
# LMSTUDIO_MODEL=qwen2.5-14b-instruct # Model loaded in LM Studio
|
| 37 |
|
| 38 |
# ─────────────────────────────────────────────────────────────────────
|
| 39 |
# EMBEDDING MODEL (shared by all backends)
|
|
|
|
| 61 |
# ─────────────────────────────────────────────────────────────────────
|
| 62 |
# SAFETY & QUALITY
|
| 63 |
# ─────────────────────────────────────────────────────────────────────
|
| 64 |
+
# Confidence gate: skip the LLM and return "not found" unless retrieval found
|
| 65 |
+
# something genuinely relevant (direct reference / verbatim text match, or):
|
| 66 |
+
# - USE_RERANKER=true → best cross-encoder relevance (0-1) >= RERANK_MIN_SCORE
|
| 67 |
+
# - USE_RERANKER=false → best dense cosine similarity >= DENSE_MIN_SCORE
|
| 68 |
+
# (multilingual-e5-large scale; recalibrate if EMBED_MODEL changes)
|
| 69 |
+
# Calibrate with: python -m eval.run_eval --show-failures
|
| 70 |
+
# On the golden set the reranker gate refuses 92% of off-topic questions vs 36% for
|
| 71 |
+
# the dense gate, for ~1 s per answer; set false only where the model can't be loaded.
|
| 72 |
+
USE_RERANKER=true
|
| 73 |
+
|
| 74 |
+
# Rewrite queries without the LLM (regex intent + thesaurus terms), ~2 s faster per
|
| 75 |
+
# question. Follow-ups with chat history always use the LLM rewrite.
|
| 76 |
+
FAST_QUERY_REWRITE=true
|
| 77 |
+
RERANK_MIN_SCORE=0.05
|
| 78 |
+
DENSE_MIN_SCORE=0.80
|
| 79 |
|
| 80 |
# Hadith boost: Score bonus when intent=hadith
|
| 81 |
# Prevents Quran verses from outranking relevant Hadiths
|
|
|
|
| 107 |
#
|
| 108 |
# Self-contained (GGUF, e.g. HF Spaces):
|
| 109 |
# LLM_BACKEND=gguf
|
| 110 |
+
# GGUF_MODEL_PATH=./models/Qwen2.5-14B-Instruct-Q8_0.gguf
|
| 111 |
#
|
| 112 |
# Development (Ollama):
|
| 113 |
# LLM_BACKEND=ollama
|
| 114 |
# OLLAMA_HOST=http://localhost:11434
|
| 115 |
+
# OLLAMA_MODEL=qwen2.5:14b
|
| 116 |
#
|
| 117 |
# Production (HuggingFace GPU):
|
| 118 |
# LLM_BACKEND=hf
|
| 119 |
+
# HF_MODEL_NAME=Qwen/Qwen2.5-14B-Instruct
|
| 120 |
# HF_DEVICE=cuda
|
| 121 |
#
|
| 122 |
# Production (HuggingFace CPU):
|
| 123 |
# LLM_BACKEND=hf
|
| 124 |
+
# HF_MODEL_NAME=Qwen/Qwen2.5-14B-Instruct
|
| 125 |
# HF_DEVICE=cpu
|
.gitignore
CHANGED
|
@@ -214,3 +214,9 @@ metadata.json
|
|
| 214 |
models/
|
| 215 |
*.gguf
|
| 216 |
scratch/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
models/
|
| 215 |
*.gguf
|
| 216 |
scratch/
|
| 217 |
+
|
| 218 |
+
# Eval run reports
|
| 219 |
+
eval/results/
|
| 220 |
+
QModel.fatwa-update.index
|
| 221 |
+
metadata.fatwa-update.json
|
| 222 |
+
*.prev-*
|
Dockerfile
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
# name: logicsoft
|
| 2 |
-
# QModel
|
| 3 |
# =============================
|
| 4 |
# Dockerfile for QModel API
|
| 5 |
# Supports both Ollama and HuggingFace backends via .env configuration
|
|
@@ -42,8 +42,8 @@ FROM python:3.11-slim
|
|
| 42 |
LABEL name="Logicsoft"
|
| 43 |
LABEL author="Abdullah Elgendy"
|
| 44 |
LABEL maintainer="Abdullah Elgendy"
|
| 45 |
-
LABEL description="QModel v8 - Quran, Hadith, Tafsir and Fiqh RAG API"
|
| 46 |
-
LABEL version="8.
|
| 47 |
|
| 48 |
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 49 |
PYTHONUNBUFFERED=1 \
|
|
@@ -72,8 +72,10 @@ USER appuser
|
|
| 72 |
# Expose port for API
|
| 73 |
EXPOSE 8000
|
| 74 |
|
| 75 |
-
# Health check — /health returns 503 until the RAG pipeline finishes loading
|
| 76 |
-
|
|
|
|
|
|
|
| 77 |
CMD curl -f http://localhost:8000/health || exit 1
|
| 78 |
|
| 79 |
# Start application
|
|
|
|
| 1 |
# name: logicsoft
|
| 2 |
+
# QModel 8.5 - Islamic RAG API
|
| 3 |
# =============================
|
| 4 |
# Dockerfile for QModel API
|
| 5 |
# Supports both Ollama and HuggingFace backends via .env configuration
|
|
|
|
| 42 |
LABEL name="Logicsoft"
|
| 43 |
LABEL author="Abdullah Elgendy"
|
| 44 |
LABEL maintainer="Abdullah Elgendy"
|
| 45 |
+
LABEL description="QModel v8.5 - Quran, Hadith, Tafsir and Fiqh RAG API"
|
| 46 |
+
LABEL version="8.5.0"
|
| 47 |
|
| 48 |
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 49 |
PYTHONUNBUFFERED=1 \
|
|
|
|
| 72 |
# Expose port for API
|
| 73 |
EXPOSE 8000
|
| 74 |
|
| 75 |
+
# Health check — /health returns 503 until the RAG pipeline finishes loading.
|
| 76 |
+
# Loading takes ~100 s (embedding model, reranker, BM25 over 56k docs), longer on
|
| 77 |
+
# first start while the models download.
|
| 78 |
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=300s --retries=3 \
|
| 79 |
CMD curl -f http://localhost:8000/health || exit 1
|
| 80 |
|
| 81 |
# Start application
|
README.md
CHANGED
|
@@ -19,12 +19,12 @@ language:
|
|
| 19 |
- en
|
| 20 |
---
|
| 21 |
|
| 22 |
-
# QModel v8.
|
| 23 |
**Specialized Qur'an, Hadith & Fatwa Knowledge System with Multi-Vector RAG & Dual LLM Support**
|
| 24 |
|
| 25 |
> A production-ready Retrieval-Augmented Generation system specialized exclusively in authenticated Islamic knowledge. No hallucinations, no outside knowledge—only content from verified sources.
|
| 26 |
|
| 27 |
-

|
| 29 |

|
| 30 |
|
|
@@ -50,17 +50,17 @@ language:
|
|
| 50 |
- **Collection Navigation**: Filter by Bukhari, Muslim, Abu Dawud, etc.
|
| 51 |
|
| 52 |
### ⚖️ Fatwa & Fiqh Capabilities
|
| 53 |
-
- **Verified Scholarly Rulings**:
|
| 54 |
- **Semantic & Source Filtering**: Filter fatwas by scholar, council, or source
|
| 55 |
- **Integrated RAG Context**: Rulings retrieved alongside Quran and Hadith evidence
|
| 56 |
- **Direct Lookup**: Fast endpoint access via `/fatwa/{id}`, `/fatwa/search`, and `/fatwa/sources`
|
| 57 |
|
| 58 |
### 🛡️ Safety Features
|
| 59 |
-
- **
|
| 60 |
- **Reasoning Support**: Capture and stream model thinking/Chain-of-Thought
|
| 61 |
-
- **Source Attribution**: Every answer cites exact verse, Hadith, or Fatwa reference
|
| 62 |
-
- **Verbatim
|
| 63 |
-
- **
|
| 64 |
|
| 65 |
### 🚀 Integration
|
| 66 |
- **OpenAI-Compatible API**: Use with Open-WebUI, Langchain, or any OpenAI client
|
|
@@ -73,7 +73,7 @@ language:
|
|
| 73 |
- **Multi-Vector Dense Retrieval**: Independent Arabic and English query vectors fused adaptively
|
| 74 |
- **Inverted BM25 Sparse Index**: Bilingual tokenization with stopword filtering and morphological root indexing
|
| 75 |
- **Adaptive RRF Fusion**: Language-weighted Reciprocal Rank Fusion balancing dense & sparse signals
|
| 76 |
-
- **Cross-Encoder Reranker**:
|
| 77 |
- **Triple LLM Backend**: Local GGUF, Ollama (dev), or HuggingFace (prod)
|
| 78 |
- **Async API & Caching**: FastAPI with TTL-based LRU response cache
|
| 79 |
|
|
@@ -90,8 +90,8 @@ language:
|
|
| 90 |
### Installation
|
| 91 |
|
| 92 |
```bash
|
| 93 |
-
#
|
| 94 |
-
|
| 95 |
python3 -m venv .venv && source .venv/bin/activate
|
| 96 |
pip install -r requirements.txt
|
| 97 |
|
|
@@ -101,9 +101,10 @@ export LLM_BACKEND=gguf
|
|
| 101 |
export GGUF_MODEL_PATH=./models/Qwen2.5-14B-Instruct-Q8_0.gguf
|
| 102 |
huggingface-cli download bartowski/Qwen2.5-14B-Instruct-GGUF Qwen2.5-14B-Instruct-Q8_0.gguf --local-dir ./models
|
| 103 |
|
| 104 |
-
# Option B — Ollama
|
|
|
|
| 105 |
export LLM_BACKEND=ollama
|
| 106 |
-
export OLLAMA_MODEL=
|
| 107 |
# Make sure Ollama is running: ollama serve
|
| 108 |
|
| 109 |
# Option C — HuggingFace (production):
|
|
@@ -122,14 +123,23 @@ API docs: http://localhost:8000/docs
|
|
| 122 |
### Data & Index
|
| 123 |
|
| 124 |
Pre-built data files are included:
|
| 125 |
-
- `metadata.json` —
|
| 126 |
-
- `QModel.index` — FAISS search index (
|
| 127 |
|
| 128 |
To rebuild after dataset changes:
|
| 129 |
```bash
|
| 130 |
python build_index.py
|
| 131 |
```
|
| 132 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
---
|
| 134 |
|
| 135 |
## Example Queries
|
|
@@ -227,12 +237,16 @@ huggingface-cli download bartowski/Qwen2.5-14B-Instruct-GGUF Qwen2.5-14B-Instruc
|
|
| 227 |
### Ollama Backend (Development)
|
| 228 |
|
| 229 |
```bash
|
|
|
|
|
|
|
| 230 |
LLM_BACKEND=ollama
|
| 231 |
OLLAMA_HOST=http://localhost:11434
|
| 232 |
-
OLLAMA_MODEL=
|
| 233 |
```
|
| 234 |
|
| 235 |
-
Requires
|
|
|
|
|
|
|
| 236 |
|
| 237 |
### HuggingFace Backend (Production)
|
| 238 |
|
|
@@ -265,7 +279,8 @@ HF_MAX_NEW_TOKENS=2048
|
|
| 265 |
| `TOP_K_SEARCH` | `20` | Candidate pool (5–100) |
|
| 266 |
| `TOP_K_RETURN` | `5` | Results shown to user (1–20) |
|
| 267 |
| `RERANK_ALPHA` | `0.6` | Dense vs Sparse weight (0.0–1.0) |
|
| 268 |
-
| `
|
|
|
|
| 269 |
| `RERANK_MODEL` | `BAAI/bge-reranker-v2-m3` | Cross-encoder model ID |
|
| 270 |
| `AYAH_WINDOW_SIZE` | `1` | Number of neighboring verses for context expansion |
|
| 271 |
| `ENABLE_HADITH_DEDUPLICATION` | `true` | Cluster near-identical Hadith narrations |
|
|
@@ -275,7 +290,8 @@ HF_MAX_NEW_TOKENS=2048
|
|
| 275 |
| `MAX_TOKENS` | `2048` | Max response length |
|
| 276 |
| `SHOW_THINKING` | `false` | Include chain-of-thought/reasoning output (`true`/`false`) |
|
| 277 |
| **Safety** | | |
|
| 278 |
-
| `
|
|
|
|
| 279 |
| `HADITH_BOOST` | `0.08` | Score boost for hadith on hadith queries |
|
| 280 |
| `QURAN_BOOST` | `0.12` | Score boost for Quran verses on tafsir/quran queries |
|
| 281 |
| **Other** | | |
|
|
@@ -293,16 +309,14 @@ GGUF_MODEL_PATH=./models/Qwen2.5-14B-Instruct-Q8_0.gguf
|
|
| 293 |
GGUF_N_CTX=8192
|
| 294 |
GGUF_N_GPU_LAYERS=-1
|
| 295 |
TEMPERATURE=0.2
|
| 296 |
-
CONFIDENCE_THRESHOLD=0.30
|
| 297 |
```
|
| 298 |
|
| 299 |
**Development (Ollama)**
|
| 300 |
```bash
|
| 301 |
LLM_BACKEND=ollama
|
| 302 |
OLLAMA_HOST=http://localhost:11434
|
| 303 |
-
OLLAMA_MODEL=
|
| 304 |
TEMPERATURE=0.2
|
| 305 |
-
CONFIDENCE_THRESHOLD=0.30
|
| 306 |
ALLOWED_ORIGINS=*
|
| 307 |
```
|
| 308 |
|
|
@@ -313,15 +327,47 @@ HF_MODEL_NAME=Qwen/Qwen2.5-14B-Instruct
|
|
| 313 |
HF_DEVICE=cuda
|
| 314 |
TOP_K_SEARCH=30
|
| 315 |
TEMPERATURE=0.1
|
| 316 |
-
|
| 317 |
ALLOWED_ORIGINS=yourdomain.com,api.yourdomain.com
|
| 318 |
```
|
| 319 |
|
| 320 |
### Tuning Tips
|
| 321 |
|
| 322 |
-
- **Better results**:
|
| 323 |
- **Faster performance**: Lower `TOP_K_SEARCH` and `TOP_K_RETURN`, reduce `MAX_TOKENS`, use Ollama
|
| 324 |
-
- **More conservative**: Increase `
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 325 |
|
| 326 |
---
|
| 327 |
|
|
@@ -384,7 +430,7 @@ this backend suitable for bundling directly into an HF Space.
|
|
| 384 |
# .env
|
| 385 |
LLM_BACKEND=ollama
|
| 386 |
OLLAMA_HOST=http://host.docker.internal:11434
|
| 387 |
-
OLLAMA_MODEL=
|
| 388 |
```
|
| 389 |
|
| 390 |
Requires Ollama running on the host (`ollama serve`).
|
|
@@ -537,21 +583,22 @@ tests/
|
|
| 537 |
1. **Conversational Coreference Rewriting**: Query rewriter accepts dialogue `history` to resolve ambiguous pronouns and anaphoric references into standalone topical queries.
|
| 538 |
2. **Dual-Vector Query Embedding**: Encodes both Arabic (`query_ar`) and English (`query_en`) query vectors using `multilingual-e5-large` with the mandatory `query: ` prefix.
|
| 539 |
3. **Multi-Vector Dense Retrieval**: Independent FAISS searches against Arabic and English dense vectors.
|
| 540 |
-
4. **Inverted BM25 Sparse Retrieval**: BM25 scoring with bilingual stopword removal and Quranic morphological root expansion.
|
| 541 |
5. **Adaptive Reciprocal Rank Fusion (RRF)**: Merges Arabic dense, English dense, and BM25 sparse hits with dynamic language-weighted multipliers.
|
| 542 |
6. **Hadith Deduplication & Ayah Windowing**:
|
| 543 |
- Clusters duplicate Hadith narrations ($\ge 0.85$ text similarity), tags *Muttafaq 'Alayh* (Bukhari + Muslim), and aggregates alternative collection references.
|
| 544 |
- Expands top Quran verses with $\pm N$ adjacent verses (`AYAH_WINDOW_SIZE`) for complete theological context.
|
| 545 |
-
7. **Cross-Encoder Reranking
|
| 546 |
-
8. **Confidence Gating & Context Assembly**:
|
| 547 |
|
| 548 |
### Anti-Hallucination Measures
|
| 549 |
|
| 550 |
-
-
|
| 551 |
-
-
|
| 552 |
-
-
|
| 553 |
-
-
|
| 554 |
-
-
|
|
|
|
| 555 |
- Grade inference from collection name
|
| 556 |
|
| 557 |
### Performance
|
|
@@ -587,7 +634,7 @@ export HF_TOKEN=hf_xxxxxxxxxxxxx # Set token for gated models
|
|
| 587 |
### "Assistant returns 'Not found'"
|
| 588 |
This is expected — QModel rejects low-confidence queries. Try:
|
| 589 |
- More specific queries
|
| 590 |
-
- Lower `
|
| 591 |
- Check raw scores: `GET /debug/scores?q=your+query`
|
| 592 |
|
| 593 |
### "Port already in use"
|
|
@@ -600,21 +647,6 @@ docker-compose down && docker system prune
|
|
| 600 |
|
| 601 |
## Roadmap
|
| 602 |
|
| 603 |
-
- [x] Grade-based filtering
|
| 604 |
-
- [x] Streaming responses (SSE)
|
| 605 |
-
- [x] Modular architecture (5 routers, 19 endpoints)
|
| 606 |
-
- [x] Triple LLM backend (GGUF + Ollama + HuggingFace)
|
| 607 |
-
- [x] Text search (exact substring + fuzzy matching)
|
| 608 |
-
- [x] Chain of narrators (Isnad highlighting)
|
| 609 |
-
- [x] Synonym expansion (curated Islamic thesaurus)
|
| 610 |
-
- [x] Root-word analysis (thematic Arabic roots & dictionary)
|
| 611 |
-
- [x] Fatwa & scholarly rulings corpus integration
|
| 612 |
-
- [x] Asymmetric E5 embedding prefixing (`passage: ` / `query: `)
|
| 613 |
-
- [x] Multi-vector dual-language dense retrieval with adaptive RRF
|
| 614 |
-
- [x] Conversational multi-turn coreference resolution
|
| 615 |
-
- [x] Hadith matn deduplication & *Muttafaq 'Alayh* clustering
|
| 616 |
-
- [x] Quranic parent-document Ayah windowing
|
| 617 |
-
- [x] Cross-encoder reranking support (`BAAI/bge-reranker-v2-m3`)
|
| 618 |
- [ ] Batch processing (multiple questions per request)
|
| 619 |
- [ ] Islamic calendar integration (Hijri dates)
|
| 620 |
|
|
@@ -625,64 +657,7 @@ docker-compose down && docker system prune
|
|
| 625 |
- **Qur'an**: [risan/quran-json](https://github.com/risan/quran-json) & [semarketir/quranjson](https://github.com/semarketir/quranjson) — 114 Surahs, 6,236 verses with Arabic text, transliteration, and English translation.
|
| 626 |
- **Tafsir**: [Tafseer Dataset & Quran.com](https://api.quran.com) — 5,430 verses enriched with classical Arabic commentaries (*Tafsir al-Sa'di*, *Tafsir al-Mukhtasar*, *Tafsir al-Jalalayn*) + English *Tafsir Ibn Kathir*.
|
| 627 |
- **Hadith**: [AhmedBaset/hadith-json](https://github.com/AhmedBaset/hadith-json) & [fawazahmed0/hadith-api](https://github.com/fawazahmed0/hadith-api) — 15 authoritative collections (9 canonical + *Riyad al-Salihin*, *Bulugh al-Maram*, *Al-Adab al-Mufrad*, *Ash-Shama'il al-Muhammadiyyah*, *An-Nawawi's 40 Hadith*, *Hadith Qudsi*), 46,416 hadiths with 21,264 scholar authenticity grades.
|
| 628 |
-
- **Fatwa**:
|
| 629 |
-
|
| 630 |
-
---
|
| 631 |
-
|
| 632 |
-
## What's New in v8
|
| 633 |
-
|
| 634 |
-
✨ **15 Authoritative Hadith Collections (46,416 Narrations)** — Expanded beyond the 9 canonical books by ingesting core thematic fiqh and ethical collections: *Riyad al-Salihin* (1,896 hadiths), *Bulugh al-Maram* (1,767 hadiths), *Al-Adab al-Mufrad* (1,326 hadiths), *Ash-Shama'il al-Muhammadiyyah* (402 hadiths), *An-Nawawi's 40 Hadith* (42 hadiths), and *Hadith Qudsi* (40 hadiths), with 21,264 scholar authenticity grades.
|
| 635 |
-
✨ **Classical Arabic Tafsir Enrichment (5,430 Verses)** — Enriched Quranic entries with verse-by-verse *Tafsir al-Sa'di* (تفسير السعدي) and *Tafsir al-Mukhtasar* (التفسير المختصر) alongside English Tafsir snippets, enabling precise semantic retrieval for theological and interpretative queries.
|
| 636 |
-
✨ **Fatwa & Fiqh Corpus Integration** — Full integration of authenticated Islamic legal rulings and scholarly answers with dedicated `/fatwa` search, source, and ID lookup endpoints.
|
| 637 |
-
✨ **Asymmetric E5 Embedding Alignment** — Prefixed indexed passages with `passage: ` while keeping queries prefixed with `query: `, maximizing dense semantic retrieval accuracy with `multilingual-e5-large`.
|
| 638 |
-
✨ **Multi-Vector Dual-Language Dense Retrieval** — Executes independent vector searches for Arabic and English translations and blends them via adaptive Reciprocal Rank Fusion (RRF) based on input script.
|
| 639 |
-
✨ **Conversational Multi-Turn Coreference Resolution** — Query rewriter now ingests prior conversation history to resolve pronouns and implicit subject references into standalone, unambiguous queries.
|
| 640 |
-
✨ **Hadith Matn Deduplication & *Muttafaq 'Alayh* Clustering** — Identifies near-identical Hadith narrations across canonical collections ($\ge 0.85$ text similarity), labels narrations recorded by both Bukhari and Muslim as *Muttafaq 'Alayh*, and aggregates alternative cross-references in context.
|
| 641 |
-
✨ **Quranic Parent-Document Ayah Windowing** — Automatically expands retrieved Quranic verses with surrounding $\pm 1$ verses (`AYAH_WINDOW_SIZE`) so the LLM receives complete theological context.
|
| 642 |
-
✨ **Bilingual Stopwords & Curated Quranic Roots** — Built-in morphological Quranic root dictionary for primary Islamic concepts (`صوم`, `صلاة`, `زكاة`, `ربا`, `طهر`, etc.) and bilingual stopword filtering for high-precision BM25 scoring.
|
| 643 |
-
✨ **Cross-Encoder Reranker Support** — Added optional second-stage neural reranking with `BAAI/bge-reranker-v2-m3` via `USE_RERANKER=true`.
|
| 644 |
-
✨ **Hardware-Accelerated Index Builder** — Enhanced `build_index.py` with automatic Apple Silicon Metal (MPS) and CUDA detection, increasing indexing throughput to 64 items/batch.
|
| 645 |
-
✨ **Extended Tafsir Capacity & Multi-Style Quote Verification** — Increased Tafsir context snippet size to 1,200 characters and hardened quote verification regex to support Arabic guillemets `«...»`, typographic quotes `“...”`, and standard quotes.
|
| 646 |
-
✨ **Corpus Scale Expansion** — Total knowledge base expanded to **52,677 authenticated documents** across Quran, Hadith, and Fatwa, with a 205.77 MB FAISS inner-product index.
|
| 647 |
-
|
| 648 |
-
---
|
| 649 |
-
|
| 650 |
-
## What's New in v7
|
| 651 |
-
|
| 652 |
-
✨ **Inverted BM25 Index** — Full-dataset sparse retrieval built at startup (replaces per-candidate linear BM25 scoring)
|
| 653 |
-
✨ **Reciprocal Rank Fusion** — Dense (FAISS) + sparse (BM25) results merged via RRF instead of fixed-weight scoring
|
| 654 |
-
✨ **Quran Boost** — New `QURAN_BOOST` config counterbalances `HADITH_BOOST` so Hadiths don't outrank relevant Quran verses on tafsir/quran-intent queries
|
| 655 |
-
✨ **Query Embedding Cache** — Repeated query vectors served from cache instead of re-encoding
|
| 656 |
-
✨ **Fast-Path Query Rewrite** — Direct Quran references (e.g. "2:255", surah names) and verse-count questions skip the LLM rewrite roundtrip
|
| 657 |
-
✨ **Improved Arabic Root Extraction** — `extract_arabic_root()` strips augmented letters for more accurate 3-letter root matching; root variants now included in tokenization
|
| 658 |
-
✨ **Hardened Docker Image** — Multi-stage build (compiler toolchain isolated to builder stage), runs as non-root user, smaller runtime image
|
| 659 |
-
✨ **Production-Safe Compose** — `docker-compose.yml` no longer bind-mounts the repo by default; data files mounted read-only; live-reload dev setup moved to `docker-compose.override.yml`
|
| 660 |
-
|
| 661 |
-
---
|
| 662 |
-
|
| 663 |
-
## What's New in v6
|
| 664 |
-
|
| 665 |
-
✨ **Reasoning Support** — Capture and stream model thinking/CoT
|
| 666 |
-
✨ **Root Analysis** — Explore Quran via 3-letter Arabic roots
|
| 667 |
-
✨ **Isnad Highlighting** — Narrators emphasized in Hadith responses
|
| 668 |
-
✨ **Synonym Expansion** — Curated Islamic thesaurus for broader search
|
| 669 |
-
✨ **Dynamic Gating** — Intent-aware confidence thresholds
|
| 670 |
-
✨ **Dual LLM Backend** — Ollama (dev) + HuggingFace (prod)
|
| 671 |
-
✨ **Grade Filtering** — Return only Sahih/Hasan authenticated Hadiths
|
| 672 |
-
✨ **Source Filtering** — Quran-only or Hadith-only queries
|
| 673 |
-
✨ **Hadith Verification** — `/hadith/verify` endpoint
|
| 674 |
-
✨ **Enhanced Frequency** — Word counts by Surah
|
| 675 |
-
✨ **OpenAI Compatible** — Use with any OpenAI client
|
| 676 |
-
✨ **Production Ready** — Structured logging, error handling, async throughout
|
| 677 |
-
|
| 678 |
-
---
|
| 679 |
-
|
| 680 |
-
## Next Steps
|
| 681 |
-
|
| 682 |
-
1. **Get Started**: See [SETUP.md](SETUP.md)
|
| 683 |
-
2. **Integrate with Open-WebUI**: See [OPEN_WEBUI.md](OPEN_WEBUI.md)
|
| 684 |
-
3. **Deploy with Docker**: See [DOCKER.md](DOCKER.md)
|
| 685 |
-
4. **Understand Architecture**: See [ARCHITECTURE.md](ARCHITECTURE.md)
|
| 686 |
|
| 687 |
---
|
| 688 |
|
|
@@ -698,4 +673,4 @@ See individual repositories for license details.
|
|
| 698 |
|
| 699 |
**Made with ❤️ for Islamic scholarship.**
|
| 700 |
|
| 701 |
-
Version 8.
|
|
|
|
| 19 |
- en
|
| 20 |
---
|
| 21 |
|
| 22 |
+
# QModel v8.5 — Islamic RAG System
|
| 23 |
**Specialized Qur'an, Hadith & Fatwa Knowledge System with Multi-Vector RAG & Dual LLM Support**
|
| 24 |
|
| 25 |
> A production-ready Retrieval-Augmented Generation system specialized exclusively in authenticated Islamic knowledge. No hallucinations, no outside knowledge—only content from verified sources.
|
| 26 |
|
| 27 |
+

|
| 28 |

|
| 29 |

|
| 30 |
|
|
|
|
| 50 |
- **Collection Navigation**: Filter by Bukhari, Muslim, Abu Dawud, etc.
|
| 51 |
|
| 52 |
### ⚖️ Fatwa & Fiqh Capabilities
|
| 53 |
+
- **Verified Scholarly Rulings**: 3,598 fatwas from Islamweb's Fatwa Center (مركز الفتوى) and Imam Ibn Baz
|
| 54 |
- **Semantic & Source Filtering**: Filter fatwas by scholar, council, or source
|
| 55 |
- **Integrated RAG Context**: Rulings retrieved alongside Quran and Hadith evidence
|
| 56 |
- **Direct Lookup**: Fast endpoint access via `/fatwa/{id}`, `/fatwa/search`, and `/fatwa/sources`
|
| 57 |
|
| 58 |
### 🛡️ Safety Features
|
| 59 |
+
- **Relevance Gating**: The LLM is only called when retrieval found something genuinely relevant (exact reference/text match, else the cross-encoder's relevance score); out-of-scope requests get a fixed "not found" answer
|
| 60 |
- **Reasoning Support**: Capture and stream model thinking/Chain-of-Thought
|
| 61 |
+
- **Source Attribution**: Every answer cites exact verse, Hadith, or Fatwa reference — hadith by collection and number, fatwas by source link (added automatically when the model omits it)
|
| 62 |
+
- **Verbatim Scripture**: Quoted verses and hadith (`﴿...﴾`, `❝...❞`, `«...»`) are checked against the retrieved passages; near-copies are restored to the canonical text, ungrounded quotes are removed
|
| 63 |
+
- **Language Guard**: Answers that drift into Chinese are regenerated (or cut off when streaming)
|
| 64 |
|
| 65 |
### 🚀 Integration
|
| 66 |
- **OpenAI-Compatible API**: Use with Open-WebUI, Langchain, or any OpenAI client
|
|
|
|
| 73 |
- **Multi-Vector Dense Retrieval**: Independent Arabic and English query vectors fused adaptively
|
| 74 |
- **Inverted BM25 Sparse Index**: Bilingual tokenization with stopword filtering and morphological root indexing
|
| 75 |
- **Adaptive RRF Fusion**: Language-weighted Reciprocal Rank Fusion balancing dense & sparse signals
|
| 76 |
+
- **Cross-Encoder Reranker**: `BAAI/bge-reranker-v2-m3` rescoring and confidence gating (on by default)
|
| 77 |
- **Triple LLM Backend**: Local GGUF, Ollama (dev), or HuggingFace (prod)
|
| 78 |
- **Async API & Caching**: FastAPI with TTL-based LRU response cache
|
| 79 |
|
|
|
|
| 90 |
### Installation
|
| 91 |
|
| 92 |
```bash
|
| 93 |
+
# Download code + search index from Hugging Face (skips the 15 GB GGUF)
|
| 94 |
+
huggingface-cli download aelgendy/QModel --exclude "models/*" --local-dir QModel && cd QModel
|
| 95 |
python3 -m venv .venv && source .venv/bin/activate
|
| 96 |
pip install -r requirements.txt
|
| 97 |
|
|
|
|
| 101 |
export GGUF_MODEL_PATH=./models/Qwen2.5-14B-Instruct-Q8_0.gguf
|
| 102 |
huggingface-cli download bartowski/Qwen2.5-14B-Instruct-GGUF Qwen2.5-14B-Instruct-Q8_0.gguf --local-dir ./models
|
| 103 |
|
| 104 |
+
# Option B — Ollama:
|
| 105 |
+
ollama pull hf.co/aelgendy/QModel
|
| 106 |
export LLM_BACKEND=ollama
|
| 107 |
+
export OLLAMA_MODEL=hf.co/aelgendy/QModel
|
| 108 |
# Make sure Ollama is running: ollama serve
|
| 109 |
|
| 110 |
# Option C — HuggingFace (production):
|
|
|
|
| 123 |
### Data & Index
|
| 124 |
|
| 125 |
Pre-built data files are included:
|
| 126 |
+
- `metadata.json` — 56,250 documents (6,236 Quran verses + 46,416 hadiths from 15 collections + 3,598 fatwas)
|
| 127 |
+
- `QModel.index` — FAISS search index (~220 MB, 56,250 vectors, 1024-d)
|
| 128 |
|
| 129 |
To rebuild after dataset changes:
|
| 130 |
```bash
|
| 131 |
python build_index.py
|
| 132 |
```
|
| 133 |
|
| 134 |
+
Fatwas are read from `data/fatwas/` (hand-collected) and `data/fatwas_scraped/` (scraper output), de-duplicated by id and content; files listed in `FATWA_EXCLUDE` in `build_index.py` are skipped. To add fatwas without re-embedding the Qur'an and hadith:
|
| 135 |
+
```bash
|
| 136 |
+
python data_pipeline/scrape_fatwas.py --source islamweb --start 8001 --end 12000 --delay 1.0
|
| 137 |
+
python scripts/update_fatwa_index.py # writes QModel.fatwa-update.index / metadata.fatwa-update.json
|
| 138 |
+
python scripts/update_fatwa_index.py --in-place # or replace the live files (previous pair saved as .bak, overwriting older .bak)
|
| 139 |
+
```
|
| 140 |
+
|
| 141 |
+
Records without answer text, and scraped listing pages (e.g. audio-lecture indexes), are skipped.
|
| 142 |
+
|
| 143 |
---
|
| 144 |
|
| 145 |
## Example Queries
|
|
|
|
| 237 |
### Ollama Backend (Development)
|
| 238 |
|
| 239 |
```bash
|
| 240 |
+
ollama pull hf.co/aelgendy/QModel
|
| 241 |
+
|
| 242 |
LLM_BACKEND=ollama
|
| 243 |
OLLAMA_HOST=http://localhost:11434
|
| 244 |
+
OLLAMA_MODEL=hf.co/aelgendy/QModel
|
| 245 |
```
|
| 246 |
|
| 247 |
+
Requires `ollama serve` running. The pull brings the GGUF with QModel's chat template, system prompt and sampling settings (the `template`, `system` and `params` files, kept in sync with `Modelfile`).
|
| 248 |
+
|
| 249 |
+
> `ollama run hf.co/aelgendy/QModel` on its own gives the bare language model, without retrieval, citation checks or verse links. For grounded answers, run the QModel API with `LLM_BACKEND=ollama` and send questions to it (or to Open-WebUI connected to it).
|
| 250 |
|
| 251 |
### HuggingFace Backend (Production)
|
| 252 |
|
|
|
|
| 279 |
| `TOP_K_SEARCH` | `20` | Candidate pool (5–100) |
|
| 280 |
| `TOP_K_RETURN` | `5` | Results shown to user (1–20) |
|
| 281 |
| `RERANK_ALPHA` | `0.6` | Dense vs Sparse weight (0.0–1.0) |
|
| 282 |
+
| `FAST_QUERY_REWRITE` | `true` | Rewrite queries without the LLM (regex intent + thesaurus terms), saving ~2 s per question. The LLM rewrite still runs for follow-up questions with chat history |
|
| 283 |
+
| `USE_RERANKER` | `true` | Cross-encoder reranker (true/false). Also drives the confidence gate: off-topic questions refused 92% with it vs 36% without |
|
| 284 |
| `RERANK_MODEL` | `BAAI/bge-reranker-v2-m3` | Cross-encoder model ID |
|
| 285 |
| `AYAH_WINDOW_SIZE` | `1` | Number of neighboring verses for context expansion |
|
| 286 |
| `ENABLE_HADITH_DEDUPLICATION` | `true` | Cluster near-identical Hadith narrations |
|
|
|
|
| 290 |
| `MAX_TOKENS` | `2048` | Max response length |
|
| 291 |
| `SHOW_THINKING` | `false` | Include chain-of-thought/reasoning output (`true`/`false`) |
|
| 292 |
| **Safety** | | |
|
| 293 |
+
| `RERANK_MIN_SCORE` | `0.05` | Confidence gate with reranker on: min cross-encoder relevance (0–1) of the best retrieved passage to call the LLM |
|
| 294 |
+
| `DENSE_MIN_SCORE` | `0.80` | Confidence gate with reranker off: min dense cosine similarity (multilingual-e5-large scale) |
|
| 295 |
| `HADITH_BOOST` | `0.08` | Score boost for hadith on hadith queries |
|
| 296 |
| `QURAN_BOOST` | `0.12` | Score boost for Quran verses on tafsir/quran queries |
|
| 297 |
| **Other** | | |
|
|
|
|
| 309 |
GGUF_N_CTX=8192
|
| 310 |
GGUF_N_GPU_LAYERS=-1
|
| 311 |
TEMPERATURE=0.2
|
|
|
|
| 312 |
```
|
| 313 |
|
| 314 |
**Development (Ollama)**
|
| 315 |
```bash
|
| 316 |
LLM_BACKEND=ollama
|
| 317 |
OLLAMA_HOST=http://localhost:11434
|
| 318 |
+
OLLAMA_MODEL=hf.co/aelgendy/QModel
|
| 319 |
TEMPERATURE=0.2
|
|
|
|
| 320 |
ALLOWED_ORIGINS=*
|
| 321 |
```
|
| 322 |
|
|
|
|
| 327 |
HF_DEVICE=cuda
|
| 328 |
TOP_K_SEARCH=30
|
| 329 |
TEMPERATURE=0.1
|
| 330 |
+
USE_RERANKER=true
|
| 331 |
ALLOWED_ORIGINS=yourdomain.com,api.yourdomain.com
|
| 332 |
```
|
| 333 |
|
| 334 |
### Tuning Tips
|
| 335 |
|
| 336 |
+
- **Better results**: keep `USE_RERANKER=true` (the default: better ranking and a far more reliable confidence gate, ~1 s per answer), increase `TOP_K_SEARCH`, use `TEMPERATURE=0.1`
|
| 337 |
- **Faster performance**: Lower `TOP_K_SEARCH` and `TOP_K_RETURN`, reduce `MAX_TOKENS`, use Ollama
|
| 338 |
+
- **More conservative**: Increase `RERANK_MIN_SCORE` / `DENSE_MIN_SCORE`, lower `TEMPERATURE` — check the trade-off with `python -m eval.run_eval --show-failures`
|
| 339 |
+
|
| 340 |
+
### Measuring Retrieval Accuracy
|
| 341 |
+
|
| 342 |
+
`eval/golden.jsonl` holds 147 labelled queries (Qur'an topic/text/reference, Hadith topic/text/authenticity, Fatwa, and out-of-scope negatives). Each case lists the dataset `id`s that count as a correct hit; where one source is decisive (5:90 for "is alcohol forbidden?", while 2:219 alone would mislead), it is also listed under `required` and scored as `complete@k`. `eval/run_eval.py` runs them through retrieval (no LLM generation) using the local index:
|
| 343 |
+
|
| 344 |
+
```bash
|
| 345 |
+
python -m eval.run_eval --out eval/results/baseline.json # full pipeline, k = TOP_K_RETURN
|
| 346 |
+
python -m eval.run_eval --mode hybrid --k 20 # hybrid_search() alone
|
| 347 |
+
python -m eval.run_eval --only fatwa,negative --show-failures # subset + list misses
|
| 348 |
+
python -m eval.run_eval --compare eval/results/baseline.json # diff against a previous run
|
| 349 |
+
```
|
| 350 |
+
|
| 351 |
+
It reports Hit@1/3/k, MRR and Recall@k overall, by category and by language, plus the confidence gate's false-reject rate (answer found but refused) and false-accept rate (out-of-scope queries let through). Run it before and after any retrieval or config change; env vars such as `USE_RERANKER=false` or `TOP_K_SEARCH=100` apply as usual.
|
| 352 |
+
|
| 353 |
+
### Measuring Answer Quality
|
| 354 |
+
|
| 355 |
+
`eval/run_answers.py` runs the same golden set through the full pipeline **with the LLM** and checks what users read:
|
| 356 |
+
|
| 357 |
+
```bash
|
| 358 |
+
python -m eval.run_answers --limit 5 # smoke test
|
| 359 |
+
python -m eval.run_answers --only hadith_text,fatwa # subset
|
| 360 |
+
python -m eval.run_answers --out eval/results/answers.json # full run (~1 h at ~25 s/answer)
|
| 361 |
+
```
|
| 362 |
+
|
| 363 |
+
- **Refusals**: out-of-scope questions get the "not found" answer; real ones don't
|
| 364 |
+
- **Citations**: every cited verse / hadith / fatwa was retrieved, at least one is a golden-relevant source, and all `required` sources are cited
|
| 365 |
+
- **Quotes**: scripture quoted by the model, before verification — verbatim, altered copy of a real verse, or fabricated — and how many the verifier restored or removed
|
| 366 |
+
- **Language**: answer matches the question's language, no CJK leakage
|
| 367 |
+
|
| 368 |
+
Every answer is saved in the report for manual review.
|
| 369 |
+
|
| 370 |
+
The labels themselves need expert review: `eval/label_review.csv` lists every golden label (question, labelled source and its text) with columns for a reviewer's verdict. Categories with 5–11 cases move 10–20 points on a single case, so read per-category numbers with that in mind.
|
| 371 |
|
| 372 |
---
|
| 373 |
|
|
|
|
| 430 |
# .env
|
| 431 |
LLM_BACKEND=ollama
|
| 432 |
OLLAMA_HOST=http://host.docker.internal:11434
|
| 433 |
+
OLLAMA_MODEL=hf.co/aelgendy/QModel
|
| 434 |
```
|
| 435 |
|
| 436 |
Requires Ollama running on the host (`ollama serve`).
|
|
|
|
| 583 |
1. **Conversational Coreference Rewriting**: Query rewriter accepts dialogue `history` to resolve ambiguous pronouns and anaphoric references into standalone topical queries.
|
| 584 |
2. **Dual-Vector Query Embedding**: Encodes both Arabic (`query_ar`) and English (`query_en`) query vectors using `multilingual-e5-large` with the mandatory `query: ` prefix.
|
| 585 |
3. **Multi-Vector Dense Retrieval**: Independent FAISS searches against Arabic and English dense vectors.
|
| 586 |
+
4. **Inverted BM25 Sparse Retrieval**: BM25 scoring with bilingual stopword removal and Quranic morphological root expansion. The light stemmer only strips an affix when at least 3 letters remain, so short words keep their root letters (فضل, الربا).
|
| 587 |
5. **Adaptive Reciprocal Rank Fusion (RRF)**: Merges Arabic dense, English dense, and BM25 sparse hits with dynamic language-weighted multipliers.
|
| 588 |
6. **Hadith Deduplication & Ayah Windowing**:
|
| 589 |
- Clusters duplicate Hadith narrations ($\ge 0.85$ text similarity), tags *Muttafaq 'Alayh* (Bukhari + Muslim), and aggregates alternative collection references.
|
| 590 |
- Expands top Quran verses with $\pm N$ adjacent verses (`AYAH_WINDOW_SIZE`) for complete theological context.
|
| 591 |
+
7. **Cross-Encoder Reranking**: Rescores the top 25 candidates using `BAAI/bge-reranker-v2-m3` (`USE_RERANKER`, on by default). Five of those slots are reserved for the best BM25-only hits, which RRF would otherwise rank below anything both dense searches found. Each passage is scored with its text in the query's language first, since the reranker truncates at 512 tokens and a long diacritized verse fills that on its own.
|
| 592 |
+
8. **Confidence Gating & Context Assembly**: Direct references and verbatim text matches pass; otherwise the best reranker relevance must reach `RERANK_MIN_SCORE` (or dense similarity `DENSE_MIN_SCORE` without the reranker). Context carries copyable citation lines: `[REF]` (verse + URLs), `[HADITH_REF]` (collection and number in English and Arabic, grade), `[FATWA_REF]` (source, scholar, URL).
|
| 593 |
|
| 594 |
### Anti-Hallucination Measures
|
| 595 |
|
| 596 |
+
- Relevance gate before generation (see step 8 above)
|
| 597 |
+
- Citation format rules and copyable `[REF]` / `[HADITH_REF]` / `[FATWA_REF]` lines in the context
|
| 598 |
+
- Scope rule: non-Islamic requests (poems, sports, trivia) get the fixed "not found" answer; the model must either answer from the context or refuse, never both
|
| 599 |
+
- Quote verification: a quote must appear in a retrieved passage; a near-copy of a retrieved verse or hadith (≥80% of its consonant skeleton) is replaced by the canonical text, anything else by a warning marker. LLMs corrupt scripture while copying it (foreign letters, changed word forms, truncation), so this runs on every answer, streamed or not
|
| 600 |
+
- Fatwa links: when the answer uses a retrieved fatwa (by title or by naming its source) without linking it, the link is appended
|
| 601 |
+
- CJK guard: an answer containing Chinese/Japanese/Korean text is regenerated once at temperature 0, then replaced by a list of the retrieved sources; when streaming, CJK is stripped and generation stops after sustained drift
|
| 602 |
- Grade inference from collection name
|
| 603 |
|
| 604 |
### Performance
|
|
|
|
| 634 |
### "Assistant returns 'Not found'"
|
| 635 |
This is expected — QModel rejects low-confidence queries. Try:
|
| 636 |
- More specific queries
|
| 637 |
+
- Lower `RERANK_MIN_SCORE` (reranker on) or `DENSE_MIN_SCORE` (reranker off) in `.env`
|
| 638 |
- Check raw scores: `GET /debug/scores?q=your+query`
|
| 639 |
|
| 640 |
### "Port already in use"
|
|
|
|
| 647 |
|
| 648 |
## Roadmap
|
| 649 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 650 |
- [ ] Batch processing (multiple questions per request)
|
| 651 |
- [ ] Islamic calendar integration (Hijri dates)
|
| 652 |
|
|
|
|
| 657 |
- **Qur'an**: [risan/quran-json](https://github.com/risan/quran-json) & [semarketir/quranjson](https://github.com/semarketir/quranjson) — 114 Surahs, 6,236 verses with Arabic text, transliteration, and English translation.
|
| 658 |
- **Tafsir**: [Tafseer Dataset & Quran.com](https://api.quran.com) — 5,430 verses enriched with classical Arabic commentaries (*Tafsir al-Sa'di*, *Tafsir al-Mukhtasar*, *Tafsir al-Jalalayn*) + English *Tafsir Ibn Kathir*.
|
| 659 |
- **Hadith**: [AhmedBaset/hadith-json](https://github.com/AhmedBaset/hadith-json) & [fawazahmed0/hadith-api](https://github.com/fawazahmed0/hadith-api) — 15 authoritative collections (9 canonical + *Riyad al-Salihin*, *Bulugh al-Maram*, *Al-Adab al-Mufrad*, *Ash-Shama'il al-Muhammadiyyah*, *An-Nawawi's 40 Hadith*, *Hadith Qudsi*), 46,416 hadiths with 21,264 scholar authenticity grades.
|
| 660 |
+
- **Fatwa**: 3,598 rulings — [Islamweb](https://www.islamweb.net/ar/fatwa/) Fatwa Center (3,534) and [Imam Ibn Baz](https://binbaz.org.sa/fatwas) (64).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 661 |
|
| 662 |
---
|
| 663 |
|
|
|
|
| 673 |
|
| 674 |
**Made with ❤️ for Islamic scholarship.**
|
| 675 |
|
| 676 |
+
Version 8.5.0 | October 2026 | Production-Ready
|
app/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
"""QModel
|
|
|
|
| 1 |
+
"""QModel v8.5 — Islamic RAG API."""
|
app/analysis.py
CHANGED
|
@@ -152,12 +152,12 @@ async def detect_analysis_intent(query: str, rewrite: dict) -> Optional[str]:
|
|
| 152 |
kw_text = " ".join(kws)
|
| 153 |
if any(w in kw_text for w in ("آيات", "آية", "verses", "ayat")):
|
| 154 |
return None
|
| 155 |
-
# The
|
| 156 |
-
|
| 157 |
-
return kws[0]
|
| 158 |
-
# Fallback: extract from query
|
| 159 |
keyword = _extract_count_keyword(query)
|
| 160 |
-
|
|
|
|
|
|
|
| 161 |
|
| 162 |
if not (_COUNT_EN.search(query) or _COUNT_AR.search(query)):
|
| 163 |
return None
|
|
|
|
| 152 |
kw_text = " ".join(kws)
|
| 153 |
if any(w in kw_text for w in ("آيات", "آية", "verses", "ayat")):
|
| 154 |
return None
|
| 155 |
+
# The question's own wording first: only the LLM rewrite puts the target word
|
| 156 |
+
# first in keywords — the fast rewrite's first keyword is just "كم" / "How".
|
|
|
|
|
|
|
| 157 |
keyword = _extract_count_keyword(query)
|
| 158 |
+
if keyword:
|
| 159 |
+
return keyword
|
| 160 |
+
return kws[0] if kws else None
|
| 161 |
|
| 162 |
if not (_COUNT_EN.search(query) or _COUNT_AR.search(query)):
|
| 163 |
return None
|
app/arabic_nlp.py
CHANGED
|
@@ -46,17 +46,34 @@ def normalize_arabic(text: str, *, aggressive: bool = False) -> str:
|
|
| 46 |
_AR_PREFIXES = re.compile(
|
| 47 |
r"^(ولل|فال|بال|كال|وال|ومن|وفي|وعن|وإلى|وعلى|لل|ال|و|ف|ب|ل)"
|
| 48 |
)
|
|
|
|
| 49 |
_AR_SUFFIXES = re.compile(
|
| 50 |
r"(ون|ين|ان|ات|ها|هم|هن|كم|كن|نا|ني|تي|ي|ه|ك|ا|وا)$"
|
| 51 |
)
|
| 52 |
|
| 53 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
def light_stem(word: str) -> str:
|
| 55 |
"""Light stemming: remove common Arabic prefixes and suffixes."""
|
| 56 |
-
w =
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
|
| 61 |
|
| 62 |
|
|
|
|
| 46 |
_AR_PREFIXES = re.compile(
|
| 47 |
r"^(ولل|فال|بال|كال|وال|ومن|وفي|وعن|وإلى|وعلى|لل|ال|و|ف|ب|ل)"
|
| 48 |
)
|
| 49 |
+
_PREFIX_SET = set(_AR_PREFIXES.pattern.strip("^()").split("|"))
|
| 50 |
_AR_SUFFIXES = re.compile(
|
| 51 |
r"(ون|ين|ان|ات|ها|هم|هن|كم|كن|نا|ني|تي|ي|ه|ك|ا|وا)$"
|
| 52 |
)
|
| 53 |
|
| 54 |
|
| 55 |
+
# An affix is only stripped when enough letters remain: in short words the "affix"
|
| 56 |
+
# is usually a root letter (فضل is not ف+ضل, الربا is not ال+رب+ا).
|
| 57 |
+
_MIN_STEM = 3
|
| 58 |
+
_MIN_STEM_AFTER_1_SUFFIX = 3
|
| 59 |
+
|
| 60 |
+
|
| 61 |
def light_stem(word: str) -> str:
|
| 62 |
"""Light stemming: remove common Arabic prefixes and suffixes."""
|
| 63 |
+
w = word
|
| 64 |
+
m = _AR_PREFIXES.match(w)
|
| 65 |
+
if m:
|
| 66 |
+
# Longest prefix that leaves a real stem (e.g. وال → و when "ال" is part of the word)
|
| 67 |
+
for k in range(len(m.group(1)), 0, -1):
|
| 68 |
+
if w[:k] in _PREFIX_SET and len(w) - k >= _MIN_STEM:
|
| 69 |
+
w = w[k:]
|
| 70 |
+
break
|
| 71 |
+
m = _AR_SUFFIXES.search(w)
|
| 72 |
+
if m:
|
| 73 |
+
need = _MIN_STEM_AFTER_1_SUFFIX if len(m.group(1)) == 1 else _MIN_STEM
|
| 74 |
+
if len(w) - len(m.group(1)) >= need:
|
| 75 |
+
w = w[: m.start()]
|
| 76 |
+
return w
|
| 77 |
|
| 78 |
|
| 79 |
|
app/config.py
CHANGED
|
@@ -58,7 +58,7 @@ class Config:
|
|
| 58 |
HADITH_BOOST: float = float(os.getenv("HADITH_BOOST", 0.08))
|
| 59 |
QURAN_BOOST: float = float(os.getenv("QURAN_BOOST", 0.12))
|
| 60 |
FATWA_BOOST: float = float(os.getenv("FATWA_BOOST", 0.10))
|
| 61 |
-
USE_RERANKER: bool = os.getenv("USE_RERANKER", "
|
| 62 |
RERANK_MODEL: str = os.getenv("RERANK_MODEL", "BAAI/bge-reranker-v2-m3")
|
| 63 |
AYAH_WINDOW_SIZE: int = int(os.getenv("AYAH_WINDOW_SIZE", 1))
|
| 64 |
ENABLE_HADITH_DEDUPLICATION: bool = os.getenv("ENABLE_HADITH_DEDUPLICATION", "true").lower() in ("true", "1", "yes")
|
|
@@ -67,18 +67,13 @@ class Config:
|
|
| 67 |
FATWA_MAX_CHARS: int = int(os.getenv("FATWA_MAX_CHARS", 1500))
|
| 68 |
CONTEXT_MAX_CHARS: int = int(os.getenv("CONTEXT_MAX_CHARS", 14000))
|
| 69 |
|
| 70 |
-
# Safety
|
| 71 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
ENABLE_CITATION_VERIFICATION: bool = os.getenv("ENABLE_CITATION_VERIFICATION", "true").lower() in ("true", "1", "yes")
|
| 73 |
-
INTENT_THRESHOLDS: Dict[str, float] = {
|
| 74 |
-
"auth": 0.45, # Stricter for authenticity checks
|
| 75 |
-
"hadith": 0.30,
|
| 76 |
-
"tafsir": 0.30,
|
| 77 |
-
"surah_info": 0.20, # More lenient for metadata
|
| 78 |
-
"count": 0.25,
|
| 79 |
-
"general": 0.28,
|
| 80 |
-
"fatwa": 0.28,
|
| 81 |
-
}
|
| 82 |
|
| 83 |
# CORS
|
| 84 |
ALLOWED_ORIGINS: str = os.getenv("ALLOWED_ORIGINS", "*")
|
|
|
|
| 58 |
HADITH_BOOST: float = float(os.getenv("HADITH_BOOST", 0.08))
|
| 59 |
QURAN_BOOST: float = float(os.getenv("QURAN_BOOST", 0.12))
|
| 60 |
FATWA_BOOST: float = float(os.getenv("FATWA_BOOST", 0.10))
|
| 61 |
+
USE_RERANKER: bool = os.getenv("USE_RERANKER", "true").lower() in ("true", "1", "yes")
|
| 62 |
RERANK_MODEL: str = os.getenv("RERANK_MODEL", "BAAI/bge-reranker-v2-m3")
|
| 63 |
AYAH_WINDOW_SIZE: int = int(os.getenv("AYAH_WINDOW_SIZE", 1))
|
| 64 |
ENABLE_HADITH_DEDUPLICATION: bool = os.getenv("ENABLE_HADITH_DEDUPLICATION", "true").lower() in ("true", "1", "yes")
|
|
|
|
| 67 |
FATWA_MAX_CHARS: int = int(os.getenv("FATWA_MAX_CHARS", 1500))
|
| 68 |
CONTEXT_MAX_CHARS: int = int(os.getenv("CONTEXT_MAX_CHARS", 14000))
|
| 69 |
|
| 70 |
+
# Safety — confidence gate. A question reaches the LLM only when retrieval shows
|
| 71 |
+
# real relevance: a direct reference / verbatim text match, else the reranker's
|
| 72 |
+
# relevance probability (when USE_RERANKER), else raw dense cosine similarity.
|
| 73 |
+
RERANK_MIN_SCORE: float = float(os.getenv("RERANK_MIN_SCORE", 0.05))
|
| 74 |
+
# Cosine scale of multilingual-e5-large; recalibrate (eval/run_eval.py) if EMBED_MODEL changes.
|
| 75 |
+
DENSE_MIN_SCORE: float = float(os.getenv("DENSE_MIN_SCORE", 0.80))
|
| 76 |
ENABLE_CITATION_VERIFICATION: bool = os.getenv("ENABLE_CITATION_VERIFICATION", "true").lower() in ("true", "1", "yes")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
|
| 78 |
# CORS
|
| 79 |
ALLOWED_ORIGINS: str = os.getenv("ALLOWED_ORIGINS", "*")
|
app/prompts.py
CHANGED
|
@@ -30,7 +30,7 @@ TASK_INSTRUCTIONS: Dict[str, str] = {
|
|
| 30 |
" ﴿{Arabic verse text copied VERBATIM from context}﴾ [[1]({tafsir_url})]\n"
|
| 31 |
" [ثم أضف شرحاً وتفسيراً للآية مع بيان معانيها المستفادة بناءً على التفسير المرفق ومعنى الآية]\n\n"
|
| 32 |
" Where {surah_url} and {tafsir_url} are taken directly from the [REF] line for this verse:\n"
|
| 33 |
-
" Surah URL: https://
|
| 34 |
" Tafsir URL: https://quran.ksu.edu.sa/tafseer/qortobi/sura{surah_number}-aya{verse_number}.html\n\n"
|
| 35 |
"3. If the user's question is in English, format your response as follows:\n"
|
| 36 |
" This holy verse is Ayah {verse_number} of Surah {surah_name_ar} ({surah_name_en}) in the Holy Quran: [[1]({surah_url})]\n"
|
|
@@ -48,9 +48,12 @@ TASK_INSTRUCTIONS: Dict[str, str] = {
|
|
| 48 |
"1. Find the best matching Hadith from the RETRIEVED RESULTS in the ISLAMIC CONTEXT.\n"
|
| 49 |
"2. Quote the Hadith text EXACTLY — both Arabic and English VERBATIM from the results.\n"
|
| 50 |
" IMPORTANT: Highlight narrator markers (e.g., **حَدَّثَنَا**, **عَنْ**, **Narrated**, **on the authority of**) in bold.\n"
|
| 51 |
-
"3. State the full reference
|
|
|
|
| 52 |
"4. If the user searched by partial text, present the complete Hadith found in the results.\n"
|
| 53 |
"5. Explain the meaning, context, and scholarly implications.\n"
|
|
|
|
|
|
|
| 54 |
"CRITICAL: If the Hadith is NOT in the results, say so clearly — do NOT generate or fabricate from memory."
|
| 55 |
),
|
| 56 |
"auth": (
|
|
@@ -61,7 +64,7 @@ TASK_INSTRUCTIONS: Dict[str, str] = {
|
|
| 61 |
" b. Use the 'grade' field from the result as the AUTHORITATIVE grade. "
|
| 62 |
"NEVER infer or guess a grade from the collection name alone.\n"
|
| 63 |
" c. If no grade field exists in the result, state: 'Grade not recorded in dataset.'\n"
|
| 64 |
-
" d. Provide the full reference
|
| 65 |
" e. Quote the full Hadith text from the results.\n"
|
| 66 |
" IMPORTANT: Highlight narrator markers (e.g., **عَنْ**, **Narrated**) in bold.\n"
|
| 67 |
" f. Explain what the grade means (e.g. Sahih = rigorously authenticated chain).\n"
|
|
@@ -95,7 +98,8 @@ TASK_INSTRUCTIONS: Dict[str, str] = {
|
|
| 95 |
"2. For Quranic verses found, format as:\n"
|
| 96 |
" هذه الآية الكريمة هي الآية رقم {verse_number} من سورة {surah_name_ar} في القرآن الكريم: [[1]({surah_url})]\n"
|
| 97 |
" ﴿{Arabic text copied VERBATIM from context}﴾ [[1]({tafsir_url})]\n"
|
| 98 |
-
"3. For Hadith found: state collection name
|
|
|
|
| 99 |
"4. For Fatwa found: state scholar/council name, title, and link [[1]({source_url})].\n"
|
| 100 |
"5. NEVER generate or guess verses, hadiths, or fatwas from memory.\n"
|
| 101 |
"6. Provide context or explanation based strictly on the retrieved results."
|
|
@@ -127,7 +131,9 @@ CITATION FORMAT RULES:
|
|
| 127 |
|
| 128 |
2. For Hadiths:
|
| 129 |
- Highlight narrator markers in bold (e.g. **عَنْ**, **حَدَّثَنَا**, **Narrated**).
|
| 130 |
-
- State the full reference (Collection,
|
|
|
|
|
|
|
| 131 |
|
| 132 |
3. For Fatwas:
|
| 133 |
- State the scholar or council name, fatwa title, answer summary, and source link [[1]({source_url})].
|
|
@@ -140,7 +146,16 @@ ABSOLUTE ZERO-HALLUCINATION RULES (CRITICAL):
|
|
| 140 |
"هذا الحديث/الآية غير موجود في قاعدة البيانات." (Arabic)
|
| 141 |
or "This Hadith/verse is not in the available dataset." (English)
|
| 142 |
• Do NOT substitute another verse or hadith from memory.
|
| 143 |
-
•
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
|
| 145 |
LANGUAGE RULE (CRITICAL — MUST FOLLOW):
|
| 146 |
• You MUST answer in the SAME language as the user's question.
|
|
|
|
| 30 |
" ﴿{Arabic verse text copied VERBATIM from context}﴾ [[1]({tafsir_url})]\n"
|
| 31 |
" [ثم أضف شرحاً وتفسيراً للآية مع بيان معانيها المستفادة بناءً على التفسير المرفق ومعنى الآية]\n\n"
|
| 32 |
" Where {surah_url} and {tafsir_url} are taken directly from the [REF] line for this verse:\n"
|
| 33 |
+
" Surah URL: https://quran.com/{surah_number}/{verse_number}\n"
|
| 34 |
" Tafsir URL: https://quran.ksu.edu.sa/tafseer/qortobi/sura{surah_number}-aya{verse_number}.html\n\n"
|
| 35 |
"3. If the user's question is in English, format your response as follows:\n"
|
| 36 |
" This holy verse is Ayah {verse_number} of Surah {surah_name_ar} ({surah_name_en}) in the Holy Quran: [[1]({surah_url})]\n"
|
|
|
|
| 48 |
"1. Find the best matching Hadith from the RETRIEVED RESULTS in the ISLAMIC CONTEXT.\n"
|
| 49 |
"2. Quote the Hadith text EXACTLY — both Arabic and English VERBATIM from the results.\n"
|
| 50 |
" IMPORTANT: Highlight narrator markers (e.g., **حَدَّثَنَا**, **عَنْ**, **Narrated**, **on the authority of**) in bold.\n"
|
| 51 |
+
"3. State the full reference — collection name AND hadith number, copied EXACTLY from the [HADITH_REF] line "
|
| 52 |
+
"(use its Arabic form in Arabic answers) — plus chapter and grade.\n"
|
| 53 |
"4. If the user searched by partial text, present the complete Hadith found in the results.\n"
|
| 54 |
"5. Explain the meaning, context, and scholarly implications.\n"
|
| 55 |
+
"Before saying a Hadith is missing, check EVERY Hadith item in the context: one with the same meaning in "
|
| 56 |
+
"different wording counts as found.\n"
|
| 57 |
"CRITICAL: If the Hadith is NOT in the results, say so clearly — do NOT generate or fabricate from memory."
|
| 58 |
),
|
| 59 |
"auth": (
|
|
|
|
| 64 |
" b. Use the 'grade' field from the result as the AUTHORITATIVE grade. "
|
| 65 |
"NEVER infer or guess a grade from the collection name alone.\n"
|
| 66 |
" c. If no grade field exists in the result, state: 'Grade not recorded in dataset.'\n"
|
| 67 |
+
" d. Provide the full reference — collection AND hadith number copied EXACTLY from the [HADITH_REF] line — and chapter.\n"
|
| 68 |
" e. Quote the full Hadith text from the results.\n"
|
| 69 |
" IMPORTANT: Highlight narrator markers (e.g., **عَنْ**, **Narrated**) in bold.\n"
|
| 70 |
" f. Explain what the grade means (e.g. Sahih = rigorously authenticated chain).\n"
|
|
|
|
| 98 |
"2. For Quranic verses found, format as:\n"
|
| 99 |
" هذه الآية الكريمة هي الآية رقم {verse_number} من سورة {surah_name_ar} في القرآن الكريم: [[1]({surah_url})]\n"
|
| 100 |
" ﴿{Arabic text copied VERBATIM from context}﴾ [[1]({tafsir_url})]\n"
|
| 101 |
+
"3. For Hadith found: state collection name and hadith number EXACTLY as in the [HADITH_REF] line, chapter, and grade, "
|
| 102 |
+
"with narrator chain highlighted.\n"
|
| 103 |
"4. For Fatwa found: state scholar/council name, title, and link [[1]({source_url})].\n"
|
| 104 |
"5. NEVER generate or guess verses, hadiths, or fatwas from memory.\n"
|
| 105 |
"6. Provide context or explanation based strictly on the retrieved results."
|
|
|
|
| 131 |
|
| 132 |
2. For Hadiths:
|
| 133 |
- Highlight narrator markers in bold (e.g. **عَنْ**, **حَدَّثَنَا**, **Narrated**).
|
| 134 |
+
- State the full reference (Collection, Number, Chapter, Grade). Copy the collection and number EXACTLY
|
| 135 |
+
from the [HADITH_REF] line — e.g. "Sahih al-Bukhari 6116", or "صحيح البخاري، رقم 6116" in Arabic.
|
| 136 |
+
Never mention a collection without its number.
|
| 137 |
|
| 138 |
3. For Fatwas:
|
| 139 |
- State the scholar or council name, fatwa title, answer summary, and source link [[1]({source_url})].
|
|
|
|
| 146 |
"هذا الحديث/الآية غير موجود في قاعدة البيانات." (Arabic)
|
| 147 |
or "This Hadith/verse is not in the available dataset." (English)
|
| 148 |
• Do NOT substitute another verse or hadith from memory.
|
| 149 |
+
• NEVER both refuse and answer: either answer from the context, or give ONLY the not-found statement.
|
| 150 |
+
Do not say something is missing from the dataset and then explain it from general knowledge.
|
| 151 |
+
• Copy references, Surah names, Ayah numbers, and URLs EXACTLY from the [REF] / [HADITH_REF] / [FATWA_REF] metadata.
|
| 152 |
+
|
| 153 |
+
SCOPE RULE (CRITICAL):
|
| 154 |
+
• You only answer questions about Islam (Quran, Hadith, Fiqh/Fatwa, Islamic belief and practice) from the context.
|
| 155 |
+
• For any other request — creative writing (poems, stories, songs), sports, trivia, coding, general knowledge —
|
| 156 |
+
even if the context contains loosely related verses, do NOT comply. Reply ONLY with:
|
| 157 |
+
"لم أجد في قاعدة البيانات ما يكفي للإجابة على هذا السؤال بدقة." (Arabic)
|
| 158 |
+
or "The available dataset does not contain sufficient information to answer this question accurately." (English)
|
| 159 |
|
| 160 |
LANGUAGE RULE (CRITICAL — MUST FOLLOW):
|
| 161 |
• You MUST answer in the SAME language as the user's question.
|
app/routers/fatwa.py
CHANGED
|
@@ -39,6 +39,7 @@ async def search_fatwas(
|
|
| 39 |
dataset=state.dataset,
|
| 40 |
top_n=top_k * 3,
|
| 41 |
source_type="fatwa",
|
|
|
|
| 42 |
bm25_index=state.bm25_index,
|
| 43 |
)
|
| 44 |
except Exception:
|
|
|
|
| 39 |
dataset=state.dataset,
|
| 40 |
top_n=top_k * 3,
|
| 41 |
source_type="fatwa",
|
| 42 |
+
type_indexes=state.type_indexes,
|
| 43 |
bm25_index=state.bm25_index,
|
| 44 |
)
|
| 45 |
except Exception:
|
app/routers/hadith.py
CHANGED
|
@@ -15,6 +15,7 @@ from app.models import (
|
|
| 15 |
)
|
| 16 |
from app.search import (
|
| 17 |
filter_results_by_collection,
|
|
|
|
| 18 |
hybrid_search,
|
| 19 |
lookup_hadith_references,
|
| 20 |
merge_search_results,
|
|
@@ -42,12 +43,15 @@ async def hadith_text_search(
|
|
| 42 |
Use this to find a hadith when you know part of the text.
|
| 43 |
"""
|
| 44 |
check_ready()
|
| 45 |
-
direct_results =
|
| 46 |
-
|
| 47 |
-
results = filter_results_by_collection(
|
| 48 |
-
merge_search_results(direct_results, text_results, limit=limit),
|
| 49 |
collection,
|
| 50 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
|
| 52 |
# If text search returns few results, augment with semantic search
|
| 53 |
if len(results) < 3:
|
|
@@ -55,10 +59,10 @@ async def hadith_text_search(
|
|
| 55 |
sem_results = await hybrid_search(
|
| 56 |
q, rewrite,
|
| 57 |
state.embed_model, state.faiss_index, state.dataset,
|
| 58 |
-
top_n=limit, source_type="hadith",
|
| 59 |
)
|
| 60 |
sem_results = filter_results_by_collection(sem_results, collection)
|
| 61 |
-
results =
|
| 62 |
|
| 63 |
return TextSearchResponse(
|
| 64 |
query=q,
|
|
@@ -99,6 +103,7 @@ async def hadith_topic_search(
|
|
| 99 |
topic, rewrite,
|
| 100 |
state.embed_model, state.faiss_index, state.dataset,
|
| 101 |
top_n=top_k, source_type="hadith", grade_filter=grade_filter,
|
|
|
|
| 102 |
)
|
| 103 |
return TextSearchResponse(
|
| 104 |
query=topic,
|
|
@@ -148,20 +153,17 @@ async def verify_hadith(
|
|
| 148 |
semantic_results = await hybrid_search(
|
| 149 |
q, rewrite,
|
| 150 |
state.embed_model, state.faiss_index, state.dataset,
|
| 151 |
-
top_n=10, source_type="hadith",
|
| 152 |
)
|
| 153 |
semantic_results = filter_results_by_collection(semantic_results, collection)
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
#
|
| 157 |
-
#
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
best = text_results[0]
|
| 163 |
-
elif merged_results:
|
| 164 |
-
best = merged_results[0]
|
| 165 |
|
| 166 |
if best:
|
| 167 |
return HadithVerifyResponse(
|
|
|
|
| 15 |
)
|
| 16 |
from app.search import (
|
| 17 |
filter_results_by_collection,
|
| 18 |
+
fuse_search_results,
|
| 19 |
hybrid_search,
|
| 20 |
lookup_hadith_references,
|
| 21 |
merge_search_results,
|
|
|
|
| 43 |
Use this to find a hadith when you know part of the text.
|
| 44 |
"""
|
| 45 |
check_ready()
|
| 46 |
+
direct_results = filter_results_by_collection(
|
| 47 |
+
lookup_hadith_references(q, state.dataset, collection=collection, limit=limit, hadith_idx=state.hadith_ref_idx),
|
|
|
|
|
|
|
| 48 |
collection,
|
| 49 |
)
|
| 50 |
+
text_results = filter_results_by_collection(
|
| 51 |
+
text_search(q, state.dataset, source_type="hadith", limit=limit),
|
| 52 |
+
collection,
|
| 53 |
+
)
|
| 54 |
+
results = merge_search_results(direct_results, text_results, limit=limit)
|
| 55 |
|
| 56 |
# If text search returns few results, augment with semantic search
|
| 57 |
if len(results) < 3:
|
|
|
|
| 59 |
sem_results = await hybrid_search(
|
| 60 |
q, rewrite,
|
| 61 |
state.embed_model, state.faiss_index, state.dataset,
|
| 62 |
+
top_n=limit, source_type="hadith", type_indexes=state.type_indexes,
|
| 63 |
)
|
| 64 |
sem_results = filter_results_by_collection(sem_results, collection)
|
| 65 |
+
results = fuse_search_results(direct_results, sem_results, [(q, text_results)], limit=limit)
|
| 66 |
|
| 67 |
return TextSearchResponse(
|
| 68 |
query=q,
|
|
|
|
| 103 |
topic, rewrite,
|
| 104 |
state.embed_model, state.faiss_index, state.dataset,
|
| 105 |
top_n=top_k, source_type="hadith", grade_filter=grade_filter,
|
| 106 |
+
type_indexes=state.type_indexes,
|
| 107 |
)
|
| 108 |
return TextSearchResponse(
|
| 109 |
query=topic,
|
|
|
|
| 153 |
semantic_results = await hybrid_search(
|
| 154 |
q, rewrite,
|
| 155 |
state.embed_model, state.faiss_index, state.dataset,
|
| 156 |
+
top_n=10, source_type="hadith", type_indexes=state.type_indexes,
|
| 157 |
)
|
| 158 |
semantic_results = filter_results_by_collection(semantic_results, collection)
|
| 159 |
+
|
| 160 |
+
# 3. Pick best result by rank fusion: direct references and verbatim text first,
|
| 161 |
+
# then semantic and phrase-level text matches. Raw scores aren't comparable
|
| 162 |
+
# across these sources, so a weak word-overlap hit must not win on score.
|
| 163 |
+
merged_results = fuse_search_results(
|
| 164 |
+
direct_results, semantic_results, [(q, text_results)], limit=10,
|
| 165 |
+
)
|
| 166 |
+
best = merged_results[0] if merged_results else None
|
|
|
|
|
|
|
|
|
|
| 167 |
|
| 168 |
if best:
|
| 169 |
return HadithVerifyResponse(
|
app/routers/ops.py
CHANGED
|
@@ -27,11 +27,12 @@ def health(response: Response):
|
|
| 27 |
response.status_code = 503
|
| 28 |
return {
|
| 29 |
"status": "ok" if state.ready else "initialising",
|
| 30 |
-
"version": "
|
| 31 |
"llm_backend": cfg.LLM_BACKEND,
|
| 32 |
"dataset_size": len(state.dataset) if state.dataset else 0,
|
| 33 |
"faiss_total": state.faiss_index.ntotal if state.faiss_index else 0,
|
| 34 |
-
"
|
|
|
|
| 35 |
}
|
| 36 |
|
| 37 |
|
|
@@ -55,19 +56,20 @@ async def debug_scores(
|
|
| 55 |
):
|
| 56 |
"""Inspect raw retrieval scores without calling the LLM.
|
| 57 |
|
| 58 |
-
|
|
|
|
| 59 |
"""
|
| 60 |
check_ready()
|
| 61 |
rewrite = await rewrite_query(q, state.llm)
|
| 62 |
results = await hybrid_search(
|
| 63 |
q, rewrite,
|
| 64 |
state.embed_model, state.faiss_index, state.dataset,
|
| 65 |
-
top_n=top_k, source_type=source_type,
|
| 66 |
)
|
| 67 |
return {
|
| 68 |
"query": q,
|
| 69 |
"intent": rewrite.get("intent", "general"),
|
| 70 |
-
"
|
| 71 |
"count": len(results),
|
| 72 |
"results": [
|
| 73 |
{
|
|
|
|
| 27 |
response.status_code = 503
|
| 28 |
return {
|
| 29 |
"status": "ok" if state.ready else "initialising",
|
| 30 |
+
"version": "8.5.0",
|
| 31 |
"llm_backend": cfg.LLM_BACKEND,
|
| 32 |
"dataset_size": len(state.dataset) if state.dataset else 0,
|
| 33 |
"faiss_total": state.faiss_index.ntotal if state.faiss_index else 0,
|
| 34 |
+
"confidence_gate": (f"reranker>={cfg.RERANK_MIN_SCORE}" if state.reranker
|
| 35 |
+
else f"dense>={cfg.DENSE_MIN_SCORE}"),
|
| 36 |
}
|
| 37 |
|
| 38 |
|
|
|
|
| 56 |
):
|
| 57 |
"""Inspect raw retrieval scores without calling the LLM.
|
| 58 |
|
| 59 |
+
`_dense` is the cosine similarity the confidence gate compares to
|
| 60 |
+
DENSE_MIN_SCORE when the reranker is off (calibrate with eval/run_eval.py).
|
| 61 |
"""
|
| 62 |
check_ready()
|
| 63 |
rewrite = await rewrite_query(q, state.llm)
|
| 64 |
results = await hybrid_search(
|
| 65 |
q, rewrite,
|
| 66 |
state.embed_model, state.faiss_index, state.dataset,
|
| 67 |
+
top_n=top_k, source_type=source_type, type_indexes=state.type_indexes,
|
| 68 |
)
|
| 69 |
return {
|
| 70 |
"query": q,
|
| 71 |
"intent": rewrite.get("intent", "general"),
|
| 72 |
+
"dense_min_score": cfg.DENSE_MIN_SCORE,
|
| 73 |
"count": len(results),
|
| 74 |
"results": [
|
| 75 |
{
|
app/routers/quran.py
CHANGED
|
@@ -21,7 +21,14 @@ from app.models import (
|
|
| 21 |
VerseItem,
|
| 22 |
WordFrequencyResponse,
|
| 23 |
)
|
| 24 |
-
from app.search import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
from app.state import check_ready, state
|
| 26 |
|
| 27 |
router = APIRouter(prefix="/quran", tags=["quran"])
|
|
@@ -64,9 +71,9 @@ async def quran_text_search(
|
|
| 64 |
semantic_results = await hybrid_search(
|
| 65 |
q, rewrite,
|
| 66 |
state.embed_model, state.faiss_index, state.dataset,
|
| 67 |
-
top_n=limit, source_type="quran",
|
| 68 |
)
|
| 69 |
-
results =
|
| 70 |
|
| 71 |
return TextSearchResponse(
|
| 72 |
query=q,
|
|
@@ -106,7 +113,7 @@ async def quran_topic_search(
|
|
| 106 |
semantic_results = await hybrid_search(
|
| 107 |
topic, rewrite,
|
| 108 |
state.embed_model, state.faiss_index, state.dataset,
|
| 109 |
-
top_n=top_k, source_type="quran",
|
| 110 |
)
|
| 111 |
results = merge_search_results(direct_results, semantic_results, limit=top_k)
|
| 112 |
return TextSearchResponse(
|
|
|
|
| 21 |
VerseItem,
|
| 22 |
WordFrequencyResponse,
|
| 23 |
)
|
| 24 |
+
from app.search import (
|
| 25 |
+
fuse_search_results,
|
| 26 |
+
hybrid_search,
|
| 27 |
+
lookup_quran_verses,
|
| 28 |
+
merge_search_results,
|
| 29 |
+
rewrite_query,
|
| 30 |
+
text_search,
|
| 31 |
+
)
|
| 32 |
from app.state import check_ready, state
|
| 33 |
|
| 34 |
router = APIRouter(prefix="/quran", tags=["quran"])
|
|
|
|
| 71 |
semantic_results = await hybrid_search(
|
| 72 |
q, rewrite,
|
| 73 |
state.embed_model, state.faiss_index, state.dataset,
|
| 74 |
+
top_n=limit, source_type="quran", type_indexes=state.type_indexes,
|
| 75 |
)
|
| 76 |
+
results = fuse_search_results(direct_results, semantic_results, [(q, text_results)], limit=limit)
|
| 77 |
|
| 78 |
return TextSearchResponse(
|
| 79 |
query=q,
|
|
|
|
| 113 |
semantic_results = await hybrid_search(
|
| 114 |
topic, rewrite,
|
| 115 |
state.embed_model, state.faiss_index, state.dataset,
|
| 116 |
+
top_n=top_k, source_type="quran", type_indexes=state.type_indexes,
|
| 117 |
)
|
| 118 |
results = merge_search_results(direct_results, semantic_results, limit=top_k)
|
| 119 |
return TextSearchResponse(
|
app/search.py
CHANGED
|
@@ -24,6 +24,8 @@ except ImportError:
|
|
| 24 |
SentenceTransformer = None
|
| 25 |
|
| 26 |
from app.arabic_nlp import (
|
|
|
|
|
|
|
| 27 |
detect_language,
|
| 28 |
extract_arabic_root,
|
| 29 |
light_stem,
|
|
@@ -35,7 +37,7 @@ from app.cache import embed_cache, rewrite_cache, search_cache
|
|
| 35 |
from app.config import cfg
|
| 36 |
from app.hadith_utils import highlight_narrators
|
| 37 |
from app.llm import LLMProvider
|
| 38 |
-
from app.synonyms import expand_synonyms
|
| 39 |
|
| 40 |
logger = logging.getLogger("qmodel.search")
|
| 41 |
|
|
@@ -57,6 +59,7 @@ class BM25InvertedIndex:
|
|
| 57 |
|
| 58 |
def build(self, dataset: List[dict]) -> None:
|
| 59 |
self.doc_count = len(dataset)
|
|
|
|
| 60 |
self.doc_lengths = [0] * self.doc_count
|
| 61 |
posting: Dict[str, Dict[int, int]] = {}
|
| 62 |
total_len = 0
|
|
@@ -77,7 +80,9 @@ class BM25InvertedIndex:
|
|
| 77 |
self.df = {term: len(docs) for term, docs in posting.items()}
|
| 78 |
self.avg_dl = total_len / max(self.doc_count, 1)
|
| 79 |
|
| 80 |
-
def search(
|
|
|
|
|
|
|
| 81 |
scores: Dict[int, float] = Counter()
|
| 82 |
for term in query_terms:
|
| 83 |
if term not in self.posting_list:
|
|
@@ -91,7 +96,10 @@ class BM25InvertedIndex:
|
|
| 91 |
num = tf * (self.k1 + 1.0)
|
| 92 |
scores[doc_idx] += idf * (num / denom)
|
| 93 |
|
| 94 |
-
|
|
|
|
|
|
|
|
|
|
| 95 |
|
| 96 |
|
| 97 |
|
|
@@ -100,7 +108,7 @@ class BM25InvertedIndex:
|
|
| 100 |
# ═══════════════════════════════════════════════════════════════════════
|
| 101 |
_REWRITE_SYSTEM = """\
|
| 102 |
You are an Islamic-scholarship search query optimizer.
|
| 103 |
-
Your ONLY job: rewrite the user's question to maximise retrieval from a
|
| 104 |
|
| 105 |
Reply ONLY with a valid JSON object — no markdown, no preamble:
|
| 106 |
{
|
|
@@ -176,6 +184,11 @@ _QURAN_REF_EN_VERSE_FIRST = re.compile(
|
|
| 176 |
r"(?:ayah|verse|ayat)\s*(\d{1,3})\s+(?:of|in)\s+(?:surah|sura)\s+([A-Za-z'\- ]+)",
|
| 177 |
re.I,
|
| 178 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
|
| 180 |
_COLLECTION_ALIASES = {
|
| 181 |
"sahih al-bukhari": "Sahih al-Bukhari",
|
|
@@ -291,6 +304,41 @@ def _extract_json_object(text: str) -> Optional[str]:
|
|
| 291 |
return _find_balanced_json(cleaned)
|
| 292 |
|
| 293 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 294 |
def _detect_intent_regex(query: str) -> str:
|
| 295 |
"""Detect intent from raw query using regex when LLM rewrite is unavailable."""
|
| 296 |
# surah_info: asking about surah metadata (verse count, type, etc.)
|
|
@@ -319,7 +367,8 @@ def _detect_intent_regex(query: str) -> str:
|
|
| 319 |
|
| 320 |
# auth: hadith authenticity check
|
| 321 |
if re.search(
|
| 322 |
-
r"صحيح[؟?]|هل صحيح|درجة الحديث|هل هذا حديث
|
|
|
|
| 323 |
query, re.I,
|
| 324 |
):
|
| 325 |
return "auth"
|
|
@@ -457,10 +506,21 @@ async def rewrite_query(
|
|
| 457 |
# Strip common query prefixes like "آية:", "حديث:", "فتوى:" so text/BM25 searches aren't distorted
|
| 458 |
cleaned_raw = re.sub(r"^\s*(?:آية|اية|الآية|الاية|حديث|الحديث|فتوى|الفتوى|سؤال|السؤال)\s*[::]\s*", "", raw).strip()
|
| 459 |
target_text = cleaned_raw if cleaned_raw else raw
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 460 |
fallback = {
|
| 461 |
"ar_query": normalize_arabic(target_text),
|
| 462 |
-
"en_query":
|
| 463 |
-
|
|
|
|
|
|
|
| 464 |
"intent": detected_intent,
|
| 465 |
}
|
| 466 |
|
|
@@ -519,6 +579,9 @@ async def rewrite_query(
|
|
| 519 |
|
| 520 |
result["ar_query"] = str(result.get("ar_query") or fallback["ar_query"]).strip()[:400]
|
| 521 |
result["en_query"] = str(result.get("en_query") or fallback["en_query"]).strip()[:400]
|
|
|
|
|
|
|
|
|
|
| 522 |
|
| 523 |
await rewrite_cache.set(result, *cache_key)
|
| 524 |
logger.info("Rewrite: intent=%s ar=%s", result["intent"], result["ar_query"][:60])
|
|
@@ -565,6 +628,73 @@ def merge_search_results(*result_groups: list, limit: Optional[int] = None) -> l
|
|
| 565 |
return results[:limit] if limit is not None else results
|
| 566 |
|
| 567 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 568 |
def normalize_collection_name(text: str) -> Optional[str]:
|
| 569 |
"""Resolve a collection alias to the canonical dataset collection name."""
|
| 570 |
if not text:
|
|
@@ -619,6 +749,19 @@ def _surah_matches(item: dict, surah_query: str) -> bool:
|
|
| 619 |
return False
|
| 620 |
|
| 621 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 622 |
_FAMOUS_AYAH_MAP = {
|
| 623 |
"ايه الكرسي": (2, 255),
|
| 624 |
"ayat al-kursi": (2, 255),
|
|
@@ -703,6 +846,17 @@ def lookup_quran_verses(
|
|
| 703 |
if matches:
|
| 704 |
break
|
| 705 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 706 |
return matches[:limit]
|
| 707 |
|
| 708 |
|
|
@@ -751,16 +905,19 @@ def _collection_rank(col_name: str) -> int:
|
|
| 751 |
|
| 752 |
|
| 753 |
def cluster_hadith_candidates(results: list) -> list:
|
| 754 |
-
"""Cluster near-identical Hadith narrations (matn deduplication) across collections.
|
|
|
|
|
|
|
|
|
|
| 755 |
if not results or not cfg.ENABLE_HADITH_DEDUPLICATION:
|
| 756 |
return results
|
| 757 |
|
| 758 |
-
|
| 759 |
hadith_clusters: List[dict] = []
|
| 760 |
|
| 761 |
for item in results:
|
| 762 |
if item.get("type") != "hadith":
|
| 763 |
-
|
| 764 |
continue
|
| 765 |
|
| 766 |
item_ar = normalize_arabic(item.get("arabic", ""), aggressive=True).lower()
|
|
@@ -838,14 +995,13 @@ def cluster_hadith_candidates(results: list) -> list:
|
|
| 838 |
item_copy["other_collections"] = []
|
| 839 |
item_copy["muttafaq_alayh"] = False
|
| 840 |
hadith_clusters.append(item_copy)
|
|
|
|
| 841 |
|
| 842 |
-
final_hadiths = []
|
| 843 |
for h in hadith_clusters:
|
| 844 |
h.pop("_matn", None)
|
| 845 |
h.pop("_words", None)
|
| 846 |
-
final_hadiths.append(h)
|
| 847 |
|
| 848 |
-
return
|
| 849 |
|
| 850 |
|
| 851 |
def lookup_hadith_references(
|
|
@@ -959,6 +1115,67 @@ def expand_quran_context(
|
|
| 959 |
return expanded
|
| 960 |
|
| 961 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 962 |
async def rerank_with_cross_encoder(
|
| 963 |
query: str,
|
| 964 |
candidates: list,
|
|
@@ -970,28 +1187,18 @@ async def rerank_with_cross_encoder(
|
|
| 970 |
return candidates[:top_k]
|
| 971 |
|
| 972 |
try:
|
| 973 |
-
pool_size = min(len(candidates),
|
| 974 |
pool = candidates[:pool_size]
|
| 975 |
tail = candidates[pool_size:]
|
| 976 |
|
| 977 |
-
|
| 978 |
-
for c in pool
|
| 979 |
-
|
| 980 |
-
if c.get("type") == "quran" and c.get("tafsir_en"):
|
| 981 |
-
doc_text += f" {c['tafsir_en'][:300]}"
|
| 982 |
-
pairs.append([query, doc_text])
|
| 983 |
|
| 984 |
-
|
| 985 |
-
|
| 986 |
-
|
| 987 |
-
|
| 988 |
-
|
| 989 |
-
for c, s in zip(pool, raw_scores):
|
| 990 |
-
logit = float(s)
|
| 991 |
-
prob = 1.0 / (1.0 + np.exp(-logit))
|
| 992 |
-
c["_cross_score"] = logit
|
| 993 |
-
# Blend normalized cross-encoder confidence (scaled to ~2.0) with base RRF score
|
| 994 |
-
c["_score"] = float(0.7 * (prob * 2.0) + 0.3 * c.get("_score", 1.0))
|
| 995 |
|
| 996 |
pool.sort(key=lambda x: x.get("_score", 0.0), reverse=True)
|
| 997 |
combined = pool + tail
|
|
@@ -1001,6 +1208,26 @@ async def rerank_with_cross_encoder(
|
|
| 1001 |
return candidates[:top_k]
|
| 1002 |
|
| 1003 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1004 |
# ═══════════════════════════════════════════════════════════════════════
|
| 1005 |
# HYBRID SEARCH — dense FAISS + BM25 re-ranking + filtering
|
| 1006 |
# ═══════════════════════════════════════════════════════════════════════
|
|
@@ -1015,8 +1242,13 @@ async def hybrid_search(
|
|
| 1015 |
grade_filter: Optional[str] = None,
|
| 1016 |
bm25_index: Optional[BM25InvertedIndex] = None,
|
| 1017 |
reranker_model: Optional[Any] = None,
|
|
|
|
| 1018 |
) -> list:
|
| 1019 |
-
"""Hybrid search: multi-vector dense FAISS + sparse BM25 with RRF fusion, deduplication, and reranking.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1020 |
ar_str = rewrite.get("ar_query") or rewrite.get("query_ar") or raw_query
|
| 1021 |
en_str = rewrite.get("en_query") or rewrite.get("query_en") or raw_query
|
| 1022 |
intent = rewrite.get("intent", "general")
|
|
@@ -1049,11 +1281,24 @@ async def hybrid_search(
|
|
| 1049 |
emb_ar, emb_en = embeddings[0], embeddings[1]
|
| 1050 |
await embed_cache.set((emb_ar, emb_en), ar_q, en_q)
|
| 1051 |
|
| 1052 |
-
# Batched FAISS search for Arabic and English vectors
|
|
|
|
|
|
|
|
|
|
| 1053 |
vec_both = np.vstack([emb_ar.reshape(1, -1), emb_en.reshape(1, -1)]).astype("float32")
|
| 1054 |
-
|
| 1055 |
-
|
| 1056 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1057 |
distances_ar, indices_ar = distances_both[0], indices_both[0]
|
| 1058 |
distances_en, indices_en = distances_both[1], indices_both[1]
|
| 1059 |
|
|
@@ -1099,12 +1344,13 @@ async def hybrid_search(
|
|
| 1099 |
rrf_scores[doc_idx] = rrf_scores.get(doc_idx, 0.0) + w_en * (1.0 / (rrf_k + rank))
|
| 1100 |
|
| 1101 |
# ── 3. Sparse Inverted BM25 Search + RRF Fusion ───────────────────
|
|
|
|
| 1102 |
query_terms: List[str] = []
|
| 1103 |
for kw in rewrite.get("keywords", raw_query.split()):
|
| 1104 |
query_terms.extend(tokenize_bilingual(kw))
|
| 1105 |
|
| 1106 |
if bm25_index is not None:
|
| 1107 |
-
sparse_hits = bm25_index.search(query_terms, top_k=
|
| 1108 |
w_sparse = 1.0
|
| 1109 |
for rank, (doc_idx, s_score) in enumerate(sparse_hits, 1):
|
| 1110 |
if 0 <= doc_idx < len(dataset):
|
|
@@ -1117,6 +1363,7 @@ async def hybrid_search(
|
|
| 1117 |
if doc_idx not in item_map:
|
| 1118 |
item_map[doc_idx] = {**item, "_dense": 0.0, "_dataset_idx": doc_idx}
|
| 1119 |
item_map[doc_idx]["_sparse"] = float(s_score)
|
|
|
|
| 1120 |
rrf_scores[doc_idx] = rrf_scores.get(doc_idx, 0.0) + w_sparse * (1.0 / (rrf_k + rank))
|
| 1121 |
|
| 1122 |
fused_candidates = []
|
|
@@ -1136,6 +1383,7 @@ async def hybrid_search(
|
|
| 1136 |
|
| 1137 |
# ── 4. Cross-Encoder Re-ranking (if configured) ───────────────────
|
| 1138 |
if reranker_model is not None and cfg.USE_RERANKER and len(fused_candidates) > 1:
|
|
|
|
| 1139 |
fused_candidates = await rerank_with_cross_encoder(
|
| 1140 |
raw_query, fused_candidates, reranker_model, top_k=len(fused_candidates)
|
| 1141 |
)
|
|
@@ -1149,9 +1397,46 @@ async def hybrid_search(
|
|
| 1149 |
|
| 1150 |
|
| 1151 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1152 |
# ═══════════════════════════════════════════════════════════════════════
|
| 1153 |
# TEXT-BASED SEARCH (exact substring + fuzzy matching)
|
| 1154 |
# ═══════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1155 |
def text_search(
|
| 1156 |
query: str,
|
| 1157 |
dataset: list,
|
|
@@ -1161,23 +1446,27 @@ def text_search(
|
|
| 1161 |
"""Search dataset by exact text match (Arabic or English).
|
| 1162 |
|
| 1163 |
Returns items sorted by relevance: exact matches first, then partial.
|
|
|
|
|
|
|
|
|
|
| 1164 |
"""
|
| 1165 |
clean_query = re.sub(r"^\s*(?:آية|اية|الآية|الاية|حديث|الحديث|فتوى|الفتوى|سؤال|السؤال)\s*[::]\s*", "", query).strip()
|
| 1166 |
q_target = clean_query if clean_query else query
|
| 1167 |
q_norm = normalize_arabic(q_target, aggressive=True).lower()
|
| 1168 |
q_lower = q_target.lower().strip()
|
|
|
|
|
|
|
|
|
|
| 1169 |
|
| 1170 |
results = []
|
| 1171 |
-
for item in dataset:
|
| 1172 |
if source_type and item.get("type") != source_type:
|
| 1173 |
continue
|
| 1174 |
|
| 1175 |
ar_raw = item.get("arabic", "")
|
| 1176 |
-
en_raw = item.get("english", "")
|
| 1177 |
-
ar_norm = normalize_arabic(ar_raw, aggressive=True).lower()
|
| 1178 |
-
en_lower = en_raw.lower()
|
| 1179 |
|
| 1180 |
score = 0.0
|
|
|
|
| 1181 |
|
| 1182 |
# Exact substring in raw Arabic (with diacritics) — highest priority
|
| 1183 |
if query.strip() in ar_raw:
|
|
@@ -1192,21 +1481,32 @@ def text_search(
|
|
| 1192 |
if q_lower and q_lower in en_lower:
|
| 1193 |
score = max(score, 2.0 + (1.0 / max(len(en_lower), 1)) * 100)
|
| 1194 |
|
| 1195 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1196 |
if score == 0.0 and q_norm:
|
| 1197 |
q_words = q_norm.split()
|
| 1198 |
if len(q_words) >= 3:
|
| 1199 |
# Check sliding windows of 3 words from query against doc
|
| 1200 |
for i in range(len(q_words) - 2):
|
|
|
|
|
|
|
| 1201 |
trigram = " ".join(q_words[i:i+3])
|
| 1202 |
if trigram in ar_norm:
|
| 1203 |
score = max(score, 2.0 + (i == 0) * 0.5)
|
|
|
|
| 1204 |
break
|
| 1205 |
if score == 0.0 and len(q_words) >= 2:
|
| 1206 |
for i in range(len(q_words) - 1):
|
|
|
|
|
|
|
| 1207 |
bigram = " ".join(q_words[i:i+2])
|
| 1208 |
if bigram in ar_norm or bigram in en_lower:
|
| 1209 |
score = max(score, 1.5)
|
|
|
|
| 1210 |
break
|
| 1211 |
|
| 1212 |
# Word-level overlap for lower-confidence matches
|
|
@@ -1232,9 +1532,10 @@ def text_search(
|
|
| 1232 |
ratio = SequenceMatcher(None, q_norm, text).ratio()
|
| 1233 |
if ratio >= 0.80:
|
| 1234 |
score = max(score, 1.0 + ratio) # 1.80–2.0 range
|
|
|
|
| 1235 |
|
| 1236 |
if score > 0:
|
| 1237 |
-
results.append({**item, "_score": score})
|
| 1238 |
|
| 1239 |
results.sort(key=lambda x: x["_score"], reverse=True)
|
| 1240 |
return results if limit is None else results[:limit]
|
|
@@ -1247,9 +1548,38 @@ def _clip(text: str, limit: int, marker: str) -> str:
|
|
| 1247 |
return text[:limit].rstrip() + marker
|
| 1248 |
|
| 1249 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1250 |
_AR_CUT = " ... [باقي النص مقتطع للاختصار]"
|
| 1251 |
_EN_CUT = " ... [remainder truncated for length]"
|
| 1252 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1253 |
|
| 1254 |
def build_context(results: list) -> str:
|
| 1255 |
"""Format search results into context block for LLM."""
|
|
@@ -1315,13 +1645,13 @@ def build_context(results: list) -> str:
|
|
| 1315 |
if doc_type == "quran":
|
| 1316 |
sn = r.get("surah_number", "")
|
| 1317 |
vn = r.get("verse_number", "")
|
| 1318 |
-
|
| 1319 |
tafsir_url = f"https://quran.ksu.edu.sa/tafseer/qortobi/sura{sn}-aya{vn}.html" if sn and vn else ""
|
| 1320 |
block += (
|
| 1321 |
f"\n [REF] Surah: {r.get('surah_name_ar', '')} ({r.get('surah_name_en', '')}) "
|
| 1322 |
f"| Surah Number: {sn} "
|
| 1323 |
f"| Ayah: {vn}\n"
|
| 1324 |
-
f" Surah URL : {
|
| 1325 |
f" Tafsir URL: {tafsir_url}"
|
| 1326 |
)
|
| 1327 |
# Neighboring verses context if present
|
|
@@ -1335,6 +1665,8 @@ def build_context(results: list) -> str:
|
|
| 1335 |
elif r.get("tafsir_en"):
|
| 1336 |
max_chars = getattr(cfg, "TAFSIR_MAX_CHARS", 1200)
|
| 1337 |
block += f"\n Tafsir : {r['tafsir_en'][:max_chars]}"
|
|
|
|
|
|
|
| 1338 |
elif doc_type == "fatwa":
|
| 1339 |
block += (
|
| 1340 |
f"\n [FATWA_REF] Source: {r.get('source', '')} | Scholar: {r.get('scholar_or_council', '')} "
|
|
|
|
| 24 |
SentenceTransformer = None
|
| 25 |
|
| 26 |
from app.arabic_nlp import (
|
| 27 |
+
ARABIC_STOPWORDS,
|
| 28 |
+
ENGLISH_STOPWORDS,
|
| 29 |
detect_language,
|
| 30 |
extract_arabic_root,
|
| 31 |
light_stem,
|
|
|
|
| 37 |
from app.config import cfg
|
| 38 |
from app.hadith_utils import highlight_narrators
|
| 39 |
from app.llm import LLMProvider
|
| 40 |
+
from app.synonyms import expand_synonyms, synonyms_in_text
|
| 41 |
|
| 42 |
logger = logging.getLogger("qmodel.search")
|
| 43 |
|
|
|
|
| 59 |
|
| 60 |
def build(self, dataset: List[dict]) -> None:
|
| 61 |
self.doc_count = len(dataset)
|
| 62 |
+
self.doc_types = [doc.get("type") for doc in dataset]
|
| 63 |
self.doc_lengths = [0] * self.doc_count
|
| 64 |
posting: Dict[str, Dict[int, int]] = {}
|
| 65 |
total_len = 0
|
|
|
|
| 80 |
self.df = {term: len(docs) for term, docs in posting.items()}
|
| 81 |
self.avg_dl = total_len / max(self.doc_count, 1)
|
| 82 |
|
| 83 |
+
def search(
|
| 84 |
+
self, query_terms: List[str], top_k: int = 50, doc_type: Optional[str] = None,
|
| 85 |
+
) -> List[Tuple[int, float]]:
|
| 86 |
scores: Dict[int, float] = Counter()
|
| 87 |
for term in query_terms:
|
| 88 |
if term not in self.posting_list:
|
|
|
|
| 96 |
num = tf * (self.k1 + 1.0)
|
| 97 |
scores[doc_idx] += idf * (num / denom)
|
| 98 |
|
| 99 |
+
items = scores.items()
|
| 100 |
+
if doc_type is not None:
|
| 101 |
+
items = [(d, sc) for d, sc in items if self.doc_types[d] == doc_type]
|
| 102 |
+
return sorted(items, key=lambda x: x[1], reverse=True)[:top_k]
|
| 103 |
|
| 104 |
|
| 105 |
|
|
|
|
| 108 |
# ═══════════════════════════════════════════════════════════════════════
|
| 109 |
_REWRITE_SYSTEM = """\
|
| 110 |
You are an Islamic-scholarship search query optimizer.
|
| 111 |
+
Your ONLY job: rewrite the user's question to maximise retrieval from a Quran + Hadith + Fatwa dataset.
|
| 112 |
|
| 113 |
Reply ONLY with a valid JSON object — no markdown, no preamble:
|
| 114 |
{
|
|
|
|
| 184 |
r"(?:ayah|verse|ayat)\s*(\d{1,3})\s+(?:of|in)\s+(?:surah|sura)\s+([A-Za-z'\- ]+)",
|
| 185 |
re.I,
|
| 186 |
)
|
| 187 |
+
# Whole query is "<surah name> <verse>" with no keyword, e.g. "Al-Baqarah 183" / "البقرة 183".
|
| 188 |
+
# Only honoured when the name is exactly a surah name (see _surah_name_equals).
|
| 189 |
+
_QURAN_REF_BARE = re.compile(
|
| 190 |
+
r"^\s*([A-Za-zء-ۿ][A-Za-zء-ۿ'\- ]{1,30}?)\s*[,:،\s]\s*(\d{1,3})\s*$",
|
| 191 |
+
)
|
| 192 |
|
| 193 |
_COLLECTION_ALIASES = {
|
| 194 |
"sahih al-bukhari": "Sahih al-Bukhari",
|
|
|
|
| 304 |
return _find_balanced_json(cleaned)
|
| 305 |
|
| 306 |
|
| 307 |
+
# Quote marks must sit at word boundaries so apostrophes ("Prophet's") don't count.
|
| 308 |
+
_AUTH_QUOTED = re.compile(r"(?:^|(?<=\s)|(?<=:))[\"“”«»❝❞']([^\"“”«»❝❞]{8,}?)[\"“”«»❝❞'](?=\s|$|[?.,؟:])")
|
| 309 |
+
_AUTH_LEADING = re.compile(
|
| 310 |
+
r"^\s*(?:"
|
| 311 |
+
r"(?:هل|ما\s+(?:صحة|درجة|مدى\s+صحة)|صحة|درجة)\s+(?:هذا\s+)?(?:ال)?حديث(?:\s+(?:صحيح|ضعيف))?"
|
| 312 |
+
r"|هل\s+(?:صحيح|يصح|ثبت)\s+(?:ان\s+|أن\s+)?(?:(?:النبي|الرسول|رسول\s+الله)\S*\s+(?:\S+\s+){0,4}?قال\s*)?"
|
| 313 |
+
r"|(?:is|was|how\s+authentic\s+is)\s+(?:the\s+|this\s+|there\s+a\s+)?hadith(?:\s+that\s+says)?"
|
| 314 |
+
r"|is\s+it\s+(?:true|authentic)\s+that\s+(?:the\s+prophet\S*\s+(?:said|says)\s*(?:that\s+)?)?"
|
| 315 |
+
r"|verify\s+(?:the\s+|this\s+)?hadith"
|
| 316 |
+
r")\s*[::]?\s*",
|
| 317 |
+
re.I,
|
| 318 |
+
)
|
| 319 |
+
_AUTH_TRAILING = re.compile(
|
| 320 |
+
r"\s*(?:"
|
| 321 |
+
r"(?:هل\s+)?(?:هو\s+)?(?:حديث\s+)?(?:صحيح|ضعيف|موضوع|ثابت)(?:\s+ام\s+(?:ضعيف|موضوع))?"
|
| 322 |
+
r"|(?:is\s+(?:it|this)\s+)?(?:authentic|sahih|weak|da'?if|fabricated|reliable|true)"
|
| 323 |
+
r")\s*[؟?]*\s*$",
|
| 324 |
+
re.I,
|
| 325 |
+
)
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
def strip_auth_phrasing(text: str) -> str:
|
| 329 |
+
"""Reduce an authenticity question to the hadith text being asked about.
|
| 330 |
+
|
| 331 |
+
"هل حديث طلب العلم فريضة على كل مسلم صحيح؟" → "طلب العلم فريضة على كل مسلم"
|
| 332 |
+
"Is the hadith 'actions are by intentions' authentic?" → "actions are by intentions"
|
| 333 |
+
The framing words otherwise dominate embeddings and text search.
|
| 334 |
+
"""
|
| 335 |
+
quoted = _AUTH_QUOTED.search(text)
|
| 336 |
+
if quoted:
|
| 337 |
+
return quoted.group(1).strip()
|
| 338 |
+
core = _AUTH_TRAILING.sub("", _AUTH_LEADING.sub("", text)).strip(" ؟?::،,")
|
| 339 |
+
return core if len(core) >= 6 else text
|
| 340 |
+
|
| 341 |
+
|
| 342 |
def _detect_intent_regex(query: str) -> str:
|
| 343 |
"""Detect intent from raw query using regex when LLM rewrite is unavailable."""
|
| 344 |
# surah_info: asking about surah metadata (verse count, type, etc.)
|
|
|
|
| 367 |
|
| 368 |
# auth: hadith authenticity check
|
| 369 |
if re.search(
|
| 370 |
+
r"صحيح[؟?]|هل صحيح|درجة الحديث|درجة حديث|صحة حديث|صحة الحديث|هل هذا حديث"
|
| 371 |
+
r"|is.+authentic|is it true that|verify hadith|hadith.+grade|hadith.+\b(?:sahih|weak|da'?if|fabricated)\b",
|
| 372 |
query, re.I,
|
| 373 |
):
|
| 374 |
return "auth"
|
|
|
|
| 506 |
# Strip common query prefixes like "آية:", "حديث:", "فتوى:" so text/BM25 searches aren't distorted
|
| 507 |
cleaned_raw = re.sub(r"^\s*(?:آية|اية|الآية|الاية|حديث|الحديث|فتوى|الفتوى|سؤال|السؤال)\s*[::]\s*", "", raw).strip()
|
| 508 |
target_text = cleaned_raw if cleaned_raw else raw
|
| 509 |
+
if detected_intent == "auth":
|
| 510 |
+
target_text = strip_auth_phrasing(target_text)
|
| 511 |
+
synonyms = synonyms_in_text(target_text)
|
| 512 |
+
en_query = target_text
|
| 513 |
+
if detect_language(target_text) == "arabic":
|
| 514 |
+
# No translation without the LLM; give the English vector the thesaurus terms
|
| 515 |
+
english = [w for w in synonyms if w.isascii()]
|
| 516 |
+
if english:
|
| 517 |
+
en_query = f"{target_text} ({', '.join(english)})"
|
| 518 |
fallback = {
|
| 519 |
"ar_query": normalize_arabic(target_text),
|
| 520 |
+
"en_query": en_query,
|
| 521 |
+
# Surface words, not stems: hybrid_search and count analysis stem them themselves,
|
| 522 |
+
# and stemming twice strips root letters (الوضوء → وضوء → ضوء).
|
| 523 |
+
"keywords": ([w for w in target_text.split() if tokenize_bilingual(w)][:7] + synonyms)[:15],
|
| 524 |
"intent": detected_intent,
|
| 525 |
}
|
| 526 |
|
|
|
|
| 579 |
|
| 580 |
result["ar_query"] = str(result.get("ar_query") or fallback["ar_query"]).strip()[:400]
|
| 581 |
result["en_query"] = str(result.get("en_query") or fallback["en_query"]).strip()[:400]
|
| 582 |
+
if result["intent"] == "auth":
|
| 583 |
+
result["ar_query"] = strip_auth_phrasing(result["ar_query"])
|
| 584 |
+
result["en_query"] = strip_auth_phrasing(result["en_query"])
|
| 585 |
|
| 586 |
await rewrite_cache.set(result, *cache_key)
|
| 587 |
logger.info("Rewrite: intent=%s ar=%s", result["intent"], result["ar_query"][:60])
|
|
|
|
| 628 |
return results[:limit] if limit is not None else results
|
| 629 |
|
| 630 |
|
| 631 |
+
def _content_word_count(query: str) -> int:
|
| 632 |
+
words = normalize_arabic(query, aggressive=True).lower().split()
|
| 633 |
+
return len({w for w in words if w not in ARABIC_STOPWORDS and w not in ENGLISH_STOPWORDS})
|
| 634 |
+
|
| 635 |
+
|
| 636 |
+
def fuse_search_results(
|
| 637 |
+
direct: list,
|
| 638 |
+
hybrid: list,
|
| 639 |
+
text_hits: List[Tuple[str, list]],
|
| 640 |
+
limit: Optional[int] = None,
|
| 641 |
+
rrf_k: int = 60,
|
| 642 |
+
) -> list:
|
| 643 |
+
"""Merge direct lookups, hybrid search and text search by rank, not raw score.
|
| 644 |
+
|
| 645 |
+
The three sources score on unrelated scales (direct 9.x, text 1–5, hybrid
|
| 646 |
+
RRF ≈0.2–0.9), so sorting on ``_score`` lets any weak text overlap outrank
|
| 647 |
+
the best semantic hit. Instead:
|
| 648 |
+
1. direct reference lookups stay first (explicit "2:255"-style requests);
|
| 649 |
+
2. exact substring hits for a query with ≥3 content words come next
|
| 650 |
+
(the user quoted the text verbatim);
|
| 651 |
+
3. everything else is ordered by reciprocal-rank fusion of the hybrid
|
| 652 |
+
ranking and the phrase/fuzzy/short-exact text rankings. Word-overlap
|
| 653 |
+
("partial") text hits are dropped — they match on common words.
|
| 654 |
+
|
| 655 |
+
``text_hits`` is a list of (query, hits) pairs, one per text_search call.
|
| 656 |
+
Items keep their original ``_score``; the returned order is the ranking.
|
| 657 |
+
Items from tiers 1–2 are marked ``_pinned`` (lexical evidence of relevance).
|
| 658 |
+
"""
|
| 659 |
+
ordered: dict[tuple, dict] = {}
|
| 660 |
+
|
| 661 |
+
def add(item: dict, pinned: bool = False) -> None:
|
| 662 |
+
key = result_key(item)
|
| 663 |
+
if key not in ordered:
|
| 664 |
+
ordered[key] = {**item, "_pinned": True} if pinned else item
|
| 665 |
+
|
| 666 |
+
for item in sorted(direct, key=lambda r: r.get("_score", 0.0), reverse=True):
|
| 667 |
+
add(item, pinned=True)
|
| 668 |
+
|
| 669 |
+
rrf: Dict[tuple, float] = {}
|
| 670 |
+
items: Dict[tuple, dict] = {}
|
| 671 |
+
|
| 672 |
+
def accumulate(ranked: list) -> None:
|
| 673 |
+
for rank, item in enumerate(ranked, 1):
|
| 674 |
+
key = result_key(item)
|
| 675 |
+
rrf[key] = rrf.get(key, 0.0) + 1.0 / (rrf_k + rank)
|
| 676 |
+
if key not in items or item.get("_score", 0.0) > items[key].get("_score", 0.0):
|
| 677 |
+
items[key] = item
|
| 678 |
+
|
| 679 |
+
accumulate(hybrid)
|
| 680 |
+
for query, hits in text_hits:
|
| 681 |
+
verbatim = _content_word_count(query) >= 3
|
| 682 |
+
ranked = []
|
| 683 |
+
for hit in hits:
|
| 684 |
+
match = hit.get("_match", "partial")
|
| 685 |
+
if match == "exact" and verbatim:
|
| 686 |
+
add(hit, pinned=True)
|
| 687 |
+
elif match != "partial":
|
| 688 |
+
ranked.append(hit)
|
| 689 |
+
accumulate(ranked)
|
| 690 |
+
|
| 691 |
+
for key in sorted(rrf, key=rrf.get, reverse=True):
|
| 692 |
+
add(items[key])
|
| 693 |
+
|
| 694 |
+
results = list(ordered.values())
|
| 695 |
+
return results[:limit] if limit is not None else results
|
| 696 |
+
|
| 697 |
+
|
| 698 |
def normalize_collection_name(text: str) -> Optional[str]:
|
| 699 |
"""Resolve a collection alias to the canonical dataset collection name."""
|
| 700 |
if not text:
|
|
|
|
| 749 |
return False
|
| 750 |
|
| 751 |
|
| 752 |
+
def _surah_name_equals(item: dict, name: str) -> bool:
|
| 753 |
+
"""Strict variant of _surah_matches: the whole name must equal a surah name."""
|
| 754 |
+
def clean(text: str) -> str:
|
| 755 |
+
text = normalize_arabic(text, aggressive=True).lower().strip()
|
| 756 |
+
return re.sub(r"^(ال|al[\-\s']*|the\s+)", "", text, flags=re.I).replace("-", " ").strip()
|
| 757 |
+
|
| 758 |
+
target = clean(name)
|
| 759 |
+
return bool(target) and any(
|
| 760 |
+
clean(item.get(field) or "") == target
|
| 761 |
+
for field in ("surah_name_ar", "surah_name_en", "surah_name_transliteration")
|
| 762 |
+
)
|
| 763 |
+
|
| 764 |
+
|
| 765 |
_FAMOUS_AYAH_MAP = {
|
| 766 |
"ايه الكرسي": (2, 255),
|
| 767 |
"ayat al-kursi": (2, 255),
|
|
|
|
| 846 |
if matches:
|
| 847 |
break
|
| 848 |
|
| 849 |
+
bare = None if matches else _QURAN_REF_BARE.match(query)
|
| 850 |
+
if bare:
|
| 851 |
+
surah_query, verse_num = bare.group(1), int(bare.group(2))
|
| 852 |
+
for item in dataset:
|
| 853 |
+
if (
|
| 854 |
+
item.get("type") == "quran"
|
| 855 |
+
and (item.get("ayah_number") or item.get("verse_number")) == verse_num
|
| 856 |
+
and _surah_name_equals(item, surah_query)
|
| 857 |
+
):
|
| 858 |
+
matches.append({**item, "_score": 9.0})
|
| 859 |
+
|
| 860 |
return matches[:limit]
|
| 861 |
|
| 862 |
|
|
|
|
| 905 |
|
| 906 |
|
| 907 |
def cluster_hadith_candidates(results: list) -> list:
|
| 908 |
+
"""Cluster near-identical Hadith narrations (matn deduplication) across collections.
|
| 909 |
+
|
| 910 |
+
Preserves input order: each cluster sits where its first narration appeared.
|
| 911 |
+
"""
|
| 912 |
if not results or not cfg.ENABLE_HADITH_DEDUPLICATION:
|
| 913 |
return results
|
| 914 |
|
| 915 |
+
ordered: list = []
|
| 916 |
hadith_clusters: List[dict] = []
|
| 917 |
|
| 918 |
for item in results:
|
| 919 |
if item.get("type") != "hadith":
|
| 920 |
+
ordered.append(item)
|
| 921 |
continue
|
| 922 |
|
| 923 |
item_ar = normalize_arabic(item.get("arabic", ""), aggressive=True).lower()
|
|
|
|
| 995 |
item_copy["other_collections"] = []
|
| 996 |
item_copy["muttafaq_alayh"] = False
|
| 997 |
hadith_clusters.append(item_copy)
|
| 998 |
+
ordered.append(item_copy)
|
| 999 |
|
|
|
|
| 1000 |
for h in hadith_clusters:
|
| 1001 |
h.pop("_matn", None)
|
| 1002 |
h.pop("_words", None)
|
|
|
|
| 1003 |
|
| 1004 |
+
return ordered
|
| 1005 |
|
| 1006 |
|
| 1007 |
def lookup_hadith_references(
|
|
|
|
| 1115 |
return expanded
|
| 1116 |
|
| 1117 |
|
| 1118 |
+
def _cross_doc_text(c: dict, query: str = "") -> str:
|
| 1119 |
+
# The reranker truncates at 512 tokens and a long diacritized verse (2:282 is ~1,000
|
| 1120 |
+
# tokens) fills that alone, so put the text in the query's language first.
|
| 1121 |
+
ar, en = c.get("arabic", ""), c.get("english", "")
|
| 1122 |
+
doc_text = f"{en} {ar}" if detect_language(query) == "english" else f"{ar} {en}"
|
| 1123 |
+
if c.get("type") == "quran" and c.get("tafsir_en"):
|
| 1124 |
+
doc_text += f" {c['tafsir_en'][:300]}"
|
| 1125 |
+
return doc_text.strip()
|
| 1126 |
+
|
| 1127 |
+
|
| 1128 |
+
# "في القرآن" says where to search (source routing handles it), not what to find; left
|
| 1129 |
+
# in, the reranker favours verses about the Qur'an itself (27:1 for "الوضوء في القرآن").
|
| 1130 |
+
# English keeps "in the Quran": without it "Is alcohol forbidden?" is too vague and
|
| 1131 |
+
# scored below the confidence gate.
|
| 1132 |
+
_SOURCE_PHRASE = re.compile(r"\s*(?:في|من|حسب)\s+(?:ال)?قر[اآ]ن(?:\s+الكريم)?")
|
| 1133 |
+
|
| 1134 |
+
|
| 1135 |
+
def _reranker_query(query: str) -> str:
|
| 1136 |
+
"""Query as the cross-encoder sees it: without "في القرآن", plus thesaurus terms in
|
| 1137 |
+
the query's own script.
|
| 1138 |
+
|
| 1139 |
+
The reranker doesn't know that "alcohol" means the Qur'an's "wine" (khamr); without
|
| 1140 |
+
the hint it scored 5:90 below a verse that merely mentions "the Qur'an".
|
| 1141 |
+
"""
|
| 1142 |
+
query = _SOURCE_PHRASE.sub("", query).strip() or query
|
| 1143 |
+
arabic = detect_language(query) == "arabic"
|
| 1144 |
+
terms = [t for t in synonyms_in_text(query) if (not t.isascii()) == arabic]
|
| 1145 |
+
return f"{query} ({', '.join(terms)})" if terms else query
|
| 1146 |
+
|
| 1147 |
+
|
| 1148 |
+
async def _cross_probabilities(cross_encoder_model: Any, pairs: list) -> List[float]:
|
| 1149 |
+
# CrossEncoder.predict already applies its default activation (sigmoid for
|
| 1150 |
+
# single-label rerankers such as bge-reranker), so outputs are probabilities.
|
| 1151 |
+
loop = asyncio.get_event_loop()
|
| 1152 |
+
raw = await loop.run_in_executor(None, lambda: cross_encoder_model.predict(pairs))
|
| 1153 |
+
return [float(s) for s in raw]
|
| 1154 |
+
|
| 1155 |
+
|
| 1156 |
+
async def score_relevance(query: str, candidates: list, cross_encoder_model: Any) -> List[float]:
|
| 1157 |
+
"""Cross-encoder relevance probability (0–1) of each candidate to *query*.
|
| 1158 |
+
|
| 1159 |
+
Reuses ``_cross_score`` values already attached by hybrid_search's reranking.
|
| 1160 |
+
"""
|
| 1161 |
+
missing = [i for i, c in enumerate(candidates) if "_cross_score" not in c]
|
| 1162 |
+
probs = [c.get("_cross_score", 0.0) for c in candidates]
|
| 1163 |
+
query = _reranker_query(query)
|
| 1164 |
+
if missing:
|
| 1165 |
+
pairs = [[query, _cross_doc_text(candidates[i], query)] for i in missing]
|
| 1166 |
+
for i, p in zip(missing, await _cross_probabilities(cross_encoder_model, pairs)):
|
| 1167 |
+
probs[i] = p
|
| 1168 |
+
return probs
|
| 1169 |
+
|
| 1170 |
+
|
| 1171 |
+
# Candidates the cross-encoder scores, and how many of them are reserved for the best
|
| 1172 |
+
# BM25-only hits: in RRF a document found by both dense searches outranks any document
|
| 1173 |
+
# only BM25 found, so exact-word matches would otherwise never reach the reranker.
|
| 1174 |
+
_RERANK_POOL = 25
|
| 1175 |
+
_SPARSE_POOL_SLOTS = 5
|
| 1176 |
+
_RERANK_WEIGHT = 0.8 # share of the reranker in the blended hybrid score (0.8–0.95 tie on the golden set; 0.7 dropped 5:90 for "is alcohol forbidden?")
|
| 1177 |
+
|
| 1178 |
+
|
| 1179 |
async def rerank_with_cross_encoder(
|
| 1180 |
query: str,
|
| 1181 |
candidates: list,
|
|
|
|
| 1187 |
return candidates[:top_k]
|
| 1188 |
|
| 1189 |
try:
|
| 1190 |
+
pool_size = min(len(candidates), _RERANK_POOL)
|
| 1191 |
pool = candidates[:pool_size]
|
| 1192 |
tail = candidates[pool_size:]
|
| 1193 |
|
| 1194 |
+
query = _reranker_query(query)
|
| 1195 |
+
pairs = [[query, _cross_doc_text(c, query)] for c in pool]
|
| 1196 |
+
probs = await _cross_probabilities(cross_encoder_model, pairs)
|
|
|
|
|
|
|
|
|
|
| 1197 |
|
| 1198 |
+
for c, prob in zip(pool, probs):
|
| 1199 |
+
c["_cross_score"] = prob
|
| 1200 |
+
# Blend cross-encoder probability (scaled to ~2.0) with base RRF score
|
| 1201 |
+
c["_score"] = float(_RERANK_WEIGHT * (prob * 2.0) + (1 - _RERANK_WEIGHT) * c.get("_score", 1.0))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1202 |
|
| 1203 |
pool.sort(key=lambda x: x.get("_score", 0.0), reverse=True)
|
| 1204 |
combined = pool + tail
|
|
|
|
| 1208 |
return candidates[:top_k]
|
| 1209 |
|
| 1210 |
|
| 1211 |
+
def build_type_indexes(index: Any, dataset: list) -> Dict[str, Tuple[Any, np.ndarray]]:
|
| 1212 |
+
"""Per-source flat sub-indexes: {type: (index, dataset positions)}.
|
| 1213 |
+
|
| 1214 |
+
Filtering a global top-k afterwards fails for the smaller sources — for an
|
| 1215 |
+
ablution question the top few hundred neighbours are all hadith (46k of them
|
| 1216 |
+
vs 6k verses), leaving a Quran-only query with nothing. Searching the
|
| 1217 |
+
source's own vectors avoids that.
|
| 1218 |
+
"""
|
| 1219 |
+
n = min(index.ntotal, len(dataset))
|
| 1220 |
+
vectors = index.reconstruct_n(0, n)
|
| 1221 |
+
out: Dict[str, Tuple[Any, np.ndarray]] = {}
|
| 1222 |
+
for doc_type in ("quran", "hadith", "fatwa"):
|
| 1223 |
+
positions = np.array([i for i in range(n) if dataset[i].get("type") == doc_type], dtype=np.int64)
|
| 1224 |
+
if len(positions):
|
| 1225 |
+
sub = faiss.IndexFlatIP(index.d)
|
| 1226 |
+
sub.add(vectors[positions])
|
| 1227 |
+
out[doc_type] = (sub, positions)
|
| 1228 |
+
return out
|
| 1229 |
+
|
| 1230 |
+
|
| 1231 |
# ═══════════════════════════════════════════════════════════════════════
|
| 1232 |
# HYBRID SEARCH — dense FAISS + BM25 re-ranking + filtering
|
| 1233 |
# ═══════════════════════════════════════════════════════════════════════
|
|
|
|
| 1242 |
grade_filter: Optional[str] = None,
|
| 1243 |
bm25_index: Optional[BM25InvertedIndex] = None,
|
| 1244 |
reranker_model: Optional[Any] = None,
|
| 1245 |
+
type_indexes: Optional[Dict[str, Tuple[Any, np.ndarray]]] = None,
|
| 1246 |
) -> list:
|
| 1247 |
+
"""Hybrid search: multi-vector dense FAISS + sparse BM25 with RRF fusion, deduplication, and reranking.
|
| 1248 |
+
|
| 1249 |
+
With ``source_type`` and ``type_indexes`` (see build_type_indexes), dense search
|
| 1250 |
+
runs over that source's vectors only.
|
| 1251 |
+
"""
|
| 1252 |
ar_str = rewrite.get("ar_query") or rewrite.get("query_ar") or raw_query
|
| 1253 |
en_str = rewrite.get("en_query") or rewrite.get("query_en") or raw_query
|
| 1254 |
intent = rewrite.get("intent", "general")
|
|
|
|
| 1281 |
emb_ar, emb_en = embeddings[0], embeddings[1]
|
| 1282 |
await embed_cache.set((emb_ar, emb_en), ar_q, en_q)
|
| 1283 |
|
| 1284 |
+
# Batched FAISS search for Arabic and English vectors. A source filter searches
|
| 1285 |
+
# that source's own sub-index; other filters (grade) are applied afterwards, so
|
| 1286 |
+
# widen the pool for them.
|
| 1287 |
+
k_search = cfg.TOP_K_SEARCH * (5 if grade_filter else 1)
|
| 1288 |
vec_both = np.vstack([emb_ar.reshape(1, -1), emb_en.reshape(1, -1)]).astype("float32")
|
| 1289 |
+
sub = type_indexes.get(source_type) if (type_indexes and source_type) else None
|
| 1290 |
+
if sub is not None:
|
| 1291 |
+
sub_index, positions = sub
|
| 1292 |
+
distances_both, sub_idx = await loop.run_in_executor(
|
| 1293 |
+
None, lambda: sub_index.search(vec_both, k_search)
|
| 1294 |
+
)
|
| 1295 |
+
indices_both = np.where(sub_idx >= 0, positions[np.clip(sub_idx, 0, None)], -1)
|
| 1296 |
+
else:
|
| 1297 |
+
if source_type:
|
| 1298 |
+
k_search *= 5
|
| 1299 |
+
distances_both, indices_both = await loop.run_in_executor(
|
| 1300 |
+
None, lambda: index.search(vec_both, k_search)
|
| 1301 |
+
)
|
| 1302 |
distances_ar, indices_ar = distances_both[0], indices_both[0]
|
| 1303 |
distances_en, indices_en = distances_both[1], indices_both[1]
|
| 1304 |
|
|
|
|
| 1344 |
rrf_scores[doc_idx] = rrf_scores.get(doc_idx, 0.0) + w_en * (1.0 / (rrf_k + rank))
|
| 1345 |
|
| 1346 |
# ── 3. Sparse Inverted BM25 Search + RRF Fusion ───────────────────
|
| 1347 |
+
sparse_order: List[int] = []
|
| 1348 |
query_terms: List[str] = []
|
| 1349 |
for kw in rewrite.get("keywords", raw_query.split()):
|
| 1350 |
query_terms.extend(tokenize_bilingual(kw))
|
| 1351 |
|
| 1352 |
if bm25_index is not None:
|
| 1353 |
+
sparse_hits = bm25_index.search(query_terms, top_k=k_search, doc_type=source_type)
|
| 1354 |
w_sparse = 1.0
|
| 1355 |
for rank, (doc_idx, s_score) in enumerate(sparse_hits, 1):
|
| 1356 |
if 0 <= doc_idx < len(dataset):
|
|
|
|
| 1363 |
if doc_idx not in item_map:
|
| 1364 |
item_map[doc_idx] = {**item, "_dense": 0.0, "_dataset_idx": doc_idx}
|
| 1365 |
item_map[doc_idx]["_sparse"] = float(s_score)
|
| 1366 |
+
sparse_order.append(doc_idx)
|
| 1367 |
rrf_scores[doc_idx] = rrf_scores.get(doc_idx, 0.0) + w_sparse * (1.0 / (rrf_k + rank))
|
| 1368 |
|
| 1369 |
fused_candidates = []
|
|
|
|
| 1383 |
|
| 1384 |
# ── 4. Cross-Encoder Re-ranking (if configured) ───────────────────
|
| 1385 |
if reranker_model is not None and cfg.USE_RERANKER and len(fused_candidates) > 1:
|
| 1386 |
+
fused_candidates = _reserve_sparse_slots(fused_candidates, sparse_order)
|
| 1387 |
fused_candidates = await rerank_with_cross_encoder(
|
| 1388 |
raw_query, fused_candidates, reranker_model, top_k=len(fused_candidates)
|
| 1389 |
)
|
|
|
|
| 1397 |
|
| 1398 |
|
| 1399 |
|
| 1400 |
+
def _reserve_sparse_slots(candidates: list, sparse_order: List[int]) -> list:
|
| 1401 |
+
"""Reorder fused candidates so the top BM25 hits fall inside the rerank pool."""
|
| 1402 |
+
head_size = _RERANK_POOL - _SPARSE_POOL_SLOTS
|
| 1403 |
+
in_head = {c.get("_dataset_idx") for c in candidates[:head_size]}
|
| 1404 |
+
promote = []
|
| 1405 |
+
for doc_idx in sparse_order:
|
| 1406 |
+
if len(promote) == _SPARSE_POOL_SLOTS:
|
| 1407 |
+
break
|
| 1408 |
+
if doc_idx not in in_head:
|
| 1409 |
+
promote.append(doc_idx)
|
| 1410 |
+
if not promote:
|
| 1411 |
+
return candidates
|
| 1412 |
+
promoted = set(promote)
|
| 1413 |
+
by_idx = {c.get("_dataset_idx"): c for c in candidates}
|
| 1414 |
+
rest = [c for c in candidates[head_size:] if c.get("_dataset_idx") not in promoted]
|
| 1415 |
+
return candidates[:head_size] + [by_idx[i] for i in promote] + rest
|
| 1416 |
+
|
| 1417 |
+
|
| 1418 |
# ═══════════════════════════════════════════════════════════════════════
|
| 1419 |
# TEXT-BASED SEARCH (exact substring + fuzzy matching)
|
| 1420 |
# ═══════════════════════════════════════════════════════════════════════
|
| 1421 |
+
_NORM_CACHE: Dict[int, Tuple[list, List[Tuple[str, str]]]] = {}
|
| 1422 |
+
|
| 1423 |
+
|
| 1424 |
+
def _normalized_texts(dataset: list) -> List[Tuple[str, str]]:
|
| 1425 |
+
"""(normalized Arabic, lowercased English) per document, computed once per dataset.
|
| 1426 |
+
|
| 1427 |
+
Normalizing ~50k documents takes ~1.5 s, and text_search runs 2–3 times per query.
|
| 1428 |
+
"""
|
| 1429 |
+
cached = _NORM_CACHE.get(id(dataset))
|
| 1430 |
+
if cached is None or cached[0] is not dataset or len(cached[1]) != len(dataset):
|
| 1431 |
+
texts = [
|
| 1432 |
+
(normalize_arabic(d.get("arabic", ""), aggressive=True).lower(), d.get("english", "").lower())
|
| 1433 |
+
for d in dataset
|
| 1434 |
+
]
|
| 1435 |
+
_NORM_CACHE.clear()
|
| 1436 |
+
_NORM_CACHE[id(dataset)] = cached = (dataset, texts)
|
| 1437 |
+
return cached[1]
|
| 1438 |
+
|
| 1439 |
+
|
| 1440 |
def text_search(
|
| 1441 |
query: str,
|
| 1442 |
dataset: list,
|
|
|
|
| 1446 |
"""Search dataset by exact text match (Arabic or English).
|
| 1447 |
|
| 1448 |
Returns items sorted by relevance: exact matches first, then partial.
|
| 1449 |
+
Each hit carries ``_match`` — "exact" (whole query is a substring),
|
| 1450 |
+
"phrase" (2–3 word content window), "fuzzy" (≥80% similar), or
|
| 1451 |
+
"partial" (word overlap) — so callers can weigh match strength.
|
| 1452 |
"""
|
| 1453 |
clean_query = re.sub(r"^\s*(?:آية|اية|الآية|الاية|حديث|الحديث|فتوى|الفتوى|سؤال|السؤال)\s*[::]\s*", "", query).strip()
|
| 1454 |
q_target = clean_query if clean_query else query
|
| 1455 |
q_norm = normalize_arabic(q_target, aggressive=True).lower()
|
| 1456 |
q_lower = q_target.lower().strip()
|
| 1457 |
+
q_content = [
|
| 1458 |
+
w not in ARABIC_STOPWORDS and w not in ENGLISH_STOPWORDS for w in q_norm.split()
|
| 1459 |
+
]
|
| 1460 |
|
| 1461 |
results = []
|
| 1462 |
+
for item, (ar_norm, en_lower) in zip(dataset, _normalized_texts(dataset)):
|
| 1463 |
if source_type and item.get("type") != source_type:
|
| 1464 |
continue
|
| 1465 |
|
| 1466 |
ar_raw = item.get("arabic", "")
|
|
|
|
|
|
|
|
|
|
| 1467 |
|
| 1468 |
score = 0.0
|
| 1469 |
+
match = "partial"
|
| 1470 |
|
| 1471 |
# Exact substring in raw Arabic (with diacritics) — highest priority
|
| 1472 |
if query.strip() in ar_raw:
|
|
|
|
| 1481 |
if q_lower and q_lower in en_lower:
|
| 1482 |
score = max(score, 2.0 + (1.0 / max(len(en_lower), 1)) * 100)
|
| 1483 |
|
| 1484 |
+
if score > 0.0:
|
| 1485 |
+
match = "exact"
|
| 1486 |
+
|
| 1487 |
+
# N-gram phrase matching for partial Arabic text (3+ word sequences).
|
| 1488 |
+
# N-grams made mostly of stopwords ("of the", "ما هو") match nearly
|
| 1489 |
+
# every document, so require ≥2 content words per window.
|
| 1490 |
if score == 0.0 and q_norm:
|
| 1491 |
q_words = q_norm.split()
|
| 1492 |
if len(q_words) >= 3:
|
| 1493 |
# Check sliding windows of 3 words from query against doc
|
| 1494 |
for i in range(len(q_words) - 2):
|
| 1495 |
+
if sum(q_content[i:i+3]) < 2:
|
| 1496 |
+
continue
|
| 1497 |
trigram = " ".join(q_words[i:i+3])
|
| 1498 |
if trigram in ar_norm:
|
| 1499 |
score = max(score, 2.0 + (i == 0) * 0.5)
|
| 1500 |
+
match = "phrase"
|
| 1501 |
break
|
| 1502 |
if score == 0.0 and len(q_words) >= 2:
|
| 1503 |
for i in range(len(q_words) - 1):
|
| 1504 |
+
if sum(q_content[i:i+2]) < 2:
|
| 1505 |
+
continue
|
| 1506 |
bigram = " ".join(q_words[i:i+2])
|
| 1507 |
if bigram in ar_norm or bigram in en_lower:
|
| 1508 |
score = max(score, 1.5)
|
| 1509 |
+
match = "phrase"
|
| 1510 |
break
|
| 1511 |
|
| 1512 |
# Word-level overlap for lower-confidence matches
|
|
|
|
| 1532 |
ratio = SequenceMatcher(None, q_norm, text).ratio()
|
| 1533 |
if ratio >= 0.80:
|
| 1534 |
score = max(score, 1.0 + ratio) # 1.80–2.0 range
|
| 1535 |
+
match = "fuzzy"
|
| 1536 |
|
| 1537 |
if score > 0:
|
| 1538 |
+
results.append({**item, "_score": score, "_match": match})
|
| 1539 |
|
| 1540 |
results.sort(key=lambda x: x["_score"], reverse=True)
|
| 1541 |
return results if limit is None else results[:limit]
|
|
|
|
| 1548 |
return text[:limit].rstrip() + marker
|
| 1549 |
|
| 1550 |
|
| 1551 |
+
def surah_url(surah, verse) -> str:
|
| 1552 |
+
"""Link to a verse on quran.com ("" when either number is missing)."""
|
| 1553 |
+
return f"https://quran.com/{int(surah)}/{int(verse)}" if surah and verse else ""
|
| 1554 |
+
|
| 1555 |
+
|
| 1556 |
_AR_CUT = " ... [باقي النص مقتطع للاختصار]"
|
| 1557 |
_EN_CUT = " ... [remainder truncated for length]"
|
| 1558 |
|
| 1559 |
+
# Arabic collection names for the [HADITH_REF] line, so Arabic answers cite in Arabic
|
| 1560 |
+
_COLLECTION_AR = {
|
| 1561 |
+
"Sahih al-Bukhari": "صحيح البخاري", "Sahih Muslim": "صحيح مسلم",
|
| 1562 |
+
"Sunan Abu Dawood": "سنن أبي داود", "Jami' at-Tirmidhi": "جامع الترمذي",
|
| 1563 |
+
"Sunan an-Nasai": "سنن النسائي", "Sunan Ibn Majah": "سنن ابن ماجه",
|
| 1564 |
+
"Muwatta Malik": "موطأ مالك", "Musnad Ahmad": "مسند أحمد", "Sunan al-Darimi": "سنن الدارمي",
|
| 1565 |
+
"Riyad al-Salihin": "رياض الصالحين", "Bulugh al-Maram": "بلوغ المرام",
|
| 1566 |
+
"Al-Adab al-Mufrad": "الأدب المفرد", "Ash-Shama'il al-Muhammadiyyah": "الشمائل المحمدية",
|
| 1567 |
+
"An-Nawawi's 40 Hadith": "الأربعون النووية", "Hadith Qudsi": "الأحاديث القدسية",
|
| 1568 |
+
}
|
| 1569 |
+
|
| 1570 |
+
|
| 1571 |
+
def _hadith_ref_line(r: dict) -> str:
|
| 1572 |
+
"""Citation line for a hadith: the exact reference in English and Arabic, plus grade."""
|
| 1573 |
+
collection, number = r.get("collection", ""), r.get("hadith_number", "")
|
| 1574 |
+
reference = r.get("reference") or f"{collection} {number}".strip()
|
| 1575 |
+
line = f"\n [HADITH_REF] Cite as: {reference}"
|
| 1576 |
+
if collection in _COLLECTION_AR and number != "":
|
| 1577 |
+
line += f" | بالعربية: {_COLLECTION_AR[collection]}، رقم {number}"
|
| 1578 |
+
line += f" | Grade: {r.get('grade') or 'not recorded in dataset'}"
|
| 1579 |
+
if r.get("chapter"):
|
| 1580 |
+
line += f" | Chapter: {r['chapter'][:120]}"
|
| 1581 |
+
return line
|
| 1582 |
+
|
| 1583 |
|
| 1584 |
def build_context(results: list) -> str:
|
| 1585 |
"""Format search results into context block for LLM."""
|
|
|
|
| 1645 |
if doc_type == "quran":
|
| 1646 |
sn = r.get("surah_number", "")
|
| 1647 |
vn = r.get("verse_number", "")
|
| 1648 |
+
verse_url = surah_url(sn, vn)
|
| 1649 |
tafsir_url = f"https://quran.ksu.edu.sa/tafseer/qortobi/sura{sn}-aya{vn}.html" if sn and vn else ""
|
| 1650 |
block += (
|
| 1651 |
f"\n [REF] Surah: {r.get('surah_name_ar', '')} ({r.get('surah_name_en', '')}) "
|
| 1652 |
f"| Surah Number: {sn} "
|
| 1653 |
f"| Ayah: {vn}\n"
|
| 1654 |
+
f" Surah URL : {verse_url}\n"
|
| 1655 |
f" Tafsir URL: {tafsir_url}"
|
| 1656 |
)
|
| 1657 |
# Neighboring verses context if present
|
|
|
|
| 1665 |
elif r.get("tafsir_en"):
|
| 1666 |
max_chars = getattr(cfg, "TAFSIR_MAX_CHARS", 1200)
|
| 1667 |
block += f"\n Tafsir : {r['tafsir_en'][:max_chars]}"
|
| 1668 |
+
elif doc_type == "hadith":
|
| 1669 |
+
block += _hadith_ref_line(r)
|
| 1670 |
elif doc_type == "fatwa":
|
| 1671 |
block += (
|
| 1672 |
f"\n [FATWA_REF] Source: {r.get('source', '')} | Scholar: {r.get('scholar_or_council', '')} "
|
app/state.py
CHANGED
|
@@ -8,7 +8,8 @@ import logging
|
|
| 8 |
import re
|
| 9 |
import time
|
| 10 |
from contextlib import asynccontextmanager
|
| 11 |
-
from
|
|
|
|
| 12 |
|
| 13 |
try:
|
| 14 |
import faiss
|
|
@@ -28,21 +29,26 @@ from app.analysis import (
|
|
| 28 |
detect_surah_info,
|
| 29 |
lookup_surah_info,
|
| 30 |
)
|
| 31 |
-
from app.arabic_nlp import detect_language, normalize_arabic, tokenize_bilingual
|
| 32 |
from app.config import cfg
|
| 33 |
from app.cache import pipeline_cache
|
| 34 |
from app.llm import LLMProvider, get_llm_provider
|
| 35 |
from app.prompts import build_messages, not_found_answer
|
| 36 |
from app.search import (
|
| 37 |
BM25InvertedIndex,
|
|
|
|
|
|
|
| 38 |
build_context,
|
|
|
|
| 39 |
cluster_hadith_candidates,
|
| 40 |
expand_quran_context,
|
|
|
|
| 41 |
hybrid_search,
|
| 42 |
lookup_hadith_references,
|
| 43 |
lookup_quran_verses,
|
| 44 |
-
merge_search_results,
|
| 45 |
rewrite_query,
|
|
|
|
|
|
|
| 46 |
text_search,
|
| 47 |
)
|
| 48 |
|
|
@@ -109,9 +115,9 @@ def _results_fallback(results: list, lang: str) -> str:
|
|
| 109 |
surah_en = r.get("surah_name_en", "")
|
| 110 |
surah_num = r.get("surah_number", "")
|
| 111 |
verse_num = r.get("verse_number", "")
|
| 112 |
-
|
| 113 |
tafsir_url = f"https://quran.ksu.edu.sa/tafseer/qortobi/sura{surah_num}-aya{verse_num}.html" if surah_num and verse_num else ""
|
| 114 |
-
surah_link = f"[[1]({
|
| 115 |
tafsir_link = f"[[1]({tafsir_url})]" if tafsir_url else ""
|
| 116 |
|
| 117 |
if lang == "arabic":
|
|
@@ -231,11 +237,177 @@ def _build_surah_name_map(results: list) -> dict:
|
|
| 231 |
return name_map
|
| 232 |
|
| 233 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
def _verify_references(answer: str, results: list) -> str:
|
| 235 |
"""Check that surah/verse references in the answer match retrieved results.
|
| 236 |
|
| 237 |
Replaces hallucinated references with corrected ones or warnings.
|
| 238 |
"""
|
|
|
|
| 239 |
valid_refs = _build_valid_refs(results)
|
| 240 |
if not valid_refs:
|
| 241 |
return answer # No quran results — nothing to verify
|
|
@@ -334,7 +506,7 @@ def _verify_references(answer: str, results: list) -> str:
|
|
| 334 |
primary = quran_results[0]
|
| 335 |
p_sn = int(primary["surah_number"])
|
| 336 |
p_vn = int(primary["verse_number"])
|
| 337 |
-
actual_surah_url =
|
| 338 |
actual_tafsir_url = f"https://quran.ksu.edu.sa/tafseer/qortobi/sura{p_sn}-aya{p_vn}.html"
|
| 339 |
answer = answer.replace("{surah_url}", actual_surah_url).replace("{tafsir_url}", actual_tafsir_url)
|
| 340 |
|
|
@@ -342,10 +514,10 @@ def _verify_references(answer: str, results: list) -> str:
|
|
| 342 |
correct_sn, correct_vn = next(iter(valid_refs))
|
| 343 |
|
| 344 |
def _check_surah_url(m: re.Match) -> str:
|
| 345 |
-
|
| 346 |
if (sn, vn) not in valid_refs:
|
| 347 |
logger.warning("Correcting hallucinated surah URL %d:%d -> %d:%d", sn, vn, correct_sn, correct_vn)
|
| 348 |
-
return
|
| 349 |
return m.group(0)
|
| 350 |
|
| 351 |
def _check_ksu_url(m: re.Match) -> str:
|
|
@@ -355,37 +527,52 @@ def _verify_references(answer: str, results: list) -> str:
|
|
| 355 |
return f"https://quran.ksu.edu.sa/tafseer/qortobi/sura{correct_sn}-aya{correct_vn}.html"
|
| 356 |
return m.group(0)
|
| 357 |
|
| 358 |
-
answer =
|
| 359 |
answer = re.sub(r"https://quran\.ksu\.edu\.sa/tafseer/qortobi/sura(\d+)-aya(\d+)\.html", _check_ksu_url, answer)
|
| 360 |
|
| 361 |
return answer
|
| 362 |
|
| 363 |
|
| 364 |
def _verify_citations(answer: str, results: list) -> str:
|
| 365 |
-
"""Check that quoted Arabic scripture in the answer
|
| 366 |
-
|
| 367 |
-
Only applies to explicit sacred quote markers (❝...❞
|
| 368 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 369 |
"""
|
| 370 |
if not getattr(cfg, "ENABLE_CITATION_VERIFICATION", True):
|
| 371 |
return answer
|
| 372 |
|
| 373 |
-
source_texts_raw = []
|
|
|
|
|
|
|
| 374 |
for r in results:
|
| 375 |
for field in ("arabic", "english", "text", "tafsir", "tafsir_ar", "tafsir_en", "explanation"):
|
| 376 |
val = r.get(field, "")
|
| 377 |
if val:
|
| 378 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 379 |
if r.get("context_ayat"):
|
| 380 |
for ctx in r["context_ayat"]:
|
| 381 |
if ctx.get("arabic"):
|
| 382 |
source_texts_raw.append(re.sub(r"\s+", " ", str(ctx["arabic"]).strip()))
|
|
|
|
| 383 |
|
| 384 |
# Pre-compute normalized versions + word sets for diacritics-insensitive comparison
|
| 385 |
-
source_entries = []
|
| 386 |
-
for s in
|
| 387 |
-
s_norm = normalize_arabic(s)
|
| 388 |
-
source_entries.append((s, s_norm, set(s_norm.split())))
|
| 389 |
|
| 390 |
def _contains(quote: str, src: str) -> bool:
|
| 391 |
# Reverse containment (source inside quote) only counts for non-trivial
|
|
@@ -402,38 +589,49 @@ def _verify_citations(answer: str, results: list) -> str:
|
|
| 402 |
if len(quoted_norm) < 15 or ar_chars < 12:
|
| 403 |
return m.group(0)
|
| 404 |
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
# 1. Check in retrieved search results (strictly grounded in retrieved indexed context)
|
| 408 |
-
for src_raw, src_norm, s_words in source_entries:
|
| 409 |
-
# Exact or normalized substring match
|
| 410 |
if _contains(quoted_raw, src_raw) or _contains(quoted_norm, src_norm):
|
| 411 |
return m.group(0)
|
| 412 |
-
# Word overlap on normalized text (≥40% of quoted words found)
|
| 413 |
-
if q_words and len(q_words & s_words) / len(q_words) >= 0.4:
|
| 414 |
-
return m.group(0)
|
| 415 |
|
| 416 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 417 |
logger.warning("Hallucination detected: quoted text not in retrieved results: %.80s...", quoted_norm)
|
|
|
|
|
|
|
|
|
|
| 418 |
|
| 419 |
-
# Auto-correct to authentic indexed Quran verse if top retrieved source is a Quran verse
|
| 420 |
-
quran_sources = [r for r in results if r.get("type") == "quran" and r.get("arabic")]
|
| 421 |
-
if quran_sources and (len(quran_sources) == 1 or quran_sources[0].get("_score", 0) >= 2.0):
|
| 422 |
-
correct_ar = quran_sources[0]["arabic"]
|
| 423 |
-
logger.info("Auto-correcting hallucinated scripture quote to indexed Quran verse %s", quran_sources[0].get("id"))
|
| 424 |
-
return f"﴿{correct_ar}﴾"
|
| 425 |
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
return f"❝ {correct_ar} ❞"
|
| 431 |
|
| 432 |
-
return "❝ ⚠️ [تم حذف نص غير موثق — النص غير موجود في البيانات المسترجعة] ❞"
|
| 433 |
|
| 434 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 435 |
|
| 436 |
|
|
|
|
| 437 |
_MIN_REVERSE_MATCH = 15
|
| 438 |
|
| 439 |
|
|
@@ -610,14 +808,13 @@ class AppState:
|
|
| 610 |
llm: Optional[LLMProvider] = None
|
| 611 |
bm25_index: Optional[BM25InvertedIndex] = None
|
| 612 |
reranker: Optional[Any] = None
|
|
|
|
| 613 |
ready: bool = False
|
| 614 |
|
| 615 |
# O(1) lookup tables built at startup
|
| 616 |
quran_verse_idx: Dict[Tuple[int, int], dict] = {} # (surah_num, verse_num) -> item
|
| 617 |
hadith_ref_idx: Dict[Tuple[str, int], dict] = {} # (collection, hadith_num) -> item
|
| 618 |
surah_info_map: Dict[str, dict] = {} # normalised name key -> info
|
| 619 |
-
# Pre-normalized Quran texts for fast citation verification: (norm_text, word_set)
|
| 620 |
-
quran_norm_texts: List[Tuple[str, FrozenSet[str]]] = []
|
| 621 |
|
| 622 |
|
| 623 |
state = AppState()
|
|
@@ -675,17 +872,11 @@ def _build_lookup_tables(dataset: list) -> None:
|
|
| 675 |
state.hadith_ref_idx = hadith_ref_idx
|
| 676 |
state.surah_info_map = surah_info_map
|
| 677 |
|
| 678 |
-
quran_norm_texts: List[Tuple[str, FrozenSet[str]]] = []
|
| 679 |
-
for item in quran_verse_idx.values():
|
| 680 |
-
v_norm = normalize_arabic(item.get("arabic", "") or "")
|
| 681 |
-
if v_norm:
|
| 682 |
-
quran_norm_texts.append((v_norm, frozenset(v_norm.split())))
|
| 683 |
-
state.quran_norm_texts = quran_norm_texts
|
| 684 |
-
|
| 685 |
logger.info("Building full-dataset inverted BM25 index...")
|
| 686 |
bm25 = BM25InvertedIndex()
|
| 687 |
bm25.build(dataset)
|
| 688 |
state.bm25_index = bm25
|
|
|
|
| 689 |
|
| 690 |
logger.info(
|
| 691 |
"Lookup tables ready: %d Quran verses | %d hadith refs | %d surah keys | %d BM25 terms",
|
|
@@ -710,6 +901,7 @@ async def lifespan(app: FastAPI):
|
|
| 710 |
|
| 711 |
logger.info("Building lookup tables...")
|
| 712 |
_build_lookup_tables(state.dataset)
|
|
|
|
| 713 |
|
| 714 |
logger.info("Initializing LLM provider: %s", cfg.LLM_BACKEND)
|
| 715 |
state.llm = get_llm_provider()
|
|
@@ -719,18 +911,18 @@ async def lifespan(app: FastAPI):
|
|
| 719 |
try:
|
| 720 |
logger.info("Loading cross-encoder reranker: %s", cfg.RERANK_MODEL)
|
| 721 |
from sentence_transformers import CrossEncoder
|
| 722 |
-
state.reranker = CrossEncoder(cfg.RERANK_MODEL)
|
| 723 |
logger.info("Cross-encoder reranker ready")
|
| 724 |
except Exception as exc:
|
| 725 |
logger.warning("Could not load CrossEncoder (%s), continuing without reranker", exc)
|
| 726 |
|
| 727 |
state.ready = True
|
| 728 |
logger.info(
|
| 729 |
-
"QModel v8 ready | backend=%s | dataset=%d | faiss=%d |
|
| 730 |
cfg.LLM_BACKEND,
|
| 731 |
len(state.dataset) if state.dataset else 0,
|
| 732 |
state.faiss_index.ntotal if state.faiss_index else 0,
|
| 733 |
-
cfg.
|
| 734 |
)
|
| 735 |
yield
|
| 736 |
state.ready = False
|
|
@@ -749,6 +941,43 @@ def check_ready():
|
|
| 749 |
# ═══════════════════════════════════════════════════════════════════════
|
| 750 |
# SHARED PRE-PROCESSING (steps 1-7, used by both pipeline variants)
|
| 751 |
# ═══════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 752 |
async def _rag_preprocess(
|
| 753 |
question: str,
|
| 754 |
top_k: int,
|
|
@@ -767,13 +996,7 @@ async def _rag_preprocess(
|
|
| 767 |
|
| 768 |
# 1b. Override source_type if explicitly mentioned in query
|
| 769 |
if not source_type:
|
| 770 |
-
|
| 771 |
-
if re.search(r"(?:^|[\s:،])(?:ايه|الايه|ايات|الايات|قران|القران|سوره|السوره)(?:[\s:،]|$)|في القران|ايات من", norm_q):
|
| 772 |
-
source_type = "quran"
|
| 773 |
-
elif re.search(r"(?:^|[\s:،])(?:حديث|الحديث|احاديث|الاحاديث)(?:[\s:،]|$)|في الحديث|حديث نبوي|قال رسول الله", norm_q):
|
| 774 |
-
source_type = "hadith"
|
| 775 |
-
elif re.search(r"(?:^|[\s:،])(?:فتوي|الفتوي|فتاوي|الفتاوي)(?:[\s:،]|$)|حكم الشرع|هل يجوز|هل يحرم", norm_q):
|
| 776 |
-
source_type = "fatwa"
|
| 777 |
|
| 778 |
# 2. Concurrent: surah info + analysis intent + hybrid search (with multi-vector & reranker)
|
| 779 |
surah_task = detect_surah_info(question, rewrite)
|
|
@@ -784,8 +1007,9 @@ async def _rag_preprocess(
|
|
| 784 |
top_k, source_type, grade_filter,
|
| 785 |
bm25_index=state.bm25_index,
|
| 786 |
reranker_model=state.reranker,
|
|
|
|
| 787 |
)
|
| 788 |
-
surah_det, analysis_kw,
|
| 789 |
surah_task, kw_task, search_task,
|
| 790 |
)
|
| 791 |
|
|
@@ -808,7 +1032,6 @@ async def _rag_preprocess(
|
|
| 808 |
lookup_hadith_references(query, state.dataset, limit=top_k,
|
| 809 |
hadith_idx=state.hadith_ref_idx)
|
| 810 |
)
|
| 811 |
-
results = merge_search_results(direct_results, results, limit=top_k)
|
| 812 |
|
| 813 |
# 2c. Text search fallback (offloaded to thread worker)
|
| 814 |
ar_q = rewrite.get("ar_query", "")
|
|
@@ -819,7 +1042,7 @@ async def _rag_preprocess(
|
|
| 819 |
text_src = "hadith"
|
| 820 |
|
| 821 |
text_limit = top_k * 2 if intent in ("auth", "hadith", "tafsir") else top_k
|
| 822 |
-
text_results = []
|
| 823 |
for q in dict.fromkeys([ar_q, question]):
|
| 824 |
if not q:
|
| 825 |
continue
|
|
@@ -844,12 +1067,18 @@ async def _rag_preprocess(
|
|
| 844 |
))
|
| 845 |
hits = hits[:20]
|
| 846 |
|
| 847 |
-
|
| 848 |
-
|
| 849 |
-
|
| 850 |
-
|
| 851 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 852 |
results = cluster_hadith_candidates(results)
|
|
|
|
|
|
|
| 853 |
results = results[:top_k]
|
| 854 |
# Apply Ayah context expansion windowing
|
| 855 |
results = expand_quran_context(results, state.quran_verse_idx, window_size=cfg.AYAH_WINDOW_SIZE)
|
|
@@ -881,9 +1110,17 @@ async def _rag_preprocess(
|
|
| 881 |
lang = detect_language(question)
|
| 882 |
top_score = results[0].get("_score", 0.0) if results else 0.0
|
| 883 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 884 |
logger.info(
|
| 885 |
-
"Search done | intent=%s | top_score=%.3f |
|
| 886 |
-
intent, top_score,
|
| 887 |
)
|
| 888 |
|
| 889 |
return {
|
|
@@ -894,12 +1131,99 @@ async def _rag_preprocess(
|
|
| 894 |
"lang": lang,
|
| 895 |
"top_score": top_score,
|
| 896 |
"intent": intent,
|
|
|
|
|
|
|
|
|
|
| 897 |
}
|
| 898 |
|
| 899 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 900 |
# ═══════════════════════════════════════════════════════════════════════
|
| 901 |
# CORE RAG PIPELINE (non-streaming, with hallucination checks)
|
| 902 |
# ═══════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 903 |
async def run_rag_pipeline(
|
| 904 |
question: str,
|
| 905 |
top_k: int = cfg.TOP_K_RETURN,
|
|
@@ -926,13 +1250,11 @@ async def run_rag_pipeline(
|
|
| 926 |
top_score = ctx["top_score"]
|
| 927 |
intent = ctx["intent"]
|
| 928 |
|
| 929 |
-
# 5. Confidence gate
|
| 930 |
-
|
| 931 |
-
threshold = cfg.INTENT_THRESHOLDS.get(intent, cfg.CONFIDENCE_THRESHOLD)
|
| 932 |
-
if not surah_info and top_score < threshold:
|
| 933 |
logger.warning(
|
| 934 |
-
"Low confidence (%
|
| 935 |
-
|
| 936 |
)
|
| 937 |
return {
|
| 938 |
"answer": not_found_answer(lang),
|
|
@@ -967,22 +1289,24 @@ async def run_rag_pipeline(
|
|
| 967 |
messages = build_messages(context, question, lang, intent, analysis, surah_info)
|
| 968 |
|
| 969 |
try:
|
| 970 |
-
answer = await state.llm.chat(
|
| 971 |
messages,
|
| 972 |
max_tokens=max_tokens or cfg.MAX_TOKENS,
|
| 973 |
temperature=cfg.TEMPERATURE,
|
| 974 |
-
)
|
| 975 |
-
|
| 976 |
-
|
| 977 |
-
if
|
| 978 |
-
|
| 979 |
-
|
| 980 |
-
|
| 981 |
-
|
| 982 |
-
|
| 983 |
-
|
| 984 |
-
|
| 985 |
-
|
|
|
|
|
|
|
| 986 |
|
| 987 |
if not answer:
|
| 988 |
logger.warning("LLM returned empty answer — using results fallback")
|
|
@@ -1001,6 +1325,7 @@ async def run_rag_pipeline(
|
|
| 1001 |
answer = _verify_references(answer, results)
|
| 1002 |
if surah_info:
|
| 1003 |
answer = _verify_surah_info(answer, surah_info)
|
|
|
|
| 1004 |
|
| 1005 |
latency = int((time.perf_counter() - t0) * 1000)
|
| 1006 |
logger.info(
|
|
@@ -1025,6 +1350,16 @@ async def run_rag_pipeline(
|
|
| 1025 |
# ═══════════════════════════════════════════════════════════════════════
|
| 1026 |
# STREAMING RAG PIPELINE (token streaming with sentence verification)
|
| 1027 |
# ═══════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1028 |
class StreamingVerificationBuffer:
|
| 1029 |
"""Sentence-buffered streaming citation & reference verification."""
|
| 1030 |
|
|
@@ -1032,40 +1367,61 @@ class StreamingVerificationBuffer:
|
|
| 1032 |
self.results = results
|
| 1033 |
self.surah_info = surah_info
|
| 1034 |
self.buffer = ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1035 |
|
| 1036 |
def feed(self, token: str) -> List[str]:
|
| 1037 |
self.buffer += token
|
| 1038 |
emitted: List[str] = []
|
| 1039 |
while True:
|
| 1040 |
# If a sacred quote was opened but not yet closed, hold until closed (up to 400 chars)
|
| 1041 |
-
if (
|
| 1042 |
-
|
| 1043 |
break
|
| 1044 |
|
| 1045 |
-
|
| 1046 |
-
|
| 1047 |
-
|
| 1048 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1049 |
chunk = self.buffer[:idx]
|
| 1050 |
self.buffer = self.buffer[idx:]
|
| 1051 |
-
|
| 1052 |
-
chunk = _verify_citations(chunk, self.results)
|
| 1053 |
-
chunk = _verify_references(chunk, self.results)
|
| 1054 |
-
if self.surah_info:
|
| 1055 |
-
chunk = _verify_surah_info(chunk, self.surah_info)
|
| 1056 |
-
emitted.append(chunk)
|
| 1057 |
return emitted
|
| 1058 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1059 |
def flush(self) -> str:
|
| 1060 |
if not self.buffer:
|
| 1061 |
return ""
|
| 1062 |
chunk = self.buffer
|
| 1063 |
self.buffer = ""
|
| 1064 |
-
|
| 1065 |
-
chunk = _verify_references(chunk, self.results)
|
| 1066 |
-
if self.surah_info:
|
| 1067 |
-
chunk = _verify_surah_info(chunk, self.surah_info)
|
| 1068 |
-
return chunk
|
| 1069 |
|
| 1070 |
|
| 1071 |
async def run_rag_pipeline_stream(
|
|
@@ -1104,7 +1460,7 @@ async def run_rag_pipeline_stream(
|
|
| 1104 |
}
|
| 1105 |
|
| 1106 |
# ── Deterministic / fallback paths ────────────────────────────────
|
| 1107 |
-
if not
|
| 1108 |
yield {"type": "token", "content": not_found_answer(lang)}
|
| 1109 |
yield {"type": "done", "latency_ms": int((time.perf_counter() - t0) * 1000)}
|
| 1110 |
return
|
|
@@ -1151,10 +1507,22 @@ async def run_rag_pipeline_stream(
|
|
| 1151 |
else:
|
| 1152 |
for verified_chunk in verifier.feed(token):
|
| 1153 |
yield {"type": "token", "content": verified_chunk}
|
| 1154 |
-
|
| 1155 |
-
|
| 1156 |
-
|
| 1157 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1158 |
|
| 1159 |
except Exception as exc:
|
| 1160 |
logger.error("LLM stream failed: %s", exc)
|
|
|
|
| 8 |
import re
|
| 9 |
import time
|
| 10 |
from contextlib import asynccontextmanager
|
| 11 |
+
from difflib import SequenceMatcher
|
| 12 |
+
from typing import Any, AsyncIterator, Dict, List, Literal, Optional, Tuple
|
| 13 |
|
| 14 |
try:
|
| 15 |
import faiss
|
|
|
|
| 29 |
detect_surah_info,
|
| 30 |
lookup_surah_info,
|
| 31 |
)
|
| 32 |
+
from app.arabic_nlp import ARABIC_STOPWORDS, detect_language, normalize_arabic, tokenize_bilingual
|
| 33 |
from app.config import cfg
|
| 34 |
from app.cache import pipeline_cache
|
| 35 |
from app.llm import LLMProvider, get_llm_provider
|
| 36 |
from app.prompts import build_messages, not_found_answer
|
| 37 |
from app.search import (
|
| 38 |
BM25InvertedIndex,
|
| 39 |
+
_COLLECTION_AR,
|
| 40 |
+
_normalized_texts,
|
| 41 |
build_context,
|
| 42 |
+
build_type_indexes,
|
| 43 |
cluster_hadith_candidates,
|
| 44 |
expand_quran_context,
|
| 45 |
+
fuse_search_results,
|
| 46 |
hybrid_search,
|
| 47 |
lookup_hadith_references,
|
| 48 |
lookup_quran_verses,
|
|
|
|
| 49 |
rewrite_query,
|
| 50 |
+
score_relevance,
|
| 51 |
+
surah_url,
|
| 52 |
text_search,
|
| 53 |
)
|
| 54 |
|
|
|
|
| 115 |
surah_en = r.get("surah_name_en", "")
|
| 116 |
surah_num = r.get("surah_number", "")
|
| 117 |
verse_num = r.get("verse_number", "")
|
| 118 |
+
verse_url = surah_url(surah_num, verse_num)
|
| 119 |
tafsir_url = f"https://quran.ksu.edu.sa/tafseer/qortobi/sura{surah_num}-aya{verse_num}.html" if surah_num and verse_num else ""
|
| 120 |
+
surah_link = f"[[1]({verse_url})]" if verse_url else ""
|
| 121 |
tafsir_link = f"[[1]({tafsir_url})]" if tafsir_url else ""
|
| 122 |
|
| 123 |
if lang == "arabic":
|
|
|
|
| 237 |
return name_map
|
| 238 |
|
| 239 |
|
| 240 |
+
# How answers name each fatwa source (matched on normalized text)
|
| 241 |
+
_FATWA_SOURCE_MENTIONS = {
|
| 242 |
+
"Islamweb": re.compile(r"اسلام ?ويب|مركز الفتوي|islam ?web", re.I),
|
| 243 |
+
"BinBaz": re.compile(r"\bا?بن باز|\b(?:bin|ibn) ?baz", re.I),
|
| 244 |
+
"AlBadr": re.compile(r"عبد الرزاق البدر|al-?badr", re.I),
|
| 245 |
+
}
|
| 246 |
+
_TITLE_MIN_COVERAGE = 0.6
|
| 247 |
+
_STOP_NORM = {normalize_arabic(w) for w in ARABIC_STOPWORDS}
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
def _content_words(text: str) -> set:
|
| 251 |
+
return {w for w in normalize_arabic(text).lower().split() if len(w) > 2 and w not in _STOP_NORM}
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
# A fatwa also counts as used when the answer shares at least _FATWA_COPY_MIN runs of
|
| 255 |
+
# _FATWA_COPY_RUN words with it (about 17 consecutive words). Quoted scripture is left
|
| 256 |
+
# out first, since fatwas quote the same verses and hadith the answer does.
|
| 257 |
+
_FATWA_COPY_RUN = 8
|
| 258 |
+
_FATWA_COPY_MIN = 10
|
| 259 |
+
_QUOTED_SPAN = re.compile(r"﴿[^﴾]*﴾|❝[^❞]*❞|«[^»]*»")
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
def _word_runs(text: str, n: int = _FATWA_COPY_RUN) -> set:
|
| 263 |
+
words = re.findall(r"\w+", normalize_arabic(text).lower())
|
| 264 |
+
return {tuple(words[i:i + n]) for i in range(len(words) - n + 1)}
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
def _copied_runs(answer: str, results: list) -> set:
|
| 268 |
+
"""Word runs of the answer outside quoted scripture and retrieved verses/hadith."""
|
| 269 |
+
runs = _word_runs(_QUOTED_SPAN.sub(" ", answer))
|
| 270 |
+
for r in results:
|
| 271 |
+
if r.get("type") in ("quran", "hadith"):
|
| 272 |
+
runs -= _word_runs(r.get("arabic", ""))
|
| 273 |
+
return runs
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
# The prompt's link template, copied literally: [[1]({source_url})] or [[1]({https://...})]
|
| 277 |
+
_LINK_PLACEHOLDER = re.compile(r"\s*\[?\[\d*\]\(\{source_url\}\)\]?")
|
| 278 |
+
_BRACED_URL = re.compile(r"\(\{(https?://[^\s{}]+)\}\)")
|
| 279 |
+
# [1](!https://...) — the stray "!" breaks the link and the UI shows the raw URL with ")]"
|
| 280 |
+
_BANG_URL = re.compile(r"\]\(\s*!+\s*(?=https?://)")
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
def _clean_links(text: str) -> str:
|
| 284 |
+
text = _BANG_URL.sub("](", _BRACED_URL.sub(r"(\1)", text))
|
| 285 |
+
return _LINK_PLACEHOLDER.sub("", text)
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
def _fatwa_links(answer: str, results: list, lang: str) -> str:
|
| 289 |
+
"""Source-link lines for fatwas the answer relies on but doesn't link ("" if none).
|
| 290 |
+
|
| 291 |
+
The LLM reliably names the fatwa council but often drops the [FATWA_REF] URL.
|
| 292 |
+
A retrieved fatwa counts as used when the answer contains most of its title's
|
| 293 |
+
words or copies a run of its text, else when the answer credits its source and
|
| 294 |
+
it is that source's top-ranked fatwa. Nothing is added if the answer already
|
| 295 |
+
links a retrieved fatwa.
|
| 296 |
+
"""
|
| 297 |
+
fatwas = [r for r in results if r.get("type") == "fatwa" and r.get("source_url")]
|
| 298 |
+
if not fatwas or any(r["source_url"].rstrip("/") in answer for r in fatwas):
|
| 299 |
+
return ""
|
| 300 |
+
|
| 301 |
+
answer_words = _content_words(answer)
|
| 302 |
+
answer_runs = _copied_runs(answer, results)
|
| 303 |
+
used = []
|
| 304 |
+
for r in fatwas:
|
| 305 |
+
title_words = _content_words(r.get("title", ""))
|
| 306 |
+
if len(title_words) >= 3 and len(title_words & answer_words) / len(title_words) >= _TITLE_MIN_COVERAGE:
|
| 307 |
+
used.append(r)
|
| 308 |
+
elif len(answer_runs & _word_runs(r.get("answer_ar") or r.get("arabic") or r.get("english", ""))) >= _FATWA_COPY_MIN:
|
| 309 |
+
used.append(r)
|
| 310 |
+
if not used:
|
| 311 |
+
answer_norm = normalize_arabic(answer)
|
| 312 |
+
for source, pattern in _FATWA_SOURCE_MENTIONS.items():
|
| 313 |
+
if pattern.search(answer_norm):
|
| 314 |
+
top = next((r for r in fatwas if r.get("source") == source), None)
|
| 315 |
+
if top:
|
| 316 |
+
used.append(top)
|
| 317 |
+
used = list({r["source_url"]: r for r in reversed(used)}.values())[::-1]
|
| 318 |
+
label = "المصدر" if lang == "arabic" else "Source"
|
| 319 |
+
return "\n".join(f"{label}: [{r.get('title') or r['source_url']}]({r['source_url']})" for r in used[:3])
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
# Word that identifies each collection when an answer names it
|
| 323 |
+
_COLLECTION_NAMES = {
|
| 324 |
+
"Sahih al-Bukhari": ("bukhari", "البخاري"), "Sahih Muslim": ("muslim", "مسلم"),
|
| 325 |
+
"Sunan Abu Dawood": ("dawud", "dawood", "داود"), "Jami' at-Tirmidhi": ("tirmidhi", "الترمذي"),
|
| 326 |
+
"Sunan an-Nasai": ("nasai", "nasa'i", "النسائي"), "Sunan Ibn Majah": ("majah", "ماجه"),
|
| 327 |
+
"Muwatta Malik": ("malik", "muwatta", "مالك", "الموطأ"), "Musnad Ahmad": ("ahmad", "أحمد"),
|
| 328 |
+
"Sunan al-Darimi": ("darimi", "الدارمي"), "Riyad al-Salihin": ("riyad", "الصالحين"),
|
| 329 |
+
"Bulugh al-Maram": ("bulugh", "المرام"), "Al-Adab al-Mufrad": ("adab", "المفرد"),
|
| 330 |
+
"Ash-Shama'il al-Muhammadiyyah": ("shama'il", "shamail", "الشمائل"),
|
| 331 |
+
"An-Nawawi's 40 Hadith": ("nawawi", "النووية"), "Hadith Qudsi": ("qudsi", "القدسية"),
|
| 332 |
+
}
|
| 333 |
+
# A hadith counts as quoted when the answer reproduces most of its matn, or a long run
|
| 334 |
+
# of its English translation. The isnad (shared narrators, so shared between unrelated
|
| 335 |
+
# hadith) ends at the first mention of the Prophet; without one, the last 60% is used.
|
| 336 |
+
_PROPHET_MENTION = re.compile(
|
| 337 |
+
r"(?:قال|عن|ان|سمعت|يقول)\s+(?:رسول\s+الله|النبي)(?:\s+صلي\s+الله\s+عليه\s+وسلم)?"
|
| 338 |
+
)
|
| 339 |
+
_MATN_MAX = 150 # consonants of the matn compared
|
| 340 |
+
_MIN_MATN_COVERAGE = 0.6
|
| 341 |
+
_MATN_MIN_RUN = 8 # ~2 words; shorter runs match by chance in a long answer
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
def _matn_skeleton(arabic: str) -> str:
|
| 345 |
+
norm = normalize_arabic(arabic)
|
| 346 |
+
m = _PROPHET_MENTION.search(norm)
|
| 347 |
+
matn = _skeleton(norm[m.end():]) if m else _skeleton(norm)[int(len(_skeleton(norm)) * 0.4):]
|
| 348 |
+
return matn[:_MATN_MAX]
|
| 349 |
+
_MIN_QUOTED_EN = 80 # characters shared in one run with the English translation
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
def _longest_shared_run(a: str, b: str) -> int:
|
| 353 |
+
if not a or not b:
|
| 354 |
+
return 0
|
| 355 |
+
return SequenceMatcher(None, a, b, autojunk=False).find_longest_match(0, len(a), 0, len(b)).size
|
| 356 |
+
|
| 357 |
+
|
| 358 |
+
def _hadith_refs(answer: str, results: list, lang: str) -> str:
|
| 359 |
+
"""Reference lines for retrieved hadith the answer quotes without citing ("" if none).
|
| 360 |
+
|
| 361 |
+
The model often quotes the hadith but drops its number, despite [HADITH_REF].
|
| 362 |
+
"""
|
| 363 |
+
lines = []
|
| 364 |
+
answer_lower = answer.lower()
|
| 365 |
+
answer_skel = _skeleton(answer)
|
| 366 |
+
for r in results:
|
| 367 |
+
number, collection = str(r.get("hadith_number", "")), r.get("collection", "")
|
| 368 |
+
if r.get("type") != "hadith" or not number or not collection:
|
| 369 |
+
continue
|
| 370 |
+
names = _COLLECTION_NAMES.get(collection, (collection.lower(),))
|
| 371 |
+
if re.search(rf"(?<!\d){re.escape(number)}(?!\d)", answer) and any(n in answer_lower for n in names):
|
| 372 |
+
continue # already cited
|
| 373 |
+
matn = _matn_skeleton(r.get("arabic", ""))
|
| 374 |
+
quoted = (len(matn) >= 15 and _skeleton_coverage(matn, answer_skel, _MATN_MIN_RUN) >= _MIN_MATN_COVERAGE
|
| 375 |
+
or _longest_shared_run(answer_lower, r.get("english", "").lower()) >= _MIN_QUOTED_EN)
|
| 376 |
+
if quoted:
|
| 377 |
+
ref = (f"{_COLLECTION_AR[collection]}، رقم {number}" if lang == "arabic" and collection in _COLLECTION_AR
|
| 378 |
+
else r.get("reference") or f"{collection} {number}")
|
| 379 |
+
lines.append(f"{'المرجع' if lang == 'arabic' else 'Reference'}: {ref}")
|
| 380 |
+
return "\n".join(lines[:3])
|
| 381 |
+
|
| 382 |
+
|
| 383 |
+
def _source_refs(answer: str, results: list, lang: str) -> str:
|
| 384 |
+
"""Citation lines to append for sources the answer uses but doesn't cite."""
|
| 385 |
+
return "\n".join(x for x in (_hadith_refs(answer, results, lang), _fatwa_links(answer, results, lang)) if x)
|
| 386 |
+
|
| 387 |
+
|
| 388 |
+
def _attach_source_refs(answer: str, results: list, lang: str) -> str:
|
| 389 |
+
answer = _clean_links(answer)
|
| 390 |
+
links = _source_refs(answer, results, lang)
|
| 391 |
+
if not links:
|
| 392 |
+
return answer
|
| 393 |
+
# Keep the closing formula last
|
| 394 |
+
closing = re.search(r"\n*\s*(والله أعلم\.?|And Allah knows best\.?)\s*$", answer)
|
| 395 |
+
if closing:
|
| 396 |
+
return f"{answer[:closing.start()].rstrip()}\n\n{links}\n\n{closing.group(1)}"
|
| 397 |
+
return f"{answer.rstrip()}\n\n{links}"
|
| 398 |
+
|
| 399 |
+
|
| 400 |
+
_SURAH_URL = re.compile(r"https://quran\.com/(\d+)/(\d+)\b")
|
| 401 |
+
# Verse links the model may still write from memory, in the old format
|
| 402 |
+
_OLD_SURAH_URL = re.compile(r"https?://(?:www\.)?surahquran\.com/aya-(\d+)-sora-(\d+)\.html")
|
| 403 |
+
|
| 404 |
+
|
| 405 |
def _verify_references(answer: str, results: list) -> str:
|
| 406 |
"""Check that surah/verse references in the answer match retrieved results.
|
| 407 |
|
| 408 |
Replaces hallucinated references with corrected ones or warnings.
|
| 409 |
"""
|
| 410 |
+
answer = _OLD_SURAH_URL.sub(lambda m: surah_url(m.group(2), m.group(1)), answer)
|
| 411 |
valid_refs = _build_valid_refs(results)
|
| 412 |
if not valid_refs:
|
| 413 |
return answer # No quran results — nothing to verify
|
|
|
|
| 506 |
primary = quran_results[0]
|
| 507 |
p_sn = int(primary["surah_number"])
|
| 508 |
p_vn = int(primary["verse_number"])
|
| 509 |
+
actual_surah_url = surah_url(p_sn, p_vn)
|
| 510 |
actual_tafsir_url = f"https://quran.ksu.edu.sa/tafseer/qortobi/sura{p_sn}-aya{p_vn}.html"
|
| 511 |
answer = answer.replace("{surah_url}", actual_surah_url).replace("{tafsir_url}", actual_tafsir_url)
|
| 512 |
|
|
|
|
| 514 |
correct_sn, correct_vn = next(iter(valid_refs))
|
| 515 |
|
| 516 |
def _check_surah_url(m: re.Match) -> str:
|
| 517 |
+
sn, vn = int(m.group(1)), int(m.group(2))
|
| 518 |
if (sn, vn) not in valid_refs:
|
| 519 |
logger.warning("Correcting hallucinated surah URL %d:%d -> %d:%d", sn, vn, correct_sn, correct_vn)
|
| 520 |
+
return surah_url(correct_sn, correct_vn)
|
| 521 |
return m.group(0)
|
| 522 |
|
| 523 |
def _check_ksu_url(m: re.Match) -> str:
|
|
|
|
| 527 |
return f"https://quran.ksu.edu.sa/tafseer/qortobi/sura{correct_sn}-aya{correct_vn}.html"
|
| 528 |
return m.group(0)
|
| 529 |
|
| 530 |
+
answer = _SURAH_URL.sub(_check_surah_url, answer)
|
| 531 |
answer = re.sub(r"https://quran\.ksu\.edu\.sa/tafseer/qortobi/sura(\d+)-aya(\d+)\.html", _check_ksu_url, answer)
|
| 532 |
|
| 533 |
return answer
|
| 534 |
|
| 535 |
|
| 536 |
def _verify_citations(answer: str, results: list) -> str:
|
| 537 |
+
"""Check that quoted Arabic scripture in the answer appears in the retrieved results.
|
| 538 |
+
|
| 539 |
+
Only applies to explicit sacred quote markers (﴿...﴾, ❝...❞, «...») containing substantive
|
| 540 |
+
Arabic text. Grounding is strict: a genuine verse that was not retrieved still fails,
|
| 541 |
+
since the answer may only quote what it was given.
|
| 542 |
+
|
| 543 |
+
1. A quote found verbatim (after normalization) in a retrieved passage is kept.
|
| 544 |
+
2. A quote that nearly matches a retrieved verse or hadith (≥ _SCRIPTURE_MIN_COVERAGE of
|
| 545 |
+
its consonant skeleton) is replaced by that passage's canonical text. LLMs corrupt
|
| 546 |
+
scripture while copying it: foreign letters, changed word forms, truncation.
|
| 547 |
+
3. Other quotes are kept only if they largely overlap a retrieved non-scripture text
|
| 548 |
+
(fatwa, tafsir, translation); otherwise they are replaced with a warning marker.
|
| 549 |
"""
|
| 550 |
if not getattr(cfg, "ENABLE_CITATION_VERIFICATION", True):
|
| 551 |
return answer
|
| 552 |
|
| 553 |
+
source_texts_raw = [] # every retrieved text: verbatim check
|
| 554 |
+
commentary_raw = [] # non-scripture texts: word-overlap check
|
| 555 |
+
scripture = [] # (canonical arabic, open, close, skeleton)
|
| 556 |
for r in results:
|
| 557 |
for field in ("arabic", "english", "text", "tafsir", "tafsir_ar", "tafsir_en", "explanation"):
|
| 558 |
val = r.get(field, "")
|
| 559 |
if val:
|
| 560 |
+
val = re.sub(r"\s+", " ", str(val).strip())
|
| 561 |
+
source_texts_raw.append(val)
|
| 562 |
+
if not (field == "arabic" and r.get("type") in ("quran", "hadith")):
|
| 563 |
+
commentary_raw.append(val)
|
| 564 |
+
if r.get("type") in ("quran", "hadith") and r.get("arabic"):
|
| 565 |
+
marks = ("﴿", "﴾") if r["type"] == "quran" else ("❝ ", " ❞")
|
| 566 |
+
scripture.append((r["arabic"], *marks, _skeleton(r["arabic"])))
|
| 567 |
if r.get("context_ayat"):
|
| 568 |
for ctx in r["context_ayat"]:
|
| 569 |
if ctx.get("arabic"):
|
| 570 |
source_texts_raw.append(re.sub(r"\s+", " ", str(ctx["arabic"]).strip()))
|
| 571 |
+
scripture.append((ctx["arabic"], "﴿", "﴾", _skeleton(ctx["arabic"])))
|
| 572 |
|
| 573 |
# Pre-compute normalized versions + word sets for diacritics-insensitive comparison
|
| 574 |
+
source_entries = [(s, normalize_arabic(s)) for s in source_texts_raw]
|
| 575 |
+
commentary_words = [set(normalize_arabic(s).split()) for s in commentary_raw]
|
|
|
|
|
|
|
| 576 |
|
| 577 |
def _contains(quote: str, src: str) -> bool:
|
| 578 |
# Reverse containment (source inside quote) only counts for non-trivial
|
|
|
|
| 589 |
if len(quoted_norm) < 15 or ar_chars < 12:
|
| 590 |
return m.group(0)
|
| 591 |
|
| 592 |
+
# 1. Verbatim in retrieved search results (strictly grounded in retrieved indexed context)
|
| 593 |
+
for src_raw, src_norm in source_entries:
|
|
|
|
|
|
|
|
|
|
| 594 |
if _contains(quoted_raw, src_raw) or _contains(quoted_norm, src_norm):
|
| 595 |
return m.group(0)
|
|
|
|
|
|
|
|
|
|
| 596 |
|
| 597 |
+
# 2. Near-copy of a retrieved verse / hadith → restore the canonical text
|
| 598 |
+
q_skel = _skeleton(quoted_raw)
|
| 599 |
+
best, best_cov = None, 0.0
|
| 600 |
+
for entry in scripture:
|
| 601 |
+
cov = _skeleton_coverage(q_skel, entry[3])
|
| 602 |
+
if cov > best_cov or (cov == best_cov and best and len(entry[0]) < len(best[0])):
|
| 603 |
+
best, best_cov = entry, cov
|
| 604 |
+
if best and best_cov >= _SCRIPTURE_MIN_COVERAGE:
|
| 605 |
+
logger.info("Restoring canonical text for corrupted scripture quote (coverage %.2f)", best_cov)
|
| 606 |
+
return f"{best[1]}{best[0]}{best[2]}"
|
| 607 |
+
|
| 608 |
+
# 3. Quoting a retrieved fatwa / tafsir / translation (≥40% of quoted words found)
|
| 609 |
+
q_words = set(quoted_norm.split())
|
| 610 |
+
if q_words and any(len(q_words & w) / len(q_words) >= 0.4 for w in commentary_words):
|
| 611 |
+
return m.group(0)
|
| 612 |
+
|
| 613 |
+
# Not grounded in retrieved results — LLM generated/recalled scripture from model weights
|
| 614 |
logger.warning("Hallucination detected: quoted text not in retrieved results: %.80s...", quoted_norm)
|
| 615 |
+
return "❝ ⚠️ [تم حذف نص غير موثق — النص غير موجود في البيانات المسترجعة] ❞"
|
| 616 |
+
|
| 617 |
+
return _QUOTE_RE.sub(_check_quote, answer)
|
| 618 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 619 |
|
| 620 |
+
def _skeleton(text: str) -> str:
|
| 621 |
+
"""Consonant skeleton: drops diacritics, long vowels, hamza seats and non-Arabic
|
| 622 |
+
characters, so Uthmani and plain spellings of the same verse compare equal."""
|
| 623 |
+
return re.sub(r"[^\u0621-\u064A]|[اويىء]", "", normalize_arabic(text))
|
|
|
|
| 624 |
|
|
|
|
| 625 |
|
| 626 |
+
def _skeleton_coverage(quote_skel: str, source_skel: str, min_run: int = 3) -> float:
|
| 627 |
+
"""Fraction of the quote's skeleton found, in order, in the source (runs of ≥ min_run letters)."""
|
| 628 |
+
if not quote_skel or not source_skel:
|
| 629 |
+
return 0.0
|
| 630 |
+
blocks = SequenceMatcher(None, quote_skel, source_skel, autojunk=False).get_matching_blocks()
|
| 631 |
+
return sum(b.size for b in blocks if b.size >= min_run) / len(quote_skel)
|
| 632 |
|
| 633 |
|
| 634 |
+
_SCRIPTURE_MIN_COVERAGE = 0.8
|
| 635 |
_MIN_REVERSE_MATCH = 15
|
| 636 |
|
| 637 |
|
|
|
|
| 808 |
llm: Optional[LLMProvider] = None
|
| 809 |
bm25_index: Optional[BM25InvertedIndex] = None
|
| 810 |
reranker: Optional[Any] = None
|
| 811 |
+
type_indexes: Dict[str, Tuple[Any, Any]] = {} # source -> (sub-index, positions)
|
| 812 |
ready: bool = False
|
| 813 |
|
| 814 |
# O(1) lookup tables built at startup
|
| 815 |
quran_verse_idx: Dict[Tuple[int, int], dict] = {} # (surah_num, verse_num) -> item
|
| 816 |
hadith_ref_idx: Dict[Tuple[str, int], dict] = {} # (collection, hadith_num) -> item
|
| 817 |
surah_info_map: Dict[str, dict] = {} # normalised name key -> info
|
|
|
|
|
|
|
| 818 |
|
| 819 |
|
| 820 |
state = AppState()
|
|
|
|
| 872 |
state.hadith_ref_idx = hadith_ref_idx
|
| 873 |
state.surah_info_map = surah_info_map
|
| 874 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 875 |
logger.info("Building full-dataset inverted BM25 index...")
|
| 876 |
bm25 = BM25InvertedIndex()
|
| 877 |
bm25.build(dataset)
|
| 878 |
state.bm25_index = bm25
|
| 879 |
+
_normalized_texts(dataset) # warm text_search's normalized-text cache
|
| 880 |
|
| 881 |
logger.info(
|
| 882 |
"Lookup tables ready: %d Quran verses | %d hadith refs | %d surah keys | %d BM25 terms",
|
|
|
|
| 901 |
|
| 902 |
logger.info("Building lookup tables...")
|
| 903 |
_build_lookup_tables(state.dataset)
|
| 904 |
+
state.type_indexes = build_type_indexes(state.faiss_index, state.dataset)
|
| 905 |
|
| 906 |
logger.info("Initializing LLM provider: %s", cfg.LLM_BACKEND)
|
| 907 |
state.llm = get_llm_provider()
|
|
|
|
| 911 |
try:
|
| 912 |
logger.info("Loading cross-encoder reranker: %s", cfg.RERANK_MODEL)
|
| 913 |
from sentence_transformers import CrossEncoder
|
| 914 |
+
state.reranker = CrossEncoder(cfg.RERANK_MODEL, max_length=512)
|
| 915 |
logger.info("Cross-encoder reranker ready")
|
| 916 |
except Exception as exc:
|
| 917 |
logger.warning("Could not load CrossEncoder (%s), continuing without reranker", exc)
|
| 918 |
|
| 919 |
state.ready = True
|
| 920 |
logger.info(
|
| 921 |
+
"QModel v8.5 ready | backend=%s | dataset=%d | faiss=%d | gate=%s",
|
| 922 |
cfg.LLM_BACKEND,
|
| 923 |
len(state.dataset) if state.dataset else 0,
|
| 924 |
state.faiss_index.ntotal if state.faiss_index else 0,
|
| 925 |
+
f"reranker≥{cfg.RERANK_MIN_SCORE}" if state.reranker else f"dense≥{cfg.DENSE_MIN_SCORE}",
|
| 926 |
)
|
| 927 |
yield
|
| 928 |
state.ready = False
|
|
|
|
| 941 |
# ═══════════════════════════════════════════════════════════════════════
|
| 942 |
# SHARED PRE-PROCESSING (steps 1-7, used by both pipeline variants)
|
| 943 |
# ═══════════════════════════════════════════════════════════════════════
|
| 944 |
+
_SOURCE_QURAN_RE = re.compile(
|
| 945 |
+
# normalize_arabic maps قران → قرآن (_SPELLING_MAP), so accept both spellings.
|
| 946 |
+
r"(?:^|[\s:،])(?:ايه|الايه|ايات|الايات|قر[اآ]ن|القر[اآ]ن|سوره|السوره)(?:[\s:،]|$)|في القر[اآ]ن|ايات من"
|
| 947 |
+
r"|\b(?:qur'?an|koran|verses?|ayahs?|ayat|surahs?|suras?)\b",
|
| 948 |
+
re.I,
|
| 949 |
+
)
|
| 950 |
+
_SOURCE_HADITH_RE = re.compile(
|
| 951 |
+
r"(?:^|[\s:،])(?:حديث|الحديث|احاديث|الاحاديث)(?:[\s:،]|$)|في الحديث|حديث نبوي|قال رسول الله"
|
| 952 |
+
r"|\b(?:hadiths?|ahadith|sunnah|the prophet|messenger of allah)\b",
|
| 953 |
+
re.I,
|
| 954 |
+
)
|
| 955 |
+
_SOURCE_FATWA_RE = re.compile(
|
| 956 |
+
r"(?:^|[\s:،])(?:فتوي|الفتوي|فتاوي|الفتاوي)(?:[\s:،]|$)|حكم الشرع|هل يجوز|هل يحرم"
|
| 957 |
+
r"|\bfatwas?\b",
|
| 958 |
+
re.I,
|
| 959 |
+
)
|
| 960 |
+
|
| 961 |
+
|
| 962 |
+
def _detect_source_type(question: str) -> Optional[Literal["quran", "hadith", "fatwa"]]:
|
| 963 |
+
"""Restrict retrieval to one source when the question names it (Arabic or English).
|
| 964 |
+
|
| 965 |
+
A question naming both the Qur'an and hadith ("Quran and Sunnah") stays unfiltered.
|
| 966 |
+
"""
|
| 967 |
+
norm_q = normalize_arabic(question).lower()
|
| 968 |
+
quran = bool(_SOURCE_QURAN_RE.search(norm_q))
|
| 969 |
+
hadith = bool(_SOURCE_HADITH_RE.search(norm_q))
|
| 970 |
+
if quran and hadith:
|
| 971 |
+
return None
|
| 972 |
+
if quran:
|
| 973 |
+
return "quran"
|
| 974 |
+
if hadith:
|
| 975 |
+
return "hadith"
|
| 976 |
+
if _SOURCE_FATWA_RE.search(norm_q):
|
| 977 |
+
return "fatwa"
|
| 978 |
+
return None
|
| 979 |
+
|
| 980 |
+
|
| 981 |
async def _rag_preprocess(
|
| 982 |
question: str,
|
| 983 |
top_k: int,
|
|
|
|
| 996 |
|
| 997 |
# 1b. Override source_type if explicitly mentioned in query
|
| 998 |
if not source_type:
|
| 999 |
+
source_type = _detect_source_type(question)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1000 |
|
| 1001 |
# 2. Concurrent: surah info + analysis intent + hybrid search (with multi-vector & reranker)
|
| 1002 |
surah_task = detect_surah_info(question, rewrite)
|
|
|
|
| 1007 |
top_k, source_type, grade_filter,
|
| 1008 |
bm25_index=state.bm25_index,
|
| 1009 |
reranker_model=state.reranker,
|
| 1010 |
+
type_indexes=state.type_indexes,
|
| 1011 |
)
|
| 1012 |
+
surah_det, analysis_kw, hybrid_results = await asyncio.gather(
|
| 1013 |
surah_task, kw_task, search_task,
|
| 1014 |
)
|
| 1015 |
|
|
|
|
| 1032 |
lookup_hadith_references(query, state.dataset, limit=top_k,
|
| 1033 |
hadith_idx=state.hadith_ref_idx)
|
| 1034 |
)
|
|
|
|
| 1035 |
|
| 1036 |
# 2c. Text search fallback (offloaded to thread worker)
|
| 1037 |
ar_q = rewrite.get("ar_query", "")
|
|
|
|
| 1042 |
text_src = "hadith"
|
| 1043 |
|
| 1044 |
text_limit = top_k * 2 if intent in ("auth", "hadith", "tafsir") else top_k
|
| 1045 |
+
text_results: list = []
|
| 1046 |
for q in dict.fromkeys([ar_q, question]):
|
| 1047 |
if not q:
|
| 1048 |
continue
|
|
|
|
| 1067 |
))
|
| 1068 |
hits = hits[:20]
|
| 1069 |
|
| 1070 |
+
hits = [
|
| 1071 |
+
{**hit, "_score": hit["_score"] + 1.0}
|
| 1072 |
+
if intent == "auth" and hit.get("_score", 0) > 2.0 else hit
|
| 1073 |
+
for hit in hits
|
| 1074 |
+
]
|
| 1075 |
+
text_results.append((q, hits))
|
| 1076 |
+
results = fuse_search_results(
|
| 1077 |
+
direct_results, hybrid_results, text_results, limit=max(top_k * 3, 20),
|
| 1078 |
+
)
|
| 1079 |
results = cluster_hadith_candidates(results)
|
| 1080 |
+
if state.reranker is not None:
|
| 1081 |
+
results = await _rerank_by_relevance(question, results)
|
| 1082 |
results = results[:top_k]
|
| 1083 |
# Apply Ayah context expansion windowing
|
| 1084 |
results = expand_quran_context(results, state.quran_verse_idx, window_size=cfg.AYAH_WINDOW_SIZE)
|
|
|
|
| 1110 |
lang = detect_language(question)
|
| 1111 |
top_score = results[0].get("_score", 0.0) if results else 0.0
|
| 1112 |
|
| 1113 |
+
# 5. Confidence: is any retrieved passage actually relevant?
|
| 1114 |
+
if surah_info or (analysis and analysis.get("total_count")):
|
| 1115 |
+
confident, confidence, conf_source = True, 1.0, "metadata"
|
| 1116 |
+
else:
|
| 1117 |
+
confident, confidence, conf_source = await _assess_confidence(
|
| 1118 |
+
question, results, hybrid_results,
|
| 1119 |
+
)
|
| 1120 |
+
|
| 1121 |
logger.info(
|
| 1122 |
+
"Search done | intent=%s | top_score=%.3f | confidence=%.3f (%s) | confident=%s",
|
| 1123 |
+
intent, top_score, confidence, conf_source, confident,
|
| 1124 |
)
|
| 1125 |
|
| 1126 |
return {
|
|
|
|
| 1131 |
"lang": lang,
|
| 1132 |
"top_score": top_score,
|
| 1133 |
"intent": intent,
|
| 1134 |
+
"confident": confident,
|
| 1135 |
+
"confidence": confidence,
|
| 1136 |
+
"confidence_source": conf_source,
|
| 1137 |
}
|
| 1138 |
|
| 1139 |
|
| 1140 |
+
async def _rerank_by_relevance(question: str, results: list) -> list:
|
| 1141 |
+
"""Order the fused pool by cross-encoder relevance; pinned lexical hits stay first.
|
| 1142 |
+
|
| 1143 |
+
Scores are stored as ``_cross_score`` so the confidence gate reuses them.
|
| 1144 |
+
"""
|
| 1145 |
+
try:
|
| 1146 |
+
probs = await score_relevance(question, results, state.reranker)
|
| 1147 |
+
except Exception as exc:
|
| 1148 |
+
logger.warning("Final reranking failed (%s) — keeping fused order", exc)
|
| 1149 |
+
return results
|
| 1150 |
+
scored = [{**r, "_cross_score": p} for r, p in zip(results, probs)]
|
| 1151 |
+
pinned = [r for r in scored if r.get("_pinned")]
|
| 1152 |
+
rest = sorted((r for r in scored if not r.get("_pinned")),
|
| 1153 |
+
key=lambda r: r["_cross_score"], reverse=True)
|
| 1154 |
+
return pinned + rest
|
| 1155 |
+
|
| 1156 |
+
|
| 1157 |
+
# Creative-writing requests are out of scope even when retrieval finds loosely related
|
| 1158 |
+
# verses (an ocean poem matches verses about the sea). Requires a writing verb plus an
|
| 1159 |
+
# indefinite creative form, so "the story of Prophet Yusuf" or "is listening to songs
|
| 1160 |
+
# haram?" still pass; Arabic needs قصيدة/أبيات rather than شعر, which also means hair.
|
| 1161 |
+
_CREATIVE_REQUEST_RE = re.compile(
|
| 1162 |
+
r"\b(?:write|compose|create|generate|make up)\s+(?:me\s+)?(?:a|an|some)\s+(?:\w+\s+){0,2}?"
|
| 1163 |
+
r"(?:poem|poetry|story|song|lyrics|rap|joke|limerick|haiku)s?\b"
|
| 1164 |
+
r"|\btell me (?:a|some) jokes?\b"
|
| 1165 |
+
r"|(?:اكتب|أكتب|اكتبي|ألف|الف|أنظم|انظم|اعطني|أعطني)\s+(?:لي\s+)?(?:\S+\s+){0,2}?"
|
| 1166 |
+
r"(?:قصيدة|قصيده|أبيات|ابيات|أغنية|اغنية|نكتة|نكته)",
|
| 1167 |
+
re.IGNORECASE,
|
| 1168 |
+
)
|
| 1169 |
+
|
| 1170 |
+
|
| 1171 |
+
async def _assess_confidence(
|
| 1172 |
+
question: str, results: list, hybrid_results: list,
|
| 1173 |
+
) -> Tuple[bool, float, str]:
|
| 1174 |
+
"""Decide whether retrieval found something genuinely relevant.
|
| 1175 |
+
|
| 1176 |
+
Rank-fusion scores only say how candidates compare to each other — an
|
| 1177 |
+
off-topic question still has a #1 result — so the gate needs an absolute
|
| 1178 |
+
signal: lexical evidence first, then the cross-encoder, then dense cosine.
|
| 1179 |
+
"""
|
| 1180 |
+
if _CREATIVE_REQUEST_RE.search(question):
|
| 1181 |
+
return False, 0.0, "scope"
|
| 1182 |
+
if any(r.get("_pinned") for r in results):
|
| 1183 |
+
return True, 1.0, "lexical"
|
| 1184 |
+
if state.reranker is not None and results:
|
| 1185 |
+
try:
|
| 1186 |
+
probs = await score_relevance(question, results, state.reranker)
|
| 1187 |
+
best = max(probs)
|
| 1188 |
+
return best >= cfg.RERANK_MIN_SCORE, best, "reranker"
|
| 1189 |
+
except Exception as exc:
|
| 1190 |
+
logger.warning("Relevance scoring failed (%s) — falling back to dense similarity", exc)
|
| 1191 |
+
best = max((r.get("_dense", 0.0) for r in hybrid_results), default=0.0)
|
| 1192 |
+
return best >= cfg.DENSE_MIN_SCORE, best, "dense"
|
| 1193 |
+
|
| 1194 |
+
|
| 1195 |
# ═══════════════════════════════════════════════════════════════════════
|
| 1196 |
# CORE RAG PIPELINE (non-streaming, with hallucination checks)
|
| 1197 |
# ═══════════════════════════════════════════════════════════════════════
|
| 1198 |
+
# Chinese / Japanese / Korean script, incl. CJK punctuation (、。) and fullwidth forms (()).
|
| 1199 |
+
# The Qwen backbone occasionally drifts into Chinese mid-answer despite the prompt rules.
|
| 1200 |
+
_CJK_RE = re.compile(r"[\u3000-\u303F\u3040-\u30FF\u3400-\u4DBF\u4E00-\u9FFF\uAC00-\uD7AF\uFF00-\uFFEF]+")
|
| 1201 |
+
_STREAM_MAX_CJK = 20
|
| 1202 |
+
|
| 1203 |
+
|
| 1204 |
+
def _split_thinking(raw: str) -> Tuple[str, Optional[str]]:
|
| 1205 |
+
"""Separate a Qwen-style <think> block from the answer."""
|
| 1206 |
+
thinking = None
|
| 1207 |
+
if getattr(cfg, "SHOW_THINKING", False):
|
| 1208 |
+
think_match = re.search(r"<think>([\s\S]*?)</think>", raw, flags=re.IGNORECASE)
|
| 1209 |
+
if think_match:
|
| 1210 |
+
thinking = think_match.group(1).strip()
|
| 1211 |
+
answer = re.sub(r"<think>[\s\S]*?</think>", "", raw, flags=re.IGNORECASE)
|
| 1212 |
+
answer = re.sub(r"<think>[\s\S]*$", "", answer, flags=re.IGNORECASE)
|
| 1213 |
+
return answer.strip(), thinking
|
| 1214 |
+
|
| 1215 |
+
|
| 1216 |
+
def _with_language_reminder(messages: List[dict], lang: str) -> List[dict]:
|
| 1217 |
+
"""Append a stricter same-language instruction to the last user message."""
|
| 1218 |
+
reminder = (
|
| 1219 |
+
"\n\nتنبيه: اكتب الإجابة كاملة باللغة العربية فقط، دون أي حرف صيني."
|
| 1220 |
+
if lang == "arabic" else
|
| 1221 |
+
"\n\nReminder: write the whole answer in English only, with no Chinese characters."
|
| 1222 |
+
)
|
| 1223 |
+
last = messages[-1]
|
| 1224 |
+
return messages[:-1] + [{**last, "content": last["content"] + reminder}]
|
| 1225 |
+
|
| 1226 |
+
|
| 1227 |
async def run_rag_pipeline(
|
| 1228 |
question: str,
|
| 1229 |
top_k: int = cfg.TOP_K_RETURN,
|
|
|
|
| 1250 |
top_score = ctx["top_score"]
|
| 1251 |
intent = ctx["intent"]
|
| 1252 |
|
| 1253 |
+
# 5. Confidence gate
|
| 1254 |
+
if not ctx["confident"]:
|
|
|
|
|
|
|
| 1255 |
logger.warning(
|
| 1256 |
+
"Low confidence (%s %.3f, intent %s) — returning safe fallback",
|
| 1257 |
+
ctx["confidence_source"], ctx["confidence"], intent,
|
| 1258 |
)
|
| 1259 |
return {
|
| 1260 |
"answer": not_found_answer(lang),
|
|
|
|
| 1289 |
messages = build_messages(context, question, lang, intent, analysis, surah_info)
|
| 1290 |
|
| 1291 |
try:
|
| 1292 |
+
answer, thinking = _split_thinking(await state.llm.chat(
|
| 1293 |
messages,
|
| 1294 |
max_tokens=max_tokens or cfg.MAX_TOKENS,
|
| 1295 |
temperature=cfg.TEMPERATURE,
|
| 1296 |
+
))
|
| 1297 |
+
|
| 1298 |
+
# Language drift into Chinese: regenerate once, deterministically and with a
|
| 1299 |
+
# stricter reminder; if it persists, fall back to listing the retrieved sources.
|
| 1300 |
+
if _CJK_RE.search(answer):
|
| 1301 |
+
logger.warning("CJK characters in answer — regenerating once")
|
| 1302 |
+
answer, thinking = _split_thinking(await state.llm.chat(
|
| 1303 |
+
_with_language_reminder(messages, lang),
|
| 1304 |
+
max_tokens=max_tokens or cfg.MAX_TOKENS,
|
| 1305 |
+
temperature=0.0,
|
| 1306 |
+
))
|
| 1307 |
+
if _CJK_RE.search(answer):
|
| 1308 |
+
logger.warning("CJK characters persist after retry — using results fallback")
|
| 1309 |
+
answer = _results_fallback(results, lang) if results else not_found_answer(lang)
|
| 1310 |
|
| 1311 |
if not answer:
|
| 1312 |
logger.warning("LLM returned empty answer — using results fallback")
|
|
|
|
| 1325 |
answer = _verify_references(answer, results)
|
| 1326 |
if surah_info:
|
| 1327 |
answer = _verify_surah_info(answer, surah_info)
|
| 1328 |
+
answer = _attach_source_refs(answer, results, lang)
|
| 1329 |
|
| 1330 |
latency = int((time.perf_counter() - t0) * 1000)
|
| 1331 |
logger.info(
|
|
|
|
| 1350 |
# ═══════════════════════════════════════════════════════════════════════
|
| 1351 |
# STREAMING RAG PIPELINE (token streaming with sentence verification)
|
| 1352 |
# ═══════════════════════════════════════════════════════════════════════
|
| 1353 |
+
_STREAM_QUOTE_SPAN = re.compile(r"❝[^❞]*❞|«[^»]*»|﴿[^﴾]*﴾")
|
| 1354 |
+
# Long sentences (an isnad is one sentence of narrator names) are released at a comma
|
| 1355 |
+
# after _STREAM_SOFT_SPLIT chars, or at a space after _STREAM_HARD_SPLIT, so the reader
|
| 1356 |
+
# isn't left waiting. Never within two words after سورة / الآية / Surah / verse: the
|
| 1357 |
+
# verifier must see "سورة البقرة، الآية 255" whole.
|
| 1358 |
+
_STREAM_SOFT_SPLIT = 120
|
| 1359 |
+
_STREAM_HARD_SPLIT = 300
|
| 1360 |
+
_REF_OPENING = re.compile(r"(?:سورة|سوره|surah|sura|الآية|الاية|آية|ayah|verse)(?:\s+\S+){0,2}\s*$", re.I)
|
| 1361 |
+
|
| 1362 |
+
|
| 1363 |
class StreamingVerificationBuffer:
|
| 1364 |
"""Sentence-buffered streaming citation & reference verification."""
|
| 1365 |
|
|
|
|
| 1367 |
self.results = results
|
| 1368 |
self.surah_info = surah_info
|
| 1369 |
self.buffer = ""
|
| 1370 |
+
self.cjk_chars = 0 # CJK characters stripped so far (language drift)
|
| 1371 |
+
self.emitted = "" # verified text sent so far
|
| 1372 |
+
|
| 1373 |
+
def _clean(self, chunk: str) -> str:
|
| 1374 |
+
chunk = _clean_links(_verify_citations(chunk, self.results))
|
| 1375 |
+
chunk = _verify_references(chunk, self.results)
|
| 1376 |
+
if self.surah_info:
|
| 1377 |
+
chunk = _verify_surah_info(chunk, self.surah_info)
|
| 1378 |
+
# Tokens already sent can't be regenerated, so drop CJK runs instead
|
| 1379 |
+
self.cjk_chars += sum(len(m) for m in _CJK_RE.findall(chunk))
|
| 1380 |
+
chunk = _CJK_RE.sub("", chunk)
|
| 1381 |
+
self.emitted += chunk
|
| 1382 |
+
return chunk
|
| 1383 |
|
| 1384 |
def feed(self, token: str) -> List[str]:
|
| 1385 |
self.buffer += token
|
| 1386 |
emitted: List[str] = []
|
| 1387 |
while True:
|
| 1388 |
# If a sacred quote was opened but not yet closed, hold until closed (up to 400 chars)
|
| 1389 |
+
if len(self.buffer) < 400 and any(o in self.buffer and c not in self.buffer
|
| 1390 |
+
for o, c in (("❝", "❞"), ("«", "»"), ("﴿", "﴾"))):
|
| 1391 |
break
|
| 1392 |
|
| 1393 |
+
# First sentence boundary outside any complete quote, so quotes reach the verifier whole
|
| 1394 |
+
quotes = [q.span() for q in _STREAM_QUOTE_SPAN.finditer(self.buffer)]
|
| 1395 |
+
match = next((m for m in re.finditer(r"(\n|\. |\? |؟ |└─+┘)", self.buffer)
|
| 1396 |
+
if not any(qs < m.start() < qe for qs, qe in quotes)), None)
|
| 1397 |
+
if match:
|
| 1398 |
+
idx = match.end()
|
| 1399 |
+
else:
|
| 1400 |
+
idx = self._soft_split(quotes)
|
| 1401 |
+
if idx is None:
|
| 1402 |
+
break
|
| 1403 |
chunk = self.buffer[:idx]
|
| 1404 |
self.buffer = self.buffer[idx:]
|
| 1405 |
+
emitted.append(self._clean(chunk))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1406 |
return emitted
|
| 1407 |
|
| 1408 |
+
def _soft_split(self, quotes: List[Tuple[int, int]]) -> Optional[int]:
|
| 1409 |
+
"""End of the last comma (else space) safe to split a long sentence at."""
|
| 1410 |
+
for limit, pattern in ((_STREAM_SOFT_SPLIT, r"[،,] "), (_STREAM_HARD_SPLIT, r" ")):
|
| 1411 |
+
if len(self.buffer) < limit:
|
| 1412 |
+
return None
|
| 1413 |
+
for m in reversed(list(re.finditer(pattern, self.buffer))):
|
| 1414 |
+
if any(qs < m.start() < qe for qs, qe in quotes) or _REF_OPENING.search(self.buffer[:m.start()]):
|
| 1415 |
+
continue
|
| 1416 |
+
return m.end()
|
| 1417 |
+
return None
|
| 1418 |
+
|
| 1419 |
def flush(self) -> str:
|
| 1420 |
if not self.buffer:
|
| 1421 |
return ""
|
| 1422 |
chunk = self.buffer
|
| 1423 |
self.buffer = ""
|
| 1424 |
+
return self._clean(chunk)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1425 |
|
| 1426 |
|
| 1427 |
async def run_rag_pipeline_stream(
|
|
|
|
| 1460 |
}
|
| 1461 |
|
| 1462 |
# ── Deterministic / fallback paths ────────────────────────────────
|
| 1463 |
+
if not ctx["confident"]:
|
| 1464 |
yield {"type": "token", "content": not_found_answer(lang)}
|
| 1465 |
yield {"type": "done", "latency_ms": int((time.perf_counter() - t0) * 1000)}
|
| 1466 |
return
|
|
|
|
| 1507 |
else:
|
| 1508 |
for verified_chunk in verifier.feed(token):
|
| 1509 |
yield {"type": "token", "content": verified_chunk}
|
| 1510 |
+
if verifier.cjk_chars > _STREAM_MAX_CJK:
|
| 1511 |
+
break
|
| 1512 |
+
|
| 1513 |
+
if verifier.cjk_chars > _STREAM_MAX_CJK:
|
| 1514 |
+
# The model switched language; stop rather than stream a gutted answer
|
| 1515 |
+
logger.warning("CJK language drift in stream — generation stopped")
|
| 1516 |
+
yield {"type": "token", "content": (
|
| 1517 |
+
"\n\n⚠️ توقفت الإجابة بسبب خلل في لغة النموذج. يُرجى إعادة المحاولة." if lang == "arabic" else
|
| 1518 |
+
"\n\n⚠️ The answer was cut short by a model language error. Please try again.")}
|
| 1519 |
+
else:
|
| 1520 |
+
remaining = verifier.flush()
|
| 1521 |
+
if remaining:
|
| 1522 |
+
yield {"type": "token", "content": remaining}
|
| 1523 |
+
links = _source_refs(verifier.emitted, results, lang)
|
| 1524 |
+
if links:
|
| 1525 |
+
yield {"type": "token", "content": f"\n\n{links}"}
|
| 1526 |
|
| 1527 |
except Exception as exc:
|
| 1528 |
logger.error("LLM stream failed: %s", exc)
|
app/synonyms.py
CHANGED
|
@@ -29,6 +29,14 @@ ISLAMIC_THESAURUS: Dict[str, List[str]] = {
|
|
| 29 |
"mirath": ["inheritance", "ميراث", "فرائض"],
|
| 30 |
"fara'id": ["inheritance", "فرائض", "laws of inheritance"],
|
| 31 |
"will": ["وصية", "wasiyyah"],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
"wasiyyah": ["will", "وصية", "testament"],
|
| 33 |
|
| 34 |
# Technical terms
|
|
@@ -52,8 +60,42 @@ ISLAMIC_THESAURUS: Dict[str, List[str]] = {
|
|
| 52 |
"jihad": ["striving", "جهاد", "struggle"],
|
| 53 |
"intercession":["shafa'ah", "شفاعة"],
|
| 54 |
"shafa'ah": ["intercession", "شفاعة"],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
}
|
| 56 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
def expand_synonyms(keywords: List[str]) -> List[str]:
|
| 58 |
"""Expand a list of keywords with synonyms from the thesaurus."""
|
| 59 |
expanded = set(keywords)
|
|
|
|
| 29 |
"mirath": ["inheritance", "ميراث", "فرائض"],
|
| 30 |
"fara'id": ["inheritance", "فرائض", "laws of inheritance"],
|
| 31 |
"will": ["وصية", "wasiyyah"],
|
| 32 |
+
# The Qur'an says "wine" (khamr); questions say "alcohol"
|
| 33 |
+
"alcohol": ["khamr", "wine", "intoxicants", "خمر", "مسكر"],
|
| 34 |
+
"khamr": ["wine", "alcohol", "خمر", "مسكر"],
|
| 35 |
+
"wine": ["khamr", "alcohol", "خمر"],
|
| 36 |
+
"خمر": ["مسكر", "wine", "alcohol"],
|
| 37 |
+
# "مصارف" alone also means "banks"; pin the zakat sense when the phrase is used
|
| 38 |
+
"مصارف الزكاة": ["مستحقي الزكاة", "أصناف الزكاة", "إنما الصدقات للفقراء", "zakat recipients"],
|
| 39 |
+
"zakat recipients": ["مصارف الزكاة", "أصناف الزكاة", "إنما الصدقات للفقراء"],
|
| 40 |
"wasiyyah": ["will", "وصية", "testament"],
|
| 41 |
|
| 42 |
# Technical terms
|
|
|
|
| 60 |
"jihad": ["striving", "جهاد", "struggle"],
|
| 61 |
"intercession":["shafa'ah", "شفاعة"],
|
| 62 |
"shafa'ah": ["intercession", "شفاعة"],
|
| 63 |
+
|
| 64 |
+
# Arabic keys with the English used in Qur'an / hadith translations, so Arabic
|
| 65 |
+
# questions also reach English text
|
| 66 |
+
"سواك": ["siwak", "miswak", "tooth-stick"],
|
| 67 |
+
"وضوء": ["wudu", "ablution", "wash your faces", "فاغسلوا وجوهكم"],
|
| 68 |
+
# 5:6 never says "ablution": "when you rise to prayer, wash your faces and your forearms"
|
| 69 |
+
"ablution": ["wudu", "wash your faces", "الوضوء", "فاغسلوا وجوهكم"],
|
| 70 |
+
"wudu": ["ablution", "wash your faces", "الوضوء", "فاغسلوا وجوهكم"],
|
| 71 |
+
"ربا": ["riba", "usury", "interest"],
|
| 72 |
+
"حج": ["hajj", "pilgrimage"],
|
| 73 |
+
"ميراث": ["inheritance", "mirath", "فرائض"],
|
| 74 |
+
"زكاة": ["zakat", "alms", "charity"],
|
| 75 |
+
"صيام": ["fasting", "sawm"],
|
| 76 |
+
"صبر": ["patience", "sabr"],
|
| 77 |
+
"debt": ["دين", "تداينتم", "loan"],
|
| 78 |
}
|
| 79 |
|
| 80 |
+
def synonyms_in_text(text: str) -> List[str]:
|
| 81 |
+
"""Synonyms of every thesaurus term that occurs in *text*.
|
| 82 |
+
|
| 83 |
+
Single words match by light stem (السواك ↔ سواك, debts ↔ debt), multi-word
|
| 84 |
+
terms by phrase. Used by the fast (no-LLM) query rewrite.
|
| 85 |
+
"""
|
| 86 |
+
from app.arabic_nlp import light_stem, normalize_arabic
|
| 87 |
+
|
| 88 |
+
norm = normalize_arabic(text, aggressive=True).lower()
|
| 89 |
+
stems = {light_stem(w) for w in norm.split()} | {w.rstrip("s") for w in norm.split()}
|
| 90 |
+
found: List[str] = []
|
| 91 |
+
for key, syns in ISLAMIC_THESAURUS.items():
|
| 92 |
+
key_norm = normalize_arabic(key, aggressive=True).lower()
|
| 93 |
+
hit = key_norm in norm if " " in key_norm else (light_stem(key_norm) in stems or key_norm.rstrip("s") in stems)
|
| 94 |
+
if hit:
|
| 95 |
+
found.extend(x for x in syns if x not in found)
|
| 96 |
+
return found
|
| 97 |
+
|
| 98 |
+
|
| 99 |
def expand_synonyms(keywords: List[str]) -> List[str]:
|
| 100 |
"""Expand a list of keywords with synonyms from the thesaurus."""
|
| 101 |
expanded = set(keywords)
|
build_index.py
CHANGED
|
@@ -33,7 +33,7 @@ import zipfile
|
|
| 33 |
import numpy as np
|
| 34 |
from pathlib import Path
|
| 35 |
from collections import defaultdict
|
| 36 |
-
from typing import Any, Dict, List, Optional, Tuple
|
| 37 |
|
| 38 |
import faiss
|
| 39 |
import requests
|
|
@@ -46,6 +46,13 @@ CACHE_DIR = BASE_DIR / "data" / "cache"
|
|
| 46 |
METADATA_PATH = BASE_DIR / "metadata.json"
|
| 47 |
INDEX_PATH = BASE_DIR / "QModel.index"
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
# ── Quran source URLs ─────────────────────────────────────────────────
|
| 50 |
QURAN_JSON_URL = (
|
| 51 |
"https://raw.githubusercontent.com/risan/quran-json/main/data/quran.json"
|
|
@@ -968,26 +975,67 @@ def build_hadith_entries(
|
|
| 968 |
# STEP 5: GENERATE METADATA
|
| 969 |
# ══════════════════════════════════════════════════════════════════════
|
| 970 |
|
| 971 |
-
def
|
| 972 |
-
|
| 973 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 974 |
entries: List[Dict] = []
|
| 975 |
-
|
| 976 |
-
|
| 977 |
|
| 978 |
print("\n" + "=" * 60)
|
| 979 |
print("Step 4c: Loading Fatwa Entries")
|
| 980 |
print("=" * 60)
|
| 981 |
|
| 982 |
-
for
|
| 983 |
-
|
| 984 |
-
|
| 985 |
-
|
| 986 |
-
|
| 987 |
-
|
| 988 |
-
|
| 989 |
-
|
| 990 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 991 |
|
| 992 |
print(f" Total Fatwa entries: {len(entries):,}")
|
| 993 |
return entries
|
|
@@ -1030,6 +1078,32 @@ def generate_metadata(
|
|
| 1030 |
# STEP 6: BUILD FAISS INDEX
|
| 1031 |
# ══════════════════════════════════════════════════════════════════════
|
| 1032 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1033 |
def build_faiss_index(
|
| 1034 |
documents: List[Dict],
|
| 1035 |
model_name: str = DEFAULT_EMBED_MODEL,
|
|
@@ -1046,31 +1120,7 @@ def build_faiss_index(
|
|
| 1046 |
embedding_dim = model.get_sentence_embedding_dimension()
|
| 1047 |
print(f" Embedding dimension: {embedding_dim}")
|
| 1048 |
|
| 1049 |
-
|
| 1050 |
-
all_texts: List[str] = []
|
| 1051 |
-
for doc in documents:
|
| 1052 |
-
doc_type = doc.get("type")
|
| 1053 |
-
if doc_type == "quran":
|
| 1054 |
-
# Include both English and Arabic tafsir snippets for rich bilingual matching
|
| 1055 |
-
tafsir_en_snip = doc.get("tafsir_en", "")[:350]
|
| 1056 |
-
tafsir_ar_snip = doc.get("tafsir_ar", "")[:350]
|
| 1057 |
-
text = (
|
| 1058 |
-
f"passage: {doc.get('arabic', '')} {doc.get('english', '')} "
|
| 1059 |
-
f"{tafsir_en_snip} {tafsir_ar_snip}"
|
| 1060 |
-
)
|
| 1061 |
-
elif doc_type == "fatwa":
|
| 1062 |
-
text = (
|
| 1063 |
-
f"passage: {doc.get('source', '')} {doc.get('scholar_or_council', '')} "
|
| 1064 |
-
f"{doc.get('title', '')} {doc.get('question_ar', '')} "
|
| 1065 |
-
f"{doc.get('answer_ar', '')[:500]}"
|
| 1066 |
-
)
|
| 1067 |
-
else: # hadith
|
| 1068 |
-
text = (
|
| 1069 |
-
f"passage: {doc.get('collection', '')} {doc.get('chapter', '')} "
|
| 1070 |
-
f"{doc.get('arabic', '')} "
|
| 1071 |
-
f"{doc.get('english', '')}"
|
| 1072 |
-
)
|
| 1073 |
-
all_texts.append(text.strip())
|
| 1074 |
|
| 1075 |
print(f"\n Generating embeddings for {len(all_texts):,} documents …")
|
| 1076 |
all_embeddings = []
|
|
|
|
| 33 |
import numpy as np
|
| 34 |
from pathlib import Path
|
| 35 |
from collections import defaultdict
|
| 36 |
+
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
|
| 37 |
|
| 38 |
import faiss
|
| 39 |
import requests
|
|
|
|
| 46 |
METADATA_PATH = BASE_DIR / "metadata.json"
|
| 47 |
INDEX_PATH = BASE_DIR / "QModel.index"
|
| 48 |
|
| 49 |
+
# Fatwa sources, loaded in order (earlier copies win on duplicates):
|
| 50 |
+
# hand-collected data/fatwas/, then data_pipeline/scrape_fatwas.py output.
|
| 51 |
+
FATWA_DIRS = [BASE_DIR / "data" / "fatwas", BASE_DIR / "data" / "fatwas_scraped"]
|
| 52 |
+
# Placeholder records whose text doesn't match the Islamweb pages their URLs point to;
|
| 53 |
+
# indexing them would cite unrelated fatwas as sources.
|
| 54 |
+
FATWA_EXCLUDE = [BASE_DIR / "data" / "fatwas" / "islamweb.json"]
|
| 55 |
+
|
| 56 |
# ── Quran source URLs ─────────────────────────────────────────────────
|
| 57 |
QURAN_JSON_URL = (
|
| 58 |
"https://raw.githubusercontent.com/risan/quran-json/main/data/quran.json"
|
|
|
|
| 975 |
# STEP 5: GENERATE METADATA
|
| 976 |
# ══════════════════════════════════════════════════════════════════════
|
| 977 |
|
| 978 |
+
def _fatwa_content_key(record: Dict) -> Tuple:
|
| 979 |
+
# BinBaz ids redirect (legacy 4213 → canonical 1307), so the same fatwa can arrive
|
| 980 |
+
# under two ids from different scrapes; match on content as well.
|
| 981 |
+
return (
|
| 982 |
+
record.get("source"),
|
| 983 |
+
" ".join(record.get("title", "").split()),
|
| 984 |
+
" ".join(record.get("answer_ar", "").split())[:200],
|
| 985 |
+
)
|
| 986 |
+
|
| 987 |
+
|
| 988 |
+
# Scraped pages that list audio lectures rather than answer a question
|
| 989 |
+
_FATWA_JUNK_MARKERS = ("المواد الصوتية تحت هذا التصنيف", "إم بي ثري")
|
| 990 |
+
|
| 991 |
+
|
| 992 |
+
def _fatwa_has_content(record: Dict) -> bool:
|
| 993 |
+
answer = (record.get("answer_ar") or record.get("answer_en") or record.get("english") or "").strip()
|
| 994 |
+
return bool(answer) and not any(m in answer for m in _FATWA_JUNK_MARKERS)
|
| 995 |
+
|
| 996 |
+
|
| 997 |
+
def load_fatwa_entries(
|
| 998 |
+
dirs: Sequence[Path] = FATWA_DIRS,
|
| 999 |
+
exclude: Iterable[Path] = FATWA_EXCLUDE,
|
| 1000 |
+
) -> List[Dict]:
|
| 1001 |
+
"""Load fatwas from each directory's *.json in order, skipping excluded files.
|
| 1002 |
+
|
| 1003 |
+
Earlier copies win when two records share an id or content.
|
| 1004 |
+
"""
|
| 1005 |
+
excluded = {Path(p).resolve() for p in exclude}
|
| 1006 |
entries: List[Dict] = []
|
| 1007 |
+
seen_ids: set = set()
|
| 1008 |
+
seen_content: set = set()
|
| 1009 |
|
| 1010 |
print("\n" + "=" * 60)
|
| 1011 |
print("Step 4c: Loading Fatwa Entries")
|
| 1012 |
print("=" * 60)
|
| 1013 |
|
| 1014 |
+
for fatwas_dir in dirs:
|
| 1015 |
+
for json_file in sorted(Path(fatwas_dir).glob("*.json")):
|
| 1016 |
+
if json_file.resolve() in excluded:
|
| 1017 |
+
print(f" - Skipping {json_file} (excluded)")
|
| 1018 |
+
continue
|
| 1019 |
+
try:
|
| 1020 |
+
with open(json_file, "r", encoding="utf-8") as f:
|
| 1021 |
+
data = json.load(f)
|
| 1022 |
+
except Exception as exc:
|
| 1023 |
+
print(f" ✗ Error loading {json_file}: {exc}")
|
| 1024 |
+
continue
|
| 1025 |
+
if not isinstance(data, list):
|
| 1026 |
+
continue
|
| 1027 |
+
kept = 0
|
| 1028 |
+
for record in data:
|
| 1029 |
+
key = _fatwa_content_key(record)
|
| 1030 |
+
if record.get("type") != "fatwa" or record.get("id") in seen_ids or key in seen_content:
|
| 1031 |
+
continue
|
| 1032 |
+
if not _fatwa_has_content(record):
|
| 1033 |
+
continue
|
| 1034 |
+
seen_ids.add(record["id"])
|
| 1035 |
+
seen_content.add(key)
|
| 1036 |
+
entries.append(record)
|
| 1037 |
+
kept += 1
|
| 1038 |
+
print(f" ✓ Loaded {kept:,} fatwas from {json_file} ({len(data) - kept} duplicates/empty skipped)")
|
| 1039 |
|
| 1040 |
print(f" Total Fatwa entries: {len(entries):,}")
|
| 1041 |
return entries
|
|
|
|
| 1078 |
# STEP 6: BUILD FAISS INDEX
|
| 1079 |
# ══════════════════════════════════════════════════════════════════════
|
| 1080 |
|
| 1081 |
+
def passage_text(doc: Dict) -> str:
|
| 1082 |
+
"""Text embedded for a document (e5 asymmetric models require the 'passage: ' prefix)."""
|
| 1083 |
+
doc_type = doc.get("type")
|
| 1084 |
+
if doc_type == "quran":
|
| 1085 |
+
# Include both English and Arabic tafsir snippets for rich bilingual matching
|
| 1086 |
+
tafsir_en_snip = doc.get("tafsir_en", "")[:350]
|
| 1087 |
+
tafsir_ar_snip = doc.get("tafsir_ar", "")[:350]
|
| 1088 |
+
text = (
|
| 1089 |
+
f"passage: {doc.get('arabic', '')} {doc.get('english', '')} "
|
| 1090 |
+
f"{tafsir_en_snip} {tafsir_ar_snip}"
|
| 1091 |
+
)
|
| 1092 |
+
elif doc_type == "fatwa":
|
| 1093 |
+
text = (
|
| 1094 |
+
f"passage: {doc.get('source', '')} {doc.get('scholar_or_council', '')} "
|
| 1095 |
+
f"{doc.get('title', '')} {doc.get('question_ar', '')} "
|
| 1096 |
+
f"{doc.get('answer_ar', '')[:500]}"
|
| 1097 |
+
)
|
| 1098 |
+
else: # hadith
|
| 1099 |
+
text = (
|
| 1100 |
+
f"passage: {doc.get('collection', '')} {doc.get('chapter', '')} "
|
| 1101 |
+
f"{doc.get('arabic', '')} "
|
| 1102 |
+
f"{doc.get('english', '')}"
|
| 1103 |
+
)
|
| 1104 |
+
return text.strip()
|
| 1105 |
+
|
| 1106 |
+
|
| 1107 |
def build_faiss_index(
|
| 1108 |
documents: List[Dict],
|
| 1109 |
model_name: str = DEFAULT_EMBED_MODEL,
|
|
|
|
| 1120 |
embedding_dim = model.get_sentence_embedding_dimension()
|
| 1121 |
print(f" Embedding dimension: {embedding_dim}")
|
| 1122 |
|
| 1123 |
+
all_texts: List[str] = [passage_text(doc) for doc in documents]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1124 |
|
| 1125 |
print(f"\n Generating embeddings for {len(all_texts):,} documents …")
|
| 1126 |
all_embeddings = []
|
data_pipeline/scrape_fatwas.py
CHANGED
|
@@ -7,7 +7,7 @@ Scrapes and normalizes fatwas from:
|
|
| 7 |
2. BinBaz Portal (https://binbaz.org.sa/fatwas)
|
| 8 |
3. Islamway (https://ar.islamway.net/fatawa)
|
| 9 |
|
| 10 |
-
Outputs structured JSON files into data/
|
| 11 |
"""
|
| 12 |
|
| 13 |
import os
|
|
@@ -16,19 +16,17 @@ import time
|
|
| 16 |
import re
|
| 17 |
import argparse
|
| 18 |
from pathlib import Path
|
| 19 |
-
from typing import Dict, Any, List, Optional
|
| 20 |
import requests
|
| 21 |
from bs4 import BeautifulSoup
|
| 22 |
|
| 23 |
BASE_DIR = Path(__file__).resolve().parent.parent
|
| 24 |
-
|
|
|
|
| 25 |
|
|
|
|
| 26 |
HEADERS = {
|
| 27 |
-
"User-Agent": (
|
| 28 |
-
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
| 29 |
-
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
| 30 |
-
"Chrome/120.0.0.0 Safari/537.36"
|
| 31 |
-
)
|
| 32 |
}
|
| 33 |
|
| 34 |
try:
|
|
@@ -38,13 +36,16 @@ except ImportError:
|
|
| 38 |
PARSER = "html.parser"
|
| 39 |
|
| 40 |
|
| 41 |
-
def
|
| 42 |
-
"""Fetch URL with retries and exponential backoff."""
|
| 43 |
for attempt in range(retries):
|
| 44 |
try:
|
| 45 |
resp = requests.get(url, headers=HEADERS, timeout=timeout)
|
| 46 |
if resp.status_code == 200:
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
| 48 |
if resp.status_code == 404:
|
| 49 |
return None
|
| 50 |
if resp.status_code == 429:
|
|
@@ -57,6 +58,17 @@ def fetch_html(url: str, timeout: int = 10, retries: int = 3) -> Optional[str]:
|
|
| 57 |
return None
|
| 58 |
|
| 59 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
class IslamwebExtractor:
|
| 61 |
SOURCE_NAME = "Islamweb"
|
| 62 |
DEFAULT_SCHOLAR = "مركز الفتوى - إسلام ويب"
|
|
@@ -65,27 +77,27 @@ class IslamwebExtractor:
|
|
| 65 |
def scrape_id(fatwa_id: int, timeout: int = 10) -> Optional[Dict[str, Any]]:
|
| 66 |
url = f"https://www.islamweb.net/ar/fatwa/{fatwa_id}/"
|
| 67 |
try:
|
| 68 |
-
|
| 69 |
-
if not
|
| 70 |
return None
|
|
|
|
|
|
|
| 71 |
|
| 72 |
soup = BeautifulSoup(html, PARSER)
|
| 73 |
-
title_el = soup.select_one('.
|
| 74 |
title = title_el.get_text(" ", strip=True) if title_el else ""
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
if not title and not answer:
|
| 89 |
return None
|
| 90 |
|
| 91 |
topics = []
|
|
@@ -122,9 +134,11 @@ class BinBazExtractor:
|
|
| 122 |
def scrape_id(fatwa_id: int, timeout: int = 10) -> Optional[Dict[str, Any]]:
|
| 123 |
url = f"https://binbaz.org.sa/fatwas/{fatwa_id}"
|
| 124 |
try:
|
| 125 |
-
|
| 126 |
-
if not
|
| 127 |
return None
|
|
|
|
|
|
|
| 128 |
|
| 129 |
soup = BeautifulSoup(html, PARSER)
|
| 130 |
title_el = soup.select_one('h1, .article-title')
|
|
@@ -307,9 +321,11 @@ def run_scraper(source: str, start_id: int, end_id: int, delay: float = 1.0):
|
|
| 307 |
continue
|
| 308 |
|
| 309 |
record = extractor.scrape_id(fid)
|
| 310 |
-
if record:
|
|
|
|
|
|
|
| 311 |
new_records.append(record)
|
| 312 |
-
existing_ids.add(
|
| 313 |
print(f"[{source}] Scraped ID {fid}: {record['title'][:50]}")
|
| 314 |
# Checkpoint every 5 records so progress is not lost
|
| 315 |
if len(new_records) % 5 == 0:
|
|
|
|
| 7 |
2. BinBaz Portal (https://binbaz.org.sa/fatwas)
|
| 8 |
3. Islamway (https://ar.islamway.net/fatawa)
|
| 9 |
|
| 10 |
+
Outputs structured JSON files into data/fatwas_scraped/ (read by build_index.py).
|
| 11 |
"""
|
| 12 |
|
| 13 |
import os
|
|
|
|
| 16 |
import re
|
| 17 |
import argparse
|
| 18 |
from pathlib import Path
|
| 19 |
+
from typing import Dict, Any, List, Optional, Tuple
|
| 20 |
import requests
|
| 21 |
from bs4 import BeautifulSoup
|
| 22 |
|
| 23 |
BASE_DIR = Path(__file__).resolve().parent.parent
|
| 24 |
+
# Hand-collected fatwas live in data/fatwas/; build_index.FATWA_DIRS reads both.
|
| 25 |
+
FATWAS_DATA_DIR = BASE_DIR / "data" / "fatwas_scraped"
|
| 26 |
|
| 27 |
+
# Identify the crawler honestly; if a site refuses it, skip that site rather than disguise requests.
|
| 28 |
HEADERS = {
|
| 29 |
+
"User-Agent": "QModelFatwaIndexer/1.0 (+https://huggingface.co/aelgendy/QModel)",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
}
|
| 31 |
|
| 32 |
try:
|
|
|
|
| 36 |
PARSER = "html.parser"
|
| 37 |
|
| 38 |
|
| 39 |
+
def fetch_page(url: str, timeout: int = 10, retries: int = 3) -> Optional[Tuple[str, str]]:
|
| 40 |
+
"""Fetch URL with retries and exponential backoff; returns (final_url, html) after redirects."""
|
| 41 |
for attempt in range(retries):
|
| 42 |
try:
|
| 43 |
resp = requests.get(url, headers=HEADERS, timeout=timeout)
|
| 44 |
if resp.status_code == 200:
|
| 45 |
+
# These sites are UTF-8; requests falls back to ISO-8859-1 when the
|
| 46 |
+
# header omits a charset, which garbles Arabic.
|
| 47 |
+
resp.encoding = "utf-8"
|
| 48 |
+
return resp.url, resp.text
|
| 49 |
if resp.status_code == 404:
|
| 50 |
return None
|
| 51 |
if resp.status_code == 429:
|
|
|
|
| 58 |
return None
|
| 59 |
|
| 60 |
|
| 61 |
+
def fetch_html(url: str, timeout: int = 10, retries: int = 3) -> Optional[str]:
|
| 62 |
+
page = fetch_page(url, timeout=timeout, retries=retries)
|
| 63 |
+
return page[1] if page else None
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def canonical_id(final_url: str, requested_id: int) -> int:
|
| 67 |
+
"""Old fatwa ids redirect to the canonical one; key records on that to avoid duplicates."""
|
| 68 |
+
m = re.search(r"/fatwas?/(\d+)", final_url)
|
| 69 |
+
return int(m.group(1)) if m else requested_id
|
| 70 |
+
|
| 71 |
+
|
| 72 |
class IslamwebExtractor:
|
| 73 |
SOURCE_NAME = "Islamweb"
|
| 74 |
DEFAULT_SCHOLAR = "مركز الفتوى - إسلام ويب"
|
|
|
|
| 77 |
def scrape_id(fatwa_id: int, timeout: int = 10) -> Optional[Dict[str, Any]]:
|
| 78 |
url = f"https://www.islamweb.net/ar/fatwa/{fatwa_id}/"
|
| 79 |
try:
|
| 80 |
+
page = fetch_page(url, timeout=timeout)
|
| 81 |
+
if not page:
|
| 82 |
return None
|
| 83 |
+
url, html = page
|
| 84 |
+
fatwa_id = canonical_id(url, fatwa_id)
|
| 85 |
|
| 86 |
soup = BeautifulSoup(html, PARSER)
|
| 87 |
+
title_el = soup.select_one('.item-fatwa h2, h2')
|
| 88 |
title = title_el.get_text(" ", strip=True) if title_el else ""
|
| 89 |
+
|
| 90 |
+
# Layout: two .quest-fatwa blocks — "السؤال …" then "الإجابــة …"
|
| 91 |
+
blocks = soup.select('.quest-fatwa')
|
| 92 |
+
question, answer = "", ""
|
| 93 |
+
for block in blocks:
|
| 94 |
+
text = block.get_text("\n", strip=True)
|
| 95 |
+
if "السؤال" in text[:200] and not question:
|
| 96 |
+
question = re.sub(r'^[\s\S]*?السؤال\s*:?\s*', '', text, count=1).strip()
|
| 97 |
+
elif re.search(r'الإجاب\S*', text[:40]) and not answer:
|
| 98 |
+
answer = re.sub(r'^\s*الإجاب\S*\s*:?\s*', '', text, count=1).strip()
|
| 99 |
+
|
| 100 |
+
if not title or not answer:
|
|
|
|
|
|
|
| 101 |
return None
|
| 102 |
|
| 103 |
topics = []
|
|
|
|
| 134 |
def scrape_id(fatwa_id: int, timeout: int = 10) -> Optional[Dict[str, Any]]:
|
| 135 |
url = f"https://binbaz.org.sa/fatwas/{fatwa_id}"
|
| 136 |
try:
|
| 137 |
+
page = fetch_page(url, timeout=timeout)
|
| 138 |
+
if not page:
|
| 139 |
return None
|
| 140 |
+
url, html = page
|
| 141 |
+
fatwa_id = canonical_id(url, fatwa_id)
|
| 142 |
|
| 143 |
soup = BeautifulSoup(html, PARSER)
|
| 144 |
title_el = soup.select_one('h1, .article-title')
|
|
|
|
| 321 |
continue
|
| 322 |
|
| 323 |
record = extractor.scrape_id(fid)
|
| 324 |
+
if record and record["id"] in existing_ids:
|
| 325 |
+
print(f"[{source}] ID {fid} redirects to already-scraped {record['id']}")
|
| 326 |
+
elif record:
|
| 327 |
new_records.append(record)
|
| 328 |
+
existing_ids.add(record["id"])
|
| 329 |
print(f"[{source}] Scraped ID {fid}: {record['title'][:50]}")
|
| 330 |
# Checkpoint every 5 records so progress is not lost
|
| 331 |
if len(new_records) % 5 == 0:
|
docker-compose.yml
CHANGED
|
@@ -52,7 +52,7 @@ services:
|
|
| 52 |
interval: 30s
|
| 53 |
timeout: 10s
|
| 54 |
retries: 3
|
| 55 |
-
start_period:
|
| 56 |
|
| 57 |
networks:
|
| 58 |
qmodel-network:
|
|
|
|
| 52 |
interval: 30s
|
| 53 |
timeout: 10s
|
| 54 |
retries: 3
|
| 55 |
+
start_period: 300s # model + index loading takes ~100 s, more on first start
|
| 56 |
|
| 57 |
networks:
|
| 58 |
qmodel-network:
|
eval/__init__.py
ADDED
|
File without changes
|
eval/golden.jsonl
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{"id": "q-topic-en-01", "category": "quran_topic", "lang": "en", "query": "Which verse says Allah does not burden a soul beyond what it can bear?", "relevant": ["2:286", "2:233", "6:152", "7:42", "23:62", "65:7"]}
|
| 2 |
+
{"id": "q-topic-en-02", "category": "quran_topic", "lang": "en", "query": "Verse about fasting being prescribed for the believers", "relevant": ["2:183"]}
|
| 3 |
+
{"id": "q-topic-en-03", "category": "quran_topic", "lang": "en", "query": "In which month was the Quran revealed?", "relevant": ["2:185", "97:1", "44:3"], "required": ["2:185"]}
|
| 4 |
+
{"id": "q-topic-en-04", "category": "quran_topic", "lang": "en", "query": "Allah says He is near and answers the call of the one who supplicates", "relevant": ["2:186"]}
|
| 5 |
+
{"id": "q-topic-en-05", "category": "quran_topic", "lang": "en", "query": "Quran verses forbidding interest and usury", "relevant": ["2:275", "2:276", "2:278", "2:279", "3:130", "4:161", "30:39"]}
|
| 6 |
+
{"id": "q-topic-en-06", "category": "quran_topic", "lang": "en", "query": "Seek help through patience and prayer", "relevant": ["2:45", "2:153"]}
|
| 7 |
+
{"id": "q-topic-en-07", "category": "quran_topic", "lang": "en", "query": "Verse that says with hardship comes ease", "relevant": ["94:5", "94:6"]}
|
| 8 |
+
{"id": "q-topic-en-08", "category": "quran_topic", "lang": "en", "query": "The verse of light", "relevant": ["24:35"]}
|
| 9 |
+
{"id": "q-topic-en-09", "category": "quran_topic", "lang": "en", "query": "Quran command for women to cover themselves with head coverings", "relevant": ["24:31", "33:59"]}
|
| 10 |
+
{"id": "q-topic-en-10", "category": "quran_topic", "lang": "en", "query": "Treat your parents kindly and do not say uff to them", "relevant": ["17:23", "17:24"], "required": ["17:23"]}
|
| 11 |
+
{"id": "q-topic-en-11", "category": "quran_topic", "lang": "en", "query": "Killing one soul is like killing all of mankind", "relevant": ["5:32"]}
|
| 12 |
+
{"id": "q-topic-en-12", "category": "quran_topic", "lang": "en", "query": "How is inheritance divided between sons and daughters?", "relevant": ["4:11", "4:12", "4:176"], "required": ["4:11"]}
|
| 13 |
+
{"id": "q-topic-en-13", "category": "quran_topic", "lang": "en", "query": "How to perform ablution before prayer according to the Quran", "relevant": ["5:6", "4:43"], "required": ["5:6"]}
|
| 14 |
+
{"id": "q-topic-en-14", "category": "quran_topic", "lang": "en", "query": "Is alcohol forbidden in the Quran?", "relevant": ["5:90", "5:91", "2:219"], "required": ["5:90"]}
|
| 15 |
+
{"id": "q-topic-en-15", "category": "quran_topic", "lang": "en", "query": "Why did Allah create jinn and humans?", "relevant": ["51:56"]}
|
| 16 |
+
{"id": "q-topic-en-16", "category": "quran_topic", "lang": "en", "query": "Do not despair of the mercy of Allah", "relevant": ["39:53", "12:87", "15:56"]}
|
| 17 |
+
{"id": "q-topic-en-17", "category": "quran_topic", "lang": "en", "query": "Hearts find rest in the remembrance of Allah", "relevant": ["13:28"]}
|
| 18 |
+
{"id": "q-topic-en-18", "category": "quran_topic", "lang": "en", "query": "Who is eligible to receive zakat?", "relevant": ["9:60"]}
|
| 19 |
+
{"id": "q-topic-en-19", "category": "quran_topic", "lang": "en", "query": "Quran verse about avoiding suspicion, spying and backbiting", "relevant": ["49:12"]}
|
| 20 |
+
{"id": "q-topic-en-20", "category": "quran_topic", "lang": "en", "query": "We made you into nations and tribes so that you may know one another", "relevant": ["49:13"]}
|
| 21 |
+
{"id": "q-topic-en-21", "category": "quran_topic", "lang": "en", "query": "Which women is a man forbidden to marry?", "relevant": ["4:22", "4:23", "4:24", "2:221"], "required": ["4:23"]}
|
| 22 |
+
{"id": "q-topic-en-22", "category": "quran_topic", "lang": "en", "query": "Can a man marry up to four wives?", "relevant": ["4:3", "4:129"], "required": ["4:3"]}
|
| 23 |
+
{"id": "q-topic-en-23", "category": "quran_topic", "lang": "en", "query": "What foods are forbidden in the Quran?", "relevant": ["2:173", "5:3", "6:145", "16:115"]}
|
| 24 |
+
{"id": "q-topic-en-24", "category": "quran_topic", "lang": "en", "query": "Who is obliged to perform Hajj?", "relevant": ["3:97", "2:196", "22:27"], "required": ["3:97"]}
|
| 25 |
+
{"id": "q-topic-en-25", "category": "quran_topic", "lang": "en", "query": "Was Jesus crucified according to the Quran?", "relevant": ["4:157", "4:158"], "required": ["4:157"]}
|
| 26 |
+
{"id": "q-topic-en-26", "category": "quran_topic", "lang": "en", "query": "Writing down debts and loan contracts", "relevant": ["2:282"]}
|
| 27 |
+
{"id": "q-topic-en-27", "category": "quran_topic", "lang": "en", "query": "The day Allah perfected the religion", "relevant": ["5:3"]}
|
| 28 |
+
{"id": "q-topic-en-28", "category": "quran_topic", "lang": "en", "query": "Luqman's advice to his son", "relevant": ["31:13", "31:14", "31:15", "31:16", "31:17", "31:18", "31:19"]}
|
| 29 |
+
{"id": "q-topic-en-29", "category": "quran_topic", "lang": "en", "query": "The first revelation: read in the name of your Lord", "relevant": ["96:1", "96:2", "96:3", "96:4", "96:5"], "required": ["96:1"]}
|
| 30 |
+
{"id": "q-topic-en-30", "category": "quran_topic", "lang": "en", "query": "The Night of Decree is better than a thousand months", "relevant": ["97:1", "97:2", "97:3", "97:4", "97:5"], "required": ["97:3"]}
|
| 31 |
+
{"id": "q-topic-ar-01", "category": "quran_topic", "lang": "ar", "query": "ما هي آية الكرسي", "relevant": ["2:255"]}
|
| 32 |
+
{"id": "q-topic-ar-02", "category": "quran_topic", "lang": "ar", "query": "آيات عن الصبر على البلاء", "relevant": ["2:155", "2:156", "2:157", "2:153", "2:45", "3:200", "39:10", "103:3", "16:127"]}
|
| 33 |
+
{"id": "q-topic-ar-03", "category": "quran_topic", "lang": "ar", "query": "بر الوالدين في القرآن", "relevant": ["17:23", "17:24", "31:14", "46:15", "29:8", "4:36", "2:83", "6:151"]}
|
| 34 |
+
{"id": "q-topic-ar-04", "category": "quran_topic", "lang": "ar", "query": "تحريم الخمر والميسر", "relevant": ["5:90", "5:91", "2:219"], "required": ["5:90"]}
|
| 35 |
+
{"id": "q-topic-ar-05", "category": "quran_topic", "lang": "ar", "query": "مصارف الزكاة", "relevant": ["9:60", "fatwa_islamweb_748", "fatwa_islamweb_2057"]}
|
| 36 |
+
{"id": "q-topic-ar-06", "category": "quran_topic", "lang": "ar", "query": "الوضوء في القرآن", "relevant": ["5:6", "4:43"], "required": ["5:6"]}
|
| 37 |
+
{"id": "q-topic-ar-07", "category": "quran_topic", "lang": "ar", "query": "الدعوة إلى الله بالحكمة والموعظة الحسنة", "relevant": ["16:125"]}
|
| 38 |
+
{"id": "q-topic-ar-08", "category": "quran_topic", "lang": "ar", "query": "الاعتصام بحبل الله وعدم التفرق", "relevant": ["3:103"]}
|
| 39 |
+
{"id": "q-topic-ar-09", "category": "quran_topic", "lang": "ar", "query": "القتال في سبيل الله وعدم الاعتداء", "relevant": ["2:190", "2:191", "2:192", "2:193"], "required": ["2:190"]}
|
| 40 |
+
{"id": "q-topic-ar-10", "category": "quran_topic", "lang": "ar", "query": "الحجاب في القرآن", "relevant": ["24:31", "33:59", "33:53"]}
|
| 41 |
+
{"id": "q-topic-ar-11", "category": "quran_topic", "lang": "ar", "query": "التوبة ومغفرة الذنوب جميعا", "relevant": ["39:53", "4:110", "66:8", "25:70"]}
|
| 42 |
+
{"id": "q-topic-ar-12", "category": "quran_topic", "lang": "ar", "query": "خلق الإنسان في أحسن تقويم", "relevant": ["95:4"]}
|
| 43 |
+
{"id": "q-topic-ar-13", "category": "quran_topic", "lang": "ar", "query": "الأمر بالعدل والإحسان", "relevant": ["16:90", "4:58", "4:135", "5:8"]}
|
| 44 |
+
{"id": "q-topic-ar-14", "category": "quran_topic", "lang": "ar", "query": "ليلة القدر", "relevant": ["97:1", "97:2", "97:3", "97:4", "97:5", "44:3", "bukhari_1944", "bukhari_35", "fatwa_islamweb_2762", "fatwa_islamweb_6198"]}
|
| 45 |
+
{"id": "q-topic-ar-15", "category": "quran_topic", "lang": "ar", "query": "تحريم الربا", "relevant": ["2:275", "2:276", "2:278", "2:279", "3:130", "4:161", "30:39"]}
|
| 46 |
+
{"id": "q-text-ar-01", "category": "quran_text", "lang": "ar", "query": "آية: إن مع العسر يسرا", "relevant": ["94:5", "94:6"]}
|
| 47 |
+
{"id": "q-text-ar-02", "category": "quran_text", "lang": "ar", "query": "لا إكراه في الدين قد تبين الرشد من الغي", "relevant": ["2:256"]}
|
| 48 |
+
{"id": "q-text-ar-03", "category": "quran_text", "lang": "ar", "query": "فبأي آلاء ربكما تكذبان", "relevant": ["55:13", "55:16", "55:18", "55:21", "55:23", "55:25", "55:28", "55:30", "55:32", "55:34", "55:36", "55:38", "55:40", "55:42", "55:45", "55:47", "55:49", "55:51", "55:53", "55:55", "55:57", "55:59", "55:61", "55:63", "55:65", "55:67", "55:69", "55:71", "55:73", "55:75", "55:77"]}
|
| 49 |
+
{"id": "q-text-ar-04", "category": "quran_text", "lang": "ar", "query": "ألا بذكر الله تطمئن القلوب", "relevant": ["13:28"]}
|
| 50 |
+
{"id": "q-text-ar-05", "category": "quran_text", "lang": "ar", "query": "وما خلقت الجن والإنس إلا ليعبدون", "relevant": ["51:56"]}
|
| 51 |
+
{"id": "q-text-ar-06", "category": "quran_text", "lang": "ar", "query": "قل هو الله أحد", "relevant": ["112:1"]}
|
| 52 |
+
{"id": "q-text-ar-07", "category": "quran_text", "lang": "ar", "query": "الله نور السماوات والأرض", "relevant": ["24:35"]}
|
| 53 |
+
{"id": "q-text-ar-08", "category": "quran_text", "lang": "ar", "query": "كل نفس ذائقة الموت", "relevant": ["3:185", "21:35", "29:57"]}
|
| 54 |
+
{"id": "q-text-en-01", "category": "quran_text", "lang": "en", "query": "Every soul will taste death", "relevant": ["3:185", "21:35", "29:57"]}
|
| 55 |
+
{"id": "q-text-en-02", "category": "quran_text", "lang": "en", "query": "Indeed, mankind is in loss", "relevant": ["103:2"]}
|
| 56 |
+
{"id": "q-text-en-03", "category": "quran_text", "lang": "en", "query": "Recite in the name of your Lord who created", "relevant": ["96:1"]}
|
| 57 |
+
{"id": "q-ref-01", "category": "quran_ref", "lang": "en", "query": "2:255", "relevant": ["2:255"]}
|
| 58 |
+
{"id": "q-ref-02", "category": "quran_ref", "lang": "ar", "query": "سورة البقرة آية 286", "relevant": ["2:286"]}
|
| 59 |
+
{"id": "q-ref-03", "category": "quran_ref", "lang": "en", "query": "Al-Baqarah 183", "relevant": ["2:183"]}
|
| 60 |
+
{"id": "q-ref-04", "category": "quran_ref", "lang": "ar", "query": "تفسير الآية 13 من سورة الحجرات", "relevant": ["49:13"]}
|
| 61 |
+
{"id": "q-ref-05", "category": "quran_ref", "lang": "en", "query": "Surah An-Nur verse 35", "relevant": ["24:35"]}
|
| 62 |
+
{"id": "h-01", "category": "hadith_topic", "lang": "en", "query": "Hadith about actions being judged by intentions", "relevant": ["bukhari_1", "bukhari_54", "bukhari_2429", "bukhari_3735", "bukhari_6445", "bukhari_6696", "muslim_4791", "abudawud_2202", "ibnmajah_3965", "tirmidhi_1696", "nasai_75", "nasai_3443", "nasai_3803", "ahmad_162", "riyad_1218", "forty_nawawi_1"]}
|
| 63 |
+
{"id": "h-02", "category": "hadith_text", "lang": "ar", "query": "حديث إنما الأعمال بالنيات", "relevant": ["bukhari_1", "bukhari_54", "bukhari_2429", "bukhari_3735", "bukhari_6445", "bukhari_6696", "muslim_4791", "abudawud_2202", "ibnmajah_3965", "tirmidhi_1696", "nasai_75", "nasai_3443", "nasai_3803", "ahmad_162", "riyad_1218", "forty_nawawi_1"]}
|
| 64 |
+
{"id": "h-03", "category": "hadith_topic", "lang": "en", "query": "What are the five pillars of Islam according to the hadith?", "relevant": ["bukhari_8", "bukhari_4312", "muslim_19", "muslim_20", "muslim_21", "tirmidhi_2679", "nasai_5010", "riyad_396", "riyad_527", "riyad_592", "forty_nawawi_3"]}
|
| 65 |
+
{"id": "h-04", "category": "hadith_text", "lang": "en", "query": "None of you truly believes until he loves for his brother what he loves for himself", "relevant": ["bukhari_13", "muslim_77", "tirmidhi_2585", "ibnmajah_4145", "nasai_5025", "nasai_5026", "nasai_5048", "darimi_2008", "riyad_1400", "riyad_1453", "forty_nawawi_13"]}
|
| 66 |
+
{"id": "h-05", "category": "hadith_topic", "lang": "ar", "query": "حديث عن حب الخير للأخ المسلم كما يحبه لنفسه", "relevant": ["bukhari_13", "muslim_77", "tirmidhi_2585", "ibnmajah_4145", "nasai_5025", "nasai_5026", "nasai_5048", "darimi_2008", "riyad_1400", "riyad_1453", "forty_nawawi_13"]}
|
| 67 |
+
{"id": "h-06", "category": "hadith_topic", "lang": "en", "query": "The Prophet's advice to a man who asked for counsel: do not get angry", "relevant": ["bukhari_5884", "tirmidhi_2088", "riyad_1265", "riyad_1855", "forty_nawawi_16"]}
|
| 68 |
+
{"id": "h-07", "category": "hadith_text", "lang": "ar", "query": "حديث ليس الشديد بالصرعة", "relevant": ["bukhari_5882", "muslim_6480", "muslim_6481", "malik_1769", "riyad_1262", "riyad_1863", "bulugh_1679", "adab_1320"]}
|
| 69 |
+
{"id": "h-08", "category": "hadith_text", "lang": "en", "query": "Hadith: the religion is sincere advice", "relevant": ["muslim_103", "abudawud_4946", "tirmidhi_1994", "nasai_4207", "nasai_4208", "nasai_4209", "nasai_4210", "darimi_2021", "riyad_1398", "bulugh_1730", "forty_nawawi_7"]}
|
| 70 |
+
{"id": "h-09", "category": "hadith_topic", "lang": "ar", "query": "حديث التبسم في وجه أخيك صدقة", "relevant": ["tirmidhi_2024", "adab_893"]}
|
| 71 |
+
{"id": "h-10", "category": "hadith_topic", "lang": "en", "query": "Is seeking knowledge obligatory on every Muslim?", "relevant": ["ibnmajah_4303"]}
|
| 72 |
+
{"id": "h-11", "category": "hadith_topic", "lang": "en", "query": "Who deserves my good companionship the most? Your mother", "relevant": ["muslim_6341", "muslim_6342", "ibnmajah_2442", "abudawud_5141", "riyad_1533"]}
|
| 73 |
+
{"id": "h-12", "category": "hadith_topic", "lang": "ar", "query": "فضل صيام رمضان إيمانا واحتسابا", "relevant": ["bukhari_38", "bukhari_1833", "bukhari_1941", "muslim_1673", "abudawud_1373", "ibnmajah_1375", "nasai_2208", "nasai_2209", "nasai_2210", "riyad_540"]}
|
| 74 |
+
{"id": "h-13", "category": "hadith_text", "lang": "en", "query": "The lawful is clear and the unlawful is clear, and between them are doubtful matters", "relevant": ["bukhari_52", "bukhari_1977", "muslim_3965", "muslim_3968", "tirmidhi_1209", "ibnmajah_3721", "nasai_5406", "nasai_5407", "darimi_1802", "darimi_2926", "forty_nawawi_6"]}
|
| 75 |
+
{"id": "h-14", "category": "hadith_text", "lang": "ar", "query": "الطهور شطر الإيمان", "relevant": ["muslim_440", "darimi_4", "riyad_352", "riyad_734", "riyad_1242", "forty_nawawi_23"]}
|
| 76 |
+
{"id": "h-15", "category": "hadith_topic", "lang": "en", "query": "Reward for relieving a believer of hardship", "relevant": ["muslim_6687", "tirmidhi_1442", "ibnmajah_4304", "riyad_1462", "bulugh_1663", "forty_nawawi_36"]}
|
| 77 |
+
{"id": "h-16", "category": "hadith_text", "lang": "ar", "query": "حديث دع ما يريبك إلى ما لا يريبك", "relevant": ["tirmidhi_2588", "nasai_5721", "darimi_2922", "riyad_1272", "riyad_1809", "forty_nawawi_11"]}
|
| 78 |
+
{"id": "h-17", "category": "hadith_topic", "lang": "en", "query": "Leaving what does not concern you is part of a person's good Islam", "relevant": ["tirmidhi_2386", "tirmidhi_2387", "ibnmajah_3713", "malik_1760", "riyad_1284", "bulugh_1673", "forty_nawawi_12"]}
|
| 79 |
+
{"id": "h-18", "category": "hadith_text", "lang": "ar", "query": "الراحمون يرحمهم الرحمن", "relevant": ["tirmidhi_1992", "abudawud_4943"]}
|
| 80 |
+
{"id": "h-19", "category": "hadith_topic", "lang": "en", "query": "Seven people who will be shaded by Allah on the Day of Judgment", "relevant": ["bukhari_642", "bukhari_1375", "bukhari_6240", "bukhari_6560", "muslim_2264", "tirmidhi_2460", "nasai_5389", "malik_1870", "riyad_1593", "riyad_1666", "riyad_1875", "bulugh_739"]}
|
| 81 |
+
{"id": "h-20", "category": "hadith_text", "lang": "ar", "query": "كلمتان خفيفتان على اللسان ثقيلتان في الميزان", "relevant": ["bukhari_6167", "bukhari_6438", "muslim_6680", "tirmidhi_3551", "ibnmajah_3542", "riyad_729", "bukhari_7277"]}
|
| 82 |
+
{"id": "h-21", "category": "hadith_text", "lang": "en", "query": "The best of you are those who learn the Quran and teach it", "relevant": ["bukhari_4820", "abudawud_1453", "tirmidhi_2990", "tirmidhi_2992", "darimi_2593", "ahmad_390", "ahmad_469", "riyad_314"]}
|
| 83 |
+
{"id": "h-22", "category": "hadith_topic", "lang": "ar", "query": "فضل السواك", "relevant": ["bukhari_866", "bukhari_6966", "muslim_495", "abudawud_47", "tirmidhi_22", "tirmidhi_23", "ibnmajah_21", "nasai_7", "malik_145", "darimi_809", "ahmad_572", "ahmad_920", "riyad_517", "bulugh_37", "nasai_5", "nasai_6", "ibnmajah_1411"]}
|
| 84 |
+
{"id": "h-23", "category": "hadith_text", "lang": "en", "query": "Pray as you have seen me praying", "relevant": ["bukhari_616", "bukhari_5780", "bukhari_6972", "darimi_579", "riyad_33", "bulugh_236", "bulugh_397", "adab_213"]}
|
| 85 |
+
{"id": "h-24", "category": "hadith_text", "lang": "ar", "query": "حديث قدسي أنا عند ظن عبدي بي", "relevant": ["bukhari_7123", "bukhari_7219", "muslim_6640", "muslim_6664", "muslim_6782", "tirmidhi_3687", "ibnmajah_3558", "darimi_2000", "riyad_756", "riyad_1657", "qudsi_15"]}
|
| 86 |
+
{"id": "h-25", "category": "hadith_topic", "lang": "en", "query": "Who is a true Muslim? The one from whose tongue and hand others are safe", "relevant": ["bukhari_10", "bukhari_6245", "muslim_69", "abudawud_2482", "tirmidhi_2697", "nasai_5005", "darimi_1984", "riyad_886", "riyad_1428", "adab_1147"]}
|
| 87 |
+
{"id": "h-26", "category": "hadith_topic", "lang": "ar", "query": "ما الذي ينفع الميت بعد موته من عمله", "relevant": ["muslim_4094", "abudawud_2881", "darimi_3317", "bulugh_1067", "adab_38"]}
|
| 88 |
+
{"id": "h-27", "category": "hadith_text", "lang": "en", "query": "Be in this world as if you were a stranger or a traveler", "relevant": ["bukhari_6177", "tirmidhi_2402", "ibnmajah_3852", "riyad_1687", "riyad_1790", "bulugh_1668", "forty_nawawi_40"]}
|
| 89 |
+
{"id": "h-28", "category": "hadith_text", "lang": "ar", "query": "لا ضرر ولا ضرار", "relevant": ["ibnmajah_2074", "ibnmajah_2075", "malik_1520", "bulugh_1060", "forty_nawawi_32"]}
|
| 90 |
+
{"id": "h-29", "category": "hadith_text", "lang": "en", "query": "Allah is beautiful and loves beauty", "relevant": ["muslim_171", "riyad_896", "riyad_1828", "tirmidhi_2067"]}
|
| 91 |
+
{"id": "h-30", "category": "hadith_text", "lang": "ar", "query": "إن الله لا ينظر إلى صوركم وأموالكم ولكن ينظر إلى قلوبكم", "relevant": ["muslim_6382", "ibnmajah_3881"]}
|
| 92 |
+
{"id": "h-31", "category": "hadith_topic", "lang": "en", "query": "Reward of praying in congregation compared to praying alone", "relevant": ["bukhari_630", "bukhari_631", "bukhari_634", "muslim_1372", "tirmidhi_215", "ibnmajah_523", "nasai_839", "malik_289", "riyad_385", "bulugh_477", "bulugh_479"]}
|
| 93 |
+
{"id": "h-32", "category": "hadith_topic", "lang": "ar", "query": "فضل بناء المساجد", "relevant": ["bukhari_443", "muslim_1091", "muslim_1092", "muslim_7285", "muslim_7286", "ibnmajah_469", "ibnmajah_472", "nasai_690", "ahmad_354", "ahmad_408", "ahmad_475"]}
|
| 94 |
+
{"id": "h-33", "category": "hadith_topic", "lang": "en", "query": "The five daily prayers are like bathing in a river five times a day", "relevant": ["bukhari_518", "muslim_1417", "tirmidhi_2950", "nasai_464", "darimi_512", "riyad_363"]}
|
| 95 |
+
{"id": "h-34", "category": "hadith_topic", "lang": "ar", "query": "حديث جبريل عن الإسلام والإيمان والإحسان", "relevant": ["bukhari_50", "muslim_5", "tirmidhi_2680", "ibnmajah_4142", "ibnmajah_4143", "nasai_5000"]}
|
| 96 |
+
{"id": "h-35", "category": "hadith_text", "lang": "en", "query": "O My servants, I have forbidden oppression for Myself", "relevant": ["muslim_6410", "bulugh_1692", "forty_nawawi_24", "qudsi_17"]}
|
| 97 |
+
{"id": "h-auth-01", "category": "hadith_auth", "lang": "ar", "query": "هل حديث طلب العلم فريضة على كل مسلم صحيح؟", "relevant": ["ibnmajah_4303"]}
|
| 98 |
+
{"id": "h-auth-02", "category": "hadith_auth", "lang": "en", "query": "Is the hadith 'actions are by intentions' authentic?", "relevant": ["bukhari_1", "bukhari_54", "bukhari_2429", "bukhari_3735", "bukhari_6445", "bukhari_6696", "muslim_4791", "abudawud_2202", "ibnmajah_3965", "tirmidhi_1696", "nasai_75", "nasai_3443", "nasai_3803", "ahmad_162", "riyad_1218", "forty_nawawi_1"]}
|
| 99 |
+
{"id": "h-auth-03", "category": "hadith_auth", "lang": "ar", "query": "ما صحة حديث الطهور شطر الإيمان", "relevant": ["muslim_440", "darimi_4", "riyad_352", "riyad_734", "riyad_1242", "forty_nawawi_23"]}
|
| 100 |
+
{"id": "h-auth-04", "category": "hadith_auth", "lang": "en", "query": "Is the hadith 'the strong man is not the one who wrestles' authentic?", "relevant": ["bukhari_5882", "muslim_6480", "muslim_6481", "malik_1769", "riyad_1262", "riyad_1863", "bulugh_1679", "adab_1320"]}
|
| 101 |
+
{"id": "h-auth-05", "category": "hadith_auth", "lang": "ar", "query": "هل صحيح أن النبي قال الدين النصيحة؟", "relevant": ["muslim_103", "abudawud_4946", "tirmidhi_1994", "nasai_4207", "nasai_4208", "nasai_4209", "nasai_4210", "darimi_2021", "riyad_1398", "bulugh_1730", "forty_nawawi_7"]}
|
| 102 |
+
{"id": "h-auth-06", "category": "hadith_auth", "lang": "ar", "query": "ما درجة حديث لا ضرر ولا ضرار", "relevant": ["ibnmajah_2074", "ibnmajah_2075", "malik_1520", "bulugh_1060", "forty_nawawi_32"]}
|
| 103 |
+
{"id": "h-auth-07", "category": "hadith_auth", "lang": "en", "query": "Is it true that the Prophet said religion is sincere advice?", "relevant": ["muslim_103", "abudawud_4946", "tirmidhi_1994", "nasai_4207", "nasai_4208", "nasai_4209", "nasai_4210", "darimi_2021", "riyad_1398", "bulugh_1730", "forty_nawawi_7"]}
|
| 104 |
+
{"id": "h-auth-08", "category": "hadith_auth", "lang": "ar", "query": "هل حديث كلمتان خفيفتان على اللسان صحيح؟", "relevant": ["bukhari_6167", "bukhari_6438", "muslim_6680", "tirmidhi_3551", "ibnmajah_3542", "riyad_729"]}
|
| 105 |
+
{"id": "f-01", "category": "fatwa", "lang": "ar", "query": "هل الحقنة في العضل تبطل الصيام؟", "relevant": ["fatwa_islamweb_857", "fatwa_islamweb_3337", "fatwa_islamweb_4008"]}
|
| 106 |
+
{"id": "f-02", "category": "fatwa", "lang": "ar", "query": "حكم الطلاق في حالة الغضب الشديد", "relevant": ["fatwa_binbaz_4213", "fatwa_islamweb_1496", "fatwa_islamweb_3073", "fatwa_islamweb_2182"]}
|
| 107 |
+
{"id": "f-03", "category": "fatwa", "lang": "ar", "query": "كم عدد الرضعات التي تحرم النكاح؟", "relevant": ["fatwa_binbaz_4215"]}
|
| 108 |
+
{"id": "f-04", "category": "fatwa", "lang": "ar", "query": "ما نصاب زكاة عروض التجارة وكيف تخرج؟", "relevant": ["fatwa_islamweb_930", "fatwa_islamweb_1008", "fatwa_islamweb_2205", "fatwa_islamweb_3078", "fatwa_islamweb_5039", "fatwa_islamweb_7609"]}
|
| 109 |
+
{"id": "f-05", "category": "fatwa", "lang": "ar", "query": "متى يجوز للمسافر الجمع والقصر في الصلاة؟", "relevant": ["fatwa_islamweb_507", "fatwa_islamweb_361", "fatwa_islamweb_178", "fatwa_islamweb_181", "fatwa_islamweb_1359", "fatwa_islamweb_6215", "fatwa_islamweb_3323", "fatwa_islamweb_6851", "fatwa_islamweb_5891", "fatwa_islamweb_4824", "fatwa_islamweb_6227"]}
|
| 110 |
+
{"id": "f-06", "category": "fatwa", "lang": "ar", "query": "ما هي شروط صحة عقد النكاح؟", "relevant": ["fatwa_islamweb_1766", "fatwa_islamweb_741", "fatwa_islamweb_964", "fatwa_islamweb_591", "fatwa_islamweb_5483", "fatwa_islamweb_7704"]}
|
| 111 |
+
{"id": "f-07", "category": "fatwa", "lang": "ar", "query": "حكم إسبال الثوب لغير الخيلاء", "relevant": ["fatwa_binbaz_4211", "fatwa_islamweb_5943", "fatwa_islamweb_3900"]}
|
| 112 |
+
{"id": "f-08", "category": "fatwa", "lang": "ar", "query": "هل كتابة الوصية واجبة وما صيغتها؟", "relevant": ["fatwa_binbaz_4210"]}
|
| 113 |
+
{"id": "f-09", "category": "fatwa", "lang": "ar", "query": "هل يجوز السلام على المرأة الأجنبية؟", "relevant": ["fatwa_binbaz_4219", "fatwa_islamweb_6158"]}
|
| 114 |
+
{"id": "f-10", "category": "fatwa", "lang": "ar", "query": "هل حديث أبغض الحلال إلى الله الطلاق صحيح؟", "relevant": ["fatwa_binbaz_4214", "abudawud_2179", "ibnmajah_1752", "bulugh_1241"]}
|
| 115 |
+
{"id": "f-11", "category": "fatwa", "lang": "en", "query": "Does an intramuscular injection break the fast?", "relevant": ["fatwa_islamweb_857", "fatwa_islamweb_3337", "fatwa_islamweb_4008"]}
|
| 116 |
+
{"id": "f-12", "category": "fatwa", "lang": "en", "query": "Rules for combining and shortening prayers while traveling", "relevant": ["fatwa_islamweb_507", "fatwa_islamweb_361", "fatwa_islamweb_178", "fatwa_islamweb_181", "fatwa_islamweb_1359", "fatwa_islamweb_6215", "fatwa_islamweb_3323", "fatwa_islamweb_6851", "fatwa_islamweb_5891", "fatwa_islamweb_4824", "fatwa_islamweb_6227"]}
|
| 117 |
+
{"id": "f-13", "category": "fatwa", "lang": "ar", "query": "هل استعمال العطر يفطر الصائم؟", "relevant": ["fatwa_islamweb_892", "fatwa_islamweb_6247"]}
|
| 118 |
+
{"id": "f-14", "category": "fatwa", "lang": "en", "query": "Is it allowed to combine two prayers without an excuse?", "relevant": ["fatwa_islamweb_134", "fatwa_islamweb_5589", "fatwa_islamweb_4724"]}
|
| 119 |
+
{"id": "f-15", "category": "fatwa", "lang": "ar", "query": "ما المسافة التي يجوز للمسافر أن يفطر فيها في رمضان؟", "relevant": ["fatwa_islamweb_233", "fatwa_islamweb_178", "fatwa_islamweb_1887", "fatwa_islamweb_4824"]}
|
| 120 |
+
{"id": "f-16", "category": "fatwa", "lang": "en", "query": "Does pulling a tooth break the fast?", "relevant": ["fatwa_islamweb_1100", "fatwa_islamweb_6477", "fatwa_islamweb_6380"]}
|
| 121 |
+
{"id": "f-17", "category": "fatwa", "lang": "ar", "query": "حكم إيداع المال في البنوك الربوية", "relevant": ["fatwa_islamweb_331", "fatwa_islamweb_518", "fatwa_islamweb_623", "fatwa_islamweb_5773", "fatwa_islamweb_1954"]}
|
| 122 |
+
{"id": "f-18", "category": "fatwa", "lang": "en", "query": "A man with liver disease must keep taking medication. May he skip fasting in Ramadan?", "relevant": ["fatwa_islamweb_2277", "fatwa_islamweb_5978", "fatwa_islamweb_7751"]}
|
| 123 |
+
{"id": "neg-01", "category": "negative", "lang": "en", "query": "What is the capital of France?", "relevant": []}
|
| 124 |
+
{"id": "neg-02", "category": "negative", "lang": "en", "query": "How do I fix a Python ImportError?", "relevant": []}
|
| 125 |
+
{"id": "neg-03", "category": "negative", "lang": "en", "query": "Give me a recipe for chocolate cake", "relevant": []}
|
| 126 |
+
{"id": "neg-04", "category": "negative", "lang": "en", "query": "Who won the 2022 FIFA World Cup?", "relevant": []}
|
| 127 |
+
{"id": "neg-05", "category": "negative", "lang": "ar", "query": "ما هي عاصمة اليابان؟", "relevant": []}
|
| 128 |
+
{"id": "neg-06", "category": "negative", "lang": "ar", "query": "اشرح نظرية النسبية لأينشتاين", "relevant": []}
|
| 129 |
+
{"id": "neg-07", "category": "negative", "lang": "en", "query": "What is the current price of Bitcoin?", "relevant": []}
|
| 130 |
+
{"id": "neg-08", "category": "negative", "lang": "ar", "query": "كيف أغير زيت السيارة؟", "relevant": []}
|
| 131 |
+
{"id": "neg-09", "category": "negative", "lang": "en", "query": "Write a poem about the ocean", "relevant": []}
|
| 132 |
+
{"id": "neg-10", "category": "negative", "lang": "ar", "query": "ما هو أفضل هاتف ذكي هذا العام؟", "relevant": []}
|
| 133 |
+
{"id": "neg-11", "category": "negative", "lang": "en", "query": "How do I reset my router password?", "relevant": []}
|
| 134 |
+
{"id": "neg-12", "category": "negative", "lang": "en", "query": "Explain how photosynthesis works", "relevant": []}
|
| 135 |
+
{"id": "neg-13", "category": "negative", "lang": "en", "query": "What's the weather like in London tomorrow?", "relevant": []}
|
| 136 |
+
{"id": "neg-14", "category": "negative", "lang": "en", "query": "Recommend a good laptop for programming", "relevant": []}
|
| 137 |
+
{"id": "neg-15", "category": "negative", "lang": "en", "query": "Translate 'good morning' into Spanish", "relevant": []}
|
| 138 |
+
{"id": "neg-16", "category": "negative", "lang": "en", "query": "How many players are on a basketball team?", "relevant": []}
|
| 139 |
+
{"id": "neg-17", "category": "negative", "lang": "en", "query": "Summarize the plot of the movie Inception", "relevant": []}
|
| 140 |
+
{"id": "neg-18", "category": "negative", "lang": "en", "query": "What is the boiling point of water in Fahrenheit?", "relevant": []}
|
| 141 |
+
{"id": "neg-19", "category": "negative", "lang": "ar", "query": "كيف أتعلم البرمجة بلغة بايثون؟", "relevant": []}
|
| 142 |
+
{"id": "neg-20", "category": "negative", "lang": "ar", "query": "ما هي أعراض الإنفلونزا؟", "relevant": []}
|
| 143 |
+
{"id": "neg-21", "category": "negative", "lang": "ar", "query": "من فاز بكأس العالم لكرة القدم 2018؟", "relevant": []}
|
| 144 |
+
{"id": "neg-22", "category": "negative", "lang": "ar", "query": "اقترح لي وصفة لعمل الكبسة", "relevant": []}
|
| 145 |
+
{"id": "neg-23", "category": "negative", "lang": "ar", "query": "كم سعر الذهب اليوم؟", "relevant": []}
|
| 146 |
+
{"id": "neg-24", "category": "negative", "lang": "ar", "query": "ما هي أفضل طريقة لتعلم اللغة الإنجليزية؟", "relevant": []}
|
| 147 |
+
{"id": "neg-25", "category": "negative", "lang": "ar", "query": "اكتب لي رسالة بريد إلكتروني لمديري أطلب إجازة", "relevant": []}
|
eval/label_review.csv
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
eval/run_answers.py
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Answer-level evaluation: run the full RAG pipeline (with the LLM) over the golden set.
|
| 3 |
+
|
| 4 |
+
Where eval/run_eval.py measures retrieval, this checks what users actually read:
|
| 5 |
+
|
| 6 |
+
refusals — out-of-scope questions get the "not found" answer; real ones don't
|
| 7 |
+
language — answer language matches the question; no CJK leakage
|
| 8 |
+
citations — every cited verse / hadith / fatwa is among the retrieved sources
|
| 9 |
+
(precision), and at least one cited source is a golden-relevant one
|
| 10 |
+
quotes — scripture quoted by the LLM, before _verify_citations: how many were
|
| 11 |
+
verbatim from the retrieved context, how many the verifier removed or
|
| 12 |
+
auto-corrected, and how many auto-corrections swapped in a different
|
| 13 |
+
real verse than the one quoted
|
| 14 |
+
|
| 15 |
+
Usage:
|
| 16 |
+
python -m eval.run_answers --limit 5 # smoke test
|
| 17 |
+
python -m eval.run_answers --only fatwa,negative
|
| 18 |
+
python -m eval.run_answers --out eval/results/answers.json
|
| 19 |
+
Per-case answers are saved in the report for manual review.
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
import argparse
|
| 25 |
+
import asyncio
|
| 26 |
+
import json
|
| 27 |
+
import logging
|
| 28 |
+
import re
|
| 29 |
+
import sys
|
| 30 |
+
import time
|
| 31 |
+
from collections import defaultdict
|
| 32 |
+
from datetime import datetime
|
| 33 |
+
from pathlib import Path
|
| 34 |
+
from typing import Dict, List, Optional, Set
|
| 35 |
+
|
| 36 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 37 |
+
sys.path.insert(0, str(ROOT))
|
| 38 |
+
|
| 39 |
+
from app.config import cfg # noqa: E402
|
| 40 |
+
from eval.run_eval import DEFAULT_GOLDEN, RESULTS_DIR, _git_commit, init_state, load_golden, result_ids, validate_labels # noqa: E402
|
| 41 |
+
|
| 42 |
+
_QURAN_URL = re.compile(r"aya-(\d+)-sora-(\d+)|sura(\d+)-aya(\d+)|quran\.com/(\d+)/(\d+)")
|
| 43 |
+
_URL = re.compile(r"https?://[^\s\)\]>\"']+")
|
| 44 |
+
_QUOTE = re.compile(r"﴿([^﴾]+)﴾|❝([^❞]+)❞|«([^»]+)»")
|
| 45 |
+
_REMOVED_MARK = "تم حذف نص غير موثق"
|
| 46 |
+
_CJK = re.compile(r"[-ヿ㐀-䶿一-鿿가-]")
|
| 47 |
+
_REFUSAL_HINTS = (
|
| 48 |
+
"does not contain sufficient information", "not found in the", "no relevant", "cannot find",
|
| 49 |
+
"لم أجد", "لا تتوفر", "لا يوجد في", "لم يرد في",
|
| 50 |
+
)
|
| 51 |
+
# Hadith references as written in answers: "<collection>[, ][No.|#|رقم] <number>".
|
| 52 |
+
# Arabic names need a qualifier ("صحيح مسلم", "رواه مسلم") since مسلم / أحمد are also common words.
|
| 53 |
+
_HADITH_COLLECTIONS = {
|
| 54 |
+
"sahih al-bukhari": "bukhari", "bukhari": "bukhari", "البخاري": "bukhari",
|
| 55 |
+
"sahih muslim": "muslim", "muslim": "muslim", "صحيح مسلم": "muslim", "رواه مسلم": "muslim", "أخرجه مسلم": "muslim",
|
| 56 |
+
"abu dawood": "abudawud", "abu dawud": "abudawud", "أبي داود": "abudawud", "أبو داود": "abudawud",
|
| 57 |
+
"tirmidhi": "tirmidhi", "الترمذي": "tirmidhi", "nasai": "nasai", "nasa'i": "nasai", "النسائي": "nasai",
|
| 58 |
+
"ibn majah": "ibnmajah", "ابن ماجه": "ibnmajah",
|
| 59 |
+
"musnad ahmad": "ahmad", "مسند أحمد": "ahmad", "رواه أحمد": "ahmad", "أخرجه أحمد": "ahmad",
|
| 60 |
+
"muwatta malik": "malik", "muwatta": "malik", "الموطأ": "malik", "موطأ مالك": "malik",
|
| 61 |
+
"darimi": "darimi", "الدارمي": "darimi",
|
| 62 |
+
"riyad al-salihin": "riyad", "رياض الصالحين": "riyad", "bulugh al-maram": "bulugh", "بلوغ المرام": "bulugh",
|
| 63 |
+
"adab al-mufrad": "adab", "الأدب المفرد": "adab",
|
| 64 |
+
"shama'il al-muhammadiyyah": "shamail", "الشمائل المحمدية": "shamail",
|
| 65 |
+
"nawawi's 40 hadith": "forty_nawawi", "الأربعون النووية": "forty_nawawi",
|
| 66 |
+
"hadith qudsi": "qudsi", "الأحاديث القدسية": "qudsi",
|
| 67 |
+
}
|
| 68 |
+
_HADITH_REF = re.compile(
|
| 69 |
+
r"(" + "|".join(re.escape(k) for k in sorted(_HADITH_COLLECTIONS, key=len, reverse=True)) + r")"
|
| 70 |
+
r"(?:[\s,،:#.()\-–*]|no\b|number|hadith|في صحيحه|(?:ال)?رقم|برقم|(?:ال)?حديث"
|
| 71 |
+
r"|(?:كتاب|book of) [^\d\n،,]{1,30}[،,])*?(\d{1,5})\b",
|
| 72 |
+
re.I,
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def cited_ids(answer: str, url_to_id: Dict[str, str]) -> Set[str]:
|
| 77 |
+
ids: Set[str] = set()
|
| 78 |
+
for m in _QURAN_URL.finditer(answer):
|
| 79 |
+
if m.group(1):
|
| 80 |
+
verse, surah = m.group(1), m.group(2)
|
| 81 |
+
elif m.group(3):
|
| 82 |
+
surah, verse = m.group(3), m.group(4)
|
| 83 |
+
else:
|
| 84 |
+
surah, verse = m.group(5), m.group(6)
|
| 85 |
+
ids.add(f"{int(surah)}:{int(verse)}")
|
| 86 |
+
for url in _URL.findall(answer):
|
| 87 |
+
key = url.rstrip("/.,")
|
| 88 |
+
if key in url_to_id:
|
| 89 |
+
ids.add(url_to_id[key])
|
| 90 |
+
for m in _HADITH_REF.finditer(answer):
|
| 91 |
+
ids.add(f"{_HADITH_COLLECTIONS[m.group(1).lower()]}_{int(m.group(2))}")
|
| 92 |
+
return ids
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def lang_ok(answer: str, lang: str) -> bool:
|
| 96 |
+
text = _URL.sub(" ", _QUOTE.sub(" ", answer))
|
| 97 |
+
ar = sum(1 for c in text if "" <= c <= "ۿ")
|
| 98 |
+
la = sum(1 for c in text if c.isascii() and c.isalpha())
|
| 99 |
+
if ar + la == 0:
|
| 100 |
+
return True
|
| 101 |
+
return ar / (ar + la) >= 0.5 if lang == "ar" else la / (ar + la) >= 0.5
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def is_refusal(answer: str, lang: str, not_found: Dict[str, str]) -> bool:
|
| 105 |
+
if not_found["arabic" if lang == "ar" else "english"].split("\n")[0] in answer:
|
| 106 |
+
return True
|
| 107 |
+
head = answer[:300].lower()
|
| 108 |
+
return any(h in head for h in _REFUSAL_HINTS) and len(answer) < 600
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
class QuoteRecorder:
|
| 112 |
+
"""Wraps _verify_citations to keep the LLM's raw output for each call."""
|
| 113 |
+
|
| 114 |
+
def __init__(self, fn):
|
| 115 |
+
self.fn, self.calls = fn, []
|
| 116 |
+
|
| 117 |
+
def __call__(self, answer, results):
|
| 118 |
+
out = self.fn(answer, results)
|
| 119 |
+
self.calls.append((answer, out))
|
| 120 |
+
return out
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def quote_stats(raw: str, verified: str, retrieved_norm: List[str], verse_by_skel: Dict[str, str],
|
| 124 |
+
normalize, corpus_norm: List[str]) -> dict:
|
| 125 |
+
from app.state import _skeleton
|
| 126 |
+
stats = {"quotes": 0, "verbatim": 0, "variant": 0, "fabricated": 0,
|
| 127 |
+
"removed": 0, "corrected": 0, "wrong_verse": 0}
|
| 128 |
+
raw_quotes = [next(g for g in m.groups() if g) for m in _QUOTE.finditer(raw)]
|
| 129 |
+
out_quotes = [next(g for g in m.groups() if g) for m in _QUOTE.finditer(verified)]
|
| 130 |
+
for i, q in enumerate(raw_quotes):
|
| 131 |
+
qn = normalize(q).strip()
|
| 132 |
+
if sum(1 for c in qn if "" <= c <= "ۿ") < 12:
|
| 133 |
+
continue # the verifier ignores short / non-Arabic quotes too
|
| 134 |
+
stats["quotes"] += 1
|
| 135 |
+
q_skel = _skeleton(q)
|
| 136 |
+
# Verses the quote is a copy of. A short verse *inside* the quote (68:7 is the tail
|
| 137 |
+
# of 16:125) marks it as real scripture but doesn't identify which verse it is.
|
| 138 |
+
real = {vid for vs, vid in verse_by_skel.items() if q_skel in vs}
|
| 139 |
+
contained = {vid for vs, vid in verse_by_skel.items() if len(vs) >= 12 and vs in q_skel}
|
| 140 |
+
if any(qn in src for src in retrieved_norm):
|
| 141 |
+
stats["verbatim"] += 1
|
| 142 |
+
elif real or contained:
|
| 143 |
+
stats["variant"] += 1 # same letters as a real verse, but altered spelling/marks/foreign chars
|
| 144 |
+
elif not any(normalize(q, aggressive=True).lower().strip() in doc for doc in corpus_norm):
|
| 145 |
+
stats["fabricated"] += 1 # not in any indexed verse, hadith or fatwa
|
| 146 |
+
out = out_quotes[i] if i < len(out_quotes) else ""
|
| 147 |
+
if _REMOVED_MARK in out:
|
| 148 |
+
stats["removed"] += 1
|
| 149 |
+
elif normalize(out).strip() != qn:
|
| 150 |
+
stats["corrected"] += 1
|
| 151 |
+
substituted = {verse_by_skel.get(_skeleton(out))} - {None}
|
| 152 |
+
if real and substituted and not (real & substituted):
|
| 153 |
+
stats["wrong_verse"] += 1
|
| 154 |
+
return stats
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
async def main_async(args) -> None:
|
| 158 |
+
from app import state as state_mod
|
| 159 |
+
from app.arabic_nlp import normalize_arabic
|
| 160 |
+
from app.prompts import not_found_answer
|
| 161 |
+
from app.search import _normalized_texts
|
| 162 |
+
from app.state import run_rag_pipeline, state
|
| 163 |
+
|
| 164 |
+
only = set(args.only.split(",")) if args.only else None
|
| 165 |
+
cases = load_golden(Path(args.golden), only)[: args.limit or None]
|
| 166 |
+
init_state(use_llm=True)
|
| 167 |
+
validate_labels(cases, state.dataset)
|
| 168 |
+
|
| 169 |
+
ref_to_id = {d["reference"]: d["id"] for d in state.dataset if d.get("type") == "hadith" and d.get("reference")}
|
| 170 |
+
url_to_id = {d["source_url"].rstrip("/"): d["id"] for d in state.dataset if d.get("source_url")}
|
| 171 |
+
from app.state import _skeleton
|
| 172 |
+
verse_by_skel = {_skeleton(d["arabic"]): d["id"] for d in state.dataset
|
| 173 |
+
if d.get("type") == "quran" and d.get("arabic")}
|
| 174 |
+
not_found = {"arabic": not_found_answer("arabic"), "english": not_found_answer("english")}
|
| 175 |
+
corpus_norm = [ar for ar, _ in _normalized_texts(state.dataset)]
|
| 176 |
+
|
| 177 |
+
recorder = QuoteRecorder(state_mod._verify_citations)
|
| 178 |
+
state_mod._verify_citations = recorder
|
| 179 |
+
|
| 180 |
+
rows, t0 = [], time.perf_counter()
|
| 181 |
+
for i, case in enumerate(cases, 1):
|
| 182 |
+
recorder.calls.clear()
|
| 183 |
+
t = time.perf_counter()
|
| 184 |
+
out = await run_rag_pipeline(case["query"], top_k=cfg.TOP_K_RETURN)
|
| 185 |
+
answer = out["answer"]
|
| 186 |
+
sources = out.get("sources") or []
|
| 187 |
+
retrieved: Set[str] = set()
|
| 188 |
+
retrieved_norm: List[str] = []
|
| 189 |
+
for s in sources:
|
| 190 |
+
retrieved |= result_ids(s, ref_to_id)
|
| 191 |
+
retrieved |= {f"{s.get('surah_number')}:{c.get('ayah')}" for c in s.get("context_ayat") or []}
|
| 192 |
+
for field in ("arabic", "answer_ar"):
|
| 193 |
+
if s.get(field):
|
| 194 |
+
retrieved_norm.append(normalize_arabic(s[field]))
|
| 195 |
+
|
| 196 |
+
relevant = set(case["relevant"])
|
| 197 |
+
refused = is_refusal(answer, case.get("lang", "en"), not_found)
|
| 198 |
+
cites = cited_ids(answer, url_to_id)
|
| 199 |
+
raw = recorder.calls[0][0] if recorder.calls else answer
|
| 200 |
+
row = {
|
| 201 |
+
"id": case["id"], "category": case["category"], "lang": case.get("lang"), "query": case["query"],
|
| 202 |
+
"intent": out.get("intent"), "refused": refused, "lang_ok": lang_ok(answer, case.get("lang", "en")),
|
| 203 |
+
"cjk": bool(_CJK.search(answer)), "cited": sorted(cites),
|
| 204 |
+
"ungrounded_citations": sorted(cites - retrieved),
|
| 205 |
+
"cites_relevant": bool(cites & relevant) if relevant else None,
|
| 206 |
+
"cites_required": set(case["required"]) <= cites if case.get("required") else None,
|
| 207 |
+
"retrieved_relevant": bool(retrieved & relevant) if relevant else None,
|
| 208 |
+
"quotes": quote_stats(raw, answer, retrieved_norm, verse_by_skel, normalize_arabic, corpus_norm),
|
| 209 |
+
"latency_s": round(time.perf_counter() - t, 1),
|
| 210 |
+
"answer": answer, "raw_answer": raw if raw != answer else None,
|
| 211 |
+
}
|
| 212 |
+
rows.append(row)
|
| 213 |
+
print(f" {i}/{len(cases)} {case['id']:<14} {row['latency_s']:>5}s "
|
| 214 |
+
f"{'REFUSED' if refused else 'answered'} cites={len(cites)} ungrounded={len(row['ungrounded_citations'])}",
|
| 215 |
+
flush=True)
|
| 216 |
+
|
| 217 |
+
summary = summarize(rows)
|
| 218 |
+
print_summary(summary, time.perf_counter() - t0, len(rows))
|
| 219 |
+
|
| 220 |
+
report = {
|
| 221 |
+
"timestamp": datetime.now().isoformat(timespec="seconds"), "git_commit": _git_commit(),
|
| 222 |
+
"config": {k: getattr(cfg, k) for k in ("LLM_BACKEND", "TOP_K_RETURN", "USE_RERANKER", "FAST_QUERY_REWRITE",
|
| 223 |
+
"TEMPERATURE", "MAX_TOKENS", "ENABLE_CITATION_VERIFICATION")},
|
| 224 |
+
"summary": summary, "cases": rows,
|
| 225 |
+
}
|
| 226 |
+
out_path = Path(args.out) if args.out else RESULTS_DIR / f"{datetime.now():%Y%m%d-%H%M%S}-answers.json"
|
| 227 |
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
| 228 |
+
out_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
| 229 |
+
print(f"\nReport written to {out_path}")
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
def _rate(flags: List[bool]) -> Optional[float]:
|
| 233 |
+
return round(sum(flags) / len(flags), 3) if flags else None
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
def summarize(rows: List[dict]) -> dict:
|
| 237 |
+
pos = [r for r in rows if r["cites_relevant"] is not None]
|
| 238 |
+
neg = [r for r in rows if r["cites_relevant"] is None]
|
| 239 |
+
answered = [r for r in pos if not r["refused"]]
|
| 240 |
+
q = defaultdict(int)
|
| 241 |
+
for r in rows:
|
| 242 |
+
for k, v in r["quotes"].items():
|
| 243 |
+
q[k] += v
|
| 244 |
+
by_cat = defaultdict(list)
|
| 245 |
+
for r in answered:
|
| 246 |
+
by_cat[r["category"]].append(r)
|
| 247 |
+
return {
|
| 248 |
+
"positives": len(pos), "negatives": len(neg),
|
| 249 |
+
"refusal": {
|
| 250 |
+
"negatives_refused": _rate([r["refused"] for r in neg]),
|
| 251 |
+
"positives_refused": _rate([r["refused"] for r in pos]),
|
| 252 |
+
"positives_refused_despite_retrieval": _rate([r["refused"] for r in pos if r["retrieved_relevant"]]),
|
| 253 |
+
},
|
| 254 |
+
"answered": {
|
| 255 |
+
"n": len(answered),
|
| 256 |
+
"cites_relevant": _rate([r["cites_relevant"] for r in answered]),
|
| 257 |
+
"cites_relevant_when_retrieved": _rate([r["cites_relevant"] for r in answered if r["retrieved_relevant"]]),
|
| 258 |
+
"cites_required": _rate([r["cites_required"] for r in answered if r.get("cites_required") is not None]),
|
| 259 |
+
"has_ungrounded_citation": _rate([bool(r["ungrounded_citations"]) for r in answered]),
|
| 260 |
+
"no_citation": _rate([not r["cited"] for r in answered]),
|
| 261 |
+
"lang_ok": _rate([r["lang_ok"] for r in answered]),
|
| 262 |
+
"cjk_leak": _rate([r["cjk"] for r in answered]),
|
| 263 |
+
},
|
| 264 |
+
"cites_relevant_by_category": {c: _rate([r["cites_relevant"] for r in rs]) for c, rs in sorted(by_cat.items())},
|
| 265 |
+
"quotes": dict(q),
|
| 266 |
+
"median_latency_s": sorted(r["latency_s"] for r in rows)[len(rows) // 2] if rows else None,
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
def print_summary(s: dict, elapsed: float, n: int) -> None:
|
| 271 |
+
print(f"\n{n} cases in {elapsed:.0f}s (median {s['median_latency_s']}s/answer)\n")
|
| 272 |
+
r, a, q = s["refusal"], s["answered"], s["quotes"]
|
| 273 |
+
print("Refusals")
|
| 274 |
+
print(f" off-topic refused: {r['negatives_refused']} (n={s['negatives']})")
|
| 275 |
+
print(f" real questions refused: {r['positives_refused']} (n={s['positives']})")
|
| 276 |
+
print(f" ...of which retrieval had the answer: {r['positives_refused_despite_retrieval']}")
|
| 277 |
+
print(f"\nAnswered real questions (n={a['n']})")
|
| 278 |
+
print(f" cites a golden-relevant source: {a['cites_relevant']}")
|
| 279 |
+
print(f" ...when retrieval had it: {a['cites_relevant_when_retrieved']}")
|
| 280 |
+
print(f" cites all must-include sources: {a.get('cites_required')}")
|
| 281 |
+
print(f" has a citation NOT in retrieved sources:{a['has_ungrounded_citation']}")
|
| 282 |
+
print(f" no parseable citation: {a['no_citation']}")
|
| 283 |
+
print(f" language matches question: {a['lang_ok']}")
|
| 284 |
+
print(f" CJK leakage: {a['cjk_leak']}")
|
| 285 |
+
for cat, v in s["cites_relevant_by_category"].items():
|
| 286 |
+
print(f" {cat:<16} cites relevant: {v}")
|
| 287 |
+
print(f"\nQuoted scripture (≥12 Arabic letters), before verification: {q.get('quotes', 0)}")
|
| 288 |
+
for k in ("verbatim", "variant", "fabricated", "removed", "corrected", "wrong_verse"):
|
| 289 |
+
print(f" {k:<12} {q.get(k, 0)}")
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
def main() -> None:
|
| 293 |
+
parser = argparse.ArgumentParser(description="Answer-level evaluation of QModel over the golden set.")
|
| 294 |
+
parser.add_argument("--golden", default=str(DEFAULT_GOLDEN))
|
| 295 |
+
parser.add_argument("--only", help="Comma-separated categories")
|
| 296 |
+
parser.add_argument("--limit", type=int, help="Evaluate only the first N cases")
|
| 297 |
+
parser.add_argument("--out", help="Report path (default eval/results/<timestamp>-answers.json)")
|
| 298 |
+
parser.add_argument("--verbose", action="store_true")
|
| 299 |
+
args = parser.parse_args()
|
| 300 |
+
logging.basicConfig(level=logging.INFO if args.verbose else logging.WARNING,
|
| 301 |
+
format="%(levelname)s %(name)s: %(message)s")
|
| 302 |
+
asyncio.run(main_async(args))
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
if __name__ == "__main__":
|
| 306 |
+
main()
|
eval/run_eval.py
ADDED
|
@@ -0,0 +1,450 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Retrieval evaluation harness for QModel.
|
| 3 |
+
|
| 4 |
+
Runs every case in a golden set through the retrieval stack (no LLM generation)
|
| 5 |
+
and reports ranking metrics plus confidence-gate behaviour.
|
| 6 |
+
|
| 7 |
+
Golden set format (JSONL, one case per line):
|
| 8 |
+
{"id": "...", "category": "...", "lang": "ar|en", "query": "...", "relevant": ["2:255", "bukhari_1", ...]}
|
| 9 |
+
`relevant` holds dataset `id`s; any one of them counts as a hit. An empty list
|
| 10 |
+
marks a negative (out-of-scope) case that the confidence gate should reject.
|
| 11 |
+
Optional `required` (a subset of `relevant`) names sources a complete answer must
|
| 12 |
+
include — e.g. 5:90 for "is alcohol forbidden?", where 2:219 alone is a hit but
|
| 13 |
+
misleading. These are scored separately as complete@k, so hit@k stays comparable.
|
| 14 |
+
|
| 15 |
+
Modes:
|
| 16 |
+
pipeline — the full pre-generation pipeline (_rag_preprocess): rewrite, hybrid
|
| 17 |
+
search, direct lookups, text search, merging, dedup. This is exactly
|
| 18 |
+
what the LLM receives as context.
|
| 19 |
+
hybrid — hybrid_search() alone (dense + BM25 + RRF [+ reranker]), useful for
|
| 20 |
+
isolating fusion / embedding changes.
|
| 21 |
+
|
| 22 |
+
Usage:
|
| 23 |
+
python -m eval.run_eval # pipeline mode, k = TOP_K_RETURN
|
| 24 |
+
python -m eval.run_eval --mode hybrid --k 20
|
| 25 |
+
python -m eval.run_eval --only fatwa,negative --show-failures
|
| 26 |
+
python -m eval.run_eval --out eval/results/baseline.json
|
| 27 |
+
python -m eval.run_eval --compare eval/results/baseline.json
|
| 28 |
+
"""
|
| 29 |
+
|
| 30 |
+
from __future__ import annotations
|
| 31 |
+
|
| 32 |
+
import argparse
|
| 33 |
+
import asyncio
|
| 34 |
+
import json
|
| 35 |
+
import logging
|
| 36 |
+
import re
|
| 37 |
+
import subprocess
|
| 38 |
+
import sys
|
| 39 |
+
import time
|
| 40 |
+
from collections import defaultdict
|
| 41 |
+
from datetime import datetime
|
| 42 |
+
from pathlib import Path
|
| 43 |
+
from typing import Dict, List, Optional
|
| 44 |
+
|
| 45 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 46 |
+
sys.path.insert(0, str(ROOT))
|
| 47 |
+
|
| 48 |
+
from app.config import cfg # noqa: E402
|
| 49 |
+
|
| 50 |
+
DEFAULT_GOLDEN = ROOT / "eval" / "golden.jsonl"
|
| 51 |
+
RESULTS_DIR = ROOT / "eval" / "results"
|
| 52 |
+
_GRADE_SUFFIX = re.compile(r"\s*\[[^\]]*\]\s*$")
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
# ═══════════════════════════════════════════════════════════════════════
|
| 56 |
+
# LOADING
|
| 57 |
+
# ═══════════════════════════════════════════════════════════════════════
|
| 58 |
+
def load_golden(path: Path, only: Optional[set]) -> List[dict]:
|
| 59 |
+
cases = []
|
| 60 |
+
with open(path, encoding="utf-8") as f:
|
| 61 |
+
for line_no, line in enumerate(f, 1):
|
| 62 |
+
line = line.strip()
|
| 63 |
+
if not line or line.startswith("//"):
|
| 64 |
+
continue
|
| 65 |
+
case = json.loads(line)
|
| 66 |
+
for key in ("id", "category", "query", "relevant"):
|
| 67 |
+
if key not in case:
|
| 68 |
+
raise ValueError(f"{path}:{line_no}: missing '{key}'")
|
| 69 |
+
extra = set(case.get("required", [])) - set(case["relevant"])
|
| 70 |
+
if extra:
|
| 71 |
+
raise ValueError(f"{path}:{line_no}: required ids not in relevant: {sorted(extra)}")
|
| 72 |
+
if only and case["category"] not in only:
|
| 73 |
+
continue
|
| 74 |
+
cases.append(case)
|
| 75 |
+
ids = [c["id"] for c in cases]
|
| 76 |
+
dupes = {i for i in ids if ids.count(i) > 1}
|
| 77 |
+
if dupes:
|
| 78 |
+
raise ValueError(f"Duplicate case ids: {sorted(dupes)}")
|
| 79 |
+
return cases
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def init_state(use_llm: bool) -> None:
|
| 83 |
+
"""Load models/index into app.state the same way the server lifespan does."""
|
| 84 |
+
import faiss
|
| 85 |
+
from sentence_transformers import SentenceTransformer
|
| 86 |
+
|
| 87 |
+
from app.search import build_type_indexes
|
| 88 |
+
from app.state import _build_lookup_tables, infer_hadith_grade, state
|
| 89 |
+
|
| 90 |
+
t0 = time.perf_counter()
|
| 91 |
+
print(f"Loading embed model {cfg.EMBED_MODEL} …", flush=True)
|
| 92 |
+
state.embed_model = SentenceTransformer(cfg.EMBED_MODEL)
|
| 93 |
+
print(f"Loading FAISS index {cfg.FAISS_INDEX} …", flush=True)
|
| 94 |
+
state.faiss_index = faiss.read_index(str(ROOT / cfg.FAISS_INDEX))
|
| 95 |
+
print(f"Loading metadata {cfg.METADATA_FILE} …", flush=True)
|
| 96 |
+
with open(ROOT / cfg.METADATA_FILE, encoding="utf-8") as f:
|
| 97 |
+
state.dataset = [infer_hadith_grade(item) for item in json.load(f)]
|
| 98 |
+
print("Building lookup tables + BM25 …", flush=True)
|
| 99 |
+
_build_lookup_tables(state.dataset)
|
| 100 |
+
state.type_indexes = build_type_indexes(state.faiss_index, state.dataset)
|
| 101 |
+
|
| 102 |
+
state.reranker = None
|
| 103 |
+
if cfg.USE_RERANKER:
|
| 104 |
+
from sentence_transformers import CrossEncoder
|
| 105 |
+
print(f"Loading reranker {cfg.RERANK_MODEL} …", flush=True)
|
| 106 |
+
state.reranker = CrossEncoder(cfg.RERANK_MODEL, max_length=512)
|
| 107 |
+
|
| 108 |
+
state.llm = None
|
| 109 |
+
if use_llm:
|
| 110 |
+
from app.llm import get_llm_provider
|
| 111 |
+
print(f"Initializing LLM provider {cfg.LLM_BACKEND} …", flush=True)
|
| 112 |
+
state.llm = get_llm_provider()
|
| 113 |
+
elif not cfg.FAST_QUERY_REWRITE:
|
| 114 |
+
print("WARNING: FAST_QUERY_REWRITE is off but --llm not given; "
|
| 115 |
+
"queries will use the regex fallback rewrite.", flush=True)
|
| 116 |
+
|
| 117 |
+
state.ready = True
|
| 118 |
+
print(f"Ready in {time.perf_counter() - t0:.1f}s | dataset={len(state.dataset):,} "
|
| 119 |
+
f"| faiss={state.faiss_index.ntotal:,} | reranker={'on' if state.reranker else 'off'}\n",
|
| 120 |
+
flush=True)
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def validate_labels(cases: List[dict], dataset: list) -> None:
|
| 124 |
+
known = {item.get("id") for item in dataset}
|
| 125 |
+
missing = [(c["id"], r) for c in cases for r in c["relevant"] if r not in known]
|
| 126 |
+
if missing:
|
| 127 |
+
lines = "\n".join(f" {cid}: {rid}" for cid, rid in missing[:20])
|
| 128 |
+
raise SystemExit(f"Golden set references {len(missing)} unknown dataset ids:\n{lines}")
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
# ═══════════════════════════════════════════════════════════════════════
|
| 132 |
+
# RETRIEVAL
|
| 133 |
+
# ═══════════════════════════════════════════════════════════════════════
|
| 134 |
+
async def retrieve(case: dict, mode: str, k: int) -> dict:
|
| 135 |
+
from app.search import hybrid_search, rewrite_query
|
| 136 |
+
from app.state import _rag_preprocess, state
|
| 137 |
+
|
| 138 |
+
if mode == "pipeline":
|
| 139 |
+
ctx = await _rag_preprocess(case["query"], k, None, None)
|
| 140 |
+
return {
|
| 141 |
+
"results": ctx["results"],
|
| 142 |
+
"intent": ctx["intent"],
|
| 143 |
+
"top_score": ctx["top_score"],
|
| 144 |
+
"gated": not ctx["confident"],
|
| 145 |
+
"confidence": ctx["confidence"],
|
| 146 |
+
"confidence_source": ctx["confidence_source"],
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
rewrite = await rewrite_query(case["query"], state.llm)
|
| 150 |
+
results = await hybrid_search(
|
| 151 |
+
case["query"], rewrite, state.embed_model, state.faiss_index, state.dataset,
|
| 152 |
+
top_n=k, bm25_index=state.bm25_index, reranker_model=state.reranker,
|
| 153 |
+
)
|
| 154 |
+
# The gate runs in _rag_preprocess, so hybrid mode doesn't evaluate it.
|
| 155 |
+
return {
|
| 156 |
+
"results": results,
|
| 157 |
+
"intent": rewrite.get("intent", "general"),
|
| 158 |
+
"top_score": results[0].get("_score", 0.0) if results else 0.0,
|
| 159 |
+
"gated": None,
|
| 160 |
+
"confidence": None,
|
| 161 |
+
"confidence_source": None,
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def result_ids(item: dict, ref_to_id: Dict[str, str]) -> set:
|
| 166 |
+
"""All dataset ids a result stands for, including narrations merged into it by dedup."""
|
| 167 |
+
ids = {item.get("id")}
|
| 168 |
+
for alt in item.get("other_collections") or []:
|
| 169 |
+
ref = _GRADE_SUFFIX.sub("", alt).strip()
|
| 170 |
+
if ref in ref_to_id:
|
| 171 |
+
ids.add(ref_to_id[ref])
|
| 172 |
+
return ids
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def score_case(case: dict, out: dict, k: int, ref_to_id: Dict[str, str]) -> dict:
|
| 176 |
+
relevant = set(case["relevant"])
|
| 177 |
+
results = out["results"][:k]
|
| 178 |
+
|
| 179 |
+
retrieved, hit_ranks, found = [], [], set()
|
| 180 |
+
for rank, item in enumerate(results, 1):
|
| 181 |
+
ids = result_ids(item, ref_to_id)
|
| 182 |
+
retrieved.append(item.get("id"))
|
| 183 |
+
matched = ids & relevant
|
| 184 |
+
if matched:
|
| 185 |
+
hit_ranks.append(rank)
|
| 186 |
+
found |= matched
|
| 187 |
+
|
| 188 |
+
first = hit_ranks[0] if hit_ranks else None
|
| 189 |
+
row = {
|
| 190 |
+
"id": case["id"],
|
| 191 |
+
"category": case["category"],
|
| 192 |
+
"lang": case.get("lang", "?"),
|
| 193 |
+
"query": case["query"],
|
| 194 |
+
"intent": out["intent"],
|
| 195 |
+
"top_score": round(float(out["top_score"]), 4),
|
| 196 |
+
"gated": out["gated"],
|
| 197 |
+
"confidence": None if out["confidence"] is None else round(float(out["confidence"]), 4),
|
| 198 |
+
"confidence_source": out["confidence_source"],
|
| 199 |
+
"retrieved": retrieved,
|
| 200 |
+
"first_hit_rank": first,
|
| 201 |
+
}
|
| 202 |
+
if relevant:
|
| 203 |
+
row.update({
|
| 204 |
+
"hit@1": int(first is not None and first <= 1),
|
| 205 |
+
"hit@3": int(first is not None and first <= 3),
|
| 206 |
+
"hit@k": int(first is not None),
|
| 207 |
+
"mrr": 1.0 / first if first else 0.0,
|
| 208 |
+
# Deduped hadith results also match the narrations merged into them, so
|
| 209 |
+
# found can exceed k; cap at 1.
|
| 210 |
+
"recall@k": min(1.0, len(found) / min(len(relevant), k)),
|
| 211 |
+
})
|
| 212 |
+
if case.get("required"):
|
| 213 |
+
missing = sorted(set(case["required"]) - found)
|
| 214 |
+
row["complete@k"] = int(not missing)
|
| 215 |
+
row["missing_required"] = missing
|
| 216 |
+
return row
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
# ═══════════════════════════════════════════════════════════════════════
|
| 220 |
+
# REPORTING
|
| 221 |
+
# ═══════════════════════════════════════════════════════════════════════
|
| 222 |
+
METRICS = ("hit@1", "hit@3", "hit@k", "mrr", "recall@k")
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
def aggregate(rows: List[dict]) -> dict:
|
| 226 |
+
pos = [r for r in rows if "hit@k" in r]
|
| 227 |
+
neg = [r for r in rows if "hit@k" not in r]
|
| 228 |
+
|
| 229 |
+
def mean(rs, key):
|
| 230 |
+
return round(sum(r[key] for r in rs) / len(rs), 4) if rs else None
|
| 231 |
+
|
| 232 |
+
def summarize(rs):
|
| 233 |
+
return {"n": len(rs), **{m: mean(rs, m) for m in METRICS}}
|
| 234 |
+
|
| 235 |
+
by_cat, by_lang = defaultdict(list), defaultdict(list)
|
| 236 |
+
for r in pos:
|
| 237 |
+
by_cat[r["category"]].append(r)
|
| 238 |
+
by_lang[r["lang"]].append(r)
|
| 239 |
+
|
| 240 |
+
req = [r for r in pos if "complete@k" in r]
|
| 241 |
+
return {
|
| 242 |
+
"overall": summarize(pos),
|
| 243 |
+
"required": {"n": len(req), "complete@k": mean(req, "complete@k")},
|
| 244 |
+
"by_category": {c: summarize(rs) for c, rs in sorted(by_cat.items())},
|
| 245 |
+
"by_lang": {lang: summarize(rs) for lang, rs in sorted(by_lang.items())},
|
| 246 |
+
"gate": _gate_summary(pos, neg),
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
def _gate_summary(pos: List[dict], neg: List[dict]) -> Optional[dict]:
|
| 251 |
+
if any(r["gated"] is None for r in pos + neg):
|
| 252 |
+
return None
|
| 253 |
+
rate = lambda flags: round(sum(flags) / len(flags), 4) if flags else None # noqa: E731
|
| 254 |
+
return {
|
| 255 |
+
# Positives refused even though retrieval found the answer.
|
| 256 |
+
"false_reject_rate": rate([int(r["gated"]) for r in pos if r["hit@k"]]),
|
| 257 |
+
# Positives refused regardless of retrieval outcome (what the user experiences).
|
| 258 |
+
"pos_reject_rate": rate([int(r["gated"]) for r in pos]),
|
| 259 |
+
# Out-of-scope queries the gate lets through to the LLM.
|
| 260 |
+
"negatives": len(neg),
|
| 261 |
+
"false_accept_rate": rate([int(not r["gated"]) for r in neg]),
|
| 262 |
+
"pos_confidence_median": _median([r["confidence"] for r in pos]),
|
| 263 |
+
"neg_confidence_median": _median([r["confidence"] for r in neg]),
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
def _median(xs: List[float]) -> Optional[float]:
|
| 268 |
+
if not xs:
|
| 269 |
+
return None
|
| 270 |
+
s = sorted(xs)
|
| 271 |
+
mid = len(s) // 2
|
| 272 |
+
return round(s[mid] if len(s) % 2 else (s[mid - 1] + s[mid]) / 2, 4)
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
def _fmt(v) -> str:
|
| 276 |
+
return " — " if v is None else f"{v:6.3f}"
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
def print_report(summary: dict, k: int) -> None:
|
| 280 |
+
header = f"{'':22}{'n':>4} " + " ".join(f"{m.replace('@k', f'@{k}'):>8}" for m in METRICS)
|
| 281 |
+
print(header)
|
| 282 |
+
print("─" * len(header))
|
| 283 |
+
|
| 284 |
+
def line(label, s):
|
| 285 |
+
print(f"{label:22}{s['n']:>4} " + " ".join(f"{_fmt(s[m]):>8}" for m in METRICS))
|
| 286 |
+
|
| 287 |
+
line("OVERALL", summary["overall"])
|
| 288 |
+
print()
|
| 289 |
+
for cat, s in summary["by_category"].items():
|
| 290 |
+
line(f" {cat}", s)
|
| 291 |
+
print()
|
| 292 |
+
for lang, s in summary["by_lang"].items():
|
| 293 |
+
line(f" lang={lang}", s)
|
| 294 |
+
req = summary.get("required") or {}
|
| 295 |
+
if req.get("n"):
|
| 296 |
+
print(f"\n complete@{k} (all must-include sources retrieved): {_fmt(req['complete@k'])} (n={req['n']})")
|
| 297 |
+
|
| 298 |
+
g = summary["gate"]
|
| 299 |
+
if g is None:
|
| 300 |
+
print("\nConfidence gate: not evaluated in hybrid mode")
|
| 301 |
+
return
|
| 302 |
+
print("\nConfidence gate")
|
| 303 |
+
print(f" false-reject rate (answer retrieved but gated): {_fmt(g['false_reject_rate'])}")
|
| 304 |
+
print(f" reject rate on all positives: {_fmt(g['pos_reject_rate'])}")
|
| 305 |
+
print(f" false-accept rate on {g['negatives']} negatives: {_fmt(g['false_accept_rate'])}")
|
| 306 |
+
print(f" median confidence positives={_fmt(g['pos_confidence_median'])} "
|
| 307 |
+
f"negatives={_fmt(g['neg_confidence_median'])}")
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
def print_failures(rows: List[dict], k: int) -> None:
|
| 311 |
+
misses = [r for r in rows if r.get("hit@k") == 0]
|
| 312 |
+
incomplete = [r for r in rows if r.get("hit@k") == 1 and r.get("complete@k") == 0]
|
| 313 |
+
leaks = [r for r in rows if "hit@k" not in r and r["gated"] is False]
|
| 314 |
+
refused = [r for r in rows if "hit@k" in r and r["gated"]]
|
| 315 |
+
if misses:
|
| 316 |
+
print(f"\nMisses (no relevant result in top {k}):")
|
| 317 |
+
for r in misses:
|
| 318 |
+
print(f" {r['id']:<14} [{r['intent']}] {r['query'][:60]}")
|
| 319 |
+
print(f" {'':14} got: {', '.join(map(str, r['retrieved'][:5]))}")
|
| 320 |
+
if incomplete:
|
| 321 |
+
print(f"\nHits missing a must-include source:")
|
| 322 |
+
for r in incomplete:
|
| 323 |
+
print(f" {r['id']:<14} {r['query'][:50]} missing: {', '.join(r['missing_required'])}")
|
| 324 |
+
for title, group in (("Negatives that pass the gate", leaks), ("Positives refused by the gate", refused)):
|
| 325 |
+
if group:
|
| 326 |
+
print(f"\n{title}:")
|
| 327 |
+
for r in group:
|
| 328 |
+
print(f" {r['id']:<14} {r['confidence_source']}={r['confidence']:.3f} "
|
| 329 |
+
f"{r['query'][:50]} → {', '.join(map(str, r['retrieved'][:3]))}")
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
def print_comparison(current: dict, prev_path: Path) -> None:
|
| 333 |
+
prev = json.loads(prev_path.read_text(encoding="utf-8"))
|
| 334 |
+
print(f"\nΔ vs {prev_path.name} ({prev.get('timestamp', '?')}, commit {prev.get('git_commit', '?')})")
|
| 335 |
+
|
| 336 |
+
def delta_line(label, cur, old):
|
| 337 |
+
cells = []
|
| 338 |
+
for m in METRICS:
|
| 339 |
+
a, b = cur.get(m), (old or {}).get(m)
|
| 340 |
+
cells.append(" — " if a is None or b is None else f"{a - b:+8.3f}")
|
| 341 |
+
print(f"{label:22} " + " ".join(cells))
|
| 342 |
+
|
| 343 |
+
delta_line("OVERALL", current["summary"]["overall"], prev["summary"]["overall"])
|
| 344 |
+
cur_req, old_req = current["summary"].get("required") or {}, prev["summary"].get("required") or {}
|
| 345 |
+
if cur_req.get("complete@k") is not None and old_req.get("complete@k") is not None:
|
| 346 |
+
print(f"{' complete@k':22} {cur_req['complete@k'] - old_req['complete@k']:+8.3f}")
|
| 347 |
+
for cat, s in current["summary"]["by_category"].items():
|
| 348 |
+
delta_line(f" {cat}", s, prev["summary"]["by_category"].get(cat))
|
| 349 |
+
|
| 350 |
+
old_rows = {r["id"]: r for r in prev["cases"]}
|
| 351 |
+
fixed, broke = [], []
|
| 352 |
+
for r in current["cases"]:
|
| 353 |
+
o = old_rows.get(r["id"])
|
| 354 |
+
if not o or "hit@k" not in r or "hit@k" not in o:
|
| 355 |
+
continue
|
| 356 |
+
if r["hit@k"] > o["hit@k"]:
|
| 357 |
+
fixed.append(r["id"])
|
| 358 |
+
elif r["hit@k"] < o["hit@k"]:
|
| 359 |
+
broke.append(r["id"])
|
| 360 |
+
print(f"\n newly hit: {', '.join(fixed) or '—'}")
|
| 361 |
+
print(f" newly lost: {', '.join(broke) or '—'}")
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
def _git_commit() -> str:
|
| 365 |
+
try:
|
| 366 |
+
sha = subprocess.check_output(["git", "rev-parse", "--short", "HEAD"], cwd=ROOT, text=True).strip()
|
| 367 |
+
dirty = subprocess.call(["git", "diff", "--quiet"], cwd=ROOT) != 0
|
| 368 |
+
return sha + ("-dirty" if dirty else "")
|
| 369 |
+
except Exception:
|
| 370 |
+
return "unknown"
|
| 371 |
+
|
| 372 |
+
|
| 373 |
+
# ═══════════════════════════════════════════════════════════════════════
|
| 374 |
+
# MAIN
|
| 375 |
+
# ═══════════════════════════════════════════════════════════════════════
|
| 376 |
+
async def main_async(args: argparse.Namespace) -> None:
|
| 377 |
+
from app.state import state
|
| 378 |
+
|
| 379 |
+
only = set(args.only.split(",")) if args.only else None
|
| 380 |
+
cases = load_golden(Path(args.golden), only)
|
| 381 |
+
init_state(args.llm)
|
| 382 |
+
validate_labels(cases, state.dataset)
|
| 383 |
+
|
| 384 |
+
ref_to_id = {
|
| 385 |
+
item["reference"]: item["id"]
|
| 386 |
+
for item in state.dataset
|
| 387 |
+
if item.get("type") == "hadith" and item.get("reference")
|
| 388 |
+
}
|
| 389 |
+
|
| 390 |
+
rows, t0 = [], time.perf_counter()
|
| 391 |
+
for i, case in enumerate(cases, 1):
|
| 392 |
+
out = await retrieve(case, args.mode, args.k)
|
| 393 |
+
rows.append(score_case(case, out, args.k, ref_to_id))
|
| 394 |
+
if sys.stdout.isatty():
|
| 395 |
+
print(f"\r {i}/{len(cases)} evaluated", end="", flush=True)
|
| 396 |
+
elapsed = time.perf_counter() - t0
|
| 397 |
+
print(f"\r {len(cases)} cases in {elapsed:.1f}s ({elapsed / max(len(cases), 1):.2f}s/query)\n")
|
| 398 |
+
|
| 399 |
+
summary = aggregate(rows)
|
| 400 |
+
print_report(summary, args.k)
|
| 401 |
+
if args.show_failures:
|
| 402 |
+
print_failures(rows, args.k)
|
| 403 |
+
|
| 404 |
+
report = {
|
| 405 |
+
"timestamp": datetime.now().isoformat(timespec="seconds"),
|
| 406 |
+
"git_commit": _git_commit(),
|
| 407 |
+
"mode": args.mode,
|
| 408 |
+
"k": args.k,
|
| 409 |
+
"golden": str(Path(args.golden).resolve().relative_to(ROOT)),
|
| 410 |
+
"config": {
|
| 411 |
+
key: getattr(cfg, key) for key in (
|
| 412 |
+
"EMBED_MODEL", "TOP_K_SEARCH", "TOP_K_RETURN", "USE_RERANKER", "RERANK_MODEL",
|
| 413 |
+
"HADITH_BOOST", "QURAN_BOOST", "FATWA_BOOST", "RERANK_MIN_SCORE", "DENSE_MIN_SCORE",
|
| 414 |
+
"FAST_QUERY_REWRITE", "ENABLE_HADITH_DEDUPLICATION",
|
| 415 |
+
)
|
| 416 |
+
},
|
| 417 |
+
"summary": summary,
|
| 418 |
+
"cases": rows,
|
| 419 |
+
}
|
| 420 |
+
|
| 421 |
+
if args.compare:
|
| 422 |
+
print_comparison(report, Path(args.compare))
|
| 423 |
+
|
| 424 |
+
out_path = Path(args.out) if args.out else RESULTS_DIR / f"{datetime.now():%Y%m%d-%H%M%S}-{args.mode}.json"
|
| 425 |
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
| 426 |
+
out_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
| 427 |
+
print(f"\nReport written to {out_path.relative_to(ROOT) if out_path.is_relative_to(ROOT) else out_path}")
|
| 428 |
+
|
| 429 |
+
|
| 430 |
+
def main() -> None:
|
| 431 |
+
parser = argparse.ArgumentParser(description="Evaluate QModel retrieval against a golden set.")
|
| 432 |
+
parser.add_argument("--golden", default=str(DEFAULT_GOLDEN), help="Golden set JSONL path")
|
| 433 |
+
parser.add_argument("--mode", choices=("pipeline", "hybrid"), default="pipeline")
|
| 434 |
+
parser.add_argument("--k", type=int, default=cfg.TOP_K_RETURN,
|
| 435 |
+
help=f"Results to score (default TOP_K_RETURN={cfg.TOP_K_RETURN}, what the LLM sees)")
|
| 436 |
+
parser.add_argument("--only", help="Comma-separated categories to run (e.g. fatwa,negative)")
|
| 437 |
+
parser.add_argument("--llm", action="store_true", help="Load the LLM backend for query rewriting")
|
| 438 |
+
parser.add_argument("--show-failures", action="store_true", help="List misses and leaked negatives")
|
| 439 |
+
parser.add_argument("--compare", help="Previous report JSON to diff against")
|
| 440 |
+
parser.add_argument("--out", help="Where to write the JSON report (default eval/results/<timestamp>.json)")
|
| 441 |
+
parser.add_argument("--verbose", action="store_true", help="Show app logging")
|
| 442 |
+
args = parser.parse_args()
|
| 443 |
+
|
| 444 |
+
logging.basicConfig(level=logging.INFO if args.verbose else logging.WARNING,
|
| 445 |
+
format="%(levelname)s %(name)s: %(message)s")
|
| 446 |
+
asyncio.run(main_async(args))
|
| 447 |
+
|
| 448 |
+
|
| 449 |
+
if __name__ == "__main__":
|
| 450 |
+
main()
|
main.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
"""
|
| 2 |
-
QModel
|
| 3 |
===========================
|
| 4 |
Specialized Quran & Hadith system with dual LLM backend support.
|
| 5 |
|
|
@@ -39,7 +39,7 @@ from app.routers import chat, fatwa, hadith, ops, quran
|
|
| 39 |
# FASTAPI APP
|
| 40 |
# ═══════════════════════════════════════════════════════════════════════
|
| 41 |
app = FastAPI(
|
| 42 |
-
title="QModel v8 — Islamic RAG API",
|
| 43 |
description=(
|
| 44 |
"Specialized Quran, Hadith & Fatwa system with dual LLM backend.\n\n"
|
| 45 |
"**Capabilities:**\n"
|
|
@@ -47,7 +47,7 @@ app = FastAPI(
|
|
| 47 |
"- Streaming support\n"
|
| 48 |
"- Islamic knowledge RAG pipeline (Quran, Hadith, Fatwa)"
|
| 49 |
),
|
| 50 |
-
version="8.
|
| 51 |
lifespan=lifespan,
|
| 52 |
)
|
| 53 |
|
|
|
|
| 1 |
"""
|
| 2 |
+
QModel v8.5 — Islamic RAG API
|
| 3 |
===========================
|
| 4 |
Specialized Quran & Hadith system with dual LLM backend support.
|
| 5 |
|
|
|
|
| 39 |
# FASTAPI APP
|
| 40 |
# ═══════════════════════════════════════════════════════════════════════
|
| 41 |
app = FastAPI(
|
| 42 |
+
title="QModel v8.5 — Islamic RAG API",
|
| 43 |
description=(
|
| 44 |
"Specialized Quran, Hadith & Fatwa system with dual LLM backend.\n\n"
|
| 45 |
"**Capabilities:**\n"
|
|
|
|
| 47 |
"- Streaming support\n"
|
| 48 |
"- Islamic knowledge RAG pipeline (Quran, Hadith, Fatwa)"
|
| 49 |
),
|
| 50 |
+
version="8.5.0",
|
| 51 |
lifespan=lifespan,
|
| 52 |
)
|
| 53 |
|
scripts/update_fatwa_index.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Replace the fatwa block of QModel.index + metadata.json without re-embedding Quran/Hadith.
|
| 4 |
+
|
| 5 |
+
Fatwas are the last block of both files (build_index.py writes quran + hadith + fatwa),
|
| 6 |
+
so this keeps every earlier vector, drops the old fatwa rows, and appends freshly
|
| 7 |
+
embedded fatwas. By default it loads the same fatwas as build_index.py (FATWA_DIRS
|
| 8 |
+
minus FATWA_EXCLUDE), so a fatwa-only update matches what a full rebuild produces.
|
| 9 |
+
|
| 10 |
+
Usage:
|
| 11 |
+
python scripts/update_fatwa_index.py # build_index.py's fatwa sources
|
| 12 |
+
python scripts/update_fatwa_index.py --in-place
|
| 13 |
+
python scripts/update_fatwa_index.py --fatwa-dir data/fatwas_scraped # override sources
|
| 14 |
+
|
| 15 |
+
Without --in-place, writes QModel.fatwa-update.index / metadata.fatwa-update.json next to
|
| 16 |
+
the originals; with it, replaces them and keeps .bak copies.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
import argparse
|
| 20 |
+
import json
|
| 21 |
+
import shutil
|
| 22 |
+
import sys
|
| 23 |
+
from pathlib import Path
|
| 24 |
+
|
| 25 |
+
import faiss
|
| 26 |
+
import numpy as np
|
| 27 |
+
from sentence_transformers import SentenceTransformer
|
| 28 |
+
|
| 29 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 30 |
+
sys.path.insert(0, str(ROOT))
|
| 31 |
+
|
| 32 |
+
from build_index import ( # noqa: E402
|
| 33 |
+
DEFAULT_EMBED_MODEL,
|
| 34 |
+
EMBED_BATCH_SIZE,
|
| 35 |
+
FATWA_DIRS,
|
| 36 |
+
FATWA_EXCLUDE,
|
| 37 |
+
INDEX_PATH,
|
| 38 |
+
METADATA_PATH,
|
| 39 |
+
load_fatwa_entries,
|
| 40 |
+
passage_text,
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def main():
|
| 45 |
+
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
| 46 |
+
parser.add_argument("--fatwa-dir", action="append", type=Path,
|
| 47 |
+
help="Directory of fatwa JSON files (repeatable; default: build_index.FATWA_DIRS)")
|
| 48 |
+
parser.add_argument("--exclude-file", action="append", type=Path,
|
| 49 |
+
help="JSON file to skip (repeatable; default: build_index.FATWA_EXCLUDE)")
|
| 50 |
+
parser.add_argument("--model", default=DEFAULT_EMBED_MODEL)
|
| 51 |
+
parser.add_argument("--in-place", action="store_true", help="Overwrite QModel.index / metadata.json (keeps .bak)")
|
| 52 |
+
args = parser.parse_args()
|
| 53 |
+
|
| 54 |
+
print(f"Loading {INDEX_PATH.name} and {METADATA_PATH.name} …")
|
| 55 |
+
index = faiss.read_index(str(INDEX_PATH))
|
| 56 |
+
metadata = json.loads(METADATA_PATH.read_text(encoding="utf-8"))
|
| 57 |
+
|
| 58 |
+
first_fatwa = next((i for i, d in enumerate(metadata) if d.get("type") == "fatwa"), len(metadata))
|
| 59 |
+
if any(d.get("type") == "fatwa" for d in metadata[:first_fatwa]) or \
|
| 60 |
+
any(d.get("type") != "fatwa" for d in metadata[first_fatwa:]):
|
| 61 |
+
sys.exit("Fatwas are not a contiguous tail block of metadata.json — run build_index.py instead.")
|
| 62 |
+
if index.ntotal < first_fatwa:
|
| 63 |
+
sys.exit(f"Index has {index.ntotal} vectors but {first_fatwa} non-fatwa records — run build_index.py instead.")
|
| 64 |
+
print(f" keeping {first_fatwa:,} Quran/Hadith rows; dropping {len(metadata) - first_fatwa} fatwa records "
|
| 65 |
+
f"and {index.ntotal - first_fatwa} fatwa vectors")
|
| 66 |
+
|
| 67 |
+
fatwas = load_fatwa_entries(args.fatwa_dir or FATWA_DIRS, args.exclude_file or FATWA_EXCLUDE)
|
| 68 |
+
if not fatwas:
|
| 69 |
+
sys.exit("No fatwas found.")
|
| 70 |
+
|
| 71 |
+
import torch
|
| 72 |
+
device = "mps" if torch.backends.mps.is_available() else ("cuda" if torch.cuda.is_available() else "cpu")
|
| 73 |
+
print(f"Embedding {len(fatwas):,} fatwas with {args.model} on {device} …")
|
| 74 |
+
model = SentenceTransformer(args.model, device=device)
|
| 75 |
+
vectors = model.encode(
|
| 76 |
+
[passage_text(f) for f in fatwas], batch_size=EMBED_BATCH_SIZE,
|
| 77 |
+
convert_to_numpy=True, show_progress_bar=True,
|
| 78 |
+
).astype(np.float32)
|
| 79 |
+
faiss.normalize_L2(vectors)
|
| 80 |
+
if vectors.shape[1] != index.d:
|
| 81 |
+
sys.exit(f"Embedding dim {vectors.shape[1]} != index dim {index.d} — use the model the index was built with.")
|
| 82 |
+
|
| 83 |
+
new_index = faiss.IndexFlatIP(index.d)
|
| 84 |
+
new_index.add(index.reconstruct_n(0, first_fatwa))
|
| 85 |
+
new_index.add(vectors)
|
| 86 |
+
new_metadata = metadata[:first_fatwa] + fatwas
|
| 87 |
+
assert new_index.ntotal == len(new_metadata)
|
| 88 |
+
|
| 89 |
+
if args.in_place:
|
| 90 |
+
out_index, out_meta = INDEX_PATH, METADATA_PATH
|
| 91 |
+
shutil.copy2(INDEX_PATH, INDEX_PATH.with_suffix(".index.bak"))
|
| 92 |
+
shutil.copy2(METADATA_PATH, METADATA_PATH.with_suffix(".json.bak"))
|
| 93 |
+
else:
|
| 94 |
+
out_index = INDEX_PATH.with_name("QModel.fatwa-update.index")
|
| 95 |
+
out_meta = METADATA_PATH.with_name("metadata.fatwa-update.json")
|
| 96 |
+
|
| 97 |
+
faiss.write_index(new_index, str(out_index))
|
| 98 |
+
out_meta.write_text(json.dumps(new_metadata, ensure_ascii=False, indent=2), encoding="utf-8")
|
| 99 |
+
print(f"Wrote {out_index.name} ({new_index.ntotal:,} vectors) and {out_meta.name} "
|
| 100 |
+
f"({len(new_metadata):,} records, {len(fatwas):,} fatwas)")
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
if __name__ == "__main__":
|
| 104 |
+
main()
|
tests/test_enhancements.py
CHANGED
|
@@ -240,10 +240,11 @@ class TestReviewFixes(unittest.IsolatedAsyncioTestCase):
|
|
| 240 |
embed = MagicMock()
|
| 241 |
embed.encode.return_value = np.zeros((2, 4), dtype="float32")
|
| 242 |
index = MagicMock()
|
| 243 |
-
#
|
|
|
|
| 244 |
index.search.side_effect = [
|
| 245 |
-
(np.array([[0.9]
|
| 246 |
-
(np.array([[0.9]
|
| 247 |
]
|
| 248 |
rw1 = {"ar_query": "تفسير آية الكرسي", "en_query": "kursi", "keywords": [], "intent": "tafsir"}
|
| 249 |
rw2 = {"ar_query": "تفسير سورة الإخلاص", "en_query": "ikhlas", "keywords": [], "intent": "tafsir"}
|
|
@@ -272,25 +273,35 @@ class TestReviewFixes(unittest.IsolatedAsyncioTestCase):
|
|
| 272 |
ctx = build_context([huge])
|
| 273 |
self.assertLessEqual(len(ctx), cfg.CONTEXT_MAX_CHARS + 100)
|
| 274 |
|
| 275 |
-
# ── #5: citation verification
|
| 276 |
-
def
|
| 277 |
-
from app import
|
| 278 |
verse = "اللَّهُ لَا إِلَٰهَ إِلَّا هُوَ الْحَيُّ الْقَيُّومُ"
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 294 |
|
| 295 |
|
| 296 |
# ── #6: arbitrary window size in expand_quran_context ───────────
|
|
@@ -315,8 +326,8 @@ class TestReviewFixes(unittest.IsolatedAsyncioTestCase):
|
|
| 315 |
from app.search import rerank_with_cross_encoder
|
| 316 |
|
| 317 |
mock_encoder = MagicMock()
|
| 318 |
-
#
|
| 319 |
-
mock_encoder.predict.return_value = [
|
| 320 |
|
| 321 |
candidates = [
|
| 322 |
{"arabic": "ضعيف الصلة", "english": "less relevant", "_score": 0.5},
|
|
@@ -330,5 +341,430 @@ class TestReviewFixes(unittest.IsolatedAsyncioTestCase):
|
|
| 330 |
self.assertGreater(reranked[1]["_score"], 0.0)
|
| 331 |
|
| 332 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 333 |
if __name__ == "__main__":
|
| 334 |
unittest.main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
embed = MagicMock()
|
| 241 |
embed.encode.return_value = np.zeros((2, 4), dtype="float32")
|
| 242 |
index = MagicMock()
|
| 243 |
+
# One batched call per search (Arabic + English vectors → two rows):
|
| 244 |
+
# the first search returns doc 0, the second doc 1
|
| 245 |
index.search.side_effect = [
|
| 246 |
+
(np.array([[0.9], [0.9]]), np.array([[0], [0]])),
|
| 247 |
+
(np.array([[0.9], [0.9]]), np.array([[1], [1]])),
|
| 248 |
]
|
| 249 |
rw1 = {"ar_query": "تفسير آية الكرسي", "en_query": "kursi", "keywords": [], "intent": "tafsir"}
|
| 250 |
rw2 = {"ar_query": "تفسير سورة الإخلاص", "en_query": "ikhlas", "keywords": [], "intent": "tafsir"}
|
|
|
|
| 273 |
ctx = build_context([huge])
|
| 274 |
self.assertLessEqual(len(ctx), cfg.CONTEXT_MAX_CHARS + 100)
|
| 275 |
|
| 276 |
+
# ── #5: citation verification is grounded in the retrieved results ──
|
| 277 |
+
def test_verify_citations_grounded_in_retrieved_results(self):
|
| 278 |
+
from app.state import _verify_citations
|
| 279 |
verse = "اللَّهُ لَا إِلَٰهَ إِلَّا هُوَ الْحَيُّ الْقَيُّومُ"
|
| 280 |
+
retrieved = [{"type": "quran", "arabic": verse + " لَا تَأْخُذُهُ سِنَةٌ وَلَا نَوْمٌ"}]
|
| 281 |
+
|
| 282 |
+
# A quote found in the retrieved passages is kept
|
| 283 |
+
self.assertIn(verse, _verify_citations(f"❝{verse}❞", results=retrieved))
|
| 284 |
+
# A genuine verse that was not retrieved still fails (strict grounding)
|
| 285 |
+
self.assertIn("⚠️", _verify_citations(f"❝{verse}❞", results=[]))
|
| 286 |
+
# A short retrieved verse must not whitelist an unrelated long quote
|
| 287 |
+
short = [{"type": "hadith", "arabic": "الم"}, {"type": "hadith", "arabic": "x"}]
|
| 288 |
+
self.assertIn("⚠️", _verify_citations("❝قال الملك العظيم كلاما مختلقا تماما هنا❞", results=short))
|
| 289 |
+
|
| 290 |
+
def test_verify_citations_restores_corrupted_scripture(self):
|
| 291 |
+
from app.state import _verify_citations
|
| 292 |
+
v_2_286 = "لَا يُكَلِّفُ ٱللَّهُ نَفۡسًا إِلَّا وُسۡعَهَاۚ لَهَا مَا كَسَبَتۡ وَعَلَيۡهَا مَا ٱكۡتَسَبَتۡۗ"
|
| 293 |
+
v_2_45 = "وَٱسۡتَعِينُواْ بِٱلصَّبۡرِ وَٱلصَّلَوٰةِۚ وَإِنَّهَا لَكَبِيرَةٌ إِلَّا عَلَى ٱلۡخَٰشِعِينَ"
|
| 294 |
+
retrieved = [{"type": "quran", "arabic": v_2_45}, {"type": "quran", "arabic": v_2_286}]
|
| 295 |
+
|
| 296 |
+
# A foreign letter inside a word (Hebrew alef) → canonical verse restored
|
| 297 |
+
corrupted = v_2_286.replace("إِلَّا", "إِلَّא")
|
| 298 |
+
self.assertEqual(_verify_citations(f"﴿{corrupted}﴾", retrieved), f"﴿{v_2_286}﴾")
|
| 299 |
+
# Plain (non-Uthmani) spelling → matched to the retrieved verse it copies, not the top one
|
| 300 |
+
plain = "واستعينوا بالصبر والصلاة وإنها لكبيرة إلا على الخاشعين"
|
| 301 |
+
self.assertEqual(_verify_citations(f"﴿{plain}﴾", retrieved), f"﴿{v_2_45}﴾")
|
| 302 |
+
# A quote far from any retrieved verse is not swapped for one
|
| 303 |
+
other = "وَٱعۡتَصِمُواْ بِحَبۡلِ ٱللَّهِ جَمِيعٗا وَلَا تَفَرَّقُواْ"
|
| 304 |
+
self.assertIn("⚠️", _verify_citations(f"﴿{other}﴾", retrieved))
|
| 305 |
|
| 306 |
|
| 307 |
# ── #6: arbitrary window size in expand_quran_context ───────────
|
|
|
|
| 326 |
from app.search import rerank_with_cross_encoder
|
| 327 |
|
| 328 |
mock_encoder = MagicMock()
|
| 329 |
+
# CrossEncoder.predict applies sigmoid for single-label rerankers → probabilities
|
| 330 |
+
mock_encoder.predict.return_value = [0.03, 0.98]
|
| 331 |
|
| 332 |
candidates = [
|
| 333 |
{"arabic": "ضعيف الصلة", "english": "less relevant", "_score": 0.5},
|
|
|
|
| 341 |
self.assertGreater(reranked[1]["_score"], 0.0)
|
| 342 |
|
| 343 |
|
| 344 |
+
class TestRetrievalRouting(unittest.TestCase):
|
| 345 |
+
|
| 346 |
+
def test_detect_source_type(self):
|
| 347 |
+
from app.state import _detect_source_type
|
| 348 |
+
|
| 349 |
+
self.assertEqual(_detect_source_type("Is alcohol forbidden in the Quran?"), "quran")
|
| 350 |
+
self.assertEqual(_detect_source_type("تحريم الخمر في القرآن"), "quran")
|
| 351 |
+
self.assertEqual(_detect_source_type("The Prophet's advice about anger"), "hadith")
|
| 352 |
+
self.assertEqual(_detect_source_type("حديث جبريل عن الإسلام"), "hadith")
|
| 353 |
+
self.assertEqual(_detect_source_type("هل يجوز السلام على المرأة الأجنبية؟"), "fatwa")
|
| 354 |
+
# Naming both sources keeps retrieval unfiltered
|
| 355 |
+
self.assertIsNone(_detect_source_type("What do the Quran and Sunnah say about fasting?"))
|
| 356 |
+
self.assertIsNone(_detect_source_type("Who is obliged to perform Hajj?"))
|
| 357 |
+
|
| 358 |
+
def test_bare_surah_verse_reference(self):
|
| 359 |
+
from app.search import lookup_quran_verses
|
| 360 |
+
|
| 361 |
+
verse = {
|
| 362 |
+
"id": "2:183", "type": "quran", "surah_number": 2, "verse_number": 183,
|
| 363 |
+
"surah_name_ar": "البقرة", "surah_name_en": "The Cow",
|
| 364 |
+
"surah_name_transliteration": "Al-Baqarah",
|
| 365 |
+
}
|
| 366 |
+
for query in ("Al-Baqarah 183", "البقرة 183", "Baqarah 183", "The Cow 183"):
|
| 367 |
+
self.assertEqual([r["id"] for r in lookup_quran_verses(query, [verse])], ["2:183"], query)
|
| 368 |
+
# A name that is not exactly a surah name must not be read as a reference
|
| 369 |
+
for query in ("iPhone 183", "Chapter 183", "Al-Baqarah tafsir 183"):
|
| 370 |
+
self.assertEqual(lookup_quran_verses(query, [verse]), [], query)
|
| 371 |
+
|
| 372 |
+
def test_fuse_ranks_semantic_hits_above_weak_text_overlap(self):
|
| 373 |
+
from app.search import fuse_search_results
|
| 374 |
+
|
| 375 |
+
semantic = [{"id": f"h{i}", "type": "fatwa", "source": f"s{i}", "arabic": f"a{i}", "_score": 0.8} for i in range(3)]
|
| 376 |
+
weak_text = [{"id": "noise", "type": "fatwa", "source": "n", "arabic": "n", "_score": 1.5, "_match": "partial"}]
|
| 377 |
+
exact = [{"id": "quote", "type": "fatwa", "source": "q", "arabic": "q", "_score": 3.0, "_match": "exact"}]
|
| 378 |
+
direct = [{"id": "ref", "type": "fatwa", "source": "r", "arabic": "r", "_score": 9.5}]
|
| 379 |
+
|
| 380 |
+
fused = fuse_search_results(
|
| 381 |
+
direct, semantic, [("three content words here", exact + weak_text)],
|
| 382 |
+
)
|
| 383 |
+
ids = [r["id"] for r in fused]
|
| 384 |
+
self.assertEqual(ids[:2], ["ref", "quote"]) # pinned tiers first
|
| 385 |
+
self.assertNotIn("noise", ids) # word-overlap hits dropped
|
| 386 |
+
self.assertEqual(ids[2:], ["h0", "h1", "h2"])
|
| 387 |
+
self.assertTrue(fused[0]["_pinned"] and fused[1]["_pinned"])
|
| 388 |
+
|
| 389 |
+
|
| 390 |
+
class TestAuthPhrasing(unittest.TestCase):
|
| 391 |
+
|
| 392 |
+
def test_strip_auth_phrasing(self):
|
| 393 |
+
from app.search import _detect_intent_regex, strip_auth_phrasing
|
| 394 |
+
|
| 395 |
+
cases = {
|
| 396 |
+
"هل حديث طلب العلم فريضة على كل مسلم صحيح؟": "طلب العلم فريضة على كل مسلم",
|
| 397 |
+
"ما درجة حديث: أبغض الحلال إلى الله الطلاق": "أبغض الحلال إلى الله الطلاق",
|
| 398 |
+
"هل صحيح أن النبي قال الجنة تحت أقدام الأمهات؟": "الجنة تحت أقدام الأمهات",
|
| 399 |
+
"Is the hadith 'actions are by intentions' authentic?": "actions are by intentions",
|
| 400 |
+
"Is it true that the Prophet said religion is sincere advice?": "religion is sincere advice",
|
| 401 |
+
}
|
| 402 |
+
for query, expected in cases.items():
|
| 403 |
+
self.assertEqual(_detect_intent_regex(query), "auth", query)
|
| 404 |
+
self.assertEqual(strip_auth_phrasing(query), expected, query)
|
| 405 |
+
# Apostrophes inside words are not quote marks
|
| 406 |
+
self.assertEqual(strip_auth_phrasing("Is the Prophet's saying about anger authentic?"),
|
| 407 |
+
"Is the Prophet's saying about anger")
|
| 408 |
+
# Nothing left to extract → keep the original text
|
| 409 |
+
self.assertEqual(strip_auth_phrasing("صحيح؟"), "صحيح؟")
|
| 410 |
+
|
| 411 |
+
|
| 412 |
+
class TestHadithVerifyRanking(unittest.IsolatedAsyncioTestCase):
|
| 413 |
+
|
| 414 |
+
async def test_verify_prefers_semantic_match_over_word_overlap(self):
|
| 415 |
+
from unittest.mock import patch
|
| 416 |
+
from app.routers import hadith as hadith_router
|
| 417 |
+
from app.state import state
|
| 418 |
+
|
| 419 |
+
weak = {"id": "noise", "type": "hadith", "collection": "Sunan an-Nasai", "hadith_number": 1,
|
| 420 |
+
"reference": "Sunan an-Nasai 1", "arabic": "x", "grade": "Da'if", "_score": 1.0, "_match": "partial"}
|
| 421 |
+
semantic = {"id": "bukhari_1", "type": "hadith", "collection": "Sahih al-Bukhari", "hadith_number": 1,
|
| 422 |
+
"reference": "Sahih al-Bukhari 1", "arabic": "y", "grade": "Sahih", "_score": 0.7}
|
| 423 |
+
|
| 424 |
+
saved_ready = state.ready
|
| 425 |
+
state.ready = True
|
| 426 |
+
try:
|
| 427 |
+
with patch.object(hadith_router, "lookup_hadith_references", return_value=[]), \
|
| 428 |
+
patch.object(hadith_router, "text_search", return_value=[weak]), \
|
| 429 |
+
patch.object(hadith_router, "rewrite_query", AsyncMock(return_value={"intent": "general"})), \
|
| 430 |
+
patch.object(hadith_router, "hybrid_search", AsyncMock(return_value=[semantic])):
|
| 431 |
+
resp = await hadith_router.verify_hadith(q="actions are by intentions", collection=None)
|
| 432 |
+
finally:
|
| 433 |
+
state.ready = saved_ready
|
| 434 |
+
|
| 435 |
+
self.assertTrue(resp.found)
|
| 436 |
+
self.assertEqual(resp.reference, "Sahih al-Bukhari 1")
|
| 437 |
+
self.assertEqual(resp.grade, "Sahih")
|
| 438 |
+
|
| 439 |
+
|
| 440 |
if __name__ == "__main__":
|
| 441 |
unittest.main()
|
| 442 |
+
|
| 443 |
+
|
| 444 |
+
class TestLanguageDriftGuard(unittest.IsolatedAsyncioTestCase):
|
| 445 |
+
"""Answers that drift into Chinese are regenerated (sync) or cut off (stream)."""
|
| 446 |
+
|
| 447 |
+
def _ctx(self):
|
| 448 |
+
return {"results": [{"type": "quran", "arabic": "x", "surah_number": 1, "verse_number": 1}],
|
| 449 |
+
"surah_info": None, "analysis": None, "lang": "english", "top_score": 1.0,
|
| 450 |
+
"intent": "tafsir", "confident": True, "confidence": 1.0, "confidence_source": "lexical"}
|
| 451 |
+
|
| 452 |
+
async def _run(self, replies):
|
| 453 |
+
from unittest.mock import MagicMock, patch
|
| 454 |
+
from app import state as state_mod
|
| 455 |
+
llm = MagicMock()
|
| 456 |
+
llm.chat = AsyncMock(side_effect=replies)
|
| 457 |
+
with patch.object(state_mod, "_rag_preprocess", AsyncMock(return_value=self._ctx())), \
|
| 458 |
+
patch.object(state_mod.state, "llm", llm), \
|
| 459 |
+
patch.object(state_mod.pipeline_cache, "get", AsyncMock(return_value=None)), \
|
| 460 |
+
patch.object(state_mod.pipeline_cache, "set", AsyncMock()):
|
| 461 |
+
out = await state_mod.run_rag_pipeline("q-drift-test")
|
| 462 |
+
return out["answer"], llm.chat
|
| 463 |
+
|
| 464 |
+
async def test_regenerates_once_on_cjk(self):
|
| 465 |
+
answer, chat = await self._run(["Fasting is 真主 obligatory.", "Fasting is obligatory."])
|
| 466 |
+
self.assertEqual(answer, "Fasting is obligatory.")
|
| 467 |
+
self.assertEqual(chat.call_count, 2)
|
| 468 |
+
self.assertEqual(chat.call_args.kwargs["temperature"], 0.0)
|
| 469 |
+
self.assertIn("no Chinese characters", chat.call_args.args[0][-1]["content"])
|
| 470 |
+
|
| 471 |
+
async def test_falls_back_when_cjk_persists(self):
|
| 472 |
+
answer, _ = await self._run(["答案 one", "答案 two"])
|
| 473 |
+
self.assertNotRegex(answer, r"[一-鿿]")
|
| 474 |
+
|
| 475 |
+
def test_stream_buffer_strips_cjk_and_holds_quran_quotes(self):
|
| 476 |
+
from app.state import StreamingVerificationBuffer
|
| 477 |
+
buf = StreamingVerificationBuffer(results=[{"type": "quran", "arabic": "الحمد لله رب العالمين"}])
|
| 478 |
+
self.assertEqual(buf.feed("Allah is 真主 merciful.\n"), ["Allah is merciful.\n"])
|
| 479 |
+
self.assertEqual(buf.cjk_chars, 2)
|
| 480 |
+
# An open ﴿ quote is held across a line break until it closes
|
| 481 |
+
self.assertEqual(buf.feed("﴿الحمد لله\n"), [])
|
| 482 |
+
self.assertEqual(buf.feed("رب العالمين﴾\n"), ["﴿الحمد لله\nرب العالمين﴾\n"])
|
| 483 |
+
|
| 484 |
+
|
| 485 |
+
class TestFatwaLinks(unittest.TestCase):
|
| 486 |
+
RESULTS = [
|
| 487 |
+
{"type": "fatwa", "source": "Islamweb", "title": "لا يقع طلاق الغضبان إذا غلب على عقله",
|
| 488 |
+
"source_url": "https://www.islamweb.net/ar/fatwa/101"},
|
| 489 |
+
{"type": "fatwa", "source": "Islamweb", "title": "حكم الحقنة في العضل للصائم",
|
| 490 |
+
"source_url": "https://www.islamweb.net/ar/fatwa/102"},
|
| 491 |
+
{"type": "fatwa", "source": "BinBaz", "title": "عدد الرضعات المحرمة",
|
| 492 |
+
"source_url": "https://binbaz.org.sa/fatwas/7"},
|
| 493 |
+
]
|
| 494 |
+
|
| 495 |
+
def test_links_fatwa_whose_title_is_used(self):
|
| 496 |
+
from app.state import _attach_source_refs
|
| 497 |
+
answer = "الأرجح أنه لا يقع طلاق الغضبان إذا غلب الغضب على عقله.\n\nوالله أعلم."
|
| 498 |
+
out = _attach_source_refs(answer, self.RESULTS, "arabic")
|
| 499 |
+
self.assertIn("(https://www.islamweb.net/ar/fatwa/101)", out)
|
| 500 |
+
self.assertNotIn("fatwa/102", out)
|
| 501 |
+
self.assertTrue(out.endswith("والله أعلم."))
|
| 502 |
+
|
| 503 |
+
def test_source_credit_links_that_sources_top_fatwa(self):
|
| 504 |
+
from app.state import _attach_source_refs
|
| 505 |
+
out = _attach_source_refs("قال الإمام ابن باز: لا بد من خمس رضعات.", self.RESULTS, "arabic")
|
| 506 |
+
self.assertIn("binbaz.org.sa/fatwas/7", out)
|
| 507 |
+
self.assertNotIn("islamweb", out)
|
| 508 |
+
|
| 509 |
+
def test_no_link_when_already_linked_or_unrelated(self):
|
| 510 |
+
from app.state import _attach_source_refs
|
| 511 |
+
linked = "See [[1](https://www.islamweb.net/ar/fatwa/102)]. And Allah knows best."
|
| 512 |
+
self.assertEqual(_attach_source_refs(linked, self.RESULTS, "english"), linked)
|
| 513 |
+
plain = "Fasting is obligatory in Ramadan. And Allah knows best."
|
| 514 |
+
self.assertEqual(_attach_source_refs(plain, self.RESULTS, "english"), plain)
|
| 515 |
+
|
| 516 |
+
COPIED = ("المسافة التي يباح للمسافر الأخذ برخص السفر من قصر وفطر وغير ذلك هي ما يعادل "
|
| 517 |
+
"ثلاثة وثمانين كيلومترا عند جمهور أهل العلم ولا يجوز الفطر فيما دون ذلك")
|
| 518 |
+
|
| 519 |
+
def test_links_fatwa_whose_text_is_copied(self):
|
| 520 |
+
from app.state import _attach_source_refs
|
| 521 |
+
fatwa = {"type": "fatwa", "source": "Islamweb", "title": "حد السفر",
|
| 522 |
+
"answer_ar": "الحمد لله، " + self.COPIED + ".", "source_url": "https://www.islamweb.net/ar/fatwa/233/"}
|
| 523 |
+
out = _attach_source_refs(self.COPIED + ".\n\nوالله أعلم.", self.RESULTS + [fatwa], "arabic")
|
| 524 |
+
self.assertIn("(https://www.islamweb.net/ar/fatwa/233/)", out)
|
| 525 |
+
self.assertNotIn("fatwa/101", out)
|
| 526 |
+
|
| 527 |
+
def test_shared_scripture_is_not_copying(self):
|
| 528 |
+
from app.state import _attach_source_refs
|
| 529 |
+
verse = {"type": "quran", "arabic": self.COPIED}
|
| 530 |
+
fatwa = {"type": "fatwa", "source": "Islamweb", "title": "حد السفر",
|
| 531 |
+
"answer_ar": self.COPIED, "source_url": "https://www.islamweb.net/ar/fatwa/233/"}
|
| 532 |
+
answer = self.COPIED + "."
|
| 533 |
+
self.assertEqual(_attach_source_refs(answer, [verse, fatwa], "arabic"), answer)
|
| 534 |
+
self.assertEqual(_attach_source_refs(f"﴿{self.COPIED}﴾", [fatwa], "arabic"), f"﴿{self.COPIED}﴾")
|
| 535 |
+
|
| 536 |
+
def test_link_template_copied_literally(self):
|
| 537 |
+
from app.state import _clean_links
|
| 538 |
+
self.assertEqual(_clean_links("[[1]({https://www.islamweb.net/ar/fatwa/930/})]"),
|
| 539 |
+
"[[1](https://www.islamweb.net/ar/fatwa/930/)]")
|
| 540 |
+
self.assertEqual(_clean_links("والله أعلم. [[1]({source_url})]"), "والله أعلم.")
|
| 541 |
+
self.assertEqual(_clean_links("[[1](!https://quran.com/4/161)]"), "[[1](https://quran.com/4/161)]")
|
| 542 |
+
self.assertEqual(_clean_links(""), "")
|
| 543 |
+
|
| 544 |
+
|
| 545 |
+
class TestLightStem(unittest.TestCase):
|
| 546 |
+
def test_short_words_keep_root_letters(self):
|
| 547 |
+
from app.arabic_nlp import light_stem, normalize_arabic
|
| 548 |
+
stem = lambda w: light_stem(normalize_arabic(w, aggressive=True))
|
| 549 |
+
# A leading و/ف/ب/ل or trailing ا in a 3-letter word is a root letter, not an affix
|
| 550 |
+
for word, expected in [("فضل", "فضل"), ("فتح", "فتح"), ("لبس", "لبس"), ("الربا", "ربا")]:
|
| 551 |
+
self.assertEqual(stem(word), expected, word)
|
| 552 |
+
# Real affixes are still removed
|
| 553 |
+
for word, expected in [("كتابه", "كتاب"), ("وقالوا", "قال"), ("مسلمون", "مسلم"), ("الوضوء", "وضوء")]:
|
| 554 |
+
self.assertEqual(stem(word), expected, word)
|
| 555 |
+
|
| 556 |
+
|
| 557 |
+
class TestSparseRerankSlots(unittest.TestCase):
|
| 558 |
+
def test_top_bm25_hits_enter_rerank_pool(self):
|
| 559 |
+
from app.search import _RERANK_POOL, _SPARSE_POOL_SLOTS, _reserve_sparse_slots
|
| 560 |
+
fused = [{"_dataset_idx": i} for i in range(40)]
|
| 561 |
+
# BM25 liked 2 (already in pool) and 30, 35 (outside it)
|
| 562 |
+
out = _reserve_sparse_slots(fused, [2, 30, 35])
|
| 563 |
+
pool = [c["_dataset_idx"] for c in out[:_RERANK_POOL]]
|
| 564 |
+
self.assertIn(30, pool)
|
| 565 |
+
self.assertIn(35, pool)
|
| 566 |
+
self.assertEqual(pool[: _RERANK_POOL - _SPARSE_POOL_SLOTS], list(range(_RERANK_POOL - _SPARSE_POOL_SLOTS)))
|
| 567 |
+
self.assertEqual(sorted(c["_dataset_idx"] for c in out), list(range(40))) # nothing lost or duplicated
|
| 568 |
+
|
| 569 |
+
|
| 570 |
+
class TestStreamingPipeline(unittest.IsolatedAsyncioTestCase):
|
| 571 |
+
"""run_rag_pipeline_stream end to end with a scripted LLM token stream."""
|
| 572 |
+
|
| 573 |
+
VERSE = "لَا يُكَلِّفُ ٱللَّهُ نَفۡسًا إِلَّا وُسۡعَهَاۚ لَهَا مَا كَسَبَتۡ وَعَلَيۡهَا مَا ٱكۡتَسَبَتۡۗ"
|
| 574 |
+
FATWA = {"type": "fatwa", "source": "Islamweb", "title": "الحقنة في العضل لا تبطل الصوم إذا كانت للتداوي",
|
| 575 |
+
"source_url": "https://www.islamweb.net/ar/fatwa/857"}
|
| 576 |
+
|
| 577 |
+
async def _stream(self, tokens, results, lang="arabic"):
|
| 578 |
+
from unittest.mock import MagicMock, patch
|
| 579 |
+
from app import state as state_mod
|
| 580 |
+
ctx = {"results": results, "surah_info": None, "analysis": None, "lang": lang, "top_score": 1.0,
|
| 581 |
+
"intent": "general", "confident": True, "confidence": 1.0, "confidence_source": "lexical"}
|
| 582 |
+
|
| 583 |
+
async def chat_stream(*_a, **_k):
|
| 584 |
+
for t in tokens:
|
| 585 |
+
yield t
|
| 586 |
+
|
| 587 |
+
llm = MagicMock()
|
| 588 |
+
llm.chat_stream = chat_stream
|
| 589 |
+
with patch.object(state_mod, "_rag_preprocess", AsyncMock(return_value=ctx)), \
|
| 590 |
+
patch.object(state_mod.state, "llm", llm):
|
| 591 |
+
events = [e async for e in state_mod.run_rag_pipeline_stream("q")]
|
| 592 |
+
return "".join(e["content"] for e in events if e["type"] == "token")
|
| 593 |
+
|
| 594 |
+
async def test_corrupted_quote_split_across_tokens_is_restored(self):
|
| 595 |
+
corrupted = self.VERSE.replace("إِلَّا", "إِلَّא") # Hebrew alef inside the word
|
| 596 |
+
half = len(corrupted) // 2
|
| 597 |
+
text = await self._stream(["قال تعالى:\n﴿", corrupted[:half], "\n", corrupted[half:], "﴾\n", "والله أعلم."],
|
| 598 |
+
[{"type": "quran", "arabic": self.VERSE}])
|
| 599 |
+
self.assertIn(f"﴿{self.VERSE}﴾", text)
|
| 600 |
+
self.assertNotIn("א", text)
|
| 601 |
+
|
| 602 |
+
async def test_fatwa_link_appended_after_stream(self):
|
| 603 |
+
text = await self._stream(["ذكر مركز الفتوى في إسلام ويب أن الحقنة لا تفطر.\n", "والله أعلم."], [self.FATWA])
|
| 604 |
+
self.assertTrue(text.rstrip().endswith("(https://www.islamweb.net/ar/fatwa/857)"))
|
| 605 |
+
|
| 606 |
+
async def test_language_drift_stops_generation(self):
|
| 607 |
+
drift = ["The ruling is clear.\n", "根据提供的教法裁定,对于因非骄傲或炫耀目的而发生的衣着过长。\n", "More text.\n"]
|
| 608 |
+
text = await self._stream(drift, [{"type": "quran", "arabic": self.VERSE}], lang="english")
|
| 609 |
+
self.assertIn("The ruling is clear.", text)
|
| 610 |
+
self.assertNotRegex(text, r"[一-鿿]")
|
| 611 |
+
self.assertNotIn("More text.", text)
|
| 612 |
+
self.assertIn("cut short", text)
|
| 613 |
+
|
| 614 |
+
|
| 615 |
+
class TestSynonyms(unittest.TestCase):
|
| 616 |
+
def test_alcohol_reaches_quranic_vocabulary(self):
|
| 617 |
+
from app.synonyms import expand_synonyms
|
| 618 |
+
self.assertIn("خمر", expand_synonyms(["Alcohol"]))
|
| 619 |
+
|
| 620 |
+
def test_zakat_recipients_phrase_but_not_bare_masarif(self):
|
| 621 |
+
from app.synonyms import expand_synonyms
|
| 622 |
+
self.assertIn("أصناف الزكاة", expand_synonyms(["مصارف الزكاة"]))
|
| 623 |
+
self.assertEqual(expand_synonyms(["مصارف"]), ["مصارف"]) # alone it may mean banks
|
| 624 |
+
|
| 625 |
+
|
| 626 |
+
class TestScopeGate(unittest.TestCase):
|
| 627 |
+
def test_creative_requests_detected(self):
|
| 628 |
+
from app.state import _CREATIVE_REQUEST_RE as rx
|
| 629 |
+
for q in ["Write a poem about the ocean", "compose me a short song about Ramadan",
|
| 630 |
+
"Tell me a joke", "اكتب قصيدة عن البحر", "اكتب لي أبيات في الصبر"]:
|
| 631 |
+
self.assertRegex(q, rx)
|
| 632 |
+
|
| 633 |
+
def test_real_questions_not_caught(self):
|
| 634 |
+
from app.state import _CREATIVE_REQUEST_RE as rx
|
| 635 |
+
for q in ["Tell me the story of Prophet Yusuf", "How do I write a will?",
|
| 636 |
+
"Is listening to songs haram?", "حكم صبغ الشعر", "هل كتابة الوصية واجبة؟",
|
| 637 |
+
"ما حكم الشعر في الإسلام؟", "Write down debts and loan contracts"]:
|
| 638 |
+
self.assertNotRegex(q, rx)
|
| 639 |
+
|
| 640 |
+
|
| 641 |
+
class TestFastRewrite(unittest.IsolatedAsyncioTestCase):
|
| 642 |
+
async def test_count_word_from_question_not_first_keyword(self):
|
| 643 |
+
from app import analysis
|
| 644 |
+
from app.search import rewrite_query
|
| 645 |
+
for q, word in [("كم مرة ذكرت كلمة الصبر في القرآن", "الصبر"),
|
| 646 |
+
("How many times is the word patience mentioned in the Quran?", "patience"),
|
| 647 |
+
("عدد مرات ذكر كلمة الجنة في القرآن", "الجنة"),
|
| 648 |
+
("كم مرة ورد لفظ الصلاة", "الصلاة"),
|
| 649 |
+
("كم مرة ذكر الصبر", "الصبر"),
|
| 650 |
+
("How many times does mercy appear in the Quran?", "mercy"),
|
| 651 |
+
("Count of the word taqwa in the Quran", "taqwa")]:
|
| 652 |
+
r = await rewrite_query(q, None)
|
| 653 |
+
self.assertEqual(await analysis.detect_analysis_intent(q, r), word)
|
| 654 |
+
|
| 655 |
+
async def test_keywords_are_stemmed_once(self):
|
| 656 |
+
from app.search import rewrite_query
|
| 657 |
+
from app.arabic_nlp import tokenize_bilingual
|
| 658 |
+
r = await rewrite_query("الوضوء في القرآن الكريم", None)
|
| 659 |
+
terms = [t for k in r["keywords"] for t in tokenize_bilingual(k)]
|
| 660 |
+
self.assertIn("وضوء", terms)
|
| 661 |
+
self.assertNotIn("ضوء", terms) # stemming twice strips the root letter و
|
| 662 |
+
|
| 663 |
+
async def test_thesaurus_reaches_fast_rewrite(self):
|
| 664 |
+
from app.search import rewrite_query
|
| 665 |
+
r = await rewrite_query("فضل السواك للصائم", None)
|
| 666 |
+
self.assertIn("siwak", r["keywords"])
|
| 667 |
+
self.assertIn("siwak", r["en_query"]) # English vector gets English terms
|
| 668 |
+
|
| 669 |
+
|
| 670 |
+
class TestHadithRefs(unittest.TestCase):
|
| 671 |
+
HADITH = {"type": "hadith", "collection": "Sahih al-Bukhari", "hadith_number": 631,
|
| 672 |
+
"reference": "Sahih al-Bukhari 631",
|
| 673 |
+
"arabic": "حَدَّثَنَا مُحَمَّدُ بْنُ الْمُثَنَّى قَالَ النَّبِيُّ صلى الله عليه وسلم وَصَلُّوا كَمَا رَأَيْتُمُونِي أُصَلِّي فَإِذَا حَضَرَتِ الصَّلاَةُ فَلْيُؤَذِّنْ لَكُمْ أَحَدُكُمْ",
|
| 674 |
+
"english": "Narrated Malik: The Prophet said, \"Pray as you have seen me praying and when the time for the prayer is due, one of you should pronounce the Adhan.\""}
|
| 675 |
+
|
| 676 |
+
def test_quoted_hadith_without_number_gets_reference(self):
|
| 677 |
+
from app.state import _attach_source_refs
|
| 678 |
+
answer = ('The Prophet said, "Pray as you have seen me praying and when the time for the prayer '
|
| 679 |
+
'is due, one of you should pronounce the Adhan."\n\nAnd Allah knows best.')
|
| 680 |
+
out = _attach_source_refs(answer, [self.HADITH], "english")
|
| 681 |
+
self.assertIn("Reference: Sahih al-Bukhari 631", out)
|
| 682 |
+
self.assertTrue(out.endswith("And Allah knows best."))
|
| 683 |
+
|
| 684 |
+
def test_arabic_answer_gets_arabic_reference(self):
|
| 685 |
+
from app.state import _attach_source_refs
|
| 686 |
+
answer = "قال النبي: وَصَلُّوا كَمَا رَأَيْتُمُونِي أُصَلِّي فَإِذَا حَضَرَتِ الصَّلاَةُ فَلْيُؤَذِّنْ لَكُمْ أَحَدُكُمْ"
|
| 687 |
+
self.assertIn("المرجع: صحيح البخاري، رقم 631", _attach_source_refs(answer, [self.HADITH], "arabic"))
|
| 688 |
+
|
| 689 |
+
def test_cited_or_unquoted_hadith_left_alone(self):
|
| 690 |
+
from app.state import _attach_source_refs
|
| 691 |
+
cited = 'Bukhari 631: "Pray as you have seen me praying and when the time for the prayer is due, one of you should pronounce the Adhan."'
|
| 692 |
+
self.assertEqual(_attach_source_refs(cited, [self.HADITH], "english"), cited)
|
| 693 |
+
unrelated = "Prayer is obligatory five times a day. And Allah knows best."
|
| 694 |
+
self.assertEqual(_attach_source_refs(unrelated, [self.HADITH], "english"), unrelated)
|
| 695 |
+
|
| 696 |
+
def test_shared_isnad_alone_is_not_a_quote(self):
|
| 697 |
+
from app.state import _attach_source_refs
|
| 698 |
+
other = dict(self.HADITH, hadith_number=1, reference="Sahih al-Bukhari 1",
|
| 699 |
+
arabic="حَدَّثَنَا مُحَمَّدُ بْنُ الْمُثَنَّى قَالَ حَدَّثَنَا عَبْدُ الْوَهَّابِ عَنْ أَيُّوبَ إِنَّمَا الأَعْمَالُ بِالنِّيَّاتِ وَإِنَّمَا لِكُلِّ امْرِئٍ مَا نَوَى",
|
| 700 |
+
english="Actions are judged by intentions.")
|
| 701 |
+
answer = "حدثنا محمد بن المثنى قال حدثنا عبد الوهاب عن أيوب: صلوا كما رأيتموني أصلي."
|
| 702 |
+
self.assertNotIn("رقم 1", _attach_source_refs(answer, [other], "arabic"))
|
| 703 |
+
|
| 704 |
+
|
| 705 |
+
class TestStreamSoftSplit(unittest.TestCase):
|
| 706 |
+
def _feed(self, text, step=7):
|
| 707 |
+
from app.state import StreamingVerificationBuffer
|
| 708 |
+
buf = StreamingVerificationBuffer(results=[])
|
| 709 |
+
out = []
|
| 710 |
+
for i in range(0, len(text), step):
|
| 711 |
+
out.extend(buf.feed(text[i:i + step]))
|
| 712 |
+
return out, buf
|
| 713 |
+
|
| 714 |
+
def test_long_isnad_released_at_commas(self):
|
| 715 |
+
isnad = ("حدثنا الحميدي عبد الله بن الزبير، قال حدثنا سفيان، قال حدثنا يحيى بن سعيد الأنصاري، "
|
| 716 |
+
"قال أخبرني محمد بن إبراهيم التيمي، أنه سمع علقمة بن وقاص الليثي، يقول سمعت عمر بن الخطاب")
|
| 717 |
+
out, buf = self._feed(isnad)
|
| 718 |
+
self.assertTrue(out, "nothing released before the sentence ended")
|
| 719 |
+
self.assertTrue(all(c.endswith("، ") for c in out))
|
| 720 |
+
self.assertEqual("".join(out) + buf.buffer, isnad)
|
| 721 |
+
|
| 722 |
+
def test_surah_reference_not_split(self):
|
| 723 |
+
text = ("وقد ذكر الله تعالى هذا المعنى في مواضع كثيرة من كتابه الكريم وبينه أهل التفسير بيانا واضحا "
|
| 724 |
+
"كما في سورة البقرة، الآية 255 وهي آية الكرسي التي")
|
| 725 |
+
out, buf = self._feed(text)
|
| 726 |
+
self.assertFalse(any(c.rstrip().endswith("سورة البقرة،") for c in out))
|
| 727 |
+
self.assertIn("سورة البقرة، الآية 255", "".join(out[-1:]) + buf.buffer)
|
| 728 |
+
|
| 729 |
+
def test_space_fallback_keeps_references_whole(self):
|
| 730 |
+
words = "وهذا بيان طويل جدا لمعنى الآية الكريمة " * 9 # > 300 chars, no commas or full stops
|
| 731 |
+
text = words + "كما في سورة البقرة الآية 255 وهي آية الكرسي العظيمة " + words
|
| 732 |
+
out, buf = self._feed(text, step=5)
|
| 733 |
+
self.assertTrue(out)
|
| 734 |
+
for c in out:
|
| 735 |
+
self.assertNotRegex(c.rstrip(), r"(?:سورة|سورة البقرة|الآية)$")
|
| 736 |
+
self.assertIn("سورة البقرة الآية 255", "".join(out) + buf.buffer)
|
| 737 |
+
|
| 738 |
+
|
| 739 |
+
class TestRerankerQuery(unittest.TestCase):
|
| 740 |
+
def test_source_phrase_dropped_and_thesaurus_added(self):
|
| 741 |
+
from app.search import _reranker_query
|
| 742 |
+
self.assertEqual(_reranker_query("Is alcohol forbidden in the Quran?"),
|
| 743 |
+
"Is alcohol forbidden in the Quran? (khamr, wine, intoxicants)") # English keeps it
|
| 744 |
+
self.assertEqual(_reranker_query("الوضوء في القرآن"), "الوضوء (فاغسلوا وجوهكم)")
|
| 745 |
+
|
| 746 |
+
def test_quran_as_subject_kept(self):
|
| 747 |
+
from app.search import _reranker_query
|
| 748 |
+
self.assertEqual(_reranker_query("In which month was the Quran revealed?"), "In which month was the Quran revealed?")
|
| 749 |
+
self.assertEqual(_reranker_query("قراءة القرآن"), "قراءة القرآن")
|
| 750 |
+
|
| 751 |
+
|
| 752 |
+
class TestSurahUrls(unittest.TestCase):
|
| 753 |
+
VERSE = {"type": "quran", "surah_number": 2, "verse_number": 278, "surah_name_en": "Al-Baqarah",
|
| 754 |
+
"surah_name_ar": "البقرة", "arabic": "يا أيها الذين آمنوا اتقوا الله وذروا ما بقي من الربا"}
|
| 755 |
+
|
| 756 |
+
def test_url_format(self):
|
| 757 |
+
from app.search import surah_url
|
| 758 |
+
self.assertEqual(surah_url(2, 278), "https://quran.com/2/278")
|
| 759 |
+
self.assertEqual(surah_url("", 278), "")
|
| 760 |
+
|
| 761 |
+
def test_old_links_rewritten_even_without_retrieved_verses(self):
|
| 762 |
+
from app.state import _verify_references
|
| 763 |
+
old = "[[1](https://surahquran.com/aya-278-sora-2.html)]"
|
| 764 |
+
self.assertEqual(_verify_references(old, []), "[[1](https://quran.com/2/278)]")
|
| 765 |
+
self.assertEqual(_verify_references(old, [self.VERSE]), "[[1](https://quran.com/2/278)]")
|
| 766 |
+
|
| 767 |
+
def test_unretrieved_verse_link_corrected(self):
|
| 768 |
+
from app.state import _verify_references
|
| 769 |
+
self.assertEqual(_verify_references("(https://quran.com/2/278)", [self.VERSE]), "(https://quran.com/2/278)")
|
| 770 |
+
self.assertEqual(_verify_references("(https://quran.com/3/130)", [self.VERSE]), "(https://quran.com/2/278)")
|
tests/test_eval_parsing.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the answer-eval citation parser (eval/run_answers.py)."""
|
| 2 |
+
|
| 3 |
+
import unittest
|
| 4 |
+
|
| 5 |
+
from eval.run_answers import cited_ids, is_refusal, lang_ok
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class TestCitedIds(unittest.TestCase):
|
| 9 |
+
def test_quran_urls(self):
|
| 10 |
+
answer = ("[[1](https://surahquran.com/aya-255-sora-2.html)] ﴿...﴾ "
|
| 11 |
+
"[[1](https://quran.ksu.edu.sa/tafseer/qortobi/sura24-aya35.html)]")
|
| 12 |
+
self.assertEqual(cited_ids(answer, {}), {"2:255", "24:35"})
|
| 13 |
+
self.assertEqual(cited_ids("[[1](https://quran.com/2/278)]", {}), {"2:278"})
|
| 14 |
+
|
| 15 |
+
def test_english_hadith_refs(self):
|
| 16 |
+
answer = ("Recorded in Sahih al-Bukhari 6116. Also Sunan Abu Dawood No. 4800 "
|
| 17 |
+
"and Jami' at-Tirmidhi #2516, Sunan Ibn Majah, hadith 224.")
|
| 18 |
+
self.assertEqual(cited_ids(answer, {}),
|
| 19 |
+
{"bukhari_6116", "abudawud_4800", "tirmidhi_2516", "ibnmajah_224"})
|
| 20 |
+
|
| 21 |
+
def test_arabic_hadith_refs(self):
|
| 22 |
+
answer = "رواه صحيح البخاري، رقم 13. وفي صحيح مسلم، رقم 45. وفي موطأ مالك، رقم 1."
|
| 23 |
+
self.assertEqual(cited_ids(answer, {}), {"bukhari_13", "muslim_45", "malik_1"})
|
| 24 |
+
answer = ("الترمذي، رقم الحديث 2024. وصحيح مسلم برقم 440. وسنن ابن ماجه، الرقم 4303. "
|
| 25 |
+
"والبخاري، كتاب الإيمان، حديث رقم 1. ورواه مسلم في صحيحه (6382).")
|
| 26 |
+
self.assertEqual(cited_ids(answer, {}),
|
| 27 |
+
{"tirmidhi_2024", "muslim_440", "ibnmajah_4303", "bukhari_1", "muslim_6382"})
|
| 28 |
+
|
| 29 |
+
def test_hadith_ref_needs_adjacent_number(self):
|
| 30 |
+
# The number belongs to the collection right before it, not one earlier in the sentence
|
| 31 |
+
self.assertEqual(cited_ids("Sahih al-Bukhari and Sahih Muslim 1907", {}), {"muslim_1907"})
|
| 32 |
+
# "مسلم" as an ordinary word ("every Muslim") is not a citation
|
| 33 |
+
self.assertEqual(cited_ids("طلب العلم فريضة على كل مسلم 3 مرات", {}), set())
|
| 34 |
+
|
| 35 |
+
def test_fatwa_urls(self):
|
| 36 |
+
url_to_id = {"https://www.islamweb.net/ar/fatwa/857": "islamweb_857"}
|
| 37 |
+
answer = "مركز الفتوى [[1](https://www.islamweb.net/ar/fatwa/857/)]."
|
| 38 |
+
self.assertEqual(cited_ids(answer, url_to_id), {"islamweb_857"})
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class TestAnswerChecks(unittest.TestCase):
|
| 42 |
+
def test_refusal_and_language(self):
|
| 43 |
+
not_found = {"arabic": "لم أجد", "english": "not found"}
|
| 44 |
+
self.assertTrue(is_refusal("The dataset does not contain sufficient information.", "en", not_found))
|
| 45 |
+
self.assertFalse(is_refusal("Yes, it is obligatory. And Allah knows best.", "en", not_found))
|
| 46 |
+
self.assertTrue(lang_ok("نعم، هذا واجب. والله أعلم.", "ar"))
|
| 47 |
+
self.assertFalse(lang_ok("Yes, it is obligatory.", "ar"))
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
if __name__ == "__main__":
|
| 51 |
+
unittest.main()
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class TestRequiredLabels(unittest.TestCase):
|
| 55 |
+
def test_hit_without_required_source_is_incomplete(self):
|
| 56 |
+
from eval.run_eval import score_case
|
| 57 |
+
case = {"id": "x", "category": "quran_topic", "query": "Is alcohol forbidden?",
|
| 58 |
+
"relevant": ["5:90", "2:219"], "required": ["5:90"]}
|
| 59 |
+
out = {"results": [{"id": "29:49"}, {"id": "2:219"}], "intent": "general", "top_score": 1.0,
|
| 60 |
+
"gated": False, "confidence": 0.9, "confidence_source": "reranker"}
|
| 61 |
+
row = score_case(case, out, 8, {})
|
| 62 |
+
self.assertEqual(row["hit@k"], 1) # still a hit, as before
|
| 63 |
+
self.assertEqual(row["complete@k"], 0) # but the decisive verse is missing
|
| 64 |
+
self.assertEqual(row["missing_required"], ["5:90"])
|
| 65 |
+
|
| 66 |
+
def test_required_must_be_subset_of_relevant(self):
|
| 67 |
+
import tempfile
|
| 68 |
+
from pathlib import Path
|
| 69 |
+
from eval.run_eval import load_golden
|
| 70 |
+
with tempfile.NamedTemporaryFile("w", suffix=".jsonl", delete=False, encoding="utf-8") as f:
|
| 71 |
+
f.write('{"id": "x", "category": "c", "query": "q", "relevant": ["2:219"], "required": ["5:90"]}\n')
|
| 72 |
+
with self.assertRaises(ValueError):
|
| 73 |
+
load_golden(Path(f.name), None)
|