"""منطق محضِ افزودن پیشوند «آقای/خانم» به نام مشتریان بر پایهٔ ``iranianNamesDataset.json``. این ماژول هیچ چیزی در دیتابیس تغییر نمی‌دهد و به Django وابسته نیست؛ فقط: * فایل JSON را **یک بار** می‌خواند و SHA-256 و index جست‌وجو می‌سازد. * برای هر نام تصمیم می‌گیرد که آیا پیشوند اضافه شود یا نه (سیاست کاملاً محافظه‌کارانه). قواعد تصمیم (به ترتیب): 1. نام تهی/فقط فاصله → دست‌نخورده. 2. اولین کلمه یکی از ``آقا``/``آقای``/``خانم`` → دست‌نخورده (پیشوند تکرار نمی‌شود). 3. نام تک‌کلمه‌ای → دست‌نخورده (ممکن است فقط نام خانوادگی باشد). 4. اولین کلمه در index نباشد → دست‌نخورده. 5. جنسیت اولین کلمه مبهم/نامعتبر باشد → دست‌نخورده. 6. در غیر این‌صورت پیشوند قطعی افزوده می‌شود. نرمال‌سازی صرفاً برای کلید جست‌وجو است (NFC + یکسان‌سازی ``ي/ی`` و ``ك/ک``). از fuzzy matching، حذف حروف یا حدس جنسیت استفاده نمی‌شود. """ import hashlib import json import unicodedata from dataclasses import dataclass, field from pathlib import Path from typing import Dict, Optional # نسخهٔ الگوریتم؛ با هر تغییر در قواعد تصمیم یا نرمال‌سازی باید افزایش یابد تا # jobهای قبلی به‌اشتباه ادامه داده نشوند. ALGORITHM_VERSION = "1" PREFIX_MALE = "آقای" PREFIX_FEMALE = "خانم" # پیشوندهایی که اگر ابتدای نام باشند، نام دست‌نخورده می‌ماند. KNOWN_PREFIXES = frozenset({"آقا", "آقای", "خانم"}) # تنها مقادیر معتبر Gender در فایل داده. هر مقدار دیگری «نامعتبر» شمرده می‌شود. GENDER_TO_PREFIX = {"M": PREFIX_MALE, "F": PREFIX_FEMALE} _ARABIC_YEH = "\u064a" _PERSIAN_YEH = "\u06cc" _ARABIC_KAF = "\u0643" _PERSIAN_KAF = "\u06a9" class DatasetError(ValueError): """ساختار فایل داده خراب است؛ هیچ تغییری مجاز نیست.""" def normalize_name_key(value) -> str: """کلید جست‌وجو: NFC + حذف فاصلهٔ ابتدا/انتها + یکسان‌سازی ي/ی و ك/ک.""" if not isinstance(value, str): return "" text = unicodedata.normalize("NFC", value).strip() return text.replace(_ARABIC_YEH, _PERSIAN_YEH).replace(_ARABIC_KAF, _PERSIAN_KAF) @dataclass class NameIndex: """نتیجهٔ خواندن فایل داده: index قابل‌استفاده + گزارش اعتبارسنجی.""" path: str sha256: str index: Dict[str, str] = field(default_factory=dict) # کلید نرمال‌شده → 'M'/'F' ambiguous: frozenset = frozenset() # کلیدهایی که قطعی نیستند report: Dict[str, int] = field(default_factory=dict) def lookup(self, key: str) -> Optional[str]: """جنسیت قطعیِ یک کلید نرمال‌شده، یا ``None`` اگر ناشناخته/مبهم باشد.""" return self.index.get(key) @dataclass class Decision: """تصمیم برای یک نام. ``new_name`` تنها هنگام تصمیم قطعی پر می‌شود.""" new_name: Optional[str] reason: str # male | female | single_word | has_prefix | empty | unknown_name | ambiguous_gender def validate_dataset(doc) -> list: """اعتبارسنجی ساختار کلی JSON. آرایهٔ ``RECORDS`` را برمی‌گرداند. در صورت خرابی ساختار (JSON غیر دیکشنری، نبودِ ``RECORDS`` یا لیست نبودن آن) ``DatasetError`` پرتاب می‌شود؛ در این حالت هیچ نام مشتری نباید تغییر کند. """ if not isinstance(doc, dict): raise DatasetError("ریشهٔ فایل JSON یک شیء (object) نیست.") if "RECORDS" not in doc: raise DatasetError("کلید «RECORDS» در فایل JSON وجود ندارد.") records = doc["RECORDS"] if not isinstance(records, list): raise DatasetError("مقدار «RECORDS» یک آرایه (list) نیست.") return records def build_name_index(records, *, path="", sha256="") -> NameIndex: """از آرایهٔ رکوردها، index محافظه‌کارانه و گزارش اعتبارسنجی می‌سازد. سیاست: یک کلید نرمال‌شده تنها وقتی قابل‌استفاده است که **همهٔ** رکوردهایش جنسیت معتبر (``M``/``F``) و یکسان داشته باشند. هر رکورد نامعتبر، جنسیت ناشناخته، یا تناقض ``M``/``F`` ⇒ کلید کاملاً کنار گذاشته می‌شود. """ groups: Dict[str, Dict[str, object]] = {} report = { "records_total": 0, "rejected_malformed": 0, # رکورد غیردیکشنری یا Names/Gender غیررشته‌ای "rejected_empty_name": 0, # نام خالی پس از strip "rejected_unknown_gender": 0, # مقدار Gender خارج از M/F "duplicate_keys": 0, "duplicate_same_gender": 0, "duplicate_conflict": 0, "usable_keys": 0, "unusable_keys": 0, } for record in records: report["records_total"] += 1 if not isinstance(record, dict): report["rejected_malformed"] += 1 continue names = record.get("Names") gender = record.get("Gender") if not isinstance(names, str) or not isinstance(gender, str): report["rejected_malformed"] += 1 continue key = normalize_name_key(names) if not key: report["rejected_empty_name"] += 1 continue entry = groups.setdefault(key, {"gender": None, "invalid": False, "count": 0}) entry["count"] += 1 if gender in GENDER_TO_PREFIX: if entry["gender"] is not None and entry["gender"] != gender: entry["invalid"] = True # تناقض M/F else: entry["gender"] = gender else: entry["invalid"] = True # جنسیت ناشناخته/نامعتبر report["rejected_unknown_gender"] += 1 index: Dict[str, str] = {} ambiguous = set() for key, entry in groups.items(): if entry["count"] > 1: report["duplicate_keys"] += 1 if entry["invalid"]: report["duplicate_conflict"] += 1 else: report["duplicate_same_gender"] += 1 if entry["invalid"] or entry["gender"] is None: ambiguous.add(key) else: index[key] = entry["gender"] report["usable_keys"] = len(index) report["unusable_keys"] = len(ambiguous) return NameIndex(path=path, sha256=sha256, index=index, ambiguous=frozenset(ambiguous), report=report) def load_name_index(path) -> NameIndex: """فایل را **یک بار** می‌خواند، hash و index می‌سازد. برای محاسبهٔ SHA-256 فایل جداگانه دوباره خوانده نمی‌شود. """ resolved = Path(path) raw = resolved.read_bytes() # تنها خواندن فایل digest = hashlib.sha256(raw).hexdigest() try: doc = json.loads(raw.decode("utf-8")) except UnicodeDecodeError as exc: raise DatasetError(f"فایل JSON با UTF-8 خوانده نشد: {exc}") from exc except json.JSONDecodeError as exc: raise DatasetError(f"JSON نامعتبر است: {exc}") from exc records = validate_dataset(doc) return build_name_index(records, path=str(resolved), sha256=digest) def decide_prefix(raw_name, name_index: NameIndex) -> Decision: """تصمیم برای یک مقدار ``name_family``. هیچ‌گاه خودِ مقدار را تغییر نمی‌دهد.""" if not isinstance(raw_name, str): return Decision(None, "empty") stripped = raw_name.strip() if not stripped: return Decision(None, "empty") tokens = stripped.split() if tokens[0] in KNOWN_PREFIXES: return Decision(None, "has_prefix") if len(tokens) < 2: return Decision(None, "single_word") key = normalize_name_key(tokens[0]) gender = name_index.lookup(key) if gender is None: reason = "ambiguous_gender" if key in name_index.ambiguous else "unknown_name" return Decision(None, reason) prefix = GENDER_TO_PREFIX[gender] return Decision(f"{prefix} {stripped}", "male" if gender == "M" else "female")