206 lines
8.8 KiB
Python
206 lines
8.8 KiB
Python
"""منطق محضِ افزودن پیشوند «آقای/خانم» به نام مشتریان بر پایهٔ ``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")
|
|
|