# دیتاست: تحلیل کیفیت و ترمیم
فایل `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))` را تولید میکند. الگوریتم ترمیم روی داده سالم بیاثر است — فقط روی داده خراب فعال میشود.