fix: Fatiha priority when query starts with Basmala + strip Basmala from search + restore honest tests
b88caf3 | # -*- 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, | |
| } |