aelgendy commited on
Commit
efd73f6
·
verified ·
1 Parent(s): 9d56fa2

Upload folder using huggingface_hub

Browse files
Files changed (8) hide show
  1. .gitattributes +0 -3
  2. .gitignore +4 -0
  3. app/llm.py +23 -4
  4. app/prompts.py +1 -10
  5. app/routers/hadith.py +29 -37
  6. app/routers/quran.py +17 -3
  7. app/search.py +385 -16
  8. app/state.py +200 -12
.gitattributes CHANGED
@@ -1,5 +1,2 @@
1
  # Auto detect text files and perform LF normalization
2
  * text=auto
3
- QModel.index filter=lfs diff=lfs merge=lfs -text
4
- metadata.json filter=lfs diff=lfs merge=lfs -text
5
- models/Qwen3-32B-Q4_K_M.gguf filter=lfs diff=lfs merge=lfs -text
 
1
  # Auto detect text files and perform LF normalization
2
  * text=auto
 
 
 
.gitignore CHANGED
@@ -206,3 +206,7 @@ data/
206
  .vim/
207
  .DS_Store
208
  .vscode/
 
 
 
 
 
206
  .vim/
207
  .DS_Store
208
  .vscode/
209
+
210
+ QModel.index
211
+ metadata.json
212
+ models/Qwen3-32B-Q4_K_M.gguf
app/llm.py CHANGED
@@ -4,6 +4,7 @@ from __future__ import annotations
4
 
5
  import asyncio
6
  import logging
 
7
  from typing import List
8
 
9
  from app.config import cfg
@@ -35,18 +36,32 @@ class OllamaProvider(LLMProvider):
35
  async def chat(
36
  self, messages: List[dict], temperature: float, max_tokens: int
37
  ) -> str:
 
 
 
 
 
 
 
 
 
 
38
  loop = asyncio.get_event_loop()
39
  try:
40
  result = await loop.run_in_executor(
41
  None,
42
  lambda: self.client.chat(
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()
 
 
 
 
50
  except Exception as exc:
51
  logger.error("Ollama chat failed: %s", exc)
52
  raise
@@ -88,7 +103,11 @@ class GGUFProvider(LLMProvider):
88
  max_tokens=max_tokens,
89
  ),
90
  )
91
- return result["choices"][0]["message"]["content"].strip()
 
 
 
 
92
  except Exception as exc:
93
  logger.error("GGUF chat failed: %s", exc)
94
  raise
 
4
 
5
  import asyncio
6
  import logging
7
+ import re
8
  from typing import List
9
 
10
  from app.config import cfg
 
36
  async def chat(
37
  self, messages: List[dict], temperature: float, max_tokens: int
38
  ) -> str:
39
+ # Qwen3 models return empty with think=False. Use think=True with
40
+ # /no_think in the system prompt so the model responds immediately
41
+ # without actually producing a <think> block.
42
+ patched = []
43
+ for msg in messages:
44
+ if msg["role"] == "system" and "/no_think" not in msg["content"]:
45
+ patched.append({"role": "system", "content": msg["content"] + "\n/no_think"})
46
+ else:
47
+ patched.append(msg)
48
+
49
  loop = asyncio.get_event_loop()
50
  try:
51
  result = await loop.run_in_executor(
52
  None,
53
  lambda: self.client.chat(
54
  model=self.model,
55
+ messages=patched,
56
  options={"temperature": temperature, "num_predict": max_tokens},
57
+ think=True,
58
  ),
59
  )
60
+ content = result["message"]["content"].strip()
61
+ # Strip any <think> blocks that slip through
62
+ content = re.sub(r"<think>[\s\S]*?</think>", "", content, flags=re.IGNORECASE)
63
+ content = re.sub(r"<think>[\s\S]*$", "", content, flags=re.IGNORECASE)
64
+ return content.strip()
65
  except Exception as exc:
66
  logger.error("Ollama chat failed: %s", exc)
67
  raise
 
103
  max_tokens=max_tokens,
104
  ),
105
  )
106
+ content = result["choices"][0]["message"]["content"] or ""
107
+ logger.debug("GGUF raw response (%d chars): %.500s", len(content), content)
108
+ # Return raw content — callers handle <think> stripping so they
109
+ # can still extract structured data from inside think blocks.
110
+ return content.strip()
111
  except Exception as exc:
112
  logger.error("GGUF chat failed: %s", exc)
113
  raise
app/prompts.py CHANGED
@@ -11,7 +11,7 @@ from app.arabic_nlp import language_instruction
11
  # ═══════════════════════════════════════════════════════════════════════
12
  PERSONA = (
13
  "You are Sheikh QModel, a meticulous Islamic scholar with expertise "
14
- "in Quran, Tafsir (Quranic exegesis), Hadith sciences, Fiqh, and Arabic. "
15
  "You respond with scholarly rigor and modern clarity."
16
  )
17
 
@@ -59,15 +59,6 @@ TASK_INSTRUCTIONS: Dict[str, str] = {
59
  " b. Do NOT guess or fabricate a grade.\n"
60
  "CRITICAL: Base authenticity ONLY on the retrieved results and collection source."
61
  ),
62
- "fatwa": (
63
- "The user seeks a religious ruling or asks about Islamic law. Steps:\n"
64
- "1. Give a direct answer to the ruling question first.\n"
65
- "2. Gather supporting evidence from Quran and Hadith in the results.\n"
66
- "3. Quote verses and hadiths with exact references from the results.\n"
67
- "4. Present scholarly reasoning based ONLY on the evidence found.\n"
68
- "5. If multiple scholarly opinions exist, mention them briefly.\n"
69
- "6. If the results lack sufficient evidence, state so explicitly."
70
- ),
71
  "count": (
72
  "The user asks about word frequency or occurrence count. Steps:\n"
73
  "1. State the ANALYSIS RESULT count PROMINENTLY and FIRST.\n"
 
11
  # ═══════════════════════════════════════════════════════════════════════
12
  PERSONA = (
13
  "You are Sheikh QModel, a meticulous Islamic scholar with expertise "
14
+ "in Quran, Tafsir (Quranic exegesis), Hadith sciences, and Arabic. "
15
  "You respond with scholarly rigor and modern clarity."
16
  )
17
 
 
59
  " b. Do NOT guess or fabricate a grade.\n"
60
  "CRITICAL: Base authenticity ONLY on the retrieved results and collection source."
61
  ),
 
 
 
 
 
 
 
 
 
62
  "count": (
63
  "The user asks about word frequency or occurrence count. Steps:\n"
64
  "1. State the ANALYSIS RESULT count PROMINENTLY and FIRST.\n"
app/routers/hadith.py CHANGED
@@ -13,7 +13,14 @@ from app.models import (
13
  HadithVerifyResponse,
14
  TextSearchResponse,
15
  )
16
- from app.search import hybrid_search, rewrite_query, text_search
 
 
 
 
 
 
 
17
  from app.state import check_ready, state
18
 
19
  router = APIRouter(prefix="/hadith", tags=["hadith"])
@@ -35,15 +42,12 @@ async def hadith_text_search(
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:
@@ -53,20 +57,8 @@ async def hadith_text_search(
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()
66
- results = [
67
- r for r in results
68
- if col_lower in (r.get("collection", "") or r.get("reference", "")).lower()
69
- ]
70
 
71
  return TextSearchResponse(
72
  query=q,
@@ -143,14 +135,12 @@ async def verify_hadith(
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 = [
151
- r for r in 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)
@@ -160,16 +150,18 @@ async def verify_hadith(
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]
169
- elif semantic_results:
170
- best = semantic_results[0]
171
- elif text_results:
172
  best = text_results[0]
 
 
173
 
174
  if best:
175
  return HadithVerifyResponse(
 
13
  HadithVerifyResponse,
14
  TextSearchResponse,
15
  )
16
+ from app.search import (
17
+ filter_results_by_collection,
18
+ hybrid_search,
19
+ lookup_hadith_references,
20
+ merge_search_results,
21
+ rewrite_query,
22
+ text_search,
23
+ )
24
  from app.state import check_ready, state
25
 
26
  router = APIRouter(prefix="/hadith", tags=["hadith"])
 
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)
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:
 
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,
 
135
  check_ready()
136
  t0 = time.perf_counter()
137
 
138
+ # 1. Try direct reference and text search first for exact matches
139
+ direct_results = lookup_hadith_references(q, state.dataset, collection=collection, limit=10)
140
+ text_results = filter_results_by_collection(
141
+ text_search(q, state.dataset, source_type="hadith", limit=10),
142
+ collection,
143
+ )
 
 
144
 
145
  # 2. Also try semantic search with auth intent for better matching
146
  rewrite = await rewrite_query(q, state.llm)
 
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(
app/routers/quran.py CHANGED
@@ -19,7 +19,7 @@ from app.models import (
19
  VerseItem,
20
  WordFrequencyResponse,
21
  )
22
- from app.search import hybrid_search, rewrite_query, text_search
23
  from app.state import check_ready, state
24
 
25
  router = APIRouter(prefix="/quran", tags=["quran"])
@@ -39,7 +39,19 @@ async def quran_text_search(
39
  Use this to find a verse when you know part of the text.
40
  """
41
  check_ready()
42
- results = text_search(q, state.dataset, source_type="quran", limit=limit)
 
 
 
 
 
 
 
 
 
 
 
 
43
  return TextSearchResponse(
44
  query=q,
45
  count=len(results),
@@ -73,12 +85,14 @@ async def quran_topic_search(
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(
78
  topic, rewrite,
79
  state.embed_model, state.faiss_index, state.dataset,
80
  top_n=top_k, source_type="quran",
81
  )
 
82
  return TextSearchResponse(
83
  query=topic,
84
  count=len(results),
 
19
  VerseItem,
20
  WordFrequencyResponse,
21
  )
22
+ from app.search import hybrid_search, lookup_quran_verses, merge_search_results, rewrite_query, text_search
23
  from app.state import check_ready, state
24
 
25
  router = APIRouter(prefix="/quran", tags=["quran"])
 
39
  Use this to find a verse when you know part of the text.
40
  """
41
  check_ready()
42
+ direct_results = lookup_quran_verses(q, state.dataset, limit=limit)
43
+ text_results = text_search(q, state.dataset, source_type="quran", limit=limit)
44
+ results = merge_search_results(direct_results, text_results, limit=limit)
45
+
46
+ if len(results) < min(limit, 3):
47
+ rewrite = await rewrite_query(q, state.llm)
48
+ semantic_results = await hybrid_search(
49
+ q, rewrite,
50
+ state.embed_model, state.faiss_index, state.dataset,
51
+ top_n=limit, source_type="quran",
52
+ )
53
+ results = merge_search_results(results, semantic_results, limit=limit)
54
+
55
  return TextSearchResponse(
56
  query=q,
57
  count=len(results),
 
85
  Finds verses about a topic even when the exact words don't appear (e.g. "patience", "charity").
86
  """
87
  check_ready()
88
+ direct_results = lookup_quran_verses(topic, state.dataset, limit=top_k)
89
  rewrite = await rewrite_query(topic, state.llm)
90
+ semantic_results = await hybrid_search(
91
  topic, rewrite,
92
  state.embed_model, state.faiss_index, state.dataset,
93
  top_n=top_k, source_type="quran",
94
  )
95
+ results = merge_search_results(direct_results, semantic_results, limit=top_k)
96
  return TextSearchResponse(
97
  query=topic,
98
  count=len(results),
app/search.py CHANGED
@@ -6,6 +6,7 @@ import json
6
  import logging
7
  import re
8
  from collections import Counter
 
9
  from difflib import SequenceMatcher
10
  from typing import Dict, List, Literal, Optional
11
 
@@ -33,7 +34,7 @@ Reply ONLY with a valid JSON object — no markdown, no preamble:
33
  "ar_query": "<query in clear Arabic فصحى, ≤25 words>",
34
  "en_query": "<query in clear English, ≤25 words>",
35
  "keywords": ["<3-7 key Arabic or English terms from the question>"],
36
- "intent": "<one of: fatwa | tafsir | hadith | count | surah_info | auth | general>"
37
  }
38
 
39
  Intent Detection Rules (CRITICAL):
@@ -52,7 +53,6 @@ Intent Detection Rules (CRITICAL):
52
  (كم مرة ذُكرت كلمة, how many times is word X mentioned, عدد مرات ذكر كلمة)
53
  NOTE: "كم عدد آيات سورة" is surah_info NOT count!
54
  IMPORTANT: The word being counted MUST be the first keyword.
55
- - 'fatwa' intent = asking for a religious ruling (ما حكم, is X halal/haram, حلال أم حرام)
56
  - 'general' intent = other Islamic questions
57
 
58
  Rewriting Rules:
@@ -76,44 +76,413 @@ Examples:
76
  - "ما معنى حديث إنما الأعمال" → intent: hadith, ar_query: "إنما الأعمال"
77
  - "ابحث عن حديث عن الصبر" → intent: hadith, keywords: ["صبر", "الصبر", "patience"]
78
  - "find hadith about fasting" → intent: hadith, keywords: ["صيام", "صوم", "fasting"]
79
- - "ما حكم الربا في الإسلام" → intent: fatwa, keywords: ["ربا", "الربا", "usury"]
80
  - "هل الحديث ده صحيح: من كان يؤمن بالله" → intent: auth, ar_query: "من كان يؤمن بالله"
81
  """
82
 
83
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  async def rewrite_query(raw: str, llm: LLMProvider) -> Dict:
85
  """Rewrite query for better retrieval."""
86
  cached = await rewrite_cache.get(raw)
87
  if cached:
88
  return cached
89
 
 
90
  fallback = {
91
  "ar_query": normalize_arabic(raw),
92
  "en_query": raw,
93
- "keywords": raw.split()[:7],
94
- "intent": "general",
95
  }
96
  try:
97
- text = await llm.chat(
98
- messages=[
99
- {"role": "system", "content": _REWRITE_SYSTEM},
100
- {"role": "user", "content": raw},
101
- ],
102
- max_tokens=220,
103
- temperature=0.0,
104
- )
105
- text = re.sub(r"```(?:json)?\n?|\n?```", "", text).strip()
106
- result = json.loads(text)
 
 
 
 
 
 
 
 
 
 
107
  for k in ("ar_query", "en_query", "keywords", "intent"):
108
  result.setdefault(k, fallback[k])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  await rewrite_cache.set(result, raw)
110
  logger.info("Rewrite: intent=%s ar=%s", result["intent"], result["ar_query"][:60])
111
  return result
112
  except Exception as exc:
113
- logger.warning("Query rewrite failed (%s) — using fallback", exc)
114
  return fallback
115
 
116
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  # ═══════════════════════════════════════════════════════════════════════
118
  # BM25 SCORING
119
  # ═══════════════════════════════════════════════════════════════════════
 
6
  import logging
7
  import re
8
  from collections import Counter
9
+ from itertools import chain
10
  from difflib import SequenceMatcher
11
  from typing import Dict, List, Literal, Optional
12
 
 
34
  "ar_query": "<query in clear Arabic فصحى, ≤25 words>",
35
  "en_query": "<query in clear English, ≤25 words>",
36
  "keywords": ["<3-7 key Arabic or English terms from the question>"],
37
+ "intent": "<one of: tafsir | hadith | count | surah_info | auth | general>"
38
  }
39
 
40
  Intent Detection Rules (CRITICAL):
 
53
  (كم مرة ذُكرت كلمة, how many times is word X mentioned, عدد مرات ذكر كلمة)
54
  NOTE: "كم عدد آيات سورة" is surah_info NOT count!
55
  IMPORTANT: The word being counted MUST be the first keyword.
 
56
  - 'general' intent = other Islamic questions
57
 
58
  Rewriting Rules:
 
76
  - "ما معنى حديث إنما الأعمال" → intent: hadith, ar_query: "إنما الأعمال"
77
  - "ابحث عن حديث عن الصبر" → intent: hadith, keywords: ["صبر", "الصبر", "patience"]
78
  - "find hadith about fasting" → intent: hadith, keywords: ["صيام", "صوم", "fasting"]
79
+ - "ما حكم الربا في الإسلام" → intent: general, keywords: ["ربا", "الربا", "usury"]
80
  - "هل الحديث ده صحيح: من كان يؤمن بالله" → intent: auth, ar_query: "من كان يؤمن بالله"
81
  """
82
 
83
 
84
+ _QURAN_REF_NUMERIC = re.compile(r"\b(\d{1,3})\s*:\s*(\d{1,3})\b")
85
+ _QURAN_REF_AR_NAME_FIRST = re.compile(
86
+ r"(?:سورة|سوره)\s+([\u0621-\u06FF\u0750-\u077F\s]+?)"
87
+ r"(?:\s+(?:الآية|آية|الايه|ايه)\s*|[\s,:،-]+)(\d{1,3})",
88
+ )
89
+ _QURAN_REF_AR_VERSE_FIRST = re.compile(
90
+ r"(?:الآية|آية|الايه|ايه)\s*(\d{1,3})\s+(?:من|في)\s+(?:سورة|سوره)\s+([\u0621-\u06FF\u0750-\u077F\s]+)",
91
+ )
92
+ _QURAN_REF_EN_NAME_FIRST = re.compile(
93
+ r"(?:surah|sura)\s+([A-Za-z'\- ]+?)"
94
+ r"(?:\s+(?:ayah|verse|ayat)\s*|[\s,:-]+)(\d{1,3})",
95
+ re.I,
96
+ )
97
+ _QURAN_REF_EN_VERSE_FIRST = re.compile(
98
+ r"(?:ayah|verse|ayat)\s*(\d{1,3})\s+(?:of|in)\s+(?:surah|sura)\s+([A-Za-z'\- ]+)",
99
+ re.I,
100
+ )
101
+
102
+ _COLLECTION_ALIASES = {
103
+ "sahih al-bukhari": "Sahih al-Bukhari",
104
+ "sahih bukhari": "Sahih al-Bukhari",
105
+ "al bukhari": "Sahih al-Bukhari",
106
+ "bukhari": "Sahih al-Bukhari",
107
+ "البخاري": "Sahih al-Bukhari",
108
+ "صحيح البخاري": "Sahih al-Bukhari",
109
+ "sahih muslim": "Sahih Muslim",
110
+ "muslim": "Sahih Muslim",
111
+ "مسلم": "Sahih Muslim",
112
+ "صحيح مسلم": "Sahih Muslim",
113
+ "sunan an nasai": "Sunan an-Nasai",
114
+ "sunan an-nasai": "Sunan an-Nasai",
115
+ "nasai": "Sunan an-Nasai",
116
+ "nasa'i": "Sunan an-Nasai",
117
+ "نسائي": "Sunan an-Nasai",
118
+ "النسائي": "Sunan an-Nasai",
119
+ "sunan abu dawood": "Sunan Abu Dawood",
120
+ "sunan abu dawood": "Sunan Abu Dawood",
121
+ "abu dawood": "Sunan Abu Dawood",
122
+ "abu dawood": "Sunan Abu Dawood",
123
+ "أبو داود": "Sunan Abu Dawood",
124
+ "ابو داود": "Sunan Abu Dawood",
125
+ "jami at tirmidhi": "Jami' at-Tirmidhi",
126
+ "jami at-tirmidhi": "Jami' at-Tirmidhi",
127
+ "tirmidhi": "Jami' at-Tirmidhi",
128
+ "الترمذي": "Jami' at-Tirmidhi",
129
+ "ترمذي": "Jami' at-Tirmidhi",
130
+ "sunan ibn majah": "Sunan Ibn Majah",
131
+ "ibn majah": "Sunan Ibn Majah",
132
+ "ابن ماجه": "Sunan Ibn Majah",
133
+ "sunan al darimi": "Sunan al-Darimi",
134
+ "sunan al-darimi": "Sunan al-Darimi",
135
+ "darimi": "Sunan al-Darimi",
136
+ "الدارمي": "Sunan al-Darimi",
137
+ "muwatta malik": "Muwatta Malik",
138
+ "muwatta": "Muwatta Malik",
139
+ "موطأ مالك": "Muwatta Malik",
140
+ "موطا مالك": "Muwatta Malik",
141
+ "malik": "Muwatta Malik",
142
+ "musnad ahmad": "Musnad Ahmad",
143
+ "ahmad": "Musnad Ahmad",
144
+ "ahmed": "Musnad Ahmad",
145
+ "أحمد": "Musnad Ahmad",
146
+ "مسند أحمد": "Musnad Ahmad",
147
+ }
148
+ _SORTED_COLLECTION_ALIASES = sorted(_COLLECTION_ALIASES.items(), key=lambda item: len(item[0]), reverse=True)
149
+
150
+
151
+ def _find_balanced_json(text: str) -> Optional[str]:
152
+ """Find the first balanced {...} in *text*; return it or None."""
153
+ start = text.find("{")
154
+ if start == -1:
155
+ return None
156
+ depth = 0
157
+ in_string = False
158
+ escaped = False
159
+ for idx in range(start, len(text)):
160
+ ch = text[idx]
161
+ if escaped:
162
+ escaped = False
163
+ continue
164
+ if ch == "\\":
165
+ escaped = True
166
+ continue
167
+ if ch == '"':
168
+ in_string = not in_string
169
+ continue
170
+ if in_string:
171
+ continue
172
+ if ch == "{":
173
+ depth += 1
174
+ elif ch == "}":
175
+ depth -= 1
176
+ if depth == 0:
177
+ return text[start:idx + 1]
178
+ return None
179
+
180
+
181
+ def _extract_json_object(text: str) -> Optional[str]:
182
+ """Extract the first balanced JSON object from model output."""
183
+ if not text:
184
+ return None
185
+
186
+ # Remove common wrappers some models add around structured responses.
187
+ cleaned = re.sub(r"```(?:json)?", "", text, flags=re.IGNORECASE)
188
+ cleaned = cleaned.replace("```", "")
189
+ # Strip closed <think> blocks
190
+ cleaned = re.sub(r"<think>[\s\S]*?</think>", "", cleaned, flags=re.IGNORECASE)
191
+ cleaned = cleaned.strip()
192
+
193
+ # Before stripping unclosed <think> (which removes everything after it),
194
+ # check if a JSON object exists anywhere in the remaining text —
195
+ # the model may have emitted JSON inside a truncated think block.
196
+ if "{" not in cleaned:
197
+ # No JSON at all, strip unclosed think and give up
198
+ cleaned = re.sub(r"<think>[\s\S]*$", "", cleaned, flags=re.IGNORECASE).strip()
199
+ if not cleaned:
200
+ return None
201
+ else:
202
+ # Try to extract JSON first; only strip unclosed <think> if that fails
203
+ candidate = _find_balanced_json(cleaned)
204
+ if candidate:
205
+ return candidate
206
+ cleaned = re.sub(r"<think>[\s\S]*$", "", cleaned, flags=re.IGNORECASE).strip()
207
+ if not cleaned:
208
+ return None
209
+
210
+ if cleaned.startswith("{") and cleaned.endswith("}"):
211
+ return cleaned
212
+
213
+ return _find_balanced_json(cleaned)
214
+
215
+
216
+ def _detect_intent_regex(query: str) -> str:
217
+ """Detect intent from raw query using regex when LLM rewrite is unavailable."""
218
+ # surah_info: asking about surah metadata (verse count, type, etc.)
219
+ if re.search(
220
+ r"كم\s+(?:عدد\s+)?آيات?|عدد\s+آيات?|كم\s+آية|how many\s+verses?|number of\s+verses?",
221
+ query, re.I,
222
+ ):
223
+ return "surah_info"
224
+ if re.search(
225
+ r"(?:هل|ما\s+نوع)\s+(?:سورة|سوره)\s+.+\s+(?:مكية|مدنية)",
226
+ query,
227
+ ):
228
+ return "surah_info"
229
+
230
+ # count: word frequency
231
+ if re.search(
232
+ r"كم مرة|كم تكرر|عدد مرات|تكرار|كم ذُكر|كم وردت?",
233
+ query,
234
+ ):
235
+ return "count"
236
+ if re.search(
237
+ r"\b(how many times?|count|frequency|occurrences? of)\b",
238
+ query, re.I,
239
+ ):
240
+ return "count"
241
+
242
+ # auth: hadith authenticity check
243
+ if re.search(
244
+ r"صحيح[؟?]|هل صحيح|درجة الحديث|هل هذا حديث|is.+authentic|verify hadith|hadith.+grade",
245
+ query, re.I,
246
+ ):
247
+ return "auth"
248
+
249
+ # hadith: hadith lookup (check before tafsir since both can match Arabic text)
250
+ if re.search(
251
+ r"حديث\s+عن|ابحث عن حديث|ما معنى حديث|find hadith|hadith about",
252
+ query, re.I,
253
+ ):
254
+ return "hadith"
255
+
256
+ # tafsir: Quranic verse lookup — Arabic verse text or explicit tafsir request
257
+ if re.search(
258
+ r"ابحث عن آية|ما تفسير|تفسير آية|آية عن|الآية التي|find verse|verse about|tafsir",
259
+ query, re.I,
260
+ ):
261
+ return "tafsir"
262
+
263
+ # If query contains substantial Arabic with Quranic markers (diacritics, special chars),
264
+ # treat as tafsir (verse text lookup)
265
+ ar_chars = len(re.findall(r"[\u0600-\u06FF]", query))
266
+ diacritics = len(re.findall(r"[\u064B-\u0655\u0670\u06D6-\u06ED\u06E1-\u06E9\u0610-\u061A]", query))
267
+ if ar_chars > 10 and diacritics >= 3:
268
+ return "tafsir"
269
+
270
+ return "general"
271
+
272
+
273
  async def rewrite_query(raw: str, llm: LLMProvider) -> Dict:
274
  """Rewrite query for better retrieval."""
275
  cached = await rewrite_cache.get(raw)
276
  if cached:
277
  return cached
278
 
279
+ detected_intent = _detect_intent_regex(raw)
280
  fallback = {
281
  "ar_query": normalize_arabic(raw),
282
  "en_query": raw,
283
+ "keywords": [light_stem(t) for t in tokenize_ar(raw)][:7],
284
+ "intent": detected_intent,
285
  }
286
  try:
287
+ messages = [
288
+ {"role": "system", "content": _REWRITE_SYSTEM},
289
+ {"role": "user", "content": raw},
290
+ ]
291
+ text = ""
292
+ for _attempt, temp in enumerate((0.0, 0.3)):
293
+ text = await llm.chat(
294
+ messages=messages, max_tokens=1024, temperature=temp,
295
+ )
296
+ if text.strip():
297
+ break
298
+ logger.warning("Empty rewrite response (attempt %d), retrying with temperature=%.1f",
299
+ _attempt + 1, 0.3)
300
+ logger.debug("Raw rewrite response (%d chars): %.300s", len(text), text)
301
+ json_payload = _extract_json_object(text)
302
+ if not json_payload:
303
+ raise ValueError(
304
+ f"Model did not return a JSON object (got {len(text)} chars: {text[:120]!r})"
305
+ )
306
+ result = json.loads(json_payload)
307
  for k in ("ar_query", "en_query", "keywords", "intent"):
308
  result.setdefault(k, fallback[k])
309
+
310
+ if not isinstance(result.get("keywords"), list):
311
+ result["keywords"] = fallback["keywords"]
312
+ else:
313
+ result["keywords"] = [str(x).strip() for x in result["keywords"] if str(x).strip()][:7]
314
+ if not result["keywords"]:
315
+ result["keywords"] = fallback["keywords"]
316
+
317
+ result["intent"] = str(result.get("intent") or fallback["intent"]).strip().lower()
318
+ if result["intent"] == "fatwa":
319
+ result["intent"] = "general"
320
+ if result["intent"] not in {"tafsir", "hadith", "count", "surah_info", "auth", "general"}:
321
+ result["intent"] = fallback["intent"]
322
+
323
+ result["ar_query"] = str(result.get("ar_query") or fallback["ar_query"]).strip()[:400]
324
+ result["en_query"] = str(result.get("en_query") or fallback["en_query"]).strip()[:400]
325
+
326
  await rewrite_cache.set(result, raw)
327
  logger.info("Rewrite: intent=%s ar=%s", result["intent"], result["ar_query"][:60])
328
  return result
329
  except Exception as exc:
330
+ logger.warning("Query rewrite failed (%s) — using fallback (intent=%s)", exc, fallback["intent"])
331
  return fallback
332
 
333
 
334
+ def result_key(item: dict) -> tuple:
335
+ """Build a stable key for deduplicating search results."""
336
+ item_type = item.get("type", "")
337
+ if item_type == "quran":
338
+ return (
339
+ "quran",
340
+ int(item.get("surah_number") or 0),
341
+ int(item.get("ayah_number") or item.get("verse_number") or 0),
342
+ )
343
+ if item_type == "hadith":
344
+ return (
345
+ "hadith",
346
+ normalize_arabic(item.get("collection", ""), aggressive=True).lower(),
347
+ int(item.get("hadith_number") or 0),
348
+ normalize_arabic(item.get("reference", ""), aggressive=True).lower(),
349
+ )
350
+ return (
351
+ item_type,
352
+ normalize_arabic(item.get("source") or item.get("reference", ""), aggressive=True).lower(),
353
+ normalize_arabic(item.get("arabic", "")[:80], aggressive=True).lower(),
354
+ item.get("english", "")[:80].lower(),
355
+ )
356
+
357
+
358
+ def merge_search_results(*result_groups: list, limit: Optional[int] = None) -> list:
359
+ """Merge multiple ranked result groups, deduplicating by stable content key."""
360
+ merged: dict[tuple, dict] = {}
361
+ for item in chain.from_iterable(result_groups):
362
+ key = result_key(item)
363
+ current = merged.get(key)
364
+ if current is None or item.get("_score", 0.0) > current.get("_score", 0.0):
365
+ merged[key] = item
366
+
367
+ results = sorted(merged.values(), key=lambda row: row.get("_score", 0.0), reverse=True)
368
+ return results[:limit] if limit is not None else results
369
+
370
+
371
+ def normalize_collection_name(text: str) -> Optional[str]:
372
+ """Resolve a collection alias to the canonical dataset collection name."""
373
+ if not text:
374
+ return None
375
+
376
+ normalized = normalize_arabic(text, aggressive=True).lower()
377
+ normalized = normalized.replace("_", " ")
378
+ normalized = re.sub(r"[^a-z0-9\u0600-\u06FF\s'\-]+", " ", normalized)
379
+ normalized = re.sub(r"\s+", " ", normalized).strip()
380
+
381
+ for alias, canonical in _SORTED_COLLECTION_ALIASES:
382
+ if alias in normalized:
383
+ return canonical
384
+ return None
385
+
386
+
387
+ def filter_results_by_collection(results: list, collection: Optional[str]) -> list:
388
+ """Filter hadith results by canonical or fuzzy collection name."""
389
+ if not collection:
390
+ return list(results)
391
+
392
+ canonical = normalize_collection_name(collection)
393
+ collection_norm = normalize_arabic(collection, aggressive=True).lower().strip()
394
+ filtered = []
395
+ for item in results:
396
+ haystack = normalize_arabic(
397
+ f"{item.get('collection', '')} {item.get('reference', '')}",
398
+ aggressive=True,
399
+ ).lower()
400
+ if canonical and item.get("collection", "") == canonical:
401
+ filtered.append(item)
402
+ continue
403
+ if collection_norm and collection_norm in haystack:
404
+ filtered.append(item)
405
+ return filtered
406
+
407
+
408
+ def _surah_matches(item: dict, surah_query: str) -> bool:
409
+ query_norm = normalize_arabic(surah_query, aggressive=True).lower().strip()
410
+ query_clean = re.sub(r"^(ال|al[\-\s']*)", "", query_norm, flags=re.I).strip()
411
+
412
+ for field in ("surah_name_ar", "surah_name_en", "surah_name_transliteration"):
413
+ value = item.get(field, "")
414
+ if not value:
415
+ continue
416
+ value_norm = normalize_arabic(value, aggressive=True).lower().strip()
417
+ value_clean = re.sub(r"^(ال|al[\-\s']*)", "", value_norm, flags=re.I).strip()
418
+ if query_norm == value_norm or query_clean == value_clean:
419
+ return True
420
+ if query_clean and value_clean and (query_clean in value_clean or value_clean in query_clean):
421
+ return True
422
+ return False
423
+
424
+
425
+ def lookup_quran_verses(query: str, dataset: list, limit: int = 5) -> list:
426
+ """Resolve direct Quran references like 2:255 or Surah Al-Baqarah 255."""
427
+ if not query:
428
+ return []
429
+
430
+ matches = []
431
+ numeric = _QURAN_REF_NUMERIC.search(query)
432
+ if numeric:
433
+ surah_num, verse_num = int(numeric.group(1)), int(numeric.group(2))
434
+ for item in dataset:
435
+ if item.get("type") != "quran":
436
+ continue
437
+ if item.get("surah_number") == surah_num and (item.get("ayah_number") or item.get("verse_number")) == verse_num:
438
+ matches.append({**item, "_score": 9.5})
439
+ return matches
440
+
441
+ named_patterns = (
442
+ (_QURAN_REF_AR_NAME_FIRST, lambda m: (m.group(1), int(m.group(2)))),
443
+ (_QURAN_REF_AR_VERSE_FIRST, lambda m: (m.group(2), int(m.group(1)))),
444
+ (_QURAN_REF_EN_NAME_FIRST, lambda m: (m.group(1), int(m.group(2)))),
445
+ (_QURAN_REF_EN_VERSE_FIRST, lambda m: (m.group(2), int(m.group(1)))),
446
+ )
447
+ for pattern, extractor in named_patterns:
448
+ match = pattern.search(query)
449
+ if not match:
450
+ continue
451
+ surah_query, verse_num = extractor(match)
452
+ for item in dataset:
453
+ if item.get("type") != "quran":
454
+ continue
455
+ if _surah_matches(item, surah_query) and (item.get("ayah_number") or item.get("verse_number")) == verse_num:
456
+ matches.append({**item, "_score": 9.0})
457
+ if matches:
458
+ break
459
+
460
+ return matches[:limit]
461
+
462
+
463
+ def lookup_hadith_references(query: str, dataset: list, collection: Optional[str] = None, limit: int = 5) -> list:
464
+ """Resolve direct hadith references like Bukhari 1 or مسلم 1907."""
465
+ if not query and not collection:
466
+ return []
467
+
468
+ canonical_collection = normalize_collection_name(collection or "") or normalize_collection_name(query)
469
+ number_match = re.search(r"\b(\d{1,5})\b", query)
470
+ if not canonical_collection or not number_match:
471
+ return []
472
+
473
+ hadith_number = int(number_match.group(1))
474
+ matches = []
475
+ for item in dataset:
476
+ if item.get("type") != "hadith":
477
+ continue
478
+ if item.get("collection") != canonical_collection:
479
+ continue
480
+ if int(item.get("hadith_number") or 0) != hadith_number:
481
+ continue
482
+ matches.append({**item, "_score": 9.0})
483
+ return matches[:limit]
484
+
485
+
486
  # ═══════════════════════════════════════════════════════════════════════
487
  # BM25 SCORING
488
  # ═══════════════════════════════════════════════════════════════════════
app/state.py CHANGED
@@ -24,11 +24,131 @@ from app.arabic_nlp import detect_language, normalize_arabic
24
  from app.config import cfg
25
  from app.llm import LLMProvider, get_llm_provider
26
  from app.prompts import build_messages, not_found_answer
27
- from app.search import build_context, hybrid_search, rewrite_query, text_search
 
 
 
 
 
 
 
 
28
 
29
  logger = logging.getLogger("qmodel.state")
30
 
31
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  # ═══════════════════════════════════════════════════════════════════════
33
  # POST-GENERATION HALLUCINATION CHECK
34
  # ═══════════════════════════════════════════════════════════════════════
@@ -278,6 +398,32 @@ def _verify_surah_info(answer: str, surah_info: dict) -> str:
278
  flags=re.I,
279
  )
280
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
281
  # ── Fix wrong verse counts ──────────────────────────────────────
282
  if correct_verses is not None:
283
  def _fix_verse_count(m: re.Match) -> str:
@@ -432,10 +578,24 @@ async def run_rag_pipeline(
432
  surah_task, kw_task, search_task,
433
  )
434
 
435
- # 2b. Text search fallback — catches exact matches missed by FAISS
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
436
  # For auth/hadith/tafsir intents, also search with the rewritten ar_query
437
  # which should contain the actual text fragment to look up.
438
- seen_ids = {r.get("id") for r in results}
439
  ar_q = rewrite.get("ar_query", "")
440
 
441
  # Determine text search source filter based on intent
@@ -446,19 +606,16 @@ async def run_rag_pipeline(
446
  text_src = "hadith"
447
 
448
  text_limit = top_k * 2 if intent in ("auth", "hadith", "tafsir") else top_k
 
449
  for q in dict.fromkeys([ar_q, question]): # deduplicated, ar_query first
450
  if not q:
451
  continue
452
  for hit in text_search(q, state.dataset, text_src, limit=text_limit):
453
- if hit.get("id") not in seen_ids:
454
- # Boost text search hits for auth intent (exact text match is crucial)
455
- if intent == "auth" and hit.get("_score", 0) > 2.0:
456
- hit["_score"] = hit["_score"] + 1.0
457
- results.append(hit)
458
- seen_ids.add(hit.get("id"))
459
- if len(results) > top_k:
460
- results.sort(key=lambda x: x.get("_score", 0), reverse=True)
461
- results = results[:top_k]
462
 
463
  # 3a. Surah metadata lookup
464
  surah_info = None
@@ -505,6 +662,24 @@ async def run_rag_pipeline(
505
  "latency_ms": int((time.perf_counter() - t0) * 1000),
506
  }
507
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
508
  # 6. Build context + prompt + LLM call
509
  context = build_context(results)
510
  messages = build_messages(context, question, lang, intent, analysis, surah_info)
@@ -515,6 +690,19 @@ async def run_rag_pipeline(
515
  max_tokens=cfg.MAX_TOKENS,
516
  temperature=cfg.TEMPERATURE,
517
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
518
  except Exception as exc:
519
  logger.error("LLM call failed: %s", exc)
520
  raise HTTPException(status_code=502, detail="LLM service unavailable")
 
24
  from app.config import cfg
25
  from app.llm import LLMProvider, get_llm_provider
26
  from app.prompts import build_messages, not_found_answer
27
+ from app.search import (
28
+ build_context,
29
+ hybrid_search,
30
+ lookup_hadith_references,
31
+ lookup_quran_verses,
32
+ merge_search_results,
33
+ rewrite_query,
34
+ text_search,
35
+ )
36
 
37
  logger = logging.getLogger("qmodel.state")
38
 
39
 
40
+ # ═══════════════════════════════════════════════════════════════════════
41
+ # SURAH INFO PROGRAMMATIC FALLBACK
42
+ # ═══════════════════════════════════════════════════════════════════════
43
+ def _results_fallback(results: list, lang: str) -> str:
44
+ """Generate a direct answer from search results when LLM returns empty."""
45
+ if not results:
46
+ return not_found_answer(lang)
47
+
48
+ top = results[0]
49
+ item_type = top.get("type", "")
50
+
51
+ if item_type == "quran":
52
+ ar_text = top.get("arabic", "")
53
+ en_text = top.get("english", "")
54
+ surah_ar = top.get("surah_name_ar", "")
55
+ surah_en = top.get("surah_name_en", "")
56
+ surah_num = top.get("surah_number", "")
57
+ verse_num = top.get("verse_number", "")
58
+ tafsir = top.get("tafsir_ar", "") if lang == "arabic" else top.get("tafsir_en", "")
59
+
60
+ if lang == "arabic":
61
+ lines = [
62
+ f"هذه الآية الكريمة من سورة {surah_ar} ({surah_en})، الآية {verse_num}.",
63
+ "",
64
+ f"┌─────────────────────────────────────────────┐",
65
+ f"│ ❝ {ar_text} ❞",
66
+ f"│ 📝 Translation: {en_text}",
67
+ f"│ 📖 Source: سورة {surah_ar} ({surah_en}) | رقم السورة: {surah_num} | الآية: {verse_num}",
68
+ f"└─────────────────────────────────────────────┘",
69
+ ]
70
+ if tafsir:
71
+ lines.append(f"\n{tafsir}")
72
+ lines.append("\nوالله أعلم.")
73
+ return "\n".join(lines)
74
+ else:
75
+ lines = [
76
+ f"This noble verse is from Surah {surah_en} ({surah_ar}), verse {verse_num}.",
77
+ "",
78
+ f"┌─────────────────────────────────────────────┐",
79
+ f"│ ❝ {ar_text} ❞",
80
+ f"│ 📝 Translation: {en_text}",
81
+ f"│ 📖 Source: Surah {surah_en} ({surah_ar}) | Surah Number: {surah_num} | Verse: {verse_num}",
82
+ f"└─────────────────────────────────────────────┘",
83
+ ]
84
+ if tafsir:
85
+ lines.append(f"\n{tafsir}")
86
+ lines.append("\nAnd Allah knows best.")
87
+ return "\n".join(lines)
88
+
89
+ elif item_type == "hadith":
90
+ ar_text = top.get("arabic", "")
91
+ en_text = top.get("english", "")
92
+ source = top.get("source") or top.get("reference", "")
93
+ grade = top.get("grade", "")
94
+ grade_str = f" [{grade}]" if grade else ""
95
+
96
+ if lang == "arabic":
97
+ lines = [
98
+ f"الحديث الشريف{grade_str}:",
99
+ "",
100
+ f"┌─────────────────────────────────────────────┐",
101
+ f"│ ❝ {ar_text} ❞",
102
+ f"│ 📝 Translation: {en_text}",
103
+ f"│ 📖 Source: {source}",
104
+ f"└─────────────────────────────────────────────┘",
105
+ "\nوالله أعلم.",
106
+ ]
107
+ return "\n".join(lines)
108
+ else:
109
+ lines = [
110
+ f"The Hadith{grade_str}:",
111
+ "",
112
+ f"┌─────────────────────────────────────────────┐",
113
+ f"│ ❝ {ar_text} ❞",
114
+ f"│ 📝 Translation: {en_text}",
115
+ f"│ 📖 Source: {source}",
116
+ f"└─────────────────────────────────────────────┘",
117
+ "\nAnd Allah knows best.",
118
+ ]
119
+ return "\n".join(lines)
120
+
121
+ return not_found_answer(lang)
122
+
123
+
124
+ def _surah_info_fallback(info: dict, lang: str) -> str:
125
+ """Generate a direct answer from surah metadata when LLM fails."""
126
+ name_ar = info.get("surah_name_ar", "")
127
+ name_en = info.get("surah_name_en", "")
128
+ number = info.get("surah_number", "")
129
+ verses = info.get("total_verses", "")
130
+ rev = info.get("revelation_type", "")
131
+ translit = info.get("surah_name_transliteration", "")
132
+
133
+ rev_ar = "مكية" if rev == "meccan" else "مدنية" if rev == "medinan" else rev
134
+ rev_en = rev.capitalize() if rev else ""
135
+
136
+ if lang == "arabic":
137
+ return (
138
+ f"سورة {name_ar} ({translit}) هي السورة رقم {number} في القرآن الكريم.\n"
139
+ f"عدد آياتها: {verses} آية.\n"
140
+ f"نوعها: {rev_ar}.\n"
141
+ f"والله أعلم."
142
+ )
143
+ return (
144
+ f"Surah {name_en} ({translit} / {name_ar}) is surah number {number} "
145
+ f"in the Holy Quran.\n"
146
+ f"Total verses: {verses}.\n"
147
+ f"Revelation type: {rev_en}.\n"
148
+ f"And Allah knows best."
149
+ )
150
+
151
+
152
  # ═══════════════════════════════════════════════════════════════════════
153
  # POST-GENERATION HALLUCINATION CHECK
154
  # ═══════════════════════════════════════════════════════════════════════
 
398
  flags=re.I,
399
  )
400
 
401
+ # ── Fix wrong surah numbers ─────────────────────────────────────
402
+ if correct_number is not None:
403
+ def _fix_surah_number(m: re.Match) -> str:
404
+ num = int(m.group(2))
405
+ if num == correct_number:
406
+ return m.group(0)
407
+ logger.warning(
408
+ "Surah info hallucination: surah number %d -> correcting to %d",
409
+ num, correct_number,
410
+ )
411
+ return m.group(1) + " " + str(correct_number)
412
+
413
+ # Arabic: "رقم X" / "رقمها X" / "ترتيبها X"
414
+ answer = re.sub(
415
+ r"(رقم[ها]*|ترتيب[ها]*)\s+(\d+)",
416
+ _fix_surah_number,
417
+ answer,
418
+ )
419
+ # English: "surah number X", "number X"
420
+ answer = re.sub(
421
+ r"((?:surah\s+)?number)\s+(\d+)",
422
+ _fix_surah_number,
423
+ answer,
424
+ flags=re.I,
425
+ )
426
+
427
  # ── Fix wrong verse counts ──────────────────────────────────────
428
  if correct_verses is not None:
429
  def _fix_verse_count(m: re.Match) -> str:
 
578
  surah_task, kw_task, search_task,
579
  )
580
 
581
+ # 2b. Direct reference lookup — catches Quran/Hadith references early.
582
+ direct_queries = list(dict.fromkeys([
583
+ question,
584
+ rewrite.get("ar_query", ""),
585
+ rewrite.get("en_query", ""),
586
+ ]))
587
+ direct_results = []
588
+ if source_type in (None, "quran"):
589
+ for query in direct_queries:
590
+ direct_results.extend(lookup_quran_verses(query, state.dataset, limit=top_k))
591
+ if source_type in (None, "hadith"):
592
+ for query in direct_queries:
593
+ direct_results.extend(lookup_hadith_references(query, state.dataset, limit=top_k))
594
+ results = merge_search_results(direct_results, results, limit=top_k)
595
+
596
+ # 2c. Text search fallback — catches exact matches missed by FAISS.
597
  # For auth/hadith/tafsir intents, also search with the rewritten ar_query
598
  # which should contain the actual text fragment to look up.
 
599
  ar_q = rewrite.get("ar_query", "")
600
 
601
  # Determine text search source filter based on intent
 
606
  text_src = "hadith"
607
 
608
  text_limit = top_k * 2 if intent in ("auth", "hadith", "tafsir") else top_k
609
+ text_results = []
610
  for q in dict.fromkeys([ar_q, question]): # deduplicated, ar_query first
611
  if not q:
612
  continue
613
  for hit in text_search(q, state.dataset, text_src, limit=text_limit):
614
+ # Boost text search hits for auth intent (exact text match is crucial)
615
+ if intent == "auth" and hit.get("_score", 0) > 2.0:
616
+ hit = {**hit, "_score": hit["_score"] + 1.0}
617
+ text_results.append(hit)
618
+ results = merge_search_results(results, text_results, limit=top_k)
 
 
 
 
619
 
620
  # 3a. Surah metadata lookup
621
  surah_info = None
 
662
  "latency_ms": int((time.perf_counter() - t0) * 1000),
663
  }
664
 
665
+ # 5b. Surah metadata: deterministic answer (faster & more reliable than LLM)
666
+ if surah_info and intent == "surah_info":
667
+ answer = _surah_info_fallback(surah_info, lang)
668
+ latency = int((time.perf_counter() - t0) * 1000)
669
+ logger.info(
670
+ "Pipeline done (surah_info deterministic) | lang=%s | %d ms",
671
+ lang, latency,
672
+ )
673
+ return {
674
+ "answer": answer,
675
+ "language": lang,
676
+ "intent": intent,
677
+ "analysis": None,
678
+ "sources": results,
679
+ "top_score": top_score,
680
+ "latency_ms": latency,
681
+ }
682
+
683
  # 6. Build context + prompt + LLM call
684
  context = build_context(results)
685
  messages = build_messages(context, question, lang, intent, analysis, surah_info)
 
690
  max_tokens=cfg.MAX_TOKENS,
691
  temperature=cfg.TEMPERATURE,
692
  )
693
+ # Strip residual <think> blocks that Qwen3 may emit
694
+ answer = re.sub(r"<think>[\s\S]*?</think>", "", answer, flags=re.IGNORECASE)
695
+ answer = re.sub(r"<think>[\s\S]*$", "", answer, flags=re.IGNORECASE)
696
+ answer = answer.strip()
697
+
698
+ if not answer:
699
+ logger.warning("LLM returned empty answer — using results fallback")
700
+ if surah_info:
701
+ answer = _surah_info_fallback(surah_info, lang)
702
+ elif results:
703
+ answer = _results_fallback(results, lang)
704
+ else:
705
+ answer = not_found_answer(lang)
706
  except Exception as exc:
707
  logger.error("LLM call failed: %s", exc)
708
  raise HTTPException(status_code=502, detail="LLM service unavailable")