bayan-api / quran.py
youssefreda9's picture
fix: Fatiha priority when query starts with Basmala + strip Basmala from search + restore honest tests
b88caf3
Raw
History Blame
16 kB
# -*- coding: utf-8 -*-
"""Quran.py
Automatically generated by Colab.
Original file is located at
https://colab.research.google.com/drive/1WwaR-xsFnY5iffCndJV5metzB0RS4_GP
"""
import sqlite3
import re
from rapidfuzz.distance import Levenshtein
# ==========================================
# طبقة التطبيع الموسعة
# ==========================================
# مجموعات الحروف المتشابهة بصرياً أو صوتياً — كلها ترجع لشكل واحد
_SIMILAR_GROUPS = [
('اأإآٱ', 'ا'),
('ةه', 'ه'),
('يىئ', 'ي'),
('وؤ', 'و'),
('ذد', 'د'), # متقاربة بصرياً للمبتدئين
('زرذ', 'ر'), # أحياناً يخلط المستخدم بينها
('طت', 'ت'),
('ضظ', 'ض'),
('سص', 'س'),
('ثت', 'ت'),
('خح', 'ح'),
('غع', 'ع'),
]
def _build_similarity_table():
table = {}
for group, canonical in _SIMILAR_GROUPS:
for ch in group:
table[ch] = canonical
return table
_SIM_TABLE = _build_similarity_table()
def normalize_arabic(text: str, *, deep: bool = False) -> str:
"""
تطبيع النص العربي.
deep=False → تطبيع خفيف (للتخزين في DB أو المقارنة الدقيقة)
deep=True → تطبيع عميق يساوي بين الحروف المتشابهة (للبحث الضبابي)
"""
if not text:
return ""
# إزالة التشكيل والعلامات القرآنية
text = re.sub(
r'[\u064B-\u065F\u0670\u0671\u0656'
r'\u06D6-\u06DC\u06DF-\u06E4\u06E7\u06E8\u06EA-\u06ED]',
'', text
)
# همزات
text = re.sub(r'[أإآٱ]', 'ا', text)
# تاء مربوطة
text = re.sub(r'ة', 'ه', text)
# ألف مقصورة
text = re.sub(r'ى', 'ي', text)
if deep:
text = ''.join(_SIM_TABLE.get(ch, ch) for ch in text)
return ' '.join(text.strip().split())
# ==========================================
# مطابقة ضبابية على مستوى الكلمات
# ==========================================
def _word_similarity(a: str, b: str) -> float:
"""نسبة التشابه بين كلمتين (0.0 → 1.0)."""
max_len = max(len(a), len(b), 1)
dist = Levenshtein.distance(a, b)
return 1.0 - dist / max_len
def _score_window(query_words: list[str], window_words: list[str],
query_deep: list[str], window_deep: list[str]) -> float:
"""
احسب نسبة تطابق نافذة كلمات مع استعلام المستخدم.
نستخدم نسختين: خفيفة (أولوية) وعميقة (احتياط).
"""
if len(window_words) != len(query_words):
return 0.0
total = 0.0
for qw, ww, qd, wd in zip(query_words, window_words, query_deep, window_deep):
# نأخذ أعلى نتيجة بين المقارنة الخفيفة والعميقة
s_light = _word_similarity(qw, ww)
s_deep = _word_similarity(qd, wd)
total += max(s_light, s_deep)
return total / len(query_words)
# ==========================================
# الدالة الرئيسية
# ==========================================
def search_bayan(query_text: str,
target_type: str = "تدقيق الايات",
fuzzy_threshold: float = 0.72,
db_path: str = None) -> dict:
"""
البحث عن آية قرآنية مع دعم الأخطاء الإملائية.
المعاملات:
query_text : النص المُدخَل من المستخدم (قد يحتوي أخطاء)
target_type : لغة الإخراج (uthmani / english / french / ...)
fuzzy_threshold : الحد الأدنى لقبول التطابق (0→1)، افتراضياً 0.72
db_path : مسار ملف قاعدة البيانات (افتراضياً بجوار هذا الملف)
المُخرج:
dict يحتوي على:
matched_segment : النص المُصحَّح بالرسم العثماني أو الترجمة
full_verse : الآيات كاملة مع التوثيق
similarity_score : درجة التشابه
metadata : تفاصيل الآيات
أو:
error : رسالة الخطأ
"""
import os
if db_path is None:
db_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'quran_master.db')
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
language_mapping = {
"تدقيق الايات": "uthmani",
"bengali": "bn", "bosnian": "bs", "english": "en", "french": "fr",
"german": "de", "indonesian": "id", "malay": "ms", "persian": "fa",
"portuguese": "pt", "russian": "ru", "spanish": "es",
"turkish": "tr", "uzbek": "uz"
}
clean_target = str(target_type).lower().strip()
lang_code = language_mapping.get(clean_target, "uthmani")
verse_column = "v.text_uthmani" if lang_code == "uthmani" else f"v.lang_{lang_code}"
sura_column = "s.ar" if lang_code == "uthmani" else f"s.lang_{lang_code}"
# ── تطبيع الاستعلام بنسختين ──
query_light = normalize_arabic(query_text, deep=False).split()
query_deep = normalize_arabic(query_text, deep=True).split()
if not query_light:
conn.close()
return {"error": "النص المُدخل فارغ"}
n = len(query_light)
# ── إذا بدأ الاستعلام بالبسملة → أعطِ الفاتحة أولوية ──
# بدون ده، "بسم الله الرحمن الرحيم الحمد لله" بيطابق ١١٢ سورة
_query_starts_basmala = False
if n >= 4:
first4_q = normalize_arabic(' '.join(query_light[:4]), deep=False)
if first4_q in ['بسم الله الرحمن الرحيم', 'بسم لله لرحمن لرحيم', 'بسم لله لرحمـن لرحيم']:
_query_starts_basmala = True
# ==========================================
# البحث بالمرساة الديناميكية (من الأطول للأقصر)
# ==========================================
candidate_starts: list[tuple[int, int]] = []
for i in range(n, 0, -1):
anchor = ' '.join(query_light[:i])
# بحث LIKE عادي أولاً (سريع)
cursor.execute("""
SELECT v.sura_num, v.aya_num
FROM verses v
WHERE v.text_clean LIKE ?
ORDER BY v.sura_num, v.aya_num
""", ('%' + anchor + '%',))
candidate_starts = cursor.fetchall()
if candidate_starts:
break
# إذا بدأ بالبسملة → الفاتحة أولوية
if _query_starts_basmala:
fatiha = (1, 1)
if fatiha not in candidate_starts:
candidate_starts.insert(0, fatiha)
else:
candidate_starts.remove(fatiha)
candidate_starts.insert(0, fatiha)
# إذا لم تجد شيئاً بالمرساة الخفيفة → جرّب المرساة العميقة
# (يستخدم text_deep إذا كان موجوداً، وإلا يعود لـ text_clean)
if not candidate_starts:
# اكتشف أعمدة الجدول
cursor.execute("PRAGMA table_info(verses)")
cols = {row[1] for row in cursor.fetchall()}
deep_col = "v.text_deep" if "text_deep" in cols else "v.text_clean"
for i in range(n, 0, -1):
anchor_deep = ' '.join(query_deep[:i])
cursor.execute(f"""
SELECT v.sura_num, v.aya_num
FROM verses v
WHERE {deep_col} LIKE ?
ORDER BY v.sura_num, v.aya_num
""", ('%' + anchor_deep + '%',))
rows = cursor.fetchall()
if rows:
candidate_starts = rows
break
# الملاذ الأخير: ابحث بكل كلمة على حدة وخذ الآيات الأكثر تكراراً
if not candidate_starts:
counts: dict[tuple, int] = {}
for word in query_light:
if len(word) < 3:
continue
cursor.execute("""
SELECT v.sura_num, v.aya_num
FROM verses v
WHERE v.text_clean LIKE ?
""", ('%' + word + '%',))
for row in cursor.fetchall():
counts[row] = counts.get(row, 0) + 1
if counts:
candidate_starts = sorted(counts, key=counts.get, reverse=True)[:15]
if not candidate_starts:
conn.close()
return {
"matched_segment": "",
"full_verse": "لم يُعثر على تطابق — تحقق من النص المُدخل"
}
# ==========================================
# النافذة المنزلقة + التقييم الضبابي
# ==========================================
best_score = -1.0
best_match_idx = -1
best_rows = None
for start_sura, start_aya in candidate_starts:
cursor.execute(f"""
SELECT v.sura_num, v.aya_num, v.text_clean,
v.text_uthmani, {verse_column}, {sura_column}
FROM verses v
JOIN suras_translated s ON v.sura_num = s.sura_number
WHERE (v.sura_num = ? AND v.aya_num >= ?) OR (v.sura_num > ?)
ORDER BY v.sura_num, v.aya_num
LIMIT 12
""", (start_sura, start_aya, start_sura))
fetched = cursor.fetchall()
QURAN_MARKS = {
'ۖ', 'ۗ', 'ۘ', 'ۙ', 'ۚ', 'ۛ', 'ۜ', '۝',
'۞', '۩'
}
# بناء خريطة الكلمات
combined_light, combined_deep, word_map = [], [], []
for row in fetched:
s_num, a_num, t_clean, t_uthmani, t_target, s_name = row
# ── شيل البسملة من text_clean في البحث (مش الفاتحة) ──
# بدون ده، anchor "بسم الله الرحمن الرحيم" بيطابق ١١٢ سورة
if a_num == 1 and s_num != 1:
tc_words = t_clean.split()
if len(tc_words) >= 4:
first4 = normalize_arabic(' '.join(tc_words[:4]), deep=False)
basmala_norms = [
'بسم الله الرحمن الرحيم',
'بسم لله لرحمن لرحيم',
'بسم لله لرحمـن لرحيم',
]
if first4 in basmala_norms:
t_clean = ' '.join(tc_words[4:])
clean_w = t_clean.split()
uthmani_w = [
token
for token in t_uthmani.split()
if token not in QURAN_MARKS
]
deep_w = normalize_arabic(t_clean, deep=True).split()
for j, cw in enumerate(clean_w):
combined_light.append(cw)
combined_deep.append(deep_w[j] if j < len(deep_w) else cw)
word_map.append({
"clean": cw,
"uthmani": uthmani_w[j] if j < len(uthmani_w) else cw,
"sura_num": s_num,
"aya_num": a_num,
"target_text": t_target,
"sura_name": s_name,
})
total_words = len(combined_light)
if total_words < n:
continue
# نافذة منزلقة — ابحث عن أعلى نتيجة
for j in range(total_words - n + 1):
score = _score_window(
query_light, combined_light[j:j+n],
query_deep, combined_deep[j:j+n]
)
if score > best_score:
best_score = score
best_match_idx = j
best_rows = word_map
# إذا وجدنا تطابقاً كاملاً، لا داعي للاستمرار
if best_score >= 0.999:
break
conn.close()
# if best_score < fuzzy_threshold or best_match_idx == -1:
# return {
# "error": (
# f"أقرب تطابق وجدناه بدرجة {best_score:.0%} وهي أقل من الحد المقبول "
# f"({fuzzy_threshold:.0%}). تحقق من النص المُدخل."
# )
# }
# ==========================================
# تشكيل المخرجات
# ==========================================
# تحويل الأرقام اللاتينية إلى عربية
def to_arabic_nums(n):
"""Convert 31 → ٣١"""
ar_digits = '٠١٢٣٤٥٦٧٨٩'
return ''.join(ar_digits[int(d)] for d in str(n))
matched_words = best_rows[best_match_idx: best_match_idx + n]
# دالة تنسيق الرقم حسب اللغة
def fmt_num(n):
"""Arabic-Indic for uthmani, Latin for translations"""
return to_arabic_nums(n) if lang_code == "uthmani" else str(n)
# الآيات المشمولة — مرتبة بالترتيب (من الـ matched window)
involved: dict[tuple, dict] = {}
for w in matched_words:
key = (w["sura_num"], w["aya_num"])
if key not in involved:
involved[key] = {
"sura_name": w["sura_name"],
"target_text": w["target_text"],
"uthmani": w.get("uthmani", ""),
}
# منع تجاوز حدود السورة: اختر السورة الأكثر تمثيلاً فقط
sura_counts: dict[int, int] = {}
for w in matched_words:
sura_counts[w["sura_num"]] = sura_counts.get(w["sura_num"], 0) + 1
primary_sura = max(sura_counts, key=sura_counts.get)
involved = {k: v for k, v in involved.items() if k[0] == primary_sura}
ayah_nums = [a_num for (_, a_num) in involved]
sura_name = next(iter(involved.values()))["sura_name"]
# ── بناء النص الكامل من الآيات الكاملة (مش من الـ window بس) ──
# إزالة البسملة من أول آية (مدمجة في الـ DB) — ماعدا الفاتحة
def _strip_basmala(text):
"""Remove Basmala from beginning of verse text"""
words = text.split()
if len(words) < 4:
return text
# Normalize first 4 words and check against known Basmala forms
first4_norm = normalize_arabic(' '.join(words[:4]), deep=False)
# ٱ (Alef Wasla) gets stripped during normalization → 'بسم لله لرحمـن لرحيم'
basmala_forms = [
'بسم الله الرحمن الرحيم', # standard alef
'بسم لله لرحمن لرحيم', # alef wasla stripped
'بسم لله لرحمـن لرحيم', # with tatweel ـ
]
if first4_norm in basmala_forms:
return ' '.join(words[4:])
return text
verse_parts = []
for (s_num, a_num), data in involved.items():
txt = data['target_text']
# شيل البسملة من الآية الأولى (إلا سورة الفاتحة)
if a_num == 1 and s_num != 1:
txt = _strip_basmala(txt)
verse_parts.append(f"{txt} ({fmt_num(a_num)})")
combined_body = " ".join(verse_parts)
# بناء المرجع: نطاق (من-إلى) بدل سرد كل الأرقام
if len(ayah_nums) == 1:
ref = f"{sura_name}: {fmt_num(ayah_nums[0])}"
else:
first = fmt_num(ayah_nums[0])
last = fmt_num(ayah_nums[-1])
ref = f"{sura_name}: {first}-{last}"
result_text = f"({combined_body}) 【{ref}】"
is_exact = best_score >= 0.999
return {
"matched_segment": result_text,
"full_verse": result_text,
}