fix mobile and names
This commit is contained in:
1 parent
e3a6d1f9d4
commit
7e5c83bea2
12 files changed
+82792
-6
No files matched your search
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user