fix mobile and names

This commit is contained in:
javad committed 2026-10-15 11:21:55 +03:30
1 parent e3a6d1f9d4
commit 7e5c83bea2
12 files changed
+82792 -6

No files matched your search

+205
View File
@@ -0,0 +1,205 @@
"""منطق محضِ افزودن پیشوند «آقای/خانم» به نام مشتریان بر پایهٔ ``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")