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