a464e550c9
Full-stack implementation of the Cyberyan full-stack trial task. Backend (Django 5 / DRF) - Normalised PostgreSQL model (Profile, Company, School, Skill, Experience, Education) as the source of truth, with GIN + trigram indexes. - Self-healing CSV reader: the supplied export carries an injected watermark column, missing columns, rows split by broken quoting and a duplicated header. A signature-scored Needleman-Wunsch alignment reconstructs 284 clean records out of 336 physical rows; only 10 rows parse correctly without it. - Pluggable search layer (Strategy): Elasticsearch by default, PostgreSQL full-text search as a fallback selected by a single environment variable. - Keyword search with per-field boosts, fuzziness and phrase boosting, six faceted filters (OR within a filter, AND across filters), an experience range, four sort orders, highlighting and facet aggregations. - JWT auth with rotation + blacklist, three roles, PII masking, scoped rate limits, a single error envelope, OpenAPI schema and health probes. Frontend (React 18 / TypeScript / Vite) - Debounced search with type-ahead, faceted filter panel, result cards, profile drawer, URL-synced search state, automatic token refresh. - Highlight snippets rendered without dangerouslySetInnerHTML. Infrastructure - Hardened multi-stage images (non-root, read-only rootfs, healthchecks). - docker-compose for the full stack plus dev and production overlays. - ELK: structured JSON logs shipped asynchronously to Logstash, routed per event type, with a composable index template and a Kibana setup script. Quality - 61 backend tests (85% coverage), ruff/black/bandit/mypy, ESLint + tsc, GitHub Actions running lint, tests and both image builds. - Persian documentation: README plus architecture, search, API, data, security and operations guides. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GnFo6fNaHCTAkJbfcdkqtG
327 lines
14 KiB
Markdown
327 lines
14 KiB
Markdown
# دیتاست: تحلیل کیفیت و ترمیم
|
||
|
||
<div dir="rtl">
|
||
|
||
فایل `data/linkedin_profiles.csv` یک export واقعی و خراب است. این سند نشان میدهد دقیقاً چه مشکلی دارد، چرا خواندن ساده جواب نمیدهد، و ماژول [`apps/profiles/dataset.py`](../backend/apps/profiles/dataset.py) چطور آن را ترمیم میکند.
|
||
|
||
</div>
|
||
|
||
---
|
||
|
||
## ۱. آنچه در فایل هست
|
||
|
||
<div dir="rtl">
|
||
|
||
| سنجه | مقدار |
|
||
|---|---|
|
||
| خطوط فیزیکی (بدون هدر) | ۳۳۶ |
|
||
| ستونهای هدر | ۷۷ |
|
||
| رکوردهای منطقی بعد از بازسازی | ۳۲۳ |
|
||
| رکوردهای یکتا بعد از حذف تکراری | **۲۸۴** |
|
||
| ردیفهایی که با خواندن ساده کاملاً سالماند | **۱۰** |
|
||
|
||
آن عدد آخر مسئله را خلاصه میکند: `csv.DictReader` روی این فایل **کار میکند** — بدون خطا، بدون هشدار — و ۹۷٪ رکوردها را غلط تحویل میدهد.
|
||
|
||
</div>
|
||
|
||
---
|
||
|
||
## ۲. چهار نقص
|
||
|
||
### ۲.۱ واترمارک تزریقشده
|
||
|
||
<div dir="rtl">
|
||
|
||
بسیاری از ردیفها یک ستون اضافه در ابتدا دارند:
|
||
|
||
</div>
|
||
|
||
```
|
||
H:\New folder\leak\international\linkedin\@BreachedData1 Lin...,joseph holland,joseph,...
|
||
└──────────────────── ستون شبح ────────────────────────────┘ └─ داده واقعی از اینجا شروع میشود
|
||
```
|
||
|
||
<div dir="rtl">
|
||
|
||
نتیجه: کل ردیف یک خانه به راست میلغزد. `full_name` مقدار واترمارک را میگیرد، `first_name` نام کامل را، و به همین ترتیب تا آخر.
|
||
|
||
**ترمیم:** هر ستون ابتدایی که شامل `@BreachedData` یا `H:\New folder` باشد حذف میشود.
|
||
|
||
### ۲.۲ ستونهای غایب (لغزش به چپ)
|
||
|
||
</div>
|
||
|
||
```
|
||
هدر: ..., job_title, job_title_role, job_title_levels, job_company_id, ...
|
||
ردیف: ..., "assistant process engineer", "[]", "aerospace-alu...", ...
|
||
▲
|
||
مقدار job_title_levels است، نه job_title_role
|
||
```
|
||
|
||
<div dir="rtl">
|
||
|
||
رکورد `job_title_role` را ندارد و بهجای نوشتن مقدار خالی، آن را کلاً ننوشته است. همهچیز بعدش یک خانه به چپ میلغزد. این نقص در **بیشتر** ردیفها وجود دارد و گاهی چند فیلد پشت هم غایباند.
|
||
|
||
اثرش با یک نمونه واقعی:
|
||
|
||
</div>
|
||
|
||
```
|
||
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
|
||
```
|
||
|
||
### ۲.۳ کوتیشن شکسته (یک رکورد در چند خط)
|
||
|
||
<div dir="rtl">
|
||
|
||
چند مقدار `summary` یک `"` فرارنشده دارند (`... Leadership and Management."`). پارسر CSV آن را پایان فیلد میفهمد و بقیه رکورد به خط بعد میریزد. نتیجه: ردیفهایی با ۶، ۹، ۳۴ یا ۳۶ ستون که ادامه رکورد قبلیاند.
|
||
|
||
### ۲.۴ دو export به هم چسبیده
|
||
|
||
خط هدر یک بار دیگر در خط ۲۵۳ تکرار شده و ۳۹ نفر دقیقاً تکراریاند. یعنی فایل حاصل `cat export1.csv export2.csv` است.
|
||
|
||
</div>
|
||
|
||
```
|
||
توزیع تعداد ستون در ردیفهای فیزیکی:
|
||
77 ستون → ۲۸۳ ردیف (ولی اکثرشان با لغزش داخلی)
|
||
78 ستون → ۲۵ ردیف (واترمارکدار)
|
||
100-133 → ۱۵ ردیف (summary شکسته + واترمارک)
|
||
6-47 → ۱۳ ردیف (تکههای ادامه رکورد)
|
||
```
|
||
|
||
---
|
||
|
||
## ۳. الگوریتم ترمیم
|
||
|
||
### ۳.۱ ایده
|
||
|
||
<div dir="rtl">
|
||
|
||
هر ستون یک **امضا** دارد — یک قاعده ساده و قابل بررسی درباره اینکه مقدار معتبرش چه شکلی است:
|
||
|
||
</div>
|
||
|
||
```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"),
|
||
... # ۴۶ ستون امضا دارند
|
||
}
|
||
```
|
||
|
||
<div dir="rtl">
|
||
|
||
ستونهایی که امضا ندارند (نام، عنوان شغلی، صنعت، خلاصه) هر متنی را میپذیرند.
|
||
|
||
سپس مسئله این میشود: **بهترین انطباق بین دنباله مقادیر ردیف و دنباله ستونهای هدر چیست؟** این دقیقاً مسئله همترازی دنباله است.
|
||
|
||
### ۳.۲ همترازی بهسبک Needleman–Wunsch
|
||
|
||
</div>
|
||
|
||
```
|
||
ستونهای هدر →
|
||
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 اگر مقدار خالی) # تکه سرگردان
|
||
```
|
||
|
||
<div dir="rtl">
|
||
|
||
**وزنها و منطقشان:**
|
||
|
||
| رویداد | امتیاز | چرا |
|
||
|---|---|---|
|
||
| تطابق امضا | **+۳** | قویترین شاهد؛ باید بر هر ترکیبی از ویرایشها بچربد |
|
||
| نقض امضا | **−۴** | «شماره تلفن در ستون ایمیل» باید گرانتر از حذف باشد |
|
||
| متن آزاد (بدون امضا) | +۰٫۳ | پاداش کوچک برای مصرف کردن مقدار |
|
||
| مقدار خالی | +۰٫۱۵ | خنثی؛ هر جایی مینشیند |
|
||
| رد کردن ستون | −۰٫۵ | ارزان: در این فایل خیلی رایج است |
|
||
| دور انداختن مقدار غیرخالی | **−۳٫۵** | گران: از دست دادن داده آخرین راهحل است |
|
||
| دور انداختن مقدار خالی | −۰٫۳ | padding انتهای ردیف بیارزش است |
|
||
|
||
پیچیدگی O(n×m) با n≈۷۷ و m=۷۷ ⇒ حدود ۶۰۰۰ خانه در هر ردیف. کل فایل در حدود ۱۵ ثانیه (که بیشترش صرف نوشتن در دیتابیس میشود، نه همترازی).
|
||
|
||
### ۳.۳ خط لوله کامل
|
||
|
||
</div>
|
||
|
||
```
|
||
۱. حذف ستون واترمارک از ابتدای هر ردیف
|
||
▼
|
||
۲. رد کردن خط هدرِ تکراری وسط فایل
|
||
▼
|
||
۳. تشخیص شروع رکورد:
|
||
«full_name و first_name وجود دارند و full_name با first_name شروع میشود»
|
||
ردیفی که شروع رکورد نیست ⇒ ادامه رکورد قبلی است
|
||
▼
|
||
۴. الحاق تکههای ادامه به رکورد قبلی — فقط اگر رکورد قبلی خودش ناقص باشد.
|
||
وگرنه بهعنوان «تکه یتیم» شمرده و کنار گذاشته میشود (۱۲ مورد).
|
||
دلیل: چسباندن یک تکه به رکوردی که کامل است، داده یک نفر را به نام
|
||
نفر دیگر ثبت میکند — بدترین حالت ممکن.
|
||
▼
|
||
۵. همترازی DP
|
||
▼
|
||
۶. اعتبارسنجی دوم: هر مقداری که هنوز امضایش را رد نکند حذف میشود
|
||
(۱۷۵ مقدار در کل فایل)
|
||
▼
|
||
۷. حذف تکراری بر اساس linkedin_id → linkedin_url → full_name (۳۹ مورد)
|
||
```
|
||
|
||
<div dir="rtl">
|
||
|
||
**چرا مرحله ۶ لازم است؟** همترازی بهترین انطباق ممکن را پیدا میکند، ولی «بهترین» همیشه «درست» نیست. مثال واقعی: در یک رکورد، ستون `work_email` مقدار `"* We are focussed on providing consumer nutritional products"` گرفته بود. اعتبارسنجی دوم (`باید شامل @ باشد`) آن را دور میاندازد. **ذخیره نکردن بهتر از ذخیره کردن داده اشتباه است.**
|
||
|
||
</div>
|
||
|
||
---
|
||
|
||
## ۴. گزارش ایمپورت
|
||
|
||
```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
|
||
}
|
||
```
|
||
|
||
<div dir="rtl">
|
||
|
||
هر عدد قابل ردیابی است. یک ایمپورت که «۲۸۴ رکورد» میگوید ولی نمیگوید چند مقدار دور ریخته، قابل اعتماد نیست.
|
||
|
||
</div>
|
||
|
||
---
|
||
|
||
## ۵. غنیسازی از داده تودرتو
|
||
|
||
<div dir="rtl">
|
||
|
||
ستونهای سطحبالا اغلب غایباند، ولی همان اطلاعات معمولاً داخل بلوب `experience` هست:
|
||
|
||
</div>
|
||
|
||
```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}]
|
||
```
|
||
|
||
<div dir="rtl">
|
||
|
||
`ProfileImporter._derive_current_position()` و `_resolve_current_company()` وقتی ستون سطحبالا خالی است، از رکورد `is_primary` استفاده میکنند:
|
||
|
||
| فیلد | فقط ستون سطحبالا | با غنیسازی | بهبود |
|
||
|---|---|---|---|
|
||
| `job_title_role` | ۱۰۸ | **۱۸۴** | +۷۰٪ |
|
||
| کارفرمای فعلی | ۲۱۹ | **۲۶۵** | +۲۱٪ |
|
||
| `job_title` | ۲۴۸ | **۲۵۹** | +۴٪ |
|
||
|
||
این تفاوت مستقیماً روی کیفیت فیلترها اثر میگذارد: فیلتر «خانواده شغلی» با ۱۰۸ رکورد تقریباً بیفایده است، با ۱۸۴ رکورد کاربردی میشود.
|
||
|
||
</div>
|
||
|
||
---
|
||
|
||
## ۶. نرمالسازی
|
||
|
||
<div dir="rtl">
|
||
|
||
| مورد | قاعده |
|
||
|---|---|
|
||
| مهارتها | 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 | فقط اگر بین ۰ و ۱۰ باشد |
|
||
|
||
</div>
|
||
|
||
---
|
||
|
||
## ۷. امنیت پارس کردن
|
||
|
||
<div dir="rtl">
|
||
|
||
ستونهای تودرتو با نگارش `repr` پایتون نوشته شدهاند (`'` بهجای `"` و `None` بهجای `null`)، پس `json.loads` کار نمیکند.
|
||
|
||
از `ast.literal_eval` استفاده شده، **نه** `eval`. تفاوت حیاتی است: `literal_eval` فقط ساختارهای داده ثابت (عدد، رشته، لیست، دیکشنری، `None`، `True`/`False`) را میپذیرد و هیچ کدی اجرا نمیکند. یک فایل CSV مخرب نمیتواند از این مسیر کد اجرا کند.
|
||
|
||
همه فراخوانیها در `_safe_literal()` بستهبندی شدهاند که `ValueError`، `SyntaxError`، `MemoryError` و `RecursionError` را میگیرد و `None` برمیگرداند.
|
||
|
||
</div>
|
||
|
||
---
|
||
|
||
## ۸. تستها
|
||
|
||
<div dir="rtl">
|
||
|
||
`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` | فایل خالی کرش نمیکند |
|
||
|
||
بدون این تستها، یک تغییر کوچک در وزنهای همترازی میتواند بیصدا کیفیت داده را خراب کند — و هیچکس تا ماهها متوجه نشود.
|
||
|
||
</div>
|
||
|
||
---
|
||
|
||
## ۹. مسیر جایگزین
|
||
|
||
<div dir="rtl">
|
||
|
||
اگر فردا یک export تمیز برسد، هیچ چیز خراب نمیشود: ردیف سالم امتیاز کامل میگیرد و همترازی دقیقاً همان `dict(zip(header, row))` را تولید میکند. الگوریتم ترمیم روی داده سالم بیاثر است — فقط روی داده خراب فعال میشود.
|
||
|
||
</div>
|