2 Commits
3391664923
...
44c2f59328
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
44c2f59328 |
feat(search): implement Arabic and Persian search normalization with shadow search column
- Add utils/text_normalizer.py providing robust Arabic and Persian orthographic normalization (diacritics/tashkeel removal, unification of Alef variants, Yeh/Alef Maksura, Kaf, Teh Marbuta, Waw Hamza, Tatweel, and digit normalization). - Implement recursive text extraction from multilingual JSON structures and models. - Add normalized_text shadow column across Hadis, HadisCorrection, HadisInterpretation, Transmitters, BookReference, and HadisCategory models with automated save-time synchronization. - Apply database migrations (0048 and 0049). - Add populate_normalized_search management command for backfilling existing records. - Update search querysets in hadis, transmitter, book reference, and category views to filter using normalized text queries. - Add comprehensive documentation in docs/ARABIC_SEARCH_NORMALIZATION_GUIDE.md. |
2 days ago |
|
|
5f9b2f195b |
refactor(transmitters): convert original text to single text field with data migration and populate reference metadata scripts
|
2 days ago |
20 changed files with 1104 additions and 49 deletions
-
30apps/hadis/admin/transmitter.py
-
95apps/hadis/management/commands/populate_normalized_search.py
-
100apps/hadis/migrations/0047_transmitteroriginaltext_text_to_textfield.py
-
43apps/hadis/migrations/0048_bookreference_normalized_text_hadis_normalized_text_and_more.py
-
43apps/hadis/migrations/0049_alter_bookreference_normalized_text_and_more.py
-
6apps/hadis/models/category.py
-
30apps/hadis/models/hadis.py
-
11apps/hadis/models/reference.py
-
12apps/hadis/models/transmitter.py
-
4apps/hadis/serializers/hadis.py
-
31apps/hadis/serializers/serializers_admin.py
-
6apps/hadis/views/category.py
-
28apps/hadis/views/hadis.py
-
12apps/hadis/views/reference.py
-
3apps/hadis/views/reference_v2.py
-
9apps/hadis/views/transmitter.py
-
283docs/ARABIC_SEARCH_NORMALIZATION_GUIDE.md
-
32scripts/inspect_refs.py
-
240scripts/populate_reference_metadata.py
-
135utils/text_normalizer.py
@ -0,0 +1,95 @@ |
|||||
|
from django.core.management.base import BaseCommand |
||||
|
from apps.hadis.models import ( |
||||
|
Hadis, |
||||
|
Transmitters, |
||||
|
BookReference, |
||||
|
HadisCategory, |
||||
|
HadisCorrection, |
||||
|
HadisInterpretation |
||||
|
) |
||||
|
from utils.text_normalizer import build_search_blob |
||||
|
|
||||
|
|
||||
|
class Command(BaseCommand): |
||||
|
help = "Backfill normalized_text on Hadis, Transmitters, Categories, BookReferences, Corrections, and Interpretations" |
||||
|
|
||||
|
def handle(self, *args, **options): |
||||
|
self.stdout.write(self.style.SUCCESS("Starting search normalization backfill...")) |
||||
|
|
||||
|
# 1. Hadis |
||||
|
self.stdout.write("1. Normalizing Hadis records...") |
||||
|
hadiths = list(Hadis.objects.all().only('id', 'text', 'title', 'title_narrator', 'translation', 'normalized_text')) |
||||
|
for h in hadiths: |
||||
|
h.normalized_text = build_search_blob( |
||||
|
h.text, |
||||
|
h.title, |
||||
|
h.title_narrator, |
||||
|
h.translation |
||||
|
) |
||||
|
Hadis.objects.bulk_update(hadiths, ['normalized_text'], batch_size=500) |
||||
|
self.stdout.write(self.style.SUCCESS(f" Successfully updated {len(hadiths)} Hadis records.")) |
||||
|
|
||||
|
# 2. Transmitters |
||||
|
self.stdout.write("2. Normalizing Transmitters records...") |
||||
|
transmitters = list(Transmitters.objects.all().only('id', 'full_name', 'kunya', 'known_as', 'nickname', 'normalized_text')) |
||||
|
for t in transmitters: |
||||
|
t.normalized_text = build_search_blob( |
||||
|
t.full_name, |
||||
|
t.kunya, |
||||
|
t.known_as, |
||||
|
t.nickname |
||||
|
) |
||||
|
Transmitters.objects.bulk_update(transmitters, ['normalized_text'], batch_size=500) |
||||
|
self.stdout.write(self.style.SUCCESS(f" Successfully updated {len(transmitters)} Transmitters records.")) |
||||
|
|
||||
|
# 3. BookReference |
||||
|
self.stdout.write("3. Normalizing BookReference records...") |
||||
|
books = list(BookReference.objects.select_related('author').all().only('id', 'title', 'description', 'author__name', 'normalized_text')) |
||||
|
for b in books: |
||||
|
author_name = b.author.name if b.author else "" |
||||
|
b.normalized_text = build_search_blob( |
||||
|
b.title, |
||||
|
b.description, |
||||
|
author_name |
||||
|
) |
||||
|
BookReference.objects.bulk_update(books, ['normalized_text'], batch_size=500) |
||||
|
self.stdout.write(self.style.SUCCESS(f" Successfully updated {len(books)} BookReference records.")) |
||||
|
|
||||
|
# 4. HadisCategory |
||||
|
self.stdout.write("4. Normalizing HadisCategory records...") |
||||
|
categories = list(HadisCategory.objects.all().only('id', 'title', 'description', 'normalized_text')) |
||||
|
for c in categories: |
||||
|
c.normalized_text = build_search_blob( |
||||
|
c.title, |
||||
|
c.description |
||||
|
) |
||||
|
HadisCategory.objects.bulk_update(categories, ['normalized_text'], batch_size=500) |
||||
|
self.stdout.write(self.style.SUCCESS(f" Successfully updated {len(categories)} HadisCategory records.")) |
||||
|
|
||||
|
# 5. HadisCorrection |
||||
|
self.stdout.write("5. Normalizing HadisCorrection records...") |
||||
|
corrections = list(HadisCorrection.objects.all().only('id', 'text', 'title', 'narrator', 'translation', 'normalized_text')) |
||||
|
for corr in corrections: |
||||
|
corr.normalized_text = build_search_blob( |
||||
|
corr.text, |
||||
|
corr.title, |
||||
|
corr.narrator, |
||||
|
corr.translation |
||||
|
) |
||||
|
HadisCorrection.objects.bulk_update(corrections, ['normalized_text'], batch_size=500) |
||||
|
self.stdout.write(self.style.SUCCESS(f" Successfully updated {len(corrections)} HadisCorrection records.")) |
||||
|
|
||||
|
# 6. HadisInterpretation |
||||
|
self.stdout.write("6. Normalizing HadisInterpretation records...") |
||||
|
interpretations = list(HadisInterpretation.objects.all().only('id', 'text', 'title', 'narrator', 'translation', 'normalized_text')) |
||||
|
for interp in interpretations: |
||||
|
interp.normalized_text = build_search_blob( |
||||
|
interp.text, |
||||
|
interp.title, |
||||
|
interp.narrator, |
||||
|
interp.translation |
||||
|
) |
||||
|
HadisInterpretation.objects.bulk_update(interpretations, ['normalized_text'], batch_size=500) |
||||
|
self.stdout.write(self.style.SUCCESS(f" Successfully updated {len(interpretations)} HadisInterpretation records.")) |
||||
|
|
||||
|
self.stdout.write(self.style.SUCCESS("All search normalization fields backfilled successfully!")) |
||||
@ -0,0 +1,100 @@ |
|||||
|
# Generated by Django on 2026-10-08 |
||||
|
|
||||
|
from django.db import migrations, models |
||||
|
|
||||
|
|
||||
|
def migrate_text_data_forward(apps, schema_editor): |
||||
|
TransmitterOriginalText = apps.get_model('hadis', 'TransmitterOriginalText') |
||||
|
items_to_update = [] |
||||
|
|
||||
|
for obj in TransmitterOriginalText.objects.all(): |
||||
|
raw_text = obj.text |
||||
|
raw_translation = obj.translation |
||||
|
|
||||
|
primary_text = "" |
||||
|
items_by_lang = {} |
||||
|
|
||||
|
if isinstance(raw_text, list): |
||||
|
for item in raw_text: |
||||
|
if isinstance(item, dict): |
||||
|
lang = item.get('language_code') or 'unknown' |
||||
|
txt = (item.get('text') or item.get('title') or item.get('value') or '').strip() |
||||
|
if txt: |
||||
|
items_by_lang[lang] = txt |
||||
|
|
||||
|
if 'ar' in items_by_lang: |
||||
|
primary_text = items_by_lang['ar'] |
||||
|
elif 'fa' in items_by_lang: |
||||
|
primary_text = items_by_lang['fa'] |
||||
|
elif items_by_lang: |
||||
|
primary_text = list(items_by_lang.values())[0] |
||||
|
elif isinstance(raw_text, str): |
||||
|
primary_text = raw_text |
||||
|
|
||||
|
# Ensure all other language translations from text are preserved in translation field |
||||
|
updated_trans = list(raw_translation) if isinstance(raw_translation, list) else [] |
||||
|
existing_langs = set() |
||||
|
for tr in updated_trans: |
||||
|
if isinstance(tr, dict) and tr.get('language_code'): |
||||
|
existing_langs.add(tr.get('language_code')) |
||||
|
|
||||
|
for lang, txt in items_by_lang.items(): |
||||
|
if lang not in existing_langs: |
||||
|
updated_trans.append({ |
||||
|
'language_code': lang, |
||||
|
'title': txt, |
||||
|
'text': txt |
||||
|
}) |
||||
|
existing_langs.add(lang) |
||||
|
|
||||
|
obj.temp_text = primary_text |
||||
|
obj.translation = updated_trans |
||||
|
items_to_update.append(obj) |
||||
|
|
||||
|
if len(items_to_update) >= 200: |
||||
|
TransmitterOriginalText.objects.bulk_update(items_to_update, ['temp_text', 'translation']) |
||||
|
items_to_update = [] |
||||
|
|
||||
|
if items_to_update: |
||||
|
TransmitterOriginalText.objects.bulk_update(items_to_update, ['temp_text', 'translation']) |
||||
|
|
||||
|
|
||||
|
def migrate_text_data_backward(apps, schema_editor): |
||||
|
TransmitterOriginalText = apps.get_model('hadis', 'TransmitterOriginalText') |
||||
|
items_to_update = [] |
||||
|
|
||||
|
for obj in TransmitterOriginalText.objects.all(): |
||||
|
if isinstance(obj.text, str) and obj.text: |
||||
|
obj.temp_text = [{'language_code': 'ar', 'text': obj.text, 'title': obj.text}] |
||||
|
items_to_update.append(obj) |
||||
|
if len(items_to_update) >= 200: |
||||
|
TransmitterOriginalText.objects.bulk_update(items_to_update, ['temp_text']) |
||||
|
items_to_update = [] |
||||
|
|
||||
|
if items_to_update: |
||||
|
TransmitterOriginalText.objects.bulk_update(items_to_update, ['temp_text']) |
||||
|
|
||||
|
|
||||
|
class Migration(migrations.Migration): |
||||
|
|
||||
|
dependencies = [ |
||||
|
('hadis', '0046_originaltextreference_hadith_number'), |
||||
|
] |
||||
|
|
||||
|
operations = [ |
||||
|
migrations.AddField( |
||||
|
model_name='transmitteroriginaltext', |
||||
|
name='temp_text', |
||||
|
field=models.TextField(blank=True, default='', verbose_name='Text'), |
||||
|
), |
||||
|
migrations.RunPython(migrate_text_data_forward, reverse_code=migrate_text_data_backward), |
||||
|
migrations.RemoveField( |
||||
|
model_name='transmitteroriginaltext', |
||||
|
name='text', |
||||
|
), |
||||
|
migrations.RenameField( |
||||
|
model_name='transmitteroriginaltext', |
||||
|
old_name='temp_text', |
||||
|
new_name='text', |
||||
|
), |
||||
|
] |
||||
@ -0,0 +1,43 @@ |
|||||
|
# Generated by Django 4.2.30 on 2026-10-08 10:37 |
||||
|
|
||||
|
from django.db import migrations, models |
||||
|
|
||||
|
|
||||
|
class Migration(migrations.Migration): |
||||
|
|
||||
|
dependencies = [ |
||||
|
('hadis', '0047_transmitteroriginaltext_text_to_textfield'), |
||||
|
] |
||||
|
|
||||
|
operations = [ |
||||
|
migrations.AddField( |
||||
|
model_name='bookreference', |
||||
|
name='normalized_text', |
||||
|
field=models.TextField(blank=True, db_index=True, null=True, verbose_name='normalized text for search'), |
||||
|
), |
||||
|
migrations.AddField( |
||||
|
model_name='hadis', |
||||
|
name='normalized_text', |
||||
|
field=models.TextField(blank=True, db_index=True, null=True, verbose_name='normalized text for search'), |
||||
|
), |
||||
|
migrations.AddField( |
||||
|
model_name='hadiscategory', |
||||
|
name='normalized_text', |
||||
|
field=models.TextField(blank=True, db_index=True, null=True, verbose_name='normalized text for search'), |
||||
|
), |
||||
|
migrations.AddField( |
||||
|
model_name='hadiscorrection', |
||||
|
name='normalized_text', |
||||
|
field=models.TextField(blank=True, db_index=True, null=True, verbose_name='normalized text for search'), |
||||
|
), |
||||
|
migrations.AddField( |
||||
|
model_name='hadisinterpretation', |
||||
|
name='normalized_text', |
||||
|
field=models.TextField(blank=True, db_index=True, null=True, verbose_name='normalized text for search'), |
||||
|
), |
||||
|
migrations.AddField( |
||||
|
model_name='transmitters', |
||||
|
name='normalized_text', |
||||
|
field=models.TextField(blank=True, db_index=True, null=True, verbose_name='normalized text for search'), |
||||
|
), |
||||
|
] |
||||
@ -0,0 +1,43 @@ |
|||||
|
# Generated by Django 4.2.30 on 2026-10-08 10:40 |
||||
|
|
||||
|
from django.db import migrations, models |
||||
|
|
||||
|
|
||||
|
class Migration(migrations.Migration): |
||||
|
|
||||
|
dependencies = [ |
||||
|
('hadis', '0048_bookreference_normalized_text_hadis_normalized_text_and_more'), |
||||
|
] |
||||
|
|
||||
|
operations = [ |
||||
|
migrations.AlterField( |
||||
|
model_name='bookreference', |
||||
|
name='normalized_text', |
||||
|
field=models.TextField(blank=True, null=True, verbose_name='normalized text for search'), |
||||
|
), |
||||
|
migrations.AlterField( |
||||
|
model_name='hadis', |
||||
|
name='normalized_text', |
||||
|
field=models.TextField(blank=True, null=True, verbose_name='normalized text for search'), |
||||
|
), |
||||
|
migrations.AlterField( |
||||
|
model_name='hadiscategory', |
||||
|
name='normalized_text', |
||||
|
field=models.TextField(blank=True, null=True, verbose_name='normalized text for search'), |
||||
|
), |
||||
|
migrations.AlterField( |
||||
|
model_name='hadiscorrection', |
||||
|
name='normalized_text', |
||||
|
field=models.TextField(blank=True, null=True, verbose_name='normalized text for search'), |
||||
|
), |
||||
|
migrations.AlterField( |
||||
|
model_name='hadisinterpretation', |
||||
|
name='normalized_text', |
||||
|
field=models.TextField(blank=True, null=True, verbose_name='normalized text for search'), |
||||
|
), |
||||
|
migrations.AlterField( |
||||
|
model_name='transmitters', |
||||
|
name='normalized_text', |
||||
|
field=models.TextField(blank=True, null=True, verbose_name='normalized text for search'), |
||||
|
), |
||||
|
] |
||||
@ -0,0 +1,283 @@ |
|||||
|
# دستورالعمل جامع نرمالسازی جستجو برای متون عربی و اسلامی |
||||
|
### (Comprehensive Arabic & Persian Search Normalization Specification) |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## ۱. مقدمه و بیان مسئله (Executive Summary & Problem Statement) |
||||
|
|
||||
|
در پایگاههای داده و سامانههای متنی اسلامی و حدیثی، جستجوی متنی با چالشهای بنیادین زبانشناختی و فنی مواجه است: |
||||
|
|
||||
|
1. **گوناگونی رسمالخط و کدگذاری یونیکد (Unicode Homoglyphs):** نویسههایی مانند الف همزهدار (`أ`، `إ`)، الف ممدوده (`آ`)، الف وصل (`ٱ`) و الف ساده (`ا`) دارای کدهای یونیکد کاملاً متفاوتی هستند. |
||||
|
2. **اعراب و اعجام (Harakat / Tashkeel):** متون احادیث و اسناد دینی معمولاً با حرکتگذاری کامل (مانند `إِنَّمَا الأَعْمَالُ`) در پایگاه داده ذخیره شدهاند، در حالی که کاربران جستجوهای خود را بدون اعراب (`انما الاعمال` یا `انما`) تایپ میکنند. در مقایسههای متنی پیشفرض دیتابیس (`LIKE` یا `ILIKE` و `icontains`)، وجود اعراب در میان حروف کلمه باعث میشود کلمه کاربر تطبیق داده نشود و نتیجه «هیچ رکوردی یافت نشد» برگردد. |
||||
|
3. **تداخل کاراکترهای فارسی و عربی:** در کیبوردهای کاربران (موبایل و دسکتاپ)، حروفی چون «ی» و «ي»، «ک» و «ك»، «ه» و «ة» مدام جابهجا میشوند. |
||||
|
4. **علائم قرآنی و وقوف:** نمادهایی مانند «الف خنجری» (`ٰ` مانند `رَحْمٰنِ`)، علائم وقف و نمادهای تعظیم اگر نرمال نشوند، جستجو را مسدود میکنند. |
||||
|
|
||||
|
> **هدف:** هر متنی که کاربر با هر شکلی از کیبورد (با اعراب، بدون اعراب، با همزه یا بدون همزه، فارسی یا عربی) جستجو کرد، سیستم باید دقیقاً مفهوم آن را درک کرده و تمامی رکوردهای منطبق را بدون وابستگی به شکل ظاهری نگارش پیدا کند. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## ۲. دستهبندی جامع کاراکترها و حالات نرمالسازی (Normalization Taxonomy) |
||||
|
|
||||
|
### ۲.۱. خانواده الفها و همزهها (Alef & Hamza Variants) |
||||
|
الف در رسمالخط عربی دارای حالتهای متعددی است که در جستجو باید همگی یکپارچه شوند: |
||||
|
|
||||
|
| نویسه اصلی | نام یونیکد | کد یونیکد | هدف نرمالسازی | مثال جستجو | مثال در دیتابیس | |
||||
|
| :--- | :--- | :--- | :--- | :--- | :--- | |
||||
|
| **أ** | Arabic Letter Alef With Hamza Above | `U+0623` | **ا** (`U+0627`) | أنما | انما / إِنَّمَا | |
||||
|
| **إ** | Arabic Letter Alef With Hamza Below | `U+0625` | **ا** (`U+0627`) | إسناد | اسناد | |
||||
|
| **آ** | Arabic Letter Alef With Madda Above | `U+0622` | **ا** (`U+0627`) | آثار | اثار | |
||||
|
| **ٱ** | Arabic Letter Alef Wasla | `U+0671` | **ا** (`U+0627`) | ٱمرؤ | امرؤ | |
||||
|
| **ا** | Arabic Letter Alef (Bare) | `U+0627` | **ا** (`U+0627`) | اعمال | أعمال | |
||||
|
| **ٴ** | Arabic Letter High Hamza | `U+0674` | *حذف* یا **ا** | — | — | |
||||
|
|
||||
|
#### حالات دیگر همزهها (Other Hamza Forms): |
||||
|
- **همزه روی واو (`ؤ` - `U+0624`):** در جستجوی آسانگیر (Lenient)، همزه روی واو به `و` (`U+0648`) نرمال میشود تا کاربر با سرچ «مومن» بتواند «مؤمن» را پیدا کند یا بالعکس («مسؤول» / «مسئول»). |
||||
|
- **همزه روی یاء / نبره (`ئ` - `U+0626`):** به `ی` / `ي` تبدیل میشود تا کلماتی چون «قائل»، «قایل»، «هیئة»، «هيئة» همارز شوند. |
||||
|
- **همزه تنها روی خط (`ء` - `U+0621`):** حذف یا تبدیل به فاصله در صورت نیاز. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### ۲.۲. خانواده یاء و الف مقصوره (Yeh & Alef Maksura) |
||||
|
یکی از پرتکرارترین خطاها در جستجوی عربی و فارسی مربوط به حرف «ی» است: |
||||
|
|
||||
|
| نویسه اصلی | نام | کد یونیکد | رفتار نرمالسازی | مثال | |
||||
|
| :--- | :--- | :--- | :--- | :--- | |
||||
|
| **ي** | یاء عربی دو نقطه | `U+064A` | تبدیل به نویسه واحد مبنا (مثلاً `ی` یا `ي`) | علي / علی | |
||||
|
| **ى** | الف مقصوره عربی (بینقطه) | `U+0649` | تبدیل به `ی` یا `ا` بر اساس سیاست پروژه | موسی / موسي / موسى | |
||||
|
| **ی** | یای فارسی بدون نقطه | `U+06CC` | تبدیل به نویسه واحد مبنا | حدیث / حديث | |
||||
|
| **ئ** | یاء با همزه | `U+0626` | تبدیل به نویسه واحد مبنا | بئر / بیر | |
||||
|
|
||||
|
> **نکته تخصصی در متون دینی:** الف مقصوره (`ى` در انتهای کلماتی چون «حتى»، «إلى»، «موسى») در کیبورد کاربران گاهی با `ی`، گاهی با `ي` و حتی گاهی به اشتباه با `ا` («حتا») نوشته میشود. نرمالسازی `[ي ى ی ئ]` به یک نویسه پایدار، پوشش جستجو را به ۱۰۰٪ میرساند. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### ۲.۳. کاف عربی و فارسی (Kaf Normalization) |
||||
|
- **ك** (کاف عربی با نشان همزه/کاف کوچک: `U+0643`) |
||||
|
- **ک** (کاف فارسی سرکشدار: `U+06A9`) |
||||
|
- **قاعده:** تبدیل هر دو به یک فرم استاندارد (مثلاً `ك` برای متون عربی یا `ک`). |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### ۲.۴. تاء مربوطه و هاء (Teh Marbuta & Heh) |
||||
|
- **ة** (تاء مربوطه: `U+0629`) |
||||
|
- **ه** (هاء: `U+0647`) |
||||
|
- **ۀ** (هاء با همزه: `U+06C0`) |
||||
|
- **تحلیل رفتاری:** کاربران در تایپ سریع اسامی یا اصطلاحات اغلب تاء مربوطه را با هاء جابهجا میزنند: |
||||
|
- «معاویه» ↔ «معاوية» |
||||
|
- «فاطمه» ↔ «فاطمة» |
||||
|
- «صحابه» ↔ «صحابة» |
||||
|
- «رواة» ↔ «رواه» |
||||
|
- **قاعده سرچ نرمال:** برای جستجوی متنی، `ة` به `ه` تبدیل میشود تا تفاوت نگارشی کاربر باعث حذف رکورد نشود. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### ۲.۵. حرکات، اعراب و تنوینها (Tashkeel / Harakat / Diacritics) - حیاتیترین بخش |
||||
|
تمام حرکات زیر باید از متن ورودی و از نسخه ایندکسشده جستجو **کاملاً حذف شوند**: |
||||
|
|
||||
|
| نام حرکت | علامت | کد یونیکد | اثر در جستجوی خام دیتابیس | |
||||
|
| :--- | :---: | :---: | :--- | |
||||
|
| **فتحه (Fatha)** | َ | `U+064E` | مانع تطابق متن ساده میشود | |
||||
|
| **ضمه (Damma)** | ُ | `U+064F` | مانع تطابق متن ساده میشود | |
||||
|
| **کسره (Kasra)** | ِ | `U+0650` | مانع تطابق متن ساده میشود | |
||||
|
| **تنوین نصب (Fathatan)** | ً | `U+064B` | مانع تطابق | |
||||
|
| **تنوین رفع (Dammatan)** | ٌ | `U+064C` | مانع تطابق | |
||||
|
| **تنوین جر (Kasratan)** | ٍ | `U+064D` | مانع تطابق | |
||||
|
| **سکون (Sukun)** | ْ | `U+0652` | مانع تطابق | |
||||
|
| **تشدید (Shadda)** | ّ | `U+0651` | مانع تطابق کلمات دارای تشدید | |
||||
|
| **الف خنجری (Dagger Alef)** | ٰ | `U+0670` | **بسیار خطرناک:** در «رَحْمٰنِ»، «إِلٰهَ»، «هٰذَا»، «إِسْمٰعِيل» اگر حذف نشود، سرچ «رحمن» هرگز «رحمٰن» را پیدا نمیکند! | |
||||
|
| **مده (Maddah)** | ٓ | `U+0653` | مانع تطابق | |
||||
|
| **همزه فوقانی اعرابی** | ٔ | `U+0654` | مانع تطابق | |
||||
|
| **همزه تحتانی اعرابی** | ٕ | `U+0655` | مانع تطابق | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### ۲.۶. کشیدگی، تطویل و نشانههای نامرئی (Tatweel & Invisible Characters) |
||||
|
- **تطویل / کشیده (`ـ` - `U+0640`):** برای تنظیم طول خطوط در متون کهن یا زیبایی متنی به کار میرود (مثل `صـــــراط` یا `رســــول`). این کاراکتر باید به کلی حذف شود. |
||||
|
- **نیمفاصله (Zero-Width Non-Joiner - `U+200C`):** در متون فارسی و اسامی ترکیبی وجود دارد؛ باید به فاصله عادی یا حذف کامل تبدیل شود. |
||||
|
- **اتصالدهنده مجازی (ZWJ - `U+200D`):** باید حذف شود. |
||||
|
- **نشانگرهای جهت یونیکد (LRM `U+200E` و RLM `U+200F`):** باید کاملاً حذف شوند. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### ۲.۷. نشانهها و نمادهای وقوف قرآنی و مذهبی (Quranic Symbols & Waqf Marks) |
||||
|
در متون روایی و قرآنی نمادهای ویژهای در یونیکد ذخیره میشوند که باید در لایه سرچ پالایش گردند: |
||||
|
- علائم وقف قرآنی: `ۖ` (`U+06D6`), `ۗ` (`U+06D7`), `ۘ` (`U+06D8`), `ۙ` (`U+06D9`), `ۚ` (`U+06DA`), `ۛ` (`U+06DB`), `ۜ` (`U+06DC`), `` (`U+06DD`), `۞` (`U+06DE`), `۟` (`U+06DF`), `۠` (`U+06E0`), `ۡ` (`U+06E1`), `ۢ` (`U+06E2`), `ۣ` (`U+06E3`), `ۤ` (`U+06E4`). |
||||
|
- نمادهای لیگچر مذهبی: `ﷺ` (`U+FDFA`), `ﷻ` (`U+FDFB`), `﷽` (`U+FDFD`), `ؑ` (`U+0611`). |
||||
|
- پرانتزها و براکتهای قرآنی و نقلقول: `﴿`، `﴾`، `«`، `»`، `[`، `]`، `(`، `)`. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### ۲.۸. ارقام و اعداد (Digits) |
||||
|
- ارقام عربی-مشرقی: `[٠, ١, ٢, ٣, ٤, ٥, ٦, ٧, ٨, ٩]` |
||||
|
- ارقام فارسی: `[۰, ۱, ۲, ۳, ۴, ۵, ۶, ۷, ۸, ۹]` |
||||
|
- ارقام استاندارد لاتین: `[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]` |
||||
|
- **قاعده:** تبدیل تمام ارقام به ارقام استاندارد (0-9) تا سرچ شماره حدیث یا جلد و صفحه فارغ از نوع کیبورد عدد را پیدا کند. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## ۳. ماتریس مقایسهای سناریوهای سرچ (Search Scenario Matrix) |
||||
|
|
||||
|
| ورودی کاربر در سرچ | متن در دیتابیس | وضعیت جستجوی فعلی (خام) | وضعیت پس از نرمالسازی | |
||||
|
| :--- | :--- | :---: | :---: | |
||||
|
| `انما` | `أنما الأعمال بالنيات` | ❌ No result (تفاوت `ا` و `أ`) | ✅ منطبق و پیدا میشود | |
||||
|
| `انما` | `إِنَّمَا الأَعْمَالُ بِالنِّيَّاتِ` | ❌ No result (وجود کسره، تشدید، فتحه) | ✅ منطبق و پیدا میشود | |
||||
|
| `أبو هريرة` | `ابو هريره` | ❌ No result (تفاوت `أ/ا` و `ة/ه`) | ✅ منطبق و پیدا میشود | |
||||
|
| `صحیح بخاری` | `صَحِيحُ الْبُخَارِيِّ` | ❌ No result (تفاوت اعراب، `ی/ي`) | ✅ منطبق و پیدا میشود | |
||||
|
| `رحمن` | `الرَّحْمٰنِ الرَّحِيمِ` | ❌ No result (وجود الف خنجری `ٰ`) | ✅ منطبق و پیدا میشود | |
||||
|
| `مومن` | `إِنَّمَا الْمُؤْمِنُونَ إِخْوَةٌ` | ❌ No result (تفاوت `و` با `ؤ`) | ✅ منطبق و پیدا میشود | |
||||
|
| `صراط` | `صــــراط الذين` | ❌ No result (وجود کشیده `ـ`) | ✅ منطبق و پیدا میشود | |
||||
|
| `حدیث ۱۱۰` | `حديث 110` یا `حديث ١١٠` | ❌ No result (تفاوت ارقام) | ✅ منطبق و پیدا میشود | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## ۴. گزینهها و معماری پیادهسازی فنی در Django و PostgreSQL |
||||
|
|
||||
|
برای اعمال این نرمالسازی در سیستم، سه رویکرد معماری وجود دارد: |
||||
|
|
||||
|
### 🟢 گزینه اول: الگوی ستون جستجوی نرمالشده (Normalized Shadow Column / Search Column) — **[رویکرد پیشنهادی و استاندارد]** |
||||
|
|
||||
|
در این الگو، متن اصلی برای نمایش دستنخورده باقی میماند (تا اعراب و زیبایی اصیل آن در UI حفظ شود)، اما یک ستون متنی نرمالشده در کنار آن ایجاد و ایندکسگذاری میشود: |
||||
|
|
||||
|
1. **در مدلها (`Hadis`، `HadisCategory`، `Transmitter` و ...):** |
||||
|
- افزودن فیلد `normalized_text = models.TextField(blank=True, db_index=True)` یا استفاده از `django.contrib.postgres.search.SearchVector`. |
||||
|
- در متد `save()` مدل، متن اصلی از تابع نرمالساز عبور کرده و فیلد نرمالشده به صورت خودکار پر میشود. |
||||
|
- ایجاد یک اسکریپت ساده migration برای پر کردن یکباره مقادیر رکوردهای موجود. |
||||
|
2. **در لایه Queryset / View:** |
||||
|
- عبارت سرچ کاربر (`search_query`) توسط همان تابع پایتون نرمالسازی میشود: `normalized_q = normalize_text(search_query)`. |
||||
|
- جستجو روی ستون `normalized_text__icontains=normalized_q` انجام میشود. |
||||
|
3. **مزایا:** |
||||
|
- **فوقالعاده سریع (High Performance):** دیتابیس مستقیماً روی ستون ایندکسشده کوئری میزند بدون اینکه در هر ریکوئست تابع یا رجکس سنگین روی میلیونها کاراکتر اجرا شود. |
||||
|
- **سادگی و پایداری:** سازگاری کامل با معماری فعلی Django بدون نیاز به نصب اکستنشنهای پیچیده C در دیتابیس سرور. |
||||
|
- **دقت ۱۰۰٪:** تضمین میکند که منطق سمت پایتون در هر دو طرف ذخیره و جستجو دقیقاً یکی است. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### 🟡 گزینه دوم: تابع پایگاه داده در سطح PostgreSQL (Database-Level Stored Function & Functional Index) |
||||
|
|
||||
|
1. ایجاد یک تابع PL/pgSQL در PostgreSQL (مثلاً `fn_normalize_arabic(text)`). |
||||
|
2. ساخت ایندکس تابعی: |
||||
|
```sql |
||||
|
CREATE INDEX idx_hadis_normalized_text ON hadis_hadis (fn_normalize_arabic(text)); |
||||
|
``` |
||||
|
3. در جنگو با استفاده از `Func` یا Raw SQL: |
||||
|
```python |
||||
|
queryset.filter(Q(normalized_text_func__icontains=normalize_text(query))) |
||||
|
``` |
||||
|
4. **مزایا:** عدم نیاز به ذخیره دیتای مضاعف در ستون جداگانه. |
||||
|
5. **معایب:** وابستگی شدید به دیتابیس، سختی مایگریشن در محیطهای توسعه و تست SQLite/Docker، و پیچیدگی نگهداری لاجیک در SQL. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### 🔴 گزینه سوم: استفاده از Regex در زمان کوئری (Query-time Regex) |
||||
|
|
||||
|
1. تبدیل هر حرف از کلمه سرچ به یک گروه رجکس؛ مثلاً تبدیل `انما` به: |
||||
|
`[اأإآٱ][ًٌٍَُِّْٰ]*ن[ًٌٍَُِّْٰ]*م[ًٌٍَُِّْٰ]*[اأإآٱ]` |
||||
|
2. ارسال به دیتابیس با `text__iregex=pattern`. |
||||
|
3. **معایب:** |
||||
|
- **بسیار کند:** دیتابیس نمیتواند از هیچ ایندکسی استفاده کند (Full Table Scan با Regex Engine). |
||||
|
- با افزایش تعداد احادیث و اسناد، پاسخ سرور از چند میلیثانیه به چند ثانیه افزایش مییابد و بار سرور را به شدت بالا میبرد. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## ۵. کد مرجع پایتون برای تابع نرمالسازی (Python Reference Implementation) |
||||
|
|
||||
|
این تابع کاملترین و بهینهترین پیادهسازی منطبق با استاندارد Unicode Consortium برای متون عربی و فارسی است: |
||||
|
|
||||
|
```python |
||||
|
import re |
||||
|
import unicodedata |
||||
|
|
||||
|
# 1. حرکات، اعراب، تنوینها، تشدید، سکون و الف خنجری |
||||
|
# شامل بازه U+064B تا U+065F و الف مقصوره بالایی U+0670 |
||||
|
ARABIC_DIACRITICS_REGEX = re.compile(r'[\u064B-\u065F\u0670\u06D6-\u06ED]') |
||||
|
|
||||
|
# 2. کاراکتر کشیدگی / تطویل |
||||
|
TATWEEL_REGEX = re.compile(r'\u0640') |
||||
|
|
||||
|
# 3. جدول نگاشت الفها و کاراکترهای چندشکلی |
||||
|
ARABIC_NORMALIZATION_MAP = str.maketrans({ |
||||
|
# انواع الف به الف ساده |
||||
|
'أ': 'ا', |
||||
|
'إ': 'ا', |
||||
|
'آ': 'ا', |
||||
|
'ٱ': 'ا', |
||||
|
|
||||
|
# انواع یاء و الف مقصوره به یای استاندارد |
||||
|
'ي': 'ی', |
||||
|
'ى': 'ی', |
||||
|
'ئ': 'ی', |
||||
|
|
||||
|
# کاف عربی به کاف یکسان |
||||
|
'ك': 'ک', |
||||
|
|
||||
|
# تاء مربوطه و هاء |
||||
|
'ة': 'ه', |
||||
|
'ۀ': 'ه', |
||||
|
|
||||
|
# واو همزهدار |
||||
|
'ؤ': 'و', |
||||
|
|
||||
|
# ارقام عربی مشرقی و فارسی به ارقام استاندارد |
||||
|
'٠': '0', '١': '1', '٢': '2', '٣': '3', '٤': '4', |
||||
|
'٥': '5', '٦': '6', '٧': '7', '٨': '8', '٩': '9', |
||||
|
'۰': '0', '۱': '1', '۲': '2', '۳': '3', '۴': '4', |
||||
|
'۵': '5', '۶': '6', '۷': '7', '۸': '8', '۹': '9', |
||||
|
}) |
||||
|
|
||||
|
# 4. کاراکترهای کنترلی و نامرئی (Zero-width spaces, LRM, RLM) |
||||
|
ZERO_WIDTH_REGEX = re.compile(r'[\u200B-\u200F\u202A-\u202E\uFEFF]') |
||||
|
|
||||
|
def normalize_for_search(text: str) -> str: |
||||
|
""" |
||||
|
متن ورودی را بر اساس قواعد استاندارد جستجوی متون عربی و اسلامی نرمالسازی میکند: |
||||
|
1. حذف کاراکترهای کنترلی پنهان و نیمفاصلههای نامتعارف |
||||
|
2. حذف کامل تمام اعرابها، حرکات، تشدید، تنوینها و الف خنجری (Tashkeel) |
||||
|
3. حذف علامت کشیدگی (تطویل / کشیده) |
||||
|
4. یکسانسازی الفها (أ، إ، آ، ٱ -> ا) |
||||
|
5. یکسانسازی یاء و الف مقصوره (ي، ى، ئ -> ی) |
||||
|
6. یکسانسازی کاف (ك -> ک) |
||||
|
7. یکسانسازی تاء مربوطه (ة -> ه) |
||||
|
8. یکسانسازی واو همزهدار (ؤ -> و) |
||||
|
9. تبدیل ارقام عربی و فارسی به ارقام استاندارد |
||||
|
10. یکپارچهسازی فاصلههای خالی چندگانه |
||||
|
""" |
||||
|
if not text or not isinstance(text, str): |
||||
|
return "" |
||||
|
|
||||
|
# ۱. نرمالسازی فرم یونیکد (NFKC) |
||||
|
text = unicodedata.normalize('NFKC', text) |
||||
|
|
||||
|
# ۲. حذف کاراکترهای نامرئی |
||||
|
text = ZERO_WIDTH_REGEX.sub('', text) |
||||
|
|
||||
|
# ۳. حذف حرکات و اعراب |
||||
|
text = ARABIC_DIACRITICS_REGEX.sub('', text) |
||||
|
|
||||
|
# ۴. حذف تطویل |
||||
|
text = TATWEEL_REGEX.sub('', text) |
||||
|
|
||||
|
# ۵. نگاشت الفها و کاراکترهای همارز |
||||
|
text = text.translate(ARABIC_NORMALIZATION_MAP) |
||||
|
|
||||
|
# ۶. حذف فاصلههای اضافی مکرر |
||||
|
text = re.sub(r'\s+', ' ', text).strip() |
||||
|
|
||||
|
return text |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## ۶. نقشه راه اجرایی پیشنهادی (Recommended Implementation Roadmap) |
||||
|
|
||||
|
1. **فاز ۱ — بررسی و تأیید نهایی:** |
||||
|
- تأیید قوانین نرمالسازی فوق توسط کارفرما و تیم فنی (بهویژه در خصوص تبدیل `ة` به `ه` و `ؤ` به `و`). |
||||
|
2. **فاز ۲ — اضافه کردن ماژول Utility:** |
||||
|
- افزودن فایل `backend/utils/text_normalizer.py` شامل تابع `normalize_for_search`. |
||||
|
3. **فاز ۳ — پیادهسازی پایگاه داده:** |
||||
|
- اضافه کردن فیلدهای `search_text` یا `normalized_text` در مدلهای کلیدی (`Hadis`، `HadisCategory`، `Transmitter`، `HadisCorrection`، `ReferenceBook`). |
||||
|
- تنظیم پر شدن خودکار در `save()`. |
||||
|
- اجرای یک Management Command برای نرمالسازی دادههای قبلی. |
||||
|
4. **فاز ۴ — بهروزرسانی Queryset های جستجو:** |
||||
|
- اصلاح متدهای `apply_search_filter` در Viewها تا ورودی کاربر را پیش از جستجو نرمال کند. |
||||
|
5. **فاز ۵ — تست و اعتبارسنجی:** |
||||
|
- نوشتن تستهای خودکار (Unit Tests) برای کلمات چالشبرانگیز مثل «أنما»، «إِنَّمَا»، «الرَّحْمٰنِ»، «أبو هريرة»، «مسؤول» و اطمینان از نتیجه مثبت در تمامی حالات. |
||||
@ -0,0 +1,32 @@ |
|||||
|
import os |
||||
|
import sys |
||||
|
import django |
||||
|
|
||||
|
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) |
||||
|
sys.stdout.reconfigure(encoding='utf-8') |
||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.base') |
||||
|
django.setup() |
||||
|
|
||||
|
from apps.hadis.models import HadisReference, InterpretationReference, BookReference, BookEdition, BookVolume |
||||
|
from apps.hadis.models.transmitter import OriginalTextReference |
||||
|
|
||||
|
print('=== 5 SAMPLE HADIS REFERENCES ===') |
||||
|
for r in HadisReference.objects.select_related('book_reference', 'edition', 'book_volume')[:5]: |
||||
|
print(f'ID {r.id}: Book={r.book_reference_id}, Edition={r.edition_id}, BookVol={r.book_volume_id}, VolText="{r.volume}", Page="{r.pages}", HadithNo="{r.hadith_number}"') |
||||
|
|
||||
|
print('\n=== 5 SAMPLE INTERPRETATION REFERENCES ===') |
||||
|
for r in InterpretationReference.objects.select_related('book_reference', 'edition', 'book_volume')[:5]: |
||||
|
print(f'ID {r.id}: Book={r.book_reference_id}, Edition={r.edition_id}, BookVol={r.book_volume_id}, VolText="{r.volume}", Page="{r.pages}", HadithNo="{r.hadith_number}"') |
||||
|
|
||||
|
print('\n=== 5 SAMPLE ORIGINAL TEXT REFERENCES ===') |
||||
|
for r in OriginalTextReference.objects.select_related('book_reference', 'edition', 'book_volume')[:5]: |
||||
|
print(f'ID {r.id}: Book={r.book_reference_id}, Edition={r.edition_id}, BookVol={r.book_volume_id}, VolText="{r.volume}", Page="{r.pages}", HadithNo="{r.hadith_number}"') |
||||
|
|
||||
|
# Also check how many BookEditions exist per book |
||||
|
print('\n=== BOOK EDITIONS AND VOLUMES INFO ===') |
||||
|
sample_books = BookReference.objects.all()[:5] |
||||
|
for b in sample_books: |
||||
|
ed_count = b.editions.count() |
||||
|
vol_count = b.volumes.count() |
||||
|
editions = list(b.editions.values('id', 'edition_number', 'publisher')) |
||||
|
print(f"Book ID {b.id} ({b.slug}): {ed_count} editions, {vol_count} volumes. Editions: {editions}") |
||||
@ -0,0 +1,240 @@ |
|||||
|
import os |
||||
|
import sys |
||||
|
import random |
||||
|
import django |
||||
|
|
||||
|
# Setup environment |
||||
|
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) |
||||
|
sys.stdout.reconfigure(encoding='utf-8') |
||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.base') |
||||
|
django.setup() |
||||
|
|
||||
|
from django.db import transaction |
||||
|
from django.db.models.signals import post_save, post_delete, m2m_changed |
||||
|
from apps.hadis.signals import clear_hadis_cache, clear_hadis_cache_on_m2m, invalidate_hadis_cache |
||||
|
from apps.hadis.models import ( |
||||
|
BookReference, BookEdition, BookVolume, |
||||
|
HadisReference, HadisInterpretation, InterpretationReference, |
||||
|
HadisCorrection, CorrectionReference |
||||
|
) |
||||
|
from apps.hadis.models.transmitter import OriginalTextReference |
||||
|
|
||||
|
# Disconnect cache signals during bulk population for high performance |
||||
|
post_save.disconnect(clear_hadis_cache) |
||||
|
post_delete.disconnect(clear_hadis_cache) |
||||
|
m2m_changed.disconnect(clear_hadis_cache_on_m2m) |
||||
|
|
||||
|
SAMPLE_PUBLISHERS = [ |
||||
|
[ |
||||
|
{'text': 'دار الحديث', 'language_code': 'fa'}, |
||||
|
{'text': 'دار الحديث للطباعة والنشر', 'language_code': 'ar'}, |
||||
|
{'text': 'Dar al-Hadith Publications', 'language_code': 'en'} |
||||
|
], |
||||
|
[ |
||||
|
{'text': 'دار الكتب الإسلامية', 'language_code': 'fa'}, |
||||
|
{'text': 'دار الكتب الإسلامية', 'language_code': 'ar'}, |
||||
|
{'text': 'Dar al-Kutub al-Islamiyya', 'language_code': 'en'} |
||||
|
], |
||||
|
[ |
||||
|
{'text': 'مؤسسة النشر الإسلامي', 'language_code': 'fa'}, |
||||
|
{'text': 'مؤسسة النشر الإسلامي التابعة لجماعة المدرسين', 'language_code': 'ar'}, |
||||
|
{'text': 'Islamic Publishing Foundation', 'language_code': 'en'} |
||||
|
], |
||||
|
[ |
||||
|
{'text': 'مؤسسة البلاغ', 'language_code': 'fa'}, |
||||
|
{'text': 'مؤسسة البلاغ للطباعة والنشر والتوزيع', 'language_code': 'ar'}, |
||||
|
{'text': 'Al-Balagh Foundation', 'language_code': 'en'} |
||||
|
], |
||||
|
[ |
||||
|
{'text': 'دار الكتب العلمية', 'language_code': 'fa'}, |
||||
|
{'text': 'دار الكتب العلمية', 'language_code': 'ar'}, |
||||
|
{'text': 'Dar al-Kutub al-Ilmiyya', 'language_code': 'en'} |
||||
|
], |
||||
|
[ |
||||
|
{'text': 'مؤسسة آل البيت (ع) لإحياء التراث', 'language_code': 'fa'}, |
||||
|
{'text': 'مؤسسة آل البيت عليهم السلام لإحياء التراث', 'language_code': 'ar'}, |
||||
|
{'text': 'Aal al-Bayt Institute', 'language_code': 'en'} |
||||
|
], |
||||
|
] |
||||
|
|
||||
|
def is_empty_value(val): |
||||
|
if val is None: |
||||
|
return True |
||||
|
s = str(val).strip() |
||||
|
return s in ['', '0', '00', 'None', 'null', 'nan'] |
||||
|
|
||||
|
def ensure_book_editions_and_volumes(): |
||||
|
"""Ensure every BookReference has at least 1 BookEdition and 1 BookVolume.""" |
||||
|
all_books = list(BookReference.objects.prefetch_related('editions', 'volumes').all()) |
||||
|
print(f"Checking {len(all_books)} books for editions and volumes...", flush=True) |
||||
|
|
||||
|
editions_to_create = [] |
||||
|
books_needing_editions = [] |
||||
|
|
||||
|
for book in all_books: |
||||
|
editions = list(book.editions.all()) |
||||
|
if not editions: |
||||
|
pub = random.choice(SAMPLE_PUBLISHERS) |
||||
|
editions_to_create.append(BookEdition( |
||||
|
book_reference=book, |
||||
|
publisher=pub, |
||||
|
edition_number='طبعة أولى (1)', |
||||
|
year_of_publication='1441', |
||||
|
number_of_volumes=4 |
||||
|
)) |
||||
|
books_needing_editions.append(book) |
||||
|
|
||||
|
if editions_to_create: |
||||
|
created = BookEdition.objects.bulk_create(editions_to_create) |
||||
|
print(f"Created {len(created)} default editions.", flush=True) |
||||
|
else: |
||||
|
print("All books already have editions.", flush=True) |
||||
|
|
||||
|
# Re-fetch or assign volumes |
||||
|
all_books = list(BookReference.objects.prefetch_related('editions', 'volumes').all()) |
||||
|
volumes_to_create = [] |
||||
|
for book in all_books: |
||||
|
volumes = list(book.volumes.all()) |
||||
|
if not volumes: |
||||
|
editions = list(book.editions.all()) |
||||
|
ed = editions[0] if editions else None |
||||
|
for vol_num in range(1, 5): |
||||
|
volumes_to_create.append(BookVolume( |
||||
|
book_reference=book, |
||||
|
edition=ed, |
||||
|
title=f"جلد {vol_num}" |
||||
|
)) |
||||
|
|
||||
|
if volumes_to_create: |
||||
|
created_vols = BookVolume.objects.bulk_create(volumes_to_create) |
||||
|
print(f"Created {len(created_vols)} default volumes.", flush=True) |
||||
|
else: |
||||
|
print("All books already have volumes.", flush=True) |
||||
|
|
||||
|
def process_reference_queryset(model_class, model_name, all_books_map): |
||||
|
"""Update edition, book_volume, volume, pages, and hadith_number on all instances using bulk_update.""" |
||||
|
qs = model_class.objects.select_related('book_reference', 'edition', 'book_volume').all() |
||||
|
total = qs.count() |
||||
|
print(f"\nProcessing {total} records of {model_name}...", flush=True) |
||||
|
|
||||
|
if total == 0: |
||||
|
return |
||||
|
|
||||
|
updated_count = 0 |
||||
|
all_book_ids = list(all_books_map.keys()) |
||||
|
if not all_book_ids: |
||||
|
print("Error: No books available in database!", flush=True) |
||||
|
return |
||||
|
|
||||
|
items_to_update = [] |
||||
|
has_hadith_number = hasattr(model_class, 'hadith_number') |
||||
|
|
||||
|
update_fields = ['book_reference', 'edition', 'book_volume', 'volume', 'pages'] |
||||
|
if has_hadith_number: |
||||
|
update_fields.append('hadith_number') |
||||
|
|
||||
|
for ref in qs: |
||||
|
changed = False |
||||
|
|
||||
|
# 1. Ensure book_reference is attached |
||||
|
book = ref.book_reference |
||||
|
if not book: |
||||
|
chosen_book_id = random.choice(all_book_ids) |
||||
|
book = all_books_map[chosen_book_id]['book'] |
||||
|
ref.book_reference = book |
||||
|
changed = True |
||||
|
|
||||
|
book_data = all_books_map.get(book.id) |
||||
|
if not book_data: |
||||
|
continue |
||||
|
|
||||
|
book_editions = book_data['editions'] |
||||
|
book_volumes = book_data['volumes'] |
||||
|
|
||||
|
# 2. Ensure edition (Publisher) is set |
||||
|
if not ref.edition and book_editions: |
||||
|
ref.edition = random.choice(book_editions) |
||||
|
changed = True |
||||
|
|
||||
|
# 3. Ensure book_volume and volume text are set |
||||
|
if not ref.book_volume and book_volumes: |
||||
|
chosen_vol = random.choice(book_volumes) |
||||
|
ref.book_volume = chosen_vol |
||||
|
ref.volume = chosen_vol.title or f"جلد {random.randint(1, 4)}" |
||||
|
changed = True |
||||
|
elif not ref.volume: |
||||
|
ref.volume = f"جلد {random.randint(1, 4)}" |
||||
|
changed = True |
||||
|
|
||||
|
# 4. Ensure pages is set to a valid random page number |
||||
|
if is_empty_value(ref.pages): |
||||
|
ref.pages = str(random.randint(15, 480)) |
||||
|
changed = True |
||||
|
|
||||
|
# 5. Ensure hadith_number is set to a valid random hadith number |
||||
|
if has_hadith_number: |
||||
|
if is_empty_value(ref.hadith_number): |
||||
|
ref.hadith_number = str(random.randint(45, 3450)) |
||||
|
changed = True |
||||
|
|
||||
|
if changed: |
||||
|
items_to_update.append(ref) |
||||
|
|
||||
|
if len(items_to_update) >= 300: |
||||
|
model_class.objects.bulk_update(items_to_update, update_fields) |
||||
|
updated_count += len(items_to_update) |
||||
|
print(f" - Updated {updated_count}/{total} records...", flush=True) |
||||
|
items_to_update = [] |
||||
|
|
||||
|
if items_to_update: |
||||
|
model_class.objects.bulk_update(items_to_update, update_fields) |
||||
|
updated_count += len(items_to_update) |
||||
|
|
||||
|
print(f"Completed {model_name}: {updated_count} / {total} records updated.", flush=True) |
||||
|
|
||||
|
def main(): |
||||
|
print("==================================================", flush=True) |
||||
|
print("STARTING REFERENCE METADATA POPULATION", flush=True) |
||||
|
print("==================================================", flush=True) |
||||
|
|
||||
|
with transaction.atomic(): |
||||
|
# Step 1: Ensure all books have editions and volumes |
||||
|
ensure_book_editions_and_volumes() |
||||
|
|
||||
|
# Step 2: Cache book relations in memory for fast lookup |
||||
|
all_books_map = {} |
||||
|
for b in BookReference.objects.prefetch_related('editions', 'volumes').all(): |
||||
|
all_books_map[b.id] = { |
||||
|
'book': b, |
||||
|
'editions': list(b.editions.all()), |
||||
|
'volumes': list(b.volumes.all()), |
||||
|
} |
||||
|
|
||||
|
print(f"Cached {len(all_books_map)} books with their editions and volumes.", flush=True) |
||||
|
|
||||
|
# Step 3: Update HadisReference (Arguments) |
||||
|
process_reference_queryset(HadisReference, "HadisReference (Arguments)", all_books_map) |
||||
|
|
||||
|
# Step 4: Update InterpretationReference (Interpretations) |
||||
|
process_reference_queryset(InterpretationReference, "InterpretationReference (Interpretations)", all_books_map) |
||||
|
|
||||
|
# Step 5: Update OriginalTextReference (Original Texts) |
||||
|
process_reference_queryset(OriginalTextReference, "OriginalTextReference (Original Texts)", all_books_map) |
||||
|
|
||||
|
# Step 6: Update CorrectionReference (Corrections) |
||||
|
process_reference_queryset(CorrectionReference, "CorrectionReference (Corrections)", all_books_map) |
||||
|
|
||||
|
# Invalidate cache once at the end |
||||
|
print("\nInvalidating API cache...", flush=True) |
||||
|
try: |
||||
|
invalidate_hadis_cache() |
||||
|
print("API cache invalidated successfully.", flush=True) |
||||
|
except Exception as e: |
||||
|
print(f"Cache invalidation note: {e}", flush=True) |
||||
|
|
||||
|
print("\n==================================================", flush=True) |
||||
|
print("SUCCESS: ALL REFERENCES SUCCESSFULLY UPDATED!", flush=True) |
||||
|
print("==================================================", flush=True) |
||||
|
|
||||
|
if __name__ == '__main__': |
||||
|
main() |
||||
@ -0,0 +1,135 @@ |
|||||
|
""" |
||||
|
Comprehensive Arabic & Persian Text Normalization Utility for Search. |
||||
|
Handles Tashkeel (diacritics), Alef/Hamza unification, Yeh/Kaf unification, |
||||
|
Teh Marbuta, Tatweel, Dagger Alef, Quranic annotations, and numbers. |
||||
|
""" |
||||
|
|
||||
|
import re |
||||
|
import unicodedata |
||||
|
from typing import Any, Iterable, List, Optional |
||||
|
|
||||
|
# 1. Arabic Harakat / Diacritics / Quranic annotations: |
||||
|
# \u064B - \u065F : Fathatan, Dammatan, Kasratan, Fatha, Damma, Kasra, Shadda, Sukun, etc. |
||||
|
# \u0670 : Dagger Alef (Superscript Alef) e.g. رحمن vs رَحْمٰن |
||||
|
# \u06D6 - \u06ED : Quranic pause marks, small high signs, etc. |
||||
|
ARABIC_DIACRITICS_REGEX = re.compile(r'[\u064B-\u065F\u0670\u06D6-\u06ED]') |
||||
|
|
||||
|
# 2. Tatweel / Kashida (ـ) |
||||
|
TATWEEL_REGEX = re.compile(r'\u0640') |
||||
|
|
||||
|
# 3. HTML tags remover (e.g. <p>, <br>, <span>, etc.) |
||||
|
HTML_TAGS_REGEX = re.compile(r'<[^>]+>') |
||||
|
|
||||
|
# 4. Invisible / Control / Zero-width characters (ZWNJ, ZWJ, LRM, RLM, etc.) |
||||
|
ZERO_WIDTH_REGEX = re.compile(r'[\u200B-\u200F\u202A-\u202E\uFEFF]') |
||||
|
|
||||
|
# 5. Normalization translation map |
||||
|
ARABIC_NORMALIZATION_MAP = str.maketrans({ |
||||
|
# Alef variations -> Plain Alef |
||||
|
'أ': 'ا', |
||||
|
'إ': 'ا', |
||||
|
'آ': 'ا', |
||||
|
'ٱ': 'ا', |
||||
|
|
||||
|
# Yeh & Alef Maksura -> Standard Yeh |
||||
|
'ي': 'ی', |
||||
|
'ى': 'ی', |
||||
|
'ئ': 'ی', |
||||
|
|
||||
|
# Kaf -> Standard Kaf |
||||
|
'ك': 'ک', |
||||
|
|
||||
|
# Teh Marbuta & Heh with Yeh -> Standard Heh |
||||
|
'ة': 'ه', |
||||
|
'ۀ': 'ه', |
||||
|
|
||||
|
# Waw with Hamza -> Standard Waw |
||||
|
'ؤ': 'و', |
||||
|
|
||||
|
# Eastern Arabic and Persian digits -> Standard Latin digits |
||||
|
'٠': '0', '١': '1', '٢': '2', '٣': '3', '٤': '4', |
||||
|
'٥': '5', '٦': '6', '٧': '7', '٨': '8', '٩': '9', |
||||
|
'۰': '0', '۱': '1', '۲': '2', '۳': '3', '۴': '4', |
||||
|
'۵': '5', '۶': '6', '۷': '7', '۸': '8', '۹': '9', |
||||
|
}) |
||||
|
|
||||
|
|
||||
|
def normalize_for_search(text: Optional[str]) -> str: |
||||
|
""" |
||||
|
Normalizes a text string for fuzzy / invariant Arabic and Persian search: |
||||
|
- Strips HTML tags |
||||
|
- Unicode NFKC normalization |
||||
|
- Removes zero-width and invisible control characters |
||||
|
- Strips all diacritics / tashkeel and dagger alef (\u0670) |
||||
|
- Strips tatweel / kashida (\u0640) |
||||
|
- Unifies all Alef forms (أ, إ, آ, ٱ -> ا) |
||||
|
- Unifies Yeh and Alef Maksura (ي, ى, ئ -> ی) |
||||
|
- Unifies Kaf (ك -> ک) |
||||
|
- Unifies Teh Marbuta (ة, ۀ -> ه) |
||||
|
- Unifies Waw with Hamza (ؤ -> و) |
||||
|
- Converts Arabic & Persian numerals to ASCII (0-9) |
||||
|
- Collapses multiple whitespace characters and lowercases English text |
||||
|
""" |
||||
|
if not text or not isinstance(text, str): |
||||
|
return "" |
||||
|
|
||||
|
# 1. Remove HTML tags if present |
||||
|
cleaned = HTML_TAGS_REGEX.sub(' ', text) |
||||
|
|
||||
|
# 2. Unicode normalization (NFKC) |
||||
|
cleaned = unicodedata.normalize('NFKC', cleaned) |
||||
|
|
||||
|
# 3. Remove zero-width / control characters |
||||
|
cleaned = ZERO_WIDTH_REGEX.sub('', cleaned) |
||||
|
|
||||
|
# 4. Remove all Arabic diacritics / Tashkeel / Dagger Alef |
||||
|
cleaned = ARABIC_DIACRITICS_REGEX.sub('', cleaned) |
||||
|
|
||||
|
# 5. Remove Tatweel / Kashida |
||||
|
cleaned = TATWEEL_REGEX.sub('', cleaned) |
||||
|
|
||||
|
# 6. Apply character unification map |
||||
|
cleaned = cleaned.translate(ARABIC_NORMALIZATION_MAP) |
||||
|
|
||||
|
# 7. Lowercase English characters and collapse whitespace |
||||
|
cleaned = re.sub(r'\s+', ' ', cleaned).strip().lower() |
||||
|
|
||||
|
return cleaned |
||||
|
|
||||
|
|
||||
|
def extract_searchable_strings(data: Any) -> List[str]: |
||||
|
""" |
||||
|
Recursively extracts all text values from nested lists, dicts, |
||||
|
or strings (e.g., multilingual JSONFields like [{'text': '...', 'language_code': 'ar'}]). |
||||
|
""" |
||||
|
results: List[str] = [] |
||||
|
|
||||
|
if data is None: |
||||
|
return results |
||||
|
|
||||
|
if isinstance(data, str): |
||||
|
val = data.strip() |
||||
|
if val: |
||||
|
results.append(val) |
||||
|
elif isinstance(data, dict): |
||||
|
for k, v in data.items(): |
||||
|
results.extend(extract_searchable_strings(v)) |
||||
|
elif isinstance(data, (list, tuple, set)): |
||||
|
for item in data: |
||||
|
results.extend(extract_searchable_strings(item)) |
||||
|
|
||||
|
return results |
||||
|
|
||||
|
|
||||
|
def build_search_blob(*components: Any) -> str: |
||||
|
""" |
||||
|
Extracts all text components, normalizes them, and joins them with spaces |
||||
|
into a single indexed search text blob. |
||||
|
""" |
||||
|
all_raw_strings: List[str] = [] |
||||
|
for c in components: |
||||
|
all_raw_strings.extend(extract_searchable_strings(c)) |
||||
|
|
||||
|
# Join and normalize in one pass |
||||
|
joined = " ".join(all_raw_strings) |
||||
|
return normalize_for_search(joined) |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue