Files
cyberyan_linkedin_search/docs/DATA.md
T
mobiniqow a464e550c9 feat: LinkedIn profile search engine (Django + React + Elasticsearch + ELK)
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
2026-09-03 08:13:50 +00:00

327 lines
14 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# دیتاست: تحلیل کیفیت و ترمیم
<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>