# دیتاست: تحلیل کیفیت و ترمیم
فایل `data/linkedin_profiles.csv` یک export واقعی و خراب است. این سند نشان می‌دهد دقیقاً چه مشکلی دارد، چرا خواندن ساده جواب نمی‌دهد، و ماژول [`apps/profiles/dataset.py`](../backend/apps/profiles/dataset.py) چطور آن را ترمیم می‌کند.
--- ## ۱. آنچه در فایل هست
| سنجه | مقدار | |---|---| | خطوط فیزیکی (بدون هدر) | ۳۳۶ | | ستون‌های هدر | ۷۷ | | رکوردهای منطقی بعد از بازسازی | ۳۲۳ | | رکوردهای یکتا بعد از حذف تکراری | **۲۸۴** | | ردیف‌هایی که با خواندن ساده کاملاً سالم‌اند | **۱۰** | آن عدد آخر مسئله را خلاصه می‌کند: `csv.DictReader` روی این فایل **کار می‌کند** — بدون خطا، بدون هشدار — و ۹۷٪ رکوردها را غلط تحویل می‌دهد.
--- ## ۲. چهار نقص ### ۲.۱ واترمارک تزریق‌شده
بسیاری از ردیف‌ها یک ستون اضافه در ابتدا دارند:
``` H:\New folder\leak\international\linkedin\@BreachedData1 Lin...,joseph holland,joseph,... └──────────────────── ستون شبح ────────────────────────────┘ └─ داده واقعی از اینجا شروع می‌شود ```
نتیجه: کل ردیف یک خانه به راست می‌لغزد. `full_name` مقدار واترمارک را می‌گیرد، `first_name` نام کامل را، و به همین ترتیب تا آخر. **ترمیم:** هر ستون ابتدایی که شامل `@BreachedData` یا `H:\New folder` باشد حذف می‌شود. ### ۲.۲ ستون‌های غایب (لغزش به چپ)
``` هدر: ..., job_title, job_title_role, job_title_levels, job_company_id, ... ردیف: ..., "assistant process engineer", "[]", "aerospace-alu...", ... ▲ مقدار job_title_levels است، نه job_title_role ```
رکورد `job_title_role` را ندارد و به‌جای نوشتن مقدار خالی، آن را کلاً ننوشته است. همه‌چیز بعدش یک خانه به چپ می‌لغزد. این نقص در **بیشتر** ردیف‌ها وجود دارد و گاهی چند فیلد پشت هم غایب‌اند. اثرش با یک نمونه واقعی:
``` naive: linkedin_connections = "55,000-70,000" ← این بازه حقوق است! inferred_salary = "18.0" ← این سال تجربه است! repaired:linkedin_connections = 32 inferred_salary = "55,000-70,000" inferred_years_exp = 18 ``` ### ۲.۳ کوتیشن شکسته (یک رکورد در چند خط)
چند مقدار `summary` یک `"` فرار‌نشده دارند (`... Leadership and Management."`). پارسر CSV آن را پایان فیلد می‌فهمد و بقیه رکورد به خط بعد می‌ریزد. نتیجه: ردیف‌هایی با ۶، ۹، ۳۴ یا ۳۶ ستون که ادامه رکورد قبلی‌اند. ### ۲.۴ دو export به هم چسبیده خط هدر یک بار دیگر در خط ۲۵۳ تکرار شده و ۳۹ نفر دقیقاً تکراری‌اند. یعنی فایل حاصل `cat export1.csv export2.csv` است.
``` توزیع تعداد ستون در ردیف‌های فیزیکی: 77 ستون → ۲۸۳ ردیف (ولی اکثرشان با لغزش داخلی) 78 ستون → ۲۵ ردیف (واترمارک‌دار) 100-133 → ۱۵ ردیف (summary شکسته + واترمارک) 6-47 → ۱۳ ردیف (تکه‌های ادامه رکورد) ``` --- ## ۳. الگوریتم ترمیم ### ۳.۱ ایده
هر ستون یک **امضا** دارد — یک قاعده ساده و قابل بررسی درباره اینکه مقدار معتبرش چه شکلی است:
```python FIELD_VALIDATORS = { "linkedin_url": _contains("linkedin.com/in/"), "gender": _one_of({"male", "female"}), "linkedin_id": _matches(r"\d{3,20}"), "location_geo": _matches(r"-?\d+\.\d+,-?\d+\.\d+"), "job_company_size": _matches(r"(1-10|11-50|51-200|201-500|...)"), "skills": _list_of_str, # literal لیست پایتون "experience": _list_of_dicts("company"), # لیست دیکشنری با کلید company "version_status": _dict_with("status"), ... # ۴۶ ستون امضا دارند } ```
ستون‌هایی که امضا ندارند (نام، عنوان شغلی، صنعت، خلاصه) هر متنی را می‌پذیرند. سپس مسئله این می‌شود: **بهترین انطباق بین دنباله مقادیر ردیف و دنباله ستون‌های هدر چیست؟** این دقیقاً مسئله هم‌ترازی دنباله است. ### ۳.۲ هم‌ترازی به‌سبک Needleman–Wunsch
``` ستون‌های هدر → full_name first_name gender linkedin_url job_title_role ... مقادیر "jane doe" ↘ ردیف "jane" ↘ ↓ "female" ↘ "linkedin.com/in/j" ↘ "['senior']" ⤵ (skip column) job_title_levels ↘ ``` ```python # سه حرکت مجاز در هر خانه جدول برنامه‌نویسی پویا match = dp[i-1][j-1] + score(column[j], value[i]) # این مقدار به این ستون skip_column = dp[i][j-1] - 0.5 # رکورد این فیلد را ندارد drop_value = dp[i-1][j] - 3.5 (یا -0.3 اگر مقدار خالی) # تکه سرگردان ```
**وزن‌ها و منطقشان:** | رویداد | امتیاز | چرا | |---|---|---| | تطابق امضا | **+۳** | قوی‌ترین شاهد؛ باید بر هر ترکیبی از ویرایش‌ها بچربد | | نقض امضا | **−۴** | «شماره تلفن در ستون ایمیل» باید گران‌تر از حذف باشد | | متن آزاد (بدون امضا) | +۰٫۳ | پاداش کوچک برای مصرف کردن مقدار | | مقدار خالی | +۰٫۱۵ | خنثی؛ هر جایی می‌نشیند | | رد کردن ستون | −۰٫۵ | ارزان: در این فایل خیلی رایج است | | دور انداختن مقدار غیرخالی | **−۳٫۵** | گران: از دست دادن داده آخرین راه‌حل است | | دور انداختن مقدار خالی | −۰٫۳ | padding انتهای ردیف بی‌ارزش است | پیچیدگی O(n×m) با n≈۷۷ و m=۷۷ ⇒ حدود ۶۰۰۰ خانه در هر ردیف. کل فایل در حدود ۱۵ ثانیه (که بیشترش صرف نوشتن در دیتابیس می‌شود، نه هم‌ترازی). ### ۳.۳ خط لوله کامل
``` ۱. حذف ستون واترمارک از ابتدای هر ردیف ▼ ۲. رد کردن خط هدرِ تکراری وسط فایل ▼ ۳. تشخیص شروع رکورد: «full_name و first_name وجود دارند و full_name با first_name شروع می‌شود» ردیفی که شروع رکورد نیست ⇒ ادامه رکورد قبلی است ▼ ۴. الحاق تکه‌های ادامه به رکورد قبلی — فقط اگر رکورد قبلی خودش ناقص باشد. وگرنه به‌عنوان «تکه یتیم» شمرده و کنار گذاشته می‌شود (۱۲ مورد). دلیل: چسباندن یک تکه به رکوردی که کامل است، داده یک نفر را به نام نفر دیگر ثبت می‌کند — بدترین حالت ممکن. ▼ ۵. هم‌ترازی DP ▼ ۶. اعتبارسنجی دوم: هر مقداری که هنوز امضایش را رد نکند حذف می‌شود (۱۷۵ مقدار در کل فایل) ▼ ۷. حذف تکراری بر اساس linkedin_id → linkedin_url → full_name (۳۹ مورد) ```
**چرا مرحله ۶ لازم است؟** هم‌ترازی بهترین انطباق ممکن را پیدا می‌کند، ولی «بهترین» همیشه «درست» نیست. مثال واقعی: در یک رکورد، ستون `work_email` مقدار `"* We are focussed on providing consumer nutritional products"` گرفته بود. اعتبارسنجی دوم (`باید شامل @ باشد`) آن را دور می‌اندازد. **ذخیره نکردن بهتر از ذخیره کردن داده اشتباه است.**
--- ## ۴. گزارش ایمپورت ```bash $ docker compose exec backend python manage.py import_profiles --truncate ``` ```json { "profiles_created": 284, "profiles_updated": 0, "skills": 2376, "companies": 1371, "schools": 610, "experiences": 2128, "educations": 870, "physical_rows": 336, "duplicate_headers": 1, "repaired_rows": 16, "orphan_fragments": 12, "duplicate_records": 39, "discarded_values": 175, "records": 284, "elapsed_seconds": 15.41 } ```
هر عدد قابل ردیابی است. یک ایمپورت که «۲۸۴ رکورد» می‌گوید ولی نمی‌گوید چند مقدار دور ریخته، قابل اعتماد نیست.
--- ## ۵. غنی‌سازی از داده تودرتو
ستون‌های سطح‌بالا اغلب غایب‌اند، ولی همان اطلاعات معمولاً داخل بلوب `experience` هست:
```python [{'company': {'name': 'garver', 'size': '501-1000', 'industry': 'civil engineering', ...}, 'title': {'name': 'recruiting manager', 'role': 'human_resources', 'sub_role': 'recruiting', 'levels': ['manager']}, 'is_primary': True, 'start_date': '2019-10', 'end_date': None}] ```
`ProfileImporter._derive_current_position()` و `_resolve_current_company()` وقتی ستون سطح‌بالا خالی است، از رکورد `is_primary` استفاده می‌کنند: | فیلد | فقط ستون سطح‌بالا | با غنی‌سازی | بهبود | |---|---|---|---| | `job_title_role` | ۱۰۸ | **۱۸۴** | ‎+۷۰٪ | | کارفرمای فعلی | ۲۱۹ | **۲۶۵** | ‎+۲۱٪ | | `job_title` | ۲۴۸ | **۲۵۹** | ‎+۴٪ | این تفاوت مستقیماً روی کیفیت فیلترها اثر می‌گذارد: فیلتر «خانواده شغلی» با ۱۰۸ رکورد تقریباً بی‌فایده است، با ۱۸۴ رکورد کاربردی می‌شود.
--- ## ۶. نرمال‌سازی
| مورد | قاعده | |---|---| | مهارت‌ها | slug (`slugify`) کلید یکتاست ⇒ «Team Building» و «team building» یک رکورد | | مهارت‌های تکراری در یک پروفایل | حذف، با حفظ ترتیب اصلی در `ProfileSkill.position` | | سقف مهارت | ۶۰ مهارت اول (بلندترین لیست ۵۰+ مورد دارد) | | اعداد | `"18.0"` → `18` با بازه معتبر (`0 ≤ years ≤ 70`)؛ خارج از بازه ⇒ `None` | | تاریخ | `"2019-10"` و `"2008"` هر دو نگه داشته می‌شوند + سال استخراج‌شده در `start_year`/`end_year` برای فیلتر | | شرکت/دانشگاه | یکتا بر اساس نام، با کش درون‌حافظه‌ای در ایمپورتر ⇒ بدون N+1 | | GPA | فقط اگر بین ۰ و ۱۰ باشد |
--- ## ۷. امنیت پارس کردن
ستون‌های تودرتو با نگارش `repr` پایتون نوشته شده‌اند (`'` به‌جای `"` و `None` به‌جای `null`)، پس `json.loads` کار نمی‌کند. از `ast.literal_eval` استفاده شده، **نه** `eval`. تفاوت حیاتی است: `literal_eval` فقط ساختارهای داده ثابت (عدد، رشته، لیست، دیکشنری، `None`، `True`/`False`) را می‌پذیرد و هیچ کدی اجرا نمی‌کند. یک فایل CSV مخرب نمی‌تواند از این مسیر کد اجرا کند. همه فراخوانی‌ها در `_safe_literal()` بسته‌بندی شده‌اند که `ValueError`، `SyntaxError`، `MemoryError` و `RecursionError` را می‌گیرد و `None` برمی‌گرداند.
--- ## ۸. تست‌ها
`backend/tests/test_dataset_repair.py` هفت تست دارد که هر کدام یکی از نقص‌های واقعی را در کوچک‌ترین شکل ممکن بازتولید می‌کنند: | تست | تضمین | |---|---| | `test_clean_row_is_read_verbatim` | ردیف سالم دست‌نخورده می‌ماند | | `test_leading_watermark_column_is_stripped` | ستون شبح حذف می‌شود | | `test_missing_column_does_not_shift_the_rest_of_the_row` | ستون غایب باعث لغزش بقیه نمی‌شود | | `test_values_that_fail_their_signature_are_discarded` | مقدار نامعتبر ذخیره نمی‌شود | | `test_repeated_header_and_duplicate_person_are_removed` | هدر و رکورد تکراری حذف می‌شوند | | `test_row_split_by_a_stray_quote_is_reassembled` | رکورد شکسته دوباره ساخته می‌شود | | `test_empty_file_yields_no_records` | فایل خالی کرش نمی‌کند | بدون این تست‌ها، یک تغییر کوچک در وزن‌های هم‌ترازی می‌تواند بی‌صدا کیفیت داده را خراب کند — و هیچ‌کس تا ماه‌ها متوجه نشود.
--- ## ۹. مسیر جایگزین
اگر فردا یک export تمیز برسد، هیچ چیز خراب نمی‌شود: ردیف سالم امتیاز کامل می‌گیرد و هم‌ترازی دقیقاً همان `dict(zip(header, row))` را تولید می‌کند. الگوریتم ترمیم روی داده سالم بی‌اثر است — فقط روی داده خراب فعال می‌شود.