aelgendy commited on
Commit
c634c96
·
1 Parent(s): 3db57db

QModel v8.5: accuracy release (code, 3,598-fatwa index, quran.com links)

Browse files
.env.example CHANGED
@@ -1,4 +1,4 @@
1
- # QModel 6 Configuration Template
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/Qwen3.6-27B-Q4_K_M.gguf
13
- GGUF_N_CTX=4096 # 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=qwen3.6:27b
21
  # Available models: llama3.1, mistral, neural-chat, openhermes
22
 
23
  # ─────────────────────────────────────────────────────────────────────
24
  # HUGGINGFACE BACKEND (if LLM_BACKEND=hf)
25
  # ─────────────────────────────────────────────────────────────────────
26
- # HF_MODEL_NAME=Qwen/Qwen3-8B
27
  # HF_DEVICE=auto # Options: auto, cuda, cpu
28
  # HF_MAX_NEW_TOKENS=2048
29
  # Popular models:
30
- # - Qwen/Qwen3-8B (excellent Arabic)
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-7b-instruct # Model loaded in LM Studio
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 threshold: Below this score, skip LLM and return "not found"
67
- # Prevents hallucinations but may miss valid results
68
- # Range: 0.0-1.0 (default 0.30)
69
- # Tune up (0.50+) for stricter, tune down (0.20) for looser
70
- CONFIDENCE_THRESHOLD=0.30
 
 
 
 
 
 
 
 
 
 
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/Qwen3.6-27B-Q4_K_M.gguf
103
  #
104
  # Development (Ollama):
105
  # LLM_BACKEND=ollama
106
  # OLLAMA_HOST=http://localhost:11434
107
- # OLLAMA_MODEL=qwen3.6:27b
108
  #
109
  # Production (HuggingFace GPU):
110
  # LLM_BACKEND=hf
111
- # HF_MODEL_NAME=Qwen/Qwen3-8B
112
  # HF_DEVICE=cuda
113
  #
114
  # Production (HuggingFace CPU):
115
  # LLM_BACKEND=hf
116
- # HF_MODEL_NAME=Qwen/Qwen3-8B
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 7 - Islamic RAG API
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.0.0"
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
- HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
 
 
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.0 — 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
- ![Version](https://img.shields.io/badge/version-8.0.0-blue)
28
  ![Backend](https://img.shields.io/badge/backend-gguf%20%7C%20ollama%20%7C%20huggingface-green)
29
  ![Status](https://img.shields.io/badge/status-production--ready-success)
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**: Ingested fatwas from recognized scholars and councils (Bin Baz, Lajnah Da'imah, etc.)
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
- - **Dynamic Confidence Gating**: Intent-aware thresholds (stricter for authenticity)
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 Quotes**: Text verified against raw sources with support for all quote formats (`«...»`, `“...”`, `❝...❞`, `"..."`)
63
- - **Anti-Hallucination**: Hardened prompts with pre/post-generation citation verification
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**: Optional `BAAI/bge-reranker-v2-m3` rescoring
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
- # Clone and enter project
94
- git clone https://github.com/Logicsoft/QModel.git && cd QModel
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 (local development):
 
105
  export LLM_BACKEND=ollama
106
- export OLLAMA_MODEL=qwen2.5:14b
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` — 52,677 documents (6,236 Quran verses + 46,416 hadiths from 15 collections + Fatwas)
126
- - `QModel.index` — FAISS search index (205.77 MB, 52,677 vectors, 1024-d)
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=qwen2.5:14b
233
  ```
234
 
235
- Requires: `ollama serve` running and model pulled (`ollama pull qwen2.5:14b`).
 
 
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
- | `USE_RERANKER` | `false` | Enable cross-encoder reranker (true/false) |
 
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
- | `CONFIDENCE_THRESHOLD` | `0.30` | Min score to call LLM (higher = fewer hallucinations) |
 
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=qwen2.5:14b
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
- CONFIDENCE_THRESHOLD=0.35
317
  ALLOWED_ORIGINS=yourdomain.com,api.yourdomain.com
318
  ```
319
 
320
  ### Tuning Tips
321
 
322
- - **Better results**: Increase `TOP_K_SEARCH`, lower `CONFIDENCE_THRESHOLD`, use `TEMPERATURE=0.1`
323
  - **Faster performance**: Lower `TOP_K_SEARCH` and `TOP_K_RETURN`, reduce `MAX_TOKENS`, use Ollama
324
- - **More conservative**: Increase `CONFIDENCE_THRESHOLD`, lower `TEMPERATURE`
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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=qwen2.5:14b
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 (Optional)**: Rescores top candidates using `BAAI/bge-reranker-v2-m3` when `USE_RERANKER=true`.
546
- 8. **Confidence Gating & Context Assembly**: Gated by intent-aware confidence thresholds before assembling verified, citation-attributed LLM context.
547
 
548
  ### Anti-Hallucination Measures
549
 
550
- - Few-shot examples including "not found" refusal path
551
- - Hardcoded citation format rules
552
- - Verbatim copy rules (no text reconstruction)
553
- - Confidence threshold gating (default: 0.30)
554
- - Post-generation citation verification
 
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 `CONFIDENCE_THRESHOLD` in `.env`
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**: Verified rulings and answers from renowned scholars and councils (Ibn Baz, Lajnah Da'imah, Ibn Uthaymeen, Islamweb, Al-Badr).
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.0.0 | October 2026 | Production-Ready
 
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
+ ![Version](https://img.shields.io/badge/version-8.5.0-blue)
28
  ![Backend](https://img.shields.io/badge/backend-gguf%20%7C%20ollama%20%7C%20huggingface-green)
29
  ![Status](https://img.shields.io/badge/status-production--ready-success)
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 v6 — Islamic RAG API."""
 
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 rewriter is instructed to put the target word as first keyword
156
- if kws:
157
- return kws[0]
158
- # Fallback: extract from query
159
  keyword = _extract_count_keyword(query)
160
- return keyword
 
 
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 = _AR_PREFIXES.sub("", word)
57
- if len(w) >= 3:
58
- w = _AR_SUFFIXES.sub("", w)
59
- return w if len(w) >= 2 else word
 
 
 
 
 
 
 
 
 
 
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", "false").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,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
- CONFIDENCE_THRESHOLD: float = float(os.getenv("CONFIDENCE_THRESHOLD", 0.30))
 
 
 
 
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://surahquran.com/aya-{verse_number}-sora-{surah_number}.html\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,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: collection name, book/chapter, hadith number, and grade from the results.\n"
 
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: collection, hadith number, chapter.\n"
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, hadith number, chapter, and grade, with narrator chain highlighted.\n"
 
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, Book, Number, Grade).
 
 
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
- • Copy references, Surah names, Ayah numbers, and URLs EXACTLY from the [REF] / [FATWA_REF] metadata.
 
 
 
 
 
 
 
 
 
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 = lookup_hadith_references(q, state.dataset, collection=collection, limit=limit, hadith_idx=state.hadith_ref_idx)
46
- text_results = text_search(q, state.dataset, source_type="hadith", limit=limit)
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 = merge_search_results(results, sem_results, limit=limit)
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
- merged_results = merge_search_results(direct_results, text_results, semantic_results, limit=10)
155
-
156
- # 3. Pick best result — prefer high-confidence text matches,
157
- # then semantic results, then lower-confidence text matches
158
- best = None
159
- if direct_results:
160
- best = direct_results[0]
161
- elif text_results and text_results[0].get("_score", 0) > 2.0:
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": "7.0.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_threshold": cfg.CONFIDENCE_THRESHOLD,
 
35
  }
36
 
37
 
@@ -55,19 +56,20 @@ async def debug_scores(
55
  ):
56
  """Inspect raw retrieval scores without calling the LLM.
57
 
58
- Use this to calibrate CONFIDENCE_THRESHOLD and debug search quality.
 
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
- "threshold": cfg.CONFIDENCE_THRESHOLD,
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 hybrid_search, lookup_quran_verses, merge_search_results, rewrite_query, text_search
 
 
 
 
 
 
 
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 = merge_search_results(results, semantic_results, limit=limit)
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(self, query_terms: List[str], top_k: int = 50) -> List[Tuple[int, float]]:
 
 
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
- return sorted(scores.items(), key=lambda x: x[1], reverse=True)[:top_k]
 
 
 
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 Quranic + Hadith dataset.
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"صحيح[؟?]|هل صحيح|درجة الحديث|هل هذا حديث|is.+authentic|verify hadith|hadith.+grade",
 
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": target_text,
463
- "keywords": [light_stem(t) for t in tokenize_bilingual(target_text)][:7],
 
 
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
- non_hadiths = []
759
  hadith_clusters: List[dict] = []
760
 
761
  for item in results:
762
  if item.get("type") != "hadith":
763
- non_hadiths.append(item)
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 sorted(non_hadiths + final_hadiths, key=lambda x: x.get("_score", 0.0), reverse=True)
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), 25)
974
  pool = candidates[:pool_size]
975
  tail = candidates[pool_size:]
976
 
977
- pairs = []
978
- for c in pool:
979
- doc_text = f"{c.get('arabic', '')} {c.get('english', '')}"
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
- loop = asyncio.get_event_loop()
985
- raw_scores = await loop.run_in_executor(
986
- None, lambda: cross_encoder_model.predict(pairs)
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
- distances_both, indices_both = await loop.run_in_executor(
1055
- None, lambda: index.search(vec_both, cfg.TOP_K_SEARCH)
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=cfg.TOP_K_SEARCH)
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
- # N-gram phrase matching for partial Arabic text (3+ word sequences)
 
 
 
 
 
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
- surah_url = f"https://surahquran.com/aya-{vn}-sora-{sn}.html" if sn and vn else ""
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 : {surah_url}\n"
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 typing import Any, AsyncIterator, Dict, FrozenSet, List, Literal, Optional, Tuple
 
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
- surah_url = f"https://surahquran.com/aya-{verse_num}-sora-{surah_num}.html" if surah_num and verse_num else ""
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]({surah_url})]" if surah_url else ""
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 = f"https://surahquran.com/aya-{p_vn}-sora-{p_sn}.html"
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
- vn, sn = int(m.group(1)), int(m.group(2))
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 f"https://surahquran.com/aya-{correct_vn}-sora-{correct_sn}.html"
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 = re.sub(r"https://surahquran\.com/aya-(\d+)-sora-(\d+)\.html", _check_surah_url, 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 actually appears in retrieved results or database.
366
-
367
- Only applies to explicit sacred quote markers (❝...❞ or «...») containing substantive Arabic text.
368
- If an Arabic scripture quote is not found in results or the Quran/Hadith dataset, it is flagged.
 
 
 
 
 
 
 
 
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
- source_texts_raw.append(re.sub(r"\s+", " ", str(val).strip()))
 
 
 
 
 
 
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 source_texts_raw:
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
- q_words = set(quoted_norm.split())
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
- # Quote not found in retrieved results — LLM generated/recalled scripture from model weights
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- hadith_sources = [r for r in results if r.get("type") == "hadith" and r.get("arabic")]
427
- if hadith_sources and len(hadith_sources) == 1:
428
- correct_ar = hadith_sources[0]["arabic"]
429
- logger.info("Auto-correcting hallucinated quote to indexed Hadith %s", hadith_sources[0].get("id"))
430
- return f"❝ {correct_ar} ❞"
431
 
432
- return "❝ ⚠️ [تم حذف نص غير موثق — النص غير موجود في البيانات المسترجعة] ❞"
433
 
434
- return _QUOTE_RE.sub(_check_quote, answer)
 
 
 
 
 
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 | threshold=%.2f",
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.CONFIDENCE_THRESHOLD,
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
- norm_q = normalize_arabic(question).lower()
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, results = await asyncio.gather(
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
- for hit in hits:
848
- if intent == "auth" and hit.get("_score", 0) > 2.0:
849
- hit = {**hit, "_score": hit["_score"] + 1.0}
850
- text_results.append(hit)
851
- results = merge_search_results(results, text_results, limit=max(top_k * 3, 20))
 
 
 
 
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 | threshold=%.2f",
886
- intent, top_score, cfg.CONFIDENCE_THRESHOLD,
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 (skip for surah_info)
930
- # Use dynamic threshold based on intent
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 (%.3f < %.2f for intent %s) — returning safe fallback",
935
- top_score, threshold, intent,
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
- # Extract reasoning content (thinking block)
976
- thinking = None
977
- if getattr(cfg, "SHOW_THINKING", False):
978
- think_match = re.search(r"<think>([\s\S]*?)</think>", answer, flags=re.IGNORECASE)
979
- if think_match:
980
- thinking = think_match.group(1).strip()
981
-
982
- # Strip residual <think> blocks that Qwen models may emit
983
- answer = re.sub(r"<think>[\s\S]*?</think>", "", answer, flags=re.IGNORECASE)
984
- answer = re.sub(r"<think>[\s\S]*$", "", answer, flags=re.IGNORECASE)
985
- answer = answer.strip()
 
 
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 ("❝" in self.buffer and "❞" not in self.buffer and len(self.buffer) < 400) or \
1042
- ("«" in self.buffer and "»" not in self.buffer and len(self.buffer) < 400):
1043
  break
1044
 
1045
- match = re.search(r"(\n|\. |\? |؟ |└─+┘)", self.buffer)
1046
- if not match:
1047
- break
1048
- idx = match.end()
 
 
 
 
 
 
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
- chunk = _verify_citations(chunk, self.results)
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 surah_info and top_score < cfg.CONFIDENCE_THRESHOLD:
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
- remaining = verifier.flush()
1156
- if remaining:
1157
- yield {"type": "token", "content": remaining}
 
 
 
 
 
 
 
 
 
 
 
 
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 load_fatwa_entries() -> List[Dict]:
972
- """Load fatwa entries from data/fatwas/*.json if present."""
973
- fatwas_dir = BASE_DIR / "data" / "fatwas"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
974
  entries: List[Dict] = []
975
- if not fatwas_dir.exists():
976
- return entries
977
 
978
  print("\n" + "=" * 60)
979
  print("Step 4c: Loading Fatwa Entries")
980
  print("=" * 60)
981
 
982
- for json_file in fatwas_dir.glob("*.json"):
983
- try:
984
- with open(json_file, "r", encoding="utf-8") as f:
985
- data = json.load(f)
986
- if isinstance(data, list):
987
- entries.extend(data)
988
- print(f" ✓ Loaded {len(data):,} fatwas from {json_file.name}")
989
- except Exception as exc:
990
- print(f" ✗ Error loading {json_file.name}: {exc}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- # Build text for each document (e5 asymmetric models require 'passage: ' prefix for documents)
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/fatwas/ directory.
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
- FATWAS_DATA_DIR = BASE_DIR / "data" / "fatwas"
 
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 fetch_html(url: str, timeout: int = 10, retries: int = 3) -> Optional[str]:
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
- return resp.text
 
 
 
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
- html = fetch_html(url, timeout=timeout)
69
- if not html:
70
  return None
 
 
71
 
72
  soup = BeautifulSoup(html, PARSER)
73
- title_el = soup.select_one('.main-title, h1, title')
74
  title = title_el.get_text(" ", strip=True) if title_el else ""
75
- if "فتاوى إسلام ويب" in title:
76
- title = title.replace("فتاوى إسلام ويب", "").strip()
77
-
78
- # Question extraction
79
- q_el = soup.select_one('.fatwa-details-text, .fatwa-details, .fatwa-question')
80
- question = q_el.get_text("\n", strip=True) if q_el else ""
81
- question = re.sub(r'^\s*السؤال\s*:?\s*', '', question).strip()
82
-
83
- # Answer extraction
84
- ans_el = soup.select_one('.fatwa-answer, .fatwa_answer')
85
- answer = ans_el.get_text("\n", strip=True) if ans_el else ""
86
- answer = re.sub(r'^\s*الإجابة\s*:?\s*', '', answer).strip()
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
- html = fetch_html(url, timeout=timeout)
126
- if not html:
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(target_id)
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: 60s
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 v7 — Islamic RAG API
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.0.0",
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
- # First call returns doc 0, later calls doc 1
 
244
  index.search.side_effect = [
245
- (np.array([[0.9]]), np.array([[0]])), (np.array([[0.9]]), np.array([[0]])),
246
- (np.array([[0.9]]), np.array([[1]])), (np.array([[0.9]]), np.array([[1]])),
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 uses precomputed Quran texts ───────
276
- def test_verify_citations_uses_precomputed_quran(self):
277
- from app import state as state_mod
278
  verse = "اللَّهُ لَا إِلَٰهَ إِلَّا هُوَ الْحَيُّ الْقَيُّومُ"
279
- saved = (state_mod.state.quran_norm_texts, state_mod.state.dataset, state_mod.state.bm25_index)
280
- try:
281
- norm = state_mod.normalize_arabic(verse)
282
- state_mod.state.quran_norm_texts = [
283
- (norm, frozenset(norm.split())),
284
- ("الم", frozenset({"الم"})), # short verse must not whitelist everything
285
- ]
286
- state_mod.state.dataset = None
287
- state_mod.state.bm25_index = None
288
- ok = state_mod._verify_citations(f"❝{verse}❞", results=[])
289
- self.assertIn(verse, ok)
290
- fake = state_mod._verify_citations("❝قال الملك العظيم كلاما مختلقا تماما هنا❞", results=[])
291
- self.assertIn("⚠️", fake)
292
- finally:
293
- (state_mod.state.quran_norm_texts, state_mod.state.dataset, state_mod.state.bm25_index) = saved
 
 
 
 
 
 
 
 
 
 
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
- # Returns raw unbounded logits (negative and positive)
319
- mock_encoder.predict.return_value = [-3.5, 4.2]
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("![img](https://x.y/a.png)"), "![img](https://x.y/a.png)")
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)