aelgendy commited on
Commit
6ab1c8e
·
1 Parent(s): 7ecdf4a

Upload folder using huggingface_hub

Browse files
.env.example CHANGED
@@ -27,7 +27,7 @@ OLLAMA_MODEL=minimax-m2.7:cloud
27
  # ─────────────────────────────────────────────────────────────────────
28
  # GGUF BACKEND (if LLM_BACKEND=gguf)
29
  # ─────────────────────────────────────────────────────────────────────
30
- # GGUF_MODEL_PATH=./models/qwen2-7b-instruct-q4_k_m.gguf
31
  # GGUF_N_CTX=4096 # Context window size
32
  # GGUF_N_GPU_LAYERS=-1 # -1 = offload all layers to GPU (Metal on Mac)
33
 
 
27
  # ─────────────────────────────────────────────────────────────────────
28
  # GGUF BACKEND (if LLM_BACKEND=gguf)
29
  # ─────────────────────────────────────────────────────────────────────
30
+ # GGUF_MODEL_PATH=./models/Qwen3-32B-Q4_K_M.gguf
31
  # GGUF_N_CTX=4096 # Context window size
32
  # GGUF_N_GPU_LAYERS=-1 # -1 = offload all layers to GPU (Metal on Mac)
33
 
.gitignore CHANGED
@@ -209,4 +209,4 @@ data/
209
 
210
  QModel.index
211
  metadata.json
212
- models/qwen2-7b-instruct-q8_0.gguf
 
209
 
210
  QModel.index
211
  metadata.json
212
+ models/Qwen3-32B-Q4_K_M.gguf
README.md CHANGED
@@ -19,12 +19,12 @@ language:
19
  - en
20
  ---
21
 
22
- # QModel 6 — Islamic RAG System
23
  **Specialized Qur'an & Hadith Knowledge System with 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-4.0.0-blue)
28
  ![Backend](https://img.shields.io/badge/backend-ollama%20%7C%20huggingface-green)
29
  ![Status](https://img.shields.io/badge/status-production--ready-success)
30
 
 
19
  - en
20
  ---
21
 
22
+ # QModel v6 — Islamic RAG System
23
  **Specialized Qur'an & Hadith Knowledge System with 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-6.0.0-blue)
28
  ![Backend](https://img.shields.io/badge/backend-ollama%20%7C%20huggingface-green)
29
  ![Status](https://img.shields.io/badge/status-production--ready-success)
30
 
app/analysis.py CHANGED
@@ -132,26 +132,77 @@ async def detect_analysis_intent(query: str, rewrite: dict) -> Optional[str]:
132
  kw_text = " ".join(kws)
133
  if any(w in kw_text for w in ("آيات", "آية", "verses", "ayat")):
134
  return None
135
- return kws[0] if kws else None
 
 
 
 
 
136
 
137
  if not (_COUNT_EN.search(query) or _COUNT_AR.search(query)):
138
  return None
139
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  for pat in (_COUNT_EN, _COUNT_AR):
141
  m = pat.search(query)
142
  if m:
143
  tail = query[m.end():].strip().split()
144
- if tail:
145
- return tail[0]
 
 
 
 
 
146
  return None
147
 
148
 
149
  # ═══════════════════════════════════════════════════════════════════════
150
  # OCCURRENCE COUNTING
151
  # ═══════════════════════════════════════════════════════════════════════
152
- async def count_occurrences(keyword: str, dataset: list) -> dict:
153
- """Count keyword occurrences with surah grouping."""
154
- cached = await analysis_cache.get(keyword)
 
 
 
 
155
  if cached:
156
  return cached
157
 
@@ -162,7 +213,7 @@ async def count_occurrences(keyword: str, dataset: list) -> dict:
162
  examples: list = []
163
 
164
  for item in dataset:
165
- if item.get("type") != "quran":
166
  continue
167
 
168
  ar_norm = normalize_arabic(item.get("arabic", ""), aggressive=True).lower()
@@ -195,7 +246,7 @@ async def count_occurrences(keyword: str, dataset: list) -> dict:
195
  "by_surah": dict(sorted(by_surah.items())),
196
  "examples": examples,
197
  }
198
- await analysis_cache.set(result, keyword)
199
  return result
200
 
201
 
 
132
  kw_text = " ".join(kws)
133
  if any(w in kw_text for w in ("آيات", "آية", "verses", "ayat")):
134
  return None
135
+ # The rewriter is instructed to put the target word as first keyword
136
+ if kws:
137
+ return kws[0]
138
+ # Fallback: extract from query
139
+ keyword = _extract_count_keyword(query)
140
+ return keyword
141
 
142
  if not (_COUNT_EN.search(query) or _COUNT_AR.search(query)):
143
  return None
144
 
145
+ keyword = _extract_count_keyword(query)
146
+ return keyword
147
+
148
+
149
+ def _extract_count_keyword(query: str) -> Optional[str]:
150
+ """Extract the keyword being counted from various question patterns."""
151
+ # Arabic patterns: كم مرة ذكرت كلمة X / كم مرة وردت X / عدد مرات ذكر X
152
+ ar_patterns = [
153
+ re.compile(r"(?:كلمة|لفظ|لفظة)\s+([\u0600-\u06FF\u0750-\u077F]+)"),
154
+ re.compile(r"(?:ذ[ُ]?كر(?:ت|)|وردت?|تكرر(?:ت|))\s+(?:كلمة\s+)?([\u0600-\u06FF\u0750-\u077F]+)"),
155
+ re.compile(r"(?:عدد\s+مرات\s+(?:ذكر|ورود))\s+(?:كلمة\s+)?([\u0600-\u06FF\u0750-\u077F]+)"),
156
+ re.compile(r"كم\s+(?:مرة|مره)\s+(?:ذ[ُ]?كر(?:ت|)|وردت?)\s+(?:كلمة\s+)?([\u0600-\u06FF\u0750-\u077F]+)"),
157
+ ]
158
+ for pat in ar_patterns:
159
+ m = pat.search(query)
160
+ if m:
161
+ word = m.group(1).strip()
162
+ # Skip common non-keyword words
163
+ if word not in ("في", "من", "عن", "إلى", "على", "هل", "ما", "كم"):
164
+ return word
165
+
166
+ # English patterns: how many times is X mentioned / count of X / occurrences of X
167
+ en_patterns = [
168
+ re.compile(r"(?:word|term)\s+['\"]?(\w+)['\"]?", re.I),
169
+ re.compile(r"(?:times?\s+(?:is|does|has)\s+)(\w+)", re.I),
170
+ re.compile(r"(?:occurrences?\s+of|frequency\s+of|count\s+of)\s+['\"]?(\w+)['\"]?", re.I),
171
+ re.compile(r"(?:mentioned|appear[s]?|occur[s]?)\s+.*?['\"]?(\w+)['\"]?\s+(?:in|throughout)", re.I),
172
+ re.compile(r"(?:how many times)\s+(?:is\s+)?['\"]?(\w+)['\"]?", re.I),
173
+ ]
174
+ for pat in en_patterns:
175
+ m = pat.search(query)
176
+ if m:
177
+ word = m.group(1).strip()
178
+ if word.lower() not in ("the", "a", "an", "in", "of", "is", "are", "was", "how", "many", "quran"):
179
+ return word
180
+
181
+ # Last resort: find the first meaningful word after count-related keywords
182
  for pat in (_COUNT_EN, _COUNT_AR):
183
  m = pat.search(query)
184
  if m:
185
  tail = query[m.end():].strip().split()
186
+ for word in tail:
187
+ clean = re.sub(r"[؟?!.,،]", "", word).strip()
188
+ if clean and clean.lower() not in (
189
+ "في", "من", "عن", "القرآن", "الكريم", "the", "quran", "in", "of",
190
+ "كلمة", "لفظ", "word", "term",
191
+ ):
192
+ return clean
193
  return None
194
 
195
 
196
  # ═══════════════════════════════════════════════════════════════════════
197
  # OCCURRENCE COUNTING
198
  # ═══════════════════════════════════════════════════════════════════════
199
+ async def count_occurrences(
200
+ keyword: str,
201
+ dataset: list,
202
+ source_type: Optional[str] = "quran",
203
+ ) -> dict:
204
+ """Count keyword occurrences with surah/collection grouping."""
205
+ cached = await analysis_cache.get(keyword, source_type or "all")
206
  if cached:
207
  return cached
208
 
 
213
  examples: list = []
214
 
215
  for item in dataset:
216
+ if source_type and item.get("type") != source_type:
217
  continue
218
 
219
  ar_norm = normalize_arabic(item.get("arabic", ""), aggressive=True).lower()
 
246
  "by_surah": dict(sorted(by_surah.items())),
247
  "examples": examples,
248
  }
249
+ await analysis_cache.set(result, keyword, source_type or "all")
250
  return result
251
 
252
 
app/arabic_nlp.py CHANGED
@@ -88,11 +88,18 @@ def language_instruction(lang: str) -> str:
88
  return {
89
  "arabic": (
90
  "يجب أن تكون الإجابة كاملةً باللغة العربية الفصحى تماماً. "
91
- "لا تستخدم الإنجليزية أو أي لغة أخرى في أي جزء من الإجابة."
 
 
92
  ),
93
  "mixed": (
94
  "The question mixes Arabic and English. Reply primarily in Arabic (الفصحى) "
95
- "but you may transliterate key terms in English where essential."
 
 
 
 
 
 
96
  ),
97
- "english": "You MUST reply entirely in clear, formal English.",
98
  }.get(lang, "You MUST reply entirely in clear, formal English.")
 
88
  return {
89
  "arabic": (
90
  "يجب أن تكون الإجابة كاملةً باللغة العربية الفصحى تماماً. "
91
+ "لا تستخدم الإنجليزية أو أي لغة أخرى في أي جزء من الإجابة، "
92
+ "باستثناء الاقتباسات الموجودة في صناديق الأدلة فقط. "
93
+ "إذا كان السؤال بالعربية، أجب بالعربية حصراً."
94
  ),
95
  "mixed": (
96
  "The question mixes Arabic and English. Reply primarily in Arabic (الفصحى) "
97
+ "but you may include English transliterations for key Islamic terms where essential. "
98
+ "Match the dominant language of the question."
99
+ ),
100
+ "english": (
101
+ "You MUST reply entirely in clear, formal English. "
102
+ "Do NOT use Arabic in your explanation — only inside evidence quotation boxes. "
103
+ "The user asked in English and expects an English answer."
104
  ),
 
105
  }.get(lang, "You MUST reply entirely in clear, formal English.")
app/llm.py CHANGED
@@ -43,6 +43,7 @@ class OllamaProvider(LLMProvider):
43
  model=self.model,
44
  messages=messages,
45
  options={"temperature": temperature, "num_predict": max_tokens},
 
46
  ),
47
  )
48
  return result["message"]["content"].strip()
@@ -69,12 +70,20 @@ class GGUFProvider(LLMProvider):
69
  async def chat(
70
  self, messages: List[dict], temperature: float, max_tokens: int
71
  ) -> str:
 
 
 
 
 
 
 
 
72
  loop = asyncio.get_event_loop()
73
  try:
74
  result = await loop.run_in_executor(
75
  None,
76
  lambda: self.llm.create_chat_completion(
77
- messages=messages,
78
  temperature=temperature,
79
  max_tokens=max_tokens,
80
  ),
@@ -177,18 +186,19 @@ class HuggingFaceProvider(LLMProvider):
177
 
178
  def get_llm_provider() -> LLMProvider:
179
  """Factory function to get the configured LLM provider."""
180
- if cfg.LLM_BACKEND == "ollama":
 
181
  logger.info("Using Ollama backend: %s @ %s", cfg.OLLAMA_MODEL, cfg.OLLAMA_HOST)
182
  return OllamaProvider(cfg.OLLAMA_HOST, cfg.OLLAMA_MODEL)
183
- elif cfg.LLM_BACKEND == "hf":
184
  logger.info("Using HuggingFace backend: %s on %s", cfg.HF_MODEL_NAME, cfg.HF_DEVICE)
185
  return HuggingFaceProvider(cfg.HF_MODEL_NAME, cfg.HF_DEVICE)
186
- elif cfg.LLM_BACKEND == "gguf":
187
  logger.info("Using GGUF backend: %s (ctx=%d, gpu_layers=%d)",
188
  cfg.GGUF_MODEL_PATH, cfg.GGUF_N_CTX, cfg.GGUF_N_GPU_LAYERS)
189
  return GGUFProvider(cfg.GGUF_MODEL_PATH, cfg.GGUF_N_CTX, cfg.GGUF_N_GPU_LAYERS)
190
- elif cfg.LLM_BACKEND == "lmstudio":
191
  logger.info("Using LM Studio backend: %s @ %s", cfg.LMSTUDIO_MODEL, cfg.LMSTUDIO_URL)
192
  return LMStudioProvider(cfg.LMSTUDIO_URL, cfg.LMSTUDIO_MODEL)
193
  else:
194
- raise ValueError(f"Unknown LLM_BACKEND: {cfg.LLM_BACKEND}")
 
43
  model=self.model,
44
  messages=messages,
45
  options={"temperature": temperature, "num_predict": max_tokens},
46
+ think=False,
47
  ),
48
  )
49
  return result["message"]["content"].strip()
 
70
  async def chat(
71
  self, messages: List[dict], temperature: float, max_tokens: int
72
  ) -> str:
73
+ # Disable Qwen3 thinking mode by appending /no_think to the system message
74
+ patched = []
75
+ for msg in messages:
76
+ if msg["role"] == "system" and "/no_think" not in msg["content"]:
77
+ patched.append({"role": "system", "content": msg["content"] + "\n/no_think"})
78
+ else:
79
+ patched.append(msg)
80
+
81
  loop = asyncio.get_event_loop()
82
  try:
83
  result = await loop.run_in_executor(
84
  None,
85
  lambda: self.llm.create_chat_completion(
86
+ messages=patched,
87
  temperature=temperature,
88
  max_tokens=max_tokens,
89
  ),
 
186
 
187
  def get_llm_provider() -> LLMProvider:
188
  """Factory function to get the configured LLM provider."""
189
+ backend = cfg.LLM_BACKEND.lower()
190
+ if backend == "ollama":
191
  logger.info("Using Ollama backend: %s @ %s", cfg.OLLAMA_MODEL, cfg.OLLAMA_HOST)
192
  return OllamaProvider(cfg.OLLAMA_HOST, cfg.OLLAMA_MODEL)
193
+ elif backend == "hf":
194
  logger.info("Using HuggingFace backend: %s on %s", cfg.HF_MODEL_NAME, cfg.HF_DEVICE)
195
  return HuggingFaceProvider(cfg.HF_MODEL_NAME, cfg.HF_DEVICE)
196
+ elif backend == "gguf":
197
  logger.info("Using GGUF backend: %s (ctx=%d, gpu_layers=%d)",
198
  cfg.GGUF_MODEL_PATH, cfg.GGUF_N_CTX, cfg.GGUF_N_GPU_LAYERS)
199
  return GGUFProvider(cfg.GGUF_MODEL_PATH, cfg.GGUF_N_CTX, cfg.GGUF_N_GPU_LAYERS)
200
+ elif backend == "lmstudio":
201
  logger.info("Using LM Studio backend: %s @ %s", cfg.LMSTUDIO_MODEL, cfg.LMSTUDIO_URL)
202
  return LMStudioProvider(cfg.LMSTUDIO_URL, cfg.LMSTUDIO_MODEL)
203
  else:
204
+ raise ValueError(f"Unknown LLM_BACKEND: {cfg.LLM_BACKEND!r}")
app/prompts.py CHANGED
@@ -17,72 +17,78 @@ PERSONA = (
17
 
18
  TASK_INSTRUCTIONS: Dict[str, str] = {
19
  "tafsir": (
20
- "The user asks about a Quranic verse. Steps:\n"
21
- "1. Identify the verse(s) from context.\n"
22
- "2. Provide Tafsir: linguistic analysis and deeper meaning.\n"
23
- "3. Draw connections to related verses.\n"
24
- "4. Answer the user's question directly."
 
 
 
 
 
25
  ),
26
  "hadith": (
27
- "The user asks about a Hadith. Structure your answer:\n\n"
28
- "1. الجواب Give a direct answer to the question first.\n\n"
29
- "2. نص الحديث — Quote the hadith text EXACTLY from context\n"
30
- " in the evidence box format. Show ALL relevant narrations found.\n\n"
31
- "3. الشرح والتوضيح Explain the meaning and implications.\n"
32
- " Mention notable scholars, narrators, or jurisprudential points.\n"
33
- " Draw connections to related Hadiths from the context.\n\n"
34
- "4. الخلاصة Summarize the key takeaway.\n\n"
35
- "CRITICAL: If the Hadith is NOT in context, say so clearly.\n"
36
- "Quote hadith text VERBATIM from context — never paraphrase the matn."
37
  ),
38
  "auth": (
39
- "The user asks about Hadith authenticity. Structure your answer:\n\n"
40
- "الجواب Start with a CLEAR, CONFIDENT verdict (صحيح/حسن/ضعيف/موضوع).\n"
41
- "Give a one-line ruling summary.\n\n"
42
- "أولًا: متن الحديث\n"
43
- "Quote ALL matching narrations from the context in evidence boxes.\n"
44
- "Show every relevant version found across different collections.\n\n"
45
- "ثانيًا: الأدلة على صحته (أو ضعفه)\n"
46
- "Provide numbered evidence points (use ١، ٢، ٣):\n"
47
- " - Which authoritative collections contain it\n"
48
- " - The grading given by scholars (from the grade field in context)\n"
49
- " - Notable narrators and scholars who transmitted or commented on it\n\n"
50
- "ثالثًا: أهمية الحديث\n"
51
- "Explain the hadith's significance, its place in Islamic scholarship,\n"
52
- "and any jurisprudential implications.\n\n"
53
- "الخلاصة — Comprehensive summary restating the verdict with key evidence.\n\n"
54
- "RULES:\n"
55
- "• If found in Sahih Bukhari or Sahih Muslim → assert AUTHENTIC (Sahih).\n"
56
- "• Quote hadith text VERBATIM from context — never paraphrase the matn.\n"
57
- "• You may add scholarly commentary to explain significance and context.\n"
58
- "• If NOT found in context → clearly state it is absent from the dataset.\n"
59
- "• NEVER fabricate hadith text, grades, or source citations."
60
  ),
61
  "fatwa": (
62
- "The user seeks a religious ruling. Steps:\n"
63
- "1. Gather evidence from Quran + Sunnah in context.\n"
64
- "2. Reason step-by-step to a conclusion.\n"
65
- "3. If insufficient, state so explicitly."
 
 
 
66
  ),
67
  "count": (
68
- "The user asks for word frequency. Steps:\n"
69
- "1. State the ANALYSIS RESULT prominently.\n"
70
- "2. List example occurrences with Surah names.\n"
71
- "3. Comment on significance."
 
 
 
72
  ),
73
  "surah_info": (
74
- "The user asks about surah metadata. Steps:\n"
75
- "1. State the answer from the SURAH INFORMATION block EXACTLY.\n"
76
- "2. Use the total_verses number precisely — do NOT guess or calculate.\n"
77
- "3. Mention the revelation type (Meccan/Medinan) if available.\n"
78
- "4. Optionally add brief scholarly context about the surah."
 
 
 
79
  ),
80
  "general": (
81
- "The user has a general Islamic question. Structure your answer:\n\n"
82
- "1. الجواب — Give a direct, clear answer first.\n\n"
83
- "2. الأدلة — Support with evidence from context, quoting relevant\n"
84
- " texts in evidence boxes. Explain the evidence with scholarly depth.\n\n"
85
- "3. الخلاصة — Conclude with a comprehensive summary."
86
  ),
87
  }
88
 
@@ -96,14 +102,21 @@ For EVERY supporting evidence, use this exact format:
96
  └─────────────────────────────────────────────┘
97
 
98
  ABSOLUTE RULES:
99
- Copy Arabic hadith text, translations, and sources VERBATIM from context. Never paraphrase.
100
- You may add scholarly commentary, explanation, and analysis around the quoted evidence.
101
- • NEVER fabricate hadith text, grades, verse numbers, or source citations.
102
  • If a specific Hadith/verse is NOT in context → respond with:
103
  "هذا الحديث/الآية غير موجود في قاعدة البيانات." (Arabic)
104
  or "This Hadith/verse is not in the available dataset." (English)
105
  • Never invent or guess content.
106
- • End with: "والله أعلم." (Arabic) or "And Allah knows best." (English)
 
 
 
 
 
 
 
 
107
  """
108
 
109
  _SYSTEM_TEMPLATE = """\
@@ -116,18 +129,10 @@ _SYSTEM_TEMPLATE = """\
116
 
117
  === OUTPUT FORMAT ===
118
  {fmt}
119
- """
120
 
121
- _CONTEXT_TEMPLATE = """\
122
- IMPORTANT: The database has already been searched for you.
123
- The relevant results are provided below — use ONLY this data to formulate your answer.
124
- Do NOT state that you need a database or ask the user for data. Answer from the context below.
125
-
126
- === RETRIEVED DATABASE RESULTS ===
127
  {context}
128
- === END DATABASE RESULTS ===
129
-
130
- Now answer the following question using ONLY the data above:
131
  """
132
 
133
 
@@ -169,18 +174,17 @@ def build_messages(
169
  lang_instruction=language_instruction(lang),
170
  task=TASK_INSTRUCTIONS.get(intent, TASK_INSTRUCTIONS["general"]),
171
  fmt=FORMAT_RULES,
 
172
  )
173
 
174
- context_block = _CONTEXT_TEMPLATE.format(context=context)
175
-
176
  cot = {
177
- "arabic": "فكّر خطوةً بخطوة، ثم أجب: ",
178
- "mixed": "Think step by step: ",
179
- }.get(lang, "Think step by step: ")
180
 
181
  return [
182
  {"role": "system", "content": system},
183
- {"role": "user", "content": context_block + cot + question},
184
  ]
185
 
186
 
 
17
 
18
  TASK_INSTRUCTIONS: Dict[str, str] = {
19
  "tafsir": (
20
+ "The user asks about a Quranic verse — by partial text, topic, or meaning. Steps:\n"
21
+ "1. Identify the matching verse(s) from the RETRIEVED RESULTS.\n"
22
+ "2. Quote the Arabic verse text EXACTLY from the results.\n"
23
+ "3. Provide the full reference: Surah name (Arabic & English), number, and Ayah number.\n"
24
+ "4. Provide the English translation EXACTLY as given in the results.\n"
25
+ "5. If the user searched by partial text, confirm the full verse found.\n"
26
+ "6. Provide Tafsir: explain the meaning, context, and significance.\n"
27
+ "7. If related verses appear in the results, draw connections.\n"
28
+ "8. Answer the user's specific question directly.\n"
29
+ "9. Do NOT reference verses that are not in the results."
30
  ),
31
  "hadith": (
32
+ "The user asks about a Hadith by partial text, topic, or meaning. Steps:\n"
33
+ "1. Find the best matching Hadith from the RETRIEVED RESULTS.\n"
34
+ "2. Quote the Hadith text EXACTLY — both Arabic and English from the results.\n"
35
+ "3. State the full reference: collection name, book/chapter, hadith number.\n"
36
+ "4. State the grade/authenticity (Sahih, Hasan, Da'if) if available in the results.\n"
37
+ "5. If the user searched by partial text, present the complete hadith found.\n"
38
+ "6. Explain the meaning, context, and scholarly implications.\n"
39
+ "7. Note any related Hadiths from the results.\n"
40
+ "CRITICAL: If the Hadith is NOT in the results, say so clearly — do NOT fabricate."
 
41
  ),
42
  "auth": (
43
+ "The user asks about Hadith authenticity or grade. YOU MUST:\n"
44
+ "1. Search the RETRIEVED RESULTS carefully for the Hadith.\n"
45
+ "2. If FOUND:\n"
46
+ " a. State the grade (Sahih, Hasan, Da'if, etc.) PROMINENTLY at the start.\n"
47
+ " b. Hadiths from Sahih al-Bukhari or Sahih Muslim are AUTHENTIC (Sahih).\n"
48
+ " c. Hadiths from Sunan an-Nasa'i are generally Sahih.\n"
49
+ " d. Hadiths from Jami' at-Tirmidhi, Sunan Abu Dawud, Sunan Ibn Majah are generally Hasan.\n"
50
+ " e. Provide the full reference: collection, hadith number, chapter.\n"
51
+ " f. Quote the full Hadith text from the results.\n"
52
+ " g. Explain why this grade applies.\n"
53
+ "3. If NOT FOUND in the results:\n"
54
+ " a. Clearly state: the hadith was not found in the authenticated dataset.\n"
55
+ " b. Do NOT guess or fabricate a grade.\n"
56
+ "CRITICAL: Base authenticity ONLY on the retrieved results and collection source."
 
 
 
 
 
 
 
57
  ),
58
  "fatwa": (
59
+ "The user seeks a religious ruling or asks about Islamic law. Steps:\n"
60
+ "1. Give a direct answer to the ruling question first.\n"
61
+ "2. Gather supporting evidence from Quran and Hadith in the results.\n"
62
+ "3. Quote verses and hadiths with exact references from the results.\n"
63
+ "4. Present scholarly reasoning based ONLY on the evidence found.\n"
64
+ "5. If multiple scholarly opinions exist, mention them briefly.\n"
65
+ "6. If the results lack sufficient evidence, state so explicitly."
66
  ),
67
  "count": (
68
+ "The user asks about word frequency or occurrence count. Steps:\n"
69
+ "1. State the ANALYSIS RESULT count PROMINENTLY and FIRST.\n"
70
+ "2. Use the EXACT numbers from the ANALYSIS RESULT — do NOT recalculate.\n"
71
+ "3. List the top example occurrences with Surah name (Arabic & English) and Ayah number.\n"
72
+ "4. Show the per-Surah breakdown from the analysis.\n"
73
+ "5. Comment on the significance and patterns of usage.\n"
74
+ "CRITICAL: The numbers in the ANALYSIS RESULT block are authoritative."
75
  ),
76
  "surah_info": (
77
+ "The user asks about surah metadata (verse count, revelation type, etc.). Steps:\n"
78
+ "1. Answer the SPECIFIC question FIRST using the SURAH INFORMATION block.\n"
79
+ "2. Use the total_verses number EXACTLY as given — do NOT guess or calculate.\n"
80
+ "3. State the revelation type (Meccan/Medinan) from the data.\n"
81
+ "4. Mention the surah name in Arabic, English, and transliteration.\n"
82
+ "5. Mention the surah number.\n"
83
+ "6. Optionally add brief scholarly context about the surah.\n"
84
+ "CRITICAL: The SURAH INFORMATION block is the ONLY authoritative source."
85
  ),
86
  "general": (
87
+ "The user has a general Islamic question. Steps:\n"
88
+ "1. Give a direct, clear answer first.\n"
89
+ "2. Support with evidence from the RETRIEVED RESULTS.\n"
90
+ "3. Quote relevant verses or hadiths from the results with references.\n"
91
+ "4. Conclude with a brief summary."
92
  ),
93
  }
94
 
 
102
  └─────────────────────────────────────────────┘
103
 
104
  ABSOLUTE RULES:
105
+ Use ONLY content from the Islamic Context block. Zero outside knowledge.
106
+ Copy Arabic text and translations VERBATIM from context. Never paraphrase.
 
107
  • If a specific Hadith/verse is NOT in context → respond with:
108
  "هذا الحديث/الآية غير موجود في قاعدة البيانات." (Arabic)
109
  or "This Hadith/verse is not in the available dataset." (English)
110
  • Never invent or guess content.
111
+
112
+ LANGUAGE RULE (CRITICAL — MUST FOLLOW):
113
+ • You MUST answer in the SAME language as the user's question.
114
+ • Arabic question → answer ENTIRELY in Arabic (العربية الفصحى). No English except inside evidence boxes.
115
+ • English question → answer ENTIRELY in English. No Arabic except inside evidence boxes.
116
+ • Mixed question → answer primarily in Arabic with English transliterations where helpful.
117
+ • The evidence boxes always show both Arabic text and English translation regardless of language.
118
+
119
+ • End with: "والله أعلم." (Arabic response) or "And Allah knows best." (English response)
120
  """
121
 
122
  _SYSTEM_TEMPLATE = """\
 
129
 
130
  === OUTPUT FORMAT ===
131
  {fmt}
 
132
 
133
+ === ISLAMIC CONTEXT ===
 
 
 
 
 
134
  {context}
135
+ === END CONTEXT ===
 
 
136
  """
137
 
138
 
 
174
  lang_instruction=language_instruction(lang),
175
  task=TASK_INSTRUCTIONS.get(intent, TASK_INSTRUCTIONS["general"]),
176
  fmt=FORMAT_RULES,
177
+ context=context,
178
  )
179
 
 
 
180
  cot = {
181
+ "arabic": "فكّر خطوةً بخطوة، ثم أجب باللغة العربية فقط: ",
182
+ "mixed": "فكّر خطوةً بخطوة، ثم أجب: ",
183
+ }.get(lang, "Think step by step, answer in English: ")
184
 
185
  return [
186
  {"role": "system", "content": system},
187
+ {"role": "user", "content": cot + question},
188
  ]
189
 
190
 
app/routers/chat.py CHANGED
@@ -1,23 +1,20 @@
1
- """Chat / inference endpoints — OpenAI-compatible + /ask."""
2
 
3
  from __future__ import annotations
4
 
5
  import json
6
  import logging
7
  import time
8
- from typing import Optional
9
 
10
- from fastapi import APIRouter, HTTPException, Query
11
  from fastapi.responses import StreamingResponse
12
 
13
  from app.config import cfg
14
  from app.models import (
15
- AskResponse,
16
  ChatCompletionChoice,
17
  ChatCompletionMessage,
18
  ChatCompletionRequest,
19
  ChatCompletionResponse,
20
- SourceItem,
21
  )
22
  from app.state import check_ready, run_rag_pipeline, state
23
 
@@ -123,41 +120,3 @@ async def _stream_response(result: dict, model: str):
123
  }
124
  yield f"data: {json.dumps(final)}\n\n"
125
  yield "data: [DONE]\n\n"
126
-
127
-
128
- # ───────────────────────────────────────────────────────
129
- # GET /ask — main inference endpoint
130
- # ───────────────────────────────────────────────────────
131
- @router.get("/ask", response_model=AskResponse)
132
- async def ask(
133
- q: str = Query(..., min_length=1, max_length=1000, description="Your Islamic question"),
134
- top_k: int = Query(cfg.TOP_K_RETURN, ge=1, le=20, description="Number of sources"),
135
- source_type: Optional[str] = Query(None, description="Filter: quran|hadith"),
136
- grade_filter: Optional[str] = Query(None, description="Filter Hadith: sahih|hasan|all"),
137
- ):
138
- """Main inference endpoint — runs the full RAG pipeline."""
139
- check_ready()
140
- result = await run_rag_pipeline(q, top_k, source_type, grade_filter)
141
-
142
- sources = [
143
- SourceItem(
144
- source=r.get("source") or r.get("reference") or "Unknown",
145
- type=r.get("type", "unknown"),
146
- grade=r.get("grade"),
147
- arabic=r.get("arabic", ""),
148
- english=r.get("english", ""),
149
- _score=r.get("_score", 0.0),
150
- )
151
- for r in result["sources"]
152
- ]
153
-
154
- return AskResponse(
155
- question=q,
156
- answer=result["answer"],
157
- language=result["language"],
158
- intent=result["intent"],
159
- analysis=result["analysis"],
160
- sources=sources,
161
- top_score=result["top_score"],
162
- latency_ms=result["latency_ms"],
163
- )
 
1
+ """Chat / inference endpoints — OpenAI-compatible."""
2
 
3
  from __future__ import annotations
4
 
5
  import json
6
  import logging
7
  import time
 
8
 
9
+ from fastapi import APIRouter, HTTPException
10
  from fastapi.responses import StreamingResponse
11
 
12
  from app.config import cfg
13
  from app.models import (
 
14
  ChatCompletionChoice,
15
  ChatCompletionMessage,
16
  ChatCompletionRequest,
17
  ChatCompletionResponse,
 
18
  )
19
  from app.state import check_ready, run_rag_pipeline, state
20
 
 
120
  }
121
  yield f"data: {json.dumps(final)}\n\n"
122
  yield "data: [DONE]\n\n"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/routers/hadith.py CHANGED
@@ -30,12 +30,36 @@ async def hadith_text_search(
30
  ):
31
  """Search for Hadith by partial text match (Arabic or English).
32
 
33
- Performs exact substring matching plus word-overlap scoring.
 
34
  Use this to find a hadith when you know part of the text.
35
  """
36
  check_ready()
37
  results = text_search(q, state.dataset, source_type="hadith", limit=limit)
38
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  # Optional collection filter
40
  if collection:
41
  col_lower = collection.lower()
@@ -72,7 +96,11 @@ async def hadith_topic_search(
72
  top_k: int = Query(10, ge=1, le=20),
73
  grade_filter: Optional[str] = Query(None, description="Grade filter: sahih|hasan|all"),
74
  ):
75
- """Search for Hadith related to a topic/theme using semantic search."""
 
 
 
 
76
  check_ready()
77
  rewrite = await rewrite_query(topic, state.llm)
78
  results = await hybrid_search(
@@ -110,12 +138,13 @@ async def verify_hadith(
110
  """Verify if a Hadith is in authenticated collections and check its grade.
111
 
112
  Uses both semantic search and text matching for best accuracy.
 
113
  """
114
  check_ready()
115
  t0 = time.perf_counter()
116
 
117
  # 1. Try text search first for exact matches
118
- text_results = text_search(q, state.dataset, source_type="hadith", limit=5)
119
  if collection:
120
  col_lower = collection.lower()
121
  text_results = [
@@ -123,15 +152,17 @@ async def verify_hadith(
123
  if col_lower in (r.get("collection", "") or r.get("reference", "")).lower()
124
  ]
125
 
126
- # 2. Also try semantic search
 
 
127
  semantic_results = await hybrid_search(
128
- q,
129
- {"ar_query": q, "en_query": q, "keywords": q.split()[:7], "intent": "auth"},
130
  state.embed_model, state.faiss_index, state.dataset,
131
- top_n=5, source_type="hadith",
132
  )
133
 
134
- # 3. Pick best result from either approach
 
135
  best = None
136
  if text_results and text_results[0].get("_score", 0) > 2.0:
137
  best = text_results[0]
 
30
  ):
31
  """Search for Hadith by partial text match (Arabic or English).
32
 
33
+ Performs exact substring matching plus word-overlap and n-gram scoring.
34
+ Falls back to semantic search when text matching yields few results.
35
  Use this to find a hadith when you know part of the text.
36
  """
37
  check_ready()
38
  results = text_search(q, state.dataset, source_type="hadith", limit=limit)
39
 
40
+ # Optional collection filter
41
+ if collection:
42
+ col_lower = collection.lower()
43
+ results = [
44
+ r for r in results
45
+ if col_lower in (r.get("collection", "") or r.get("reference", "")).lower()
46
+ ]
47
+
48
+ # If text search returns few results, augment with semantic search
49
+ if len(results) < 3:
50
+ rewrite = await rewrite_query(q, state.llm)
51
+ sem_results = await hybrid_search(
52
+ q, rewrite,
53
+ state.embed_model, state.faiss_index, state.dataset,
54
+ top_n=limit, source_type="hadith",
55
+ )
56
+ seen_ids = {r.get("id") for r in results}
57
+ for sr in sem_results:
58
+ if sr.get("id") not in seen_ids:
59
+ results.append(sr)
60
+ seen_ids.add(sr.get("id"))
61
+ results = sorted(results, key=lambda x: x.get("_score", 0), reverse=True)[:limit]
62
+
63
  # Optional collection filter
64
  if collection:
65
  col_lower = collection.lower()
 
96
  top_k: int = Query(10, ge=1, le=20),
97
  grade_filter: Optional[str] = Query(None, description="Grade filter: sahih|hasan|all"),
98
  ):
99
+ """Search for Hadith related to a topic/theme using semantic search.
100
+
101
+ Finds hadiths about a topic even when the exact words don't appear (e.g. "patience", "charity").
102
+ Optionally filter by authenticity grade.
103
+ """
104
  check_ready()
105
  rewrite = await rewrite_query(topic, state.llm)
106
  results = await hybrid_search(
 
138
  """Verify if a Hadith is in authenticated collections and check its grade.
139
 
140
  Uses both semantic search and text matching for best accuracy.
141
+ Returns authenticity grade based on collection source.
142
  """
143
  check_ready()
144
  t0 = time.perf_counter()
145
 
146
  # 1. Try text search first for exact matches
147
+ text_results = text_search(q, state.dataset, source_type="hadith", limit=10)
148
  if collection:
149
  col_lower = collection.lower()
150
  text_results = [
 
152
  if col_lower in (r.get("collection", "") or r.get("reference", "")).lower()
153
  ]
154
 
155
+ # 2. Also try semantic search with auth intent for better matching
156
+ rewrite = await rewrite_query(q, state.llm)
157
+ rewrite["intent"] = "auth" # Force auth intent for grade-aware ranking
158
  semantic_results = await hybrid_search(
159
+ q, rewrite,
 
160
  state.embed_model, state.faiss_index, state.dataset,
161
+ top_n=10, source_type="hadith",
162
  )
163
 
164
+ # 3. Pick best result prefer high-confidence text matches,
165
+ # then semantic results, then lower-confidence text matches
166
  best = None
167
  if text_results and text_results[0].get("_score", 0) > 2.0:
168
  best = text_results[0]
app/routers/ops.py CHANGED
@@ -1,16 +1,14 @@
1
- """Operational endpoints — health, models, debug."""
2
 
3
  from __future__ import annotations
4
 
5
  import time
6
- from typing import Optional
7
 
8
- from fastapi import APIRouter, Query
9
 
10
  from app.config import cfg
11
  from app.models import ModelInfo, ModelsListResponse
12
- from app.search import hybrid_search, rewrite_query
13
- from app.state import check_ready, state
14
 
15
  router = APIRouter(tags=["ops"])
16
 
@@ -37,33 +35,3 @@ def list_models():
37
  ModelInfo(id="qmodel", created=int(time.time()), owned_by="elgendy"),
38
  ]
39
  )
40
-
41
-
42
- @router.get("/debug/scores")
43
- async def debug_scores(
44
- q: str = Query(..., min_length=1, max_length=1000),
45
- top_k: int = Query(10, ge=1, le=20),
46
- ):
47
- """Debug: inspect raw retrieval scores without LLM generation."""
48
- check_ready()
49
- rewrite = await rewrite_query(q, state.llm)
50
- results = await hybrid_search(
51
- q, rewrite,
52
- state.embed_model, state.faiss_index, state.dataset, top_k,
53
- )
54
- return {
55
- "intent": rewrite.get("intent"),
56
- "threshold": cfg.CONFIDENCE_THRESHOLD,
57
- "results": [
58
- {
59
- "rank": i + 1,
60
- "source": r.get("source") or r.get("reference"),
61
- "type": r.get("type"),
62
- "grade": r.get("grade"),
63
- "_dense": round(r.get("_dense", 0), 4),
64
- "_sparse": round(r.get("_sparse", 0), 4),
65
- "_score": round(r.get("_score", 0), 4),
66
- }
67
- for i, r in enumerate(results)
68
- ],
69
- }
 
1
+ """Operational endpoints — health, models."""
2
 
3
  from __future__ import annotations
4
 
5
  import time
 
6
 
7
+ from fastapi import APIRouter
8
 
9
  from app.config import cfg
10
  from app.models import ModelInfo, ModelsListResponse
11
+ from app.state import state
 
12
 
13
  router = APIRouter(tags=["ops"])
14
 
 
35
  ModelInfo(id="qmodel", created=int(time.time()), owned_by="elgendy"),
36
  ]
37
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/routers/quran.py CHANGED
@@ -35,7 +35,7 @@ async def quran_text_search(
35
  ):
36
  """Search for Quran verses by partial text match (Arabic or English).
37
 
38
- This performs exact substring matching plus fuzzy word-overlap matching.
39
  Use this to find a verse when you know part of the text.
40
  """
41
  check_ready()
@@ -45,14 +45,15 @@ async def quran_text_search(
45
  count=len(results),
46
  results=[
47
  {
48
- "surah_number": r.get("surah_number"),
49
- "surah_name_ar": r.get("surah_name_ar", ""),
50
- "surah_name_en": r.get("surah_name_en", ""),
51
- "ayah": r.get("ayah_number") or r.get("verse_number"),
52
- "arabic": r.get("arabic", ""),
53
- "english": r.get("english", ""),
54
- "source": r.get("source", ""),
55
- "score": round(r.get("_score", 0), 4),
 
56
  }
57
  for r in results
58
  ],
@@ -67,7 +68,10 @@ async def quran_topic_search(
67
  topic: str = Query(..., min_length=1, max_length=500, description="Topic or theme to search for"),
68
  top_k: int = Query(10, ge=1, le=20),
69
  ):
70
- """Search for Quran verses related to a topic/theme using semantic search."""
 
 
 
71
  check_ready()
72
  rewrite = await rewrite_query(topic, state.llm)
73
  results = await hybrid_search(
@@ -80,14 +84,15 @@ async def quran_topic_search(
80
  count=len(results),
81
  results=[
82
  {
83
- "surah_number": r.get("surah_number"),
84
- "surah_name_ar": r.get("surah_name_ar", ""),
85
- "surah_name_en": r.get("surah_name_en", ""),
86
- "ayah": r.get("ayah_number") or r.get("verse_number"),
87
- "arabic": r.get("arabic", ""),
88
- "english": r.get("english", ""),
89
- "source": r.get("source", ""),
90
- "score": round(r.get("_score", 0), 4),
 
91
  }
92
  for r in results
93
  ],
 
35
  ):
36
  """Search for Quran verses by partial text match (Arabic or English).
37
 
38
+ This performs exact substring matching, n-gram phrase matching, and word-overlap matching.
39
  Use this to find a verse when you know part of the text.
40
  """
41
  check_ready()
 
45
  count=len(results),
46
  results=[
47
  {
48
+ "surah_number": r.get("surah_number"),
49
+ "surah_name_ar": r.get("surah_name_ar", ""),
50
+ "surah_name_en": r.get("surah_name_en", ""),
51
+ "surah_name_transliteration": r.get("surah_name_transliteration", ""),
52
+ "ayah": r.get("ayah_number") or r.get("verse_number"),
53
+ "arabic": r.get("arabic", ""),
54
+ "english": r.get("english", ""),
55
+ "source": r.get("source", ""),
56
+ "score": round(r.get("_score", 0), 4),
57
  }
58
  for r in results
59
  ],
 
68
  topic: str = Query(..., min_length=1, max_length=500, description="Topic or theme to search for"),
69
  top_k: int = Query(10, ge=1, le=20),
70
  ):
71
+ """Search for Quran verses related to a topic/theme using semantic search.
72
+
73
+ Finds verses about a topic even when the exact words don't appear (e.g. "patience", "charity").
74
+ """
75
  check_ready()
76
  rewrite = await rewrite_query(topic, state.llm)
77
  results = await hybrid_search(
 
84
  count=len(results),
85
  results=[
86
  {
87
+ "surah_number": r.get("surah_number"),
88
+ "surah_name_ar": r.get("surah_name_ar", ""),
89
+ "surah_name_en": r.get("surah_name_en", ""),
90
+ "surah_name_transliteration": r.get("surah_name_transliteration", ""),
91
+ "ayah": r.get("ayah_number") or r.get("verse_number"),
92
+ "arabic": r.get("arabic", ""),
93
+ "english": r.get("english", ""),
94
+ "source": r.get("source", ""),
95
+ "score": round(r.get("_score", 0), 4),
96
  }
97
  for r in results
98
  ],
app/search.py CHANGED
@@ -36,24 +36,47 @@ Reply ONLY with a valid JSON object — no markdown, no preamble:
36
  }
37
 
38
  Intent Detection Rules (CRITICAL):
 
 
 
 
 
 
 
 
 
39
  - 'surah_info' intent = asking about surah metadata: verse count, revelation type, surah number
40
  (كم عدد آيات سورة, كم آية في سورة, how many verses in surah, is surah X meccan/medinan)
41
- - 'count' intent = asking for WORD frequency/occurrence count (كم مرة ذُكرت كلمة, how many times is word X mentioned)
 
42
  NOTE: "كم عدد آيات سورة" is surah_info NOT count!
43
- - 'auth' intent = asking about authenticity (صحيح؟, هل صحيح, is it authentic, verify hadith grade)
44
- - 'hadith' intent = asking about specific hadith meaning/text (not authenticity)
45
- - 'tafsir' intent = asking about Quranic verses or Islamic ruling (fatwa)
46
- - 'general' intent = other questions
 
 
 
 
 
47
 
48
  Examples:
49
- - "كم عدد آيات سورة آل عمران" → intent: surah_info (asking about surah metadata!)
 
 
 
50
  - "كم آية في سورة البقرة" → intent: surah_info
51
  - "how many verses in surah al-baqara" → intent: surah_info
52
  - "هل سورة الفاتحة مكية أم مدنية" → intent: surah_info
53
- - "كم مرة ذُكرت كلمة مريم" → intent: count (asking about WORD frequency!)
54
- - "هل حديث إنما الأعمال بالنيات صحيح" → intent: auth (asking if authentic!)
55
- - "ما معنى حديث إنما الأعمال" → intent: hadith
56
- - "ما حكم الربا في الإسلام" intent: fatwa
 
 
 
 
 
57
  """
58
 
59
 
@@ -240,18 +263,35 @@ def text_search(
240
 
241
  score = 0.0
242
 
 
 
 
 
243
  # Exact substring in normalized Arabic
244
  if q_norm and q_norm in ar_norm:
245
  # Boost for shorter docs (more specific match)
246
- score = 3.0 + (1.0 / max(len(ar_norm), 1)) * 100
247
 
248
  # Exact substring in English
249
  if q_lower and q_lower in en_lower:
250
  score = max(score, 2.0 + (1.0 / max(len(en_lower), 1)) * 100)
251
 
252
- # Exact substring in raw Arabic (with diacritics)
253
- if query.strip() in ar_raw:
254
- score = max(score, 4.0)
 
 
 
 
 
 
 
 
 
 
 
 
 
255
 
256
  # Word-level overlap for lower-confidence matches
257
  if score == 0.0:
 
36
  }
37
 
38
  Intent Detection Rules (CRITICAL):
39
+ - 'tafsir' intent = looking up Quranic verse(s) by partial text, topic, word, or asking about meaning
40
+ (ابحث عن آية, find verse, ما تفسير, verse about X, آية عن, الآية التي فيها, verse that says)
41
+ IMPORTANT: When user provides Arabic verse text to find, put that text in ar_query verbatim.
42
+ - 'hadith' intent = looking up Hadith by text, topic, or asking about meaning (NOT authenticity)
43
+ (ابحث عن حديث, find hadith, hadith about, حديث عن, ما معنى حديث, hadith that says)
44
+ IMPORTANT: When user provides Arabic hadith text to find, put that text in ar_query verbatim.
45
+ - 'auth' intent = asking about Hadith authenticity/grade/verification
46
+ (صحيح؟, هل صحيح, is it authentic, verify hadith, درجة الحديث, is this hadith real, هل هذا حديث صحيح)
47
+ IMPORTANT: Include the hadith text fragment in ar_query for matching.
48
  - 'surah_info' intent = asking about surah metadata: verse count, revelation type, surah number
49
  (كم عدد آيات سورة, كم آية في سورة, how many verses in surah, is surah X meccan/medinan)
50
+ - 'count' intent = asking for WORD frequency/occurrence count
51
+ (كم مرة ذُكرت كلمة, how many times is word X mentioned, عدد مرات ذكر كلمة)
52
  NOTE: "كم عدد آيات سورة" is surah_info NOT count!
53
+ IMPORTANT: The word being counted MUST be the first keyword.
54
+ - 'fatwa' intent = asking for a religious ruling (ما حكم, is X halal/haram, حلال أم حرام)
55
+ - 'general' intent = other Islamic questions
56
+
57
+ Rewriting Rules:
58
+ - For verse/hadith text lookups: include the EXACT Arabic text fragment in ar_query
59
+ - For topic searches: expand the topic with Arabic synonyms and related terms in keywords
60
+ - For word frequency: extract the EXACT keyword being counted as the FIRST keyword
61
+ - keywords MUST include core Arabic terms for matching (e.g. صبر, رحمة, صلاة)
62
 
63
  Examples:
64
+ - "ابحث عن الآية التي فيها إنا أعطيناك الكوثر" → intent: tafsir, ar_query: "إنا أعطيناك الكوثر"
65
+ - "Find the verse about patience" → intent: tafsir, keywords: ["صبر", "patience", "الصبر"]
66
+ - "ما الآية التي تتحدث عن الصدقة" → intent: tafsir, keywords: ["صدقة", "الصدقة", "إنفاق"]
67
+ - "كم عدد آيات سورة آل عمران" → intent: surah_info
68
  - "كم آية في سورة البقرة" → intent: surah_info
69
  - "how many verses in surah al-baqara" → intent: surah_info
70
  - "هل سورة الفاتحة مكية أم مدنية" → intent: surah_info
71
+ - "كم مرة ذُكرت كلمة مريم في القرآن" → intent: count, keywords: ["مريم", ...]
72
+ - "how many times is mercy mentioned in Quran" → intent: count, keywords: ["رحمة", "mercy", "الرحمة"]
73
+ - "هل حديث إنما الأعمال بالنيات صحيح" → intent: auth, ar_query: "إنما الأعمال بالنيات"
74
+ - "is the hadith about actions by intentions authentic" → intent: auth, keywords: ["إنما الأعمال بالنيات", "actions", "intentions"]
75
+ - "ما معنى حديث إنما الأعمال" → intent: hadith, ar_query: "إنما الأعمال"
76
+ - "ابحث عن حديث عن الصبر" → intent: hadith, keywords: ["صبر", "الصبر", "patience"]
77
+ - "find hadith about fasting" → intent: hadith, keywords: ["صيام", "صوم", "fasting"]
78
+ - "ما حكم الربا في الإسلام" → intent: fatwa, keywords: ["ربا", "الربا", "usury"]
79
+ - "هل الحديث ده صحيح: من كان يؤمن بالله" → intent: auth, ar_query: "من كان يؤمن بالله"
80
  """
81
 
82
 
 
263
 
264
  score = 0.0
265
 
266
+ # Exact substring in raw Arabic (with diacritics) — highest priority
267
+ if query.strip() in ar_raw:
268
+ score = max(score, 5.0)
269
+
270
  # Exact substring in normalized Arabic
271
  if q_norm and q_norm in ar_norm:
272
  # Boost for shorter docs (more specific match)
273
+ score = max(score, 3.0 + (1.0 / max(len(ar_norm), 1)) * 100)
274
 
275
  # Exact substring in English
276
  if q_lower and q_lower in en_lower:
277
  score = max(score, 2.0 + (1.0 / max(len(en_lower), 1)) * 100)
278
 
279
+ # N-gram phrase matching for partial Arabic text (3+ word sequences)
280
+ if score == 0.0 and q_norm:
281
+ q_words = q_norm.split()
282
+ if len(q_words) >= 3:
283
+ # Check sliding windows of 3 words from query against doc
284
+ for i in range(len(q_words) - 2):
285
+ trigram = " ".join(q_words[i:i+3])
286
+ if trigram in ar_norm:
287
+ score = max(score, 2.0 + (i == 0) * 0.5)
288
+ break
289
+ if score == 0.0 and len(q_words) >= 2:
290
+ for i in range(len(q_words) - 1):
291
+ bigram = " ".join(q_words[i:i+2])
292
+ if bigram in ar_norm or bigram in en_lower:
293
+ score = max(score, 1.5)
294
+ break
295
 
296
  # Word-level overlap for lower-confidence matches
297
  if score == 0.0:
app/state.py CHANGED
@@ -5,6 +5,7 @@ from __future__ import annotations
5
  import asyncio
6
  import json
7
  import logging
 
8
  import time
9
  from contextlib import asynccontextmanager
10
  from typing import Literal, Optional
@@ -28,6 +29,47 @@ from app.search import build_context, hybrid_search, rewrite_query, text_search
28
  logger = logging.getLogger("qmodel.state")
29
 
30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  # ═══════════════════════════════════════════════════════════════════════
32
  # HADITH GRADE INFERENCE
33
  # ═══════════════════════════════════════════════════════════════════════
@@ -145,15 +187,27 @@ async def run_rag_pipeline(
145
  )
146
 
147
  # 2b. Text search fallback — catches exact matches missed by FAISS
148
- # (e.g. hadith text buried in long isnad chains)
149
- # Use rewritten ar_query (clean hadith text) + raw question for coverage.
150
  seen_ids = {r.get("id") for r in results}
151
  ar_q = rewrite.get("ar_query", "")
 
 
 
 
 
 
 
 
 
152
  for q in dict.fromkeys([ar_q, question]): # deduplicated, ar_query first
153
  if not q:
154
  continue
155
- for hit in text_search(q, state.dataset, source_type, limit=top_k):
156
  if hit.get("id") not in seen_ids:
 
 
 
157
  results.append(hit)
158
  seen_ids.add(hit.get("id"))
159
  if len(results) > top_k:
@@ -176,8 +230,9 @@ async def run_rag_pipeline(
176
  # 3b. Word frequency count
177
  analysis = None
178
  if analysis_kw and not surah_info:
179
- analysis = await count_occurrences(analysis_kw, state.dataset)
180
- logger.info("Analysis: kw=%s count=%d", analysis_kw, analysis["total_count"])
 
181
 
182
  # 4. Language detection
183
  lang = detect_language(question)
@@ -218,6 +273,9 @@ async def run_rag_pipeline(
218
  logger.error("LLM call failed: %s", exc)
219
  raise HTTPException(status_code=502, detail="LLM service unavailable")
220
 
 
 
 
221
  latency = int((time.perf_counter() - t0) * 1000)
222
  logger.info(
223
  "Pipeline done | intent=%s | lang=%s | top_score=%.3f | %d ms",
 
5
  import asyncio
6
  import json
7
  import logging
8
+ import re
9
  import time
10
  from contextlib import asynccontextmanager
11
  from typing import Literal, Optional
 
29
  logger = logging.getLogger("qmodel.state")
30
 
31
 
32
+ # ═══════════════════════════════════════════════════════════════════════
33
+ # POST-GENERATION HALLUCINATION CHECK
34
+ # ═══════════════════════════════════════════════════════════════════════
35
+ _QUOTE_RE = re.compile(r"❝\s*(.+?)\s*❞", re.DOTALL)
36
+
37
+
38
+ def _verify_citations(answer: str, results: list) -> str:
39
+ """Check that quoted Arabic text in the answer actually appears in retrieved results.
40
+
41
+ If a quoted block doesn't match any source, replace it with a warning.
42
+ This prevents the model from fabricating hadith or verse text.
43
+ """
44
+ source_texts = set()
45
+ for r in results:
46
+ for field in ("arabic", "english", "text"):
47
+ val = r.get(field, "")
48
+ if val:
49
+ # Normalize whitespace for comparison
50
+ source_texts.add(re.sub(r"\s+", " ", val.strip()))
51
+
52
+ def _check_quote(m: re.Match) -> str:
53
+ quoted = re.sub(r"\s+", " ", m.group(1).strip())
54
+ # Check if any source text contains a significant portion of the quote
55
+ for src in source_texts:
56
+ # Use a substring match — LLMs sometimes trim edges
57
+ if len(quoted) < 10:
58
+ return m.group(0) # too short to verify
59
+ if quoted in src or src in quoted:
60
+ return m.group(0) # verified
61
+ # Check overlap: at least 60% of words match
62
+ q_words = set(quoted.split())
63
+ s_words = set(src.split())
64
+ if q_words and len(q_words & s_words) / len(q_words) >= 0.6:
65
+ return m.group(0) # close enough match
66
+ # Quote not found in any source — flag it
67
+ logger.warning("Hallucination detected: quoted text not in sources: %.80s...", quoted)
68
+ return "❝ ⚠️ [تم حذف نص غير موثق — النص غير موجود في قاعدة البيانات] ❞"
69
+
70
+ return _QUOTE_RE.sub(_check_quote, answer)
71
+
72
+
73
  # ═══════════════════════════════════════════════════════════════════════
74
  # HADITH GRADE INFERENCE
75
  # ═══════════════════════════════════════════════════════════════════════
 
187
  )
188
 
189
  # 2b. Text search fallback — catches exact matches missed by FAISS
190
+ # For auth/hadith/tafsir intents, also search with the rewritten ar_query
191
+ # which should contain the actual text fragment to look up.
192
  seen_ids = {r.get("id") for r in results}
193
  ar_q = rewrite.get("ar_query", "")
194
+
195
+ # Determine text search source filter based on intent
196
+ text_src = source_type
197
+ if not text_src and intent in ("tafsir", "count", "surah_info"):
198
+ text_src = "quran"
199
+ elif not text_src and intent in ("hadith", "auth"):
200
+ text_src = "hadith"
201
+
202
+ text_limit = top_k * 2 if intent in ("auth", "hadith", "tafsir") else top_k
203
  for q in dict.fromkeys([ar_q, question]): # deduplicated, ar_query first
204
  if not q:
205
  continue
206
+ for hit in text_search(q, state.dataset, text_src, limit=text_limit):
207
  if hit.get("id") not in seen_ids:
208
+ # Boost text search hits for auth intent (exact text match is crucial)
209
+ if intent == "auth" and hit.get("_score", 0) > 2.0:
210
+ hit["_score"] = hit["_score"] + 1.0
211
  results.append(hit)
212
  seen_ids.add(hit.get("id"))
213
  if len(results) > top_k:
 
230
  # 3b. Word frequency count
231
  analysis = None
232
  if analysis_kw and not surah_info:
233
+ count_src = "hadith" if intent in ("hadith", "auth") else "quran"
234
+ analysis = await count_occurrences(analysis_kw, state.dataset, source_type=count_src)
235
+ logger.info("Analysis: kw=%s src=%s count=%d", analysis_kw, count_src, analysis["total_count"])
236
 
237
  # 4. Language detection
238
  lang = detect_language(question)
 
273
  logger.error("LLM call failed: %s", exc)
274
  raise HTTPException(status_code=502, detail="LLM service unavailable")
275
 
276
+ # 7. Post-generation hallucination check — verify quoted text exists in sources
277
+ answer = _verify_citations(answer, results)
278
+
279
  latency = int((time.perf_counter() - t0) * 1000)
280
  logger.info(
281
  "Pipeline done | intent=%s | lang=%s | top_score=%.3f | %d ms",
main.py CHANGED
@@ -33,7 +33,7 @@ logging.basicConfig(
33
 
34
  from app.config import cfg
35
  from app.state import lifespan
36
- from app.routers import chat, hadith, ops, quran
37
 
38
  # ═══════════════════════════════════════════════════════════════════════
39
  # FASTAPI APP
@@ -43,11 +43,9 @@ app = FastAPI(
43
  description=(
44
  "Specialized Quran & Hadith system with dual LLM backend.\n\n"
45
  "**Capabilities:**\n"
46
- "- Quran verse lookup by text or topic\n"
47
- "- Quran word frequency & analytics\n"
48
- "- Hadith lookup by text or topic\n"
49
- "- Hadith authenticity verification\n"
50
- "- OpenAI-compatible chat completions"
51
  ),
52
  version="5.0.0",
53
  lifespan=lifespan,
@@ -64,8 +62,6 @@ app.add_middleware(
64
  # Register routers
65
  app.include_router(ops.router)
66
  app.include_router(chat.router)
67
- app.include_router(quran.router)
68
- app.include_router(hadith.router)
69
 
70
 
71
  if __name__ == "__main__":
 
33
 
34
  from app.config import cfg
35
  from app.state import lifespan
36
+ from app.routers import chat, ops
37
 
38
  # ═══════════════════════════════════════════════════════════════════════
39
  # FASTAPI APP
 
43
  description=(
44
  "Specialized Quran & Hadith system with dual LLM backend.\n\n"
45
  "**Capabilities:**\n"
46
+ "- OpenAI-compatible chat completions\n"
47
+ "- Streaming support\n"
48
+ "- Islamic knowledge RAG pipeline"
 
 
49
  ),
50
  version="5.0.0",
51
  lifespan=lifespan,
 
62
  # Register routers
63
  app.include_router(ops.router)
64
  app.include_router(chat.router)
 
 
65
 
66
 
67
  if __name__ == "__main__":