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

14 KiB
Raw Blame History

دیتاست: تحلیل کیفیت و ترمیم

فایل data/linkedin_profiles.csv یک export واقعی و خراب است. این سند نشان می‌دهد دقیقاً چه مشکلی دارد، چرا خواندن ساده جواب نمی‌دهد، و ماژول 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     →  ۱۳ ردیف    (تکه‌های ادامه رکورد)

۳. الگوریتم ترمیم

۳.۱ ایده

هر ستون یک امضا دارد — یک قاعده ساده و قابل بررسی درباره اینکه مقدار معتبرش چه شکلی است:

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 ↘
# سه حرکت مجاز در هر خانه جدول برنامه‌نویسی پویا
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" گرفته بود. اعتبارسنجی دوم (باید شامل @ باشد) آن را دور می‌اندازد. ذخیره نکردن بهتر از ذخیره کردن داده اشتباه است.


۴. گزارش ایمپورت

$ docker compose exec backend python manage.py import_profiles --truncate
{
  "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 هست:

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